Skip to content

Both copy text to your clipboard — Build with AI copies a setup prompt to paste into Claude Code, Cursor, Codex or Copilot; Copy page as Markdown copies this page to paste into a chat. How it works

Add a Heatmap in Flutter

This tutorial shows how to create a heatmap overlay to visualize data density on your MapMetrics Flutter map.

Prerequisites

Before you begin, ensure you have:

Native Heatmap Layer

MapMetrics Flutter has a real HeatmapStyleLayer (it maps directly to the MapLibre style spec heatmap layer type), so you don't need to fake density with overlapping circles. Feed it point data through a GeoJsonSource, and use paint expressions to control weight, intensity, color ramp, and radius by zoom level:

dart
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';

class HeatmapExampleScreen extends StatefulWidget {
  const HeatmapExampleScreen({super.key});

  @override
  State<HeatmapExampleScreen> createState() => _HeatmapExampleScreenState();
}

class _HeatmapExampleScreenState extends State<HeatmapExampleScreen> {
  MapController? mapController;
  static const _sourceId = 'poi-heat';
  static const _layerId = 'poi-heatmap';

  // Sample data: locations with intensity (weight)
  final List<Map<String, Object>> heatData = const [
    {'lng': 2.2945, 'lat': 48.8584, 'weight': 0.9},  // Eiffel Tower
    {'lng': 2.3376, 'lat': 48.8606, 'weight': 0.85}, // Louvre
    {'lng': 2.3499, 'lat': 48.8530, 'weight': 0.8},  // Notre-Dame
    {'lng': 2.3431, 'lat': 48.8867, 'weight': 0.7},  // Sacre-Coeur
    {'lng': 2.2950, 'lat': 48.8738, 'weight': 0.75}, // Arc de Triomphe
    {'lng': 2.3520, 'lat': 48.8620, 'weight': 0.5},
    {'lng': 2.3400, 'lat': 48.8450, 'weight': 0.4},
    {'lng': 2.3100, 'lat': 48.8700, 'weight': 0.6},
    {'lng': 2.3700, 'lat': 48.8550, 'weight': 0.3},
    {'lng': 2.3200, 'lat': 48.8480, 'weight': 0.35},
    {'lng': 2.3300, 'lat': 48.8650, 'weight': 0.55},
    {'lng': 2.3050, 'lat': 48.8580, 'weight': 0.45},
    {'lng': 2.3500, 'lat': 48.8750, 'weight': 0.5},
    {'lng': 2.2800, 'lat': 48.8500, 'weight': 0.25},
    {'lng': 2.3200, 'lat': 48.8800, 'weight': 0.4},
  ];

  String _toGeoJson() {
    return jsonEncode({
      'type': 'FeatureCollection',
      'features': [
        for (final p in heatData)
          {
            'type': 'Feature',
            'geometry': {
              'type': 'Point',
              'coordinates': [p['lng'], p['lat']],
            },
            'properties': {'weight': p['weight']},
          },
      ],
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Heatmap')),
      body: MapMetricsView(
        options: MapOptions(
          initStyle:
              'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
          initCenter: Position(2.3300, 48.8600),
          initZoom: 13,
        ),
        onMapCreated: (controller) => mapController = controller,
        onStyleLoaded: (style) async {
          await style.addSource(
            GeoJsonSource(id: _sourceId, data: _toGeoJson()),
          );
          await style.addLayer(_heatmapLayer);
        },
      ),
    );
  }
}

const _heatmapLayer = HeatmapStyleLayer(
  id: 'poi-heatmap',
  sourceId: 'poi-heat',
  paint: {
    // Weight each point by its 'weight' property (0-1)
    'heatmap-weight': [
      'interpolate',
      ['linear'],
      ['get', 'weight'],
      0,
      0,
      1,
      1,
    ],
    // heatmap-intensity is a multiplier on top of heatmap-weight, by zoom
    'heatmap-intensity': [
      'interpolate',
      ['linear'],
      ['zoom'],
      10,
      1,
      16,
      3,
    ],
    // Color ramp: transparent -> green -> yellow -> orange -> red
    'heatmap-color': [
      'interpolate',
      ['linear'],
      ['heatmap-density'],
      0,
      'rgba(0,0,0,0)',
      0.25,
      'rgb(76,175,80)',
      0.5,
      'rgb(255,235,59)',
      0.75,
      'rgb(255,152,0)',
      1,
      'rgb(244,67,54)',
    ],
    'heatmap-radius': [
      'interpolate',
      ['linear'],
      ['zoom'],
      10,
      15,
      16,
      35,
    ],
    'heatmap-opacity': 0.8,
  },
);

Generate Heatmap from Random Data

The same pattern scales to large datasets — build the FeatureCollection once and hand it to GeoJsonSource.data:

dart
String _generateHeatGeoJson(Position center, int count) {
  final random = Random(42);
  final features = List.generate(count, (_) {
    final lng = center.lng + (random.nextDouble() - 0.5) * 0.08;
    final lat = center.lat + (random.nextDouble() - 0.5) * 0.05;
    final dist = sqrt(pow(lng - center.lng, 2) + pow(lat - center.lat, 2));
    final weight = max(0.1, 1.0 - dist * 30);

    return {
      'type': 'Feature',
      'geometry': {
        'type': 'Point',
        'coordinates': [lng, lat],
      },
      'properties': {'weight': weight},
    };
  });

  return jsonEncode({'type': 'FeatureCollection', 'features': features});
}

Toggle Heatmap Visibility

There's no boolean visible flag you can flip at runtime — remove and re-add the layer instead:

dart
bool showHeatmap = true;

Future<void> _toggleHeatmap(StyleController style) async {
  if (showHeatmap) {
    await style.removeLayer('poi-heatmap');
  } else {
    await style.addLayer(_heatmapLayer);
  }
  setState(() => showHeatmap = !showHeatmap);
}

Heatmap Color Scales

ScaleColorsBest For
TrafficGreen -> Yellow -> RedCongestion, density
TemperatureBlue -> Green -> Yellow -> RedWeather, temperature data
MonochromeLight blue -> Dark blueClean, minimal design
ViridisPurple -> Blue -> Green -> YellowScientific data

Swap the heatmap-color interpolation stops in the paint map to use any of these — the layer definition is just a MapLibre style expression, not a fixed enum.

Next Steps


Tip: Keep heatmap-opacity near 1 and transition it toward 0 at high zoom via a 'zoom' expression if you want to hand off from a heatmap to individual markers as the user zooms in — much cheaper than swapping layers.