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:
- Completed the Flutter Setup Guide
- A MapMetrics API key and style URL from the MapMetrics Portal
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:
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:
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:
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
| Scale | Colors | Best For |
|---|---|---|
| Traffic | Green -> Yellow -> Red | Congestion, density |
| Temperature | Blue -> Green -> Yellow -> Red | Weather, temperature data |
| Monochrome | Light blue -> Dark blue | Clean, minimal design |
| Viridis | Purple -> Blue -> Green -> Yellow | Scientific 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
- Add a Cluster — Group markers instead of blending them
- Add a Polygon — Combine heatmap with other layers
- Add a Polyline — Draw routes alongside the heatmap
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.