Add Clusters in Flutter
This tutorial shows how to group nearby markers into clusters for better performance and readability when you have many data points on the map.
Prerequisites
Before you begin, ensure you have:
- Completed the Flutter Setup Guide
- A MapMetrics API key and style URL from the MapMetrics Portal
Basic Clustering
Clustering is a native GeoJsonSource feature — set cluster: true and MapLibre groups nearby points into a single feature carrying a point_count property. You then render that with three style layers: circles for clusters, text labels for the counts, and circles for the individual (unclustered) points:
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class ClusterExampleScreen extends StatefulWidget {
const ClusterExampleScreen({super.key});
@override
State<ClusterExampleScreen> createState() => _ClusterExampleScreenState();
}
class _ClusterExampleScreenState extends State<ClusterExampleScreen> {
MapController? mapController;
static const _sourceId = 'poi-points';
// Sample data: 100 random points around Paris
late final List<Position> allPoints;
@override
void initState() {
super.initState();
final random = Random(42);
allPoints = List.generate(100, (_) {
return Position(
2.28 + random.nextDouble() * 0.14, // lng range around Paris
48.82 + random.nextDouble() * 0.08, // lat range around Paris
);
});
}
String _toGeoJson() {
return jsonEncode({
'type': 'FeatureCollection',
'features': [
for (final p in allPoints)
{
'type': 'Feature',
'geometry': {
'type': 'Point',
'coordinates': [p.lng, p.lat],
},
'properties': {},
},
],
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Clustered Markers')),
body: Column(
children: [
Container(
padding: const EdgeInsets.all(10),
color: Colors.grey[100],
child: Text(
'${allPoints.length} total points — zoom in to break clusters apart',
style: const TextStyle(fontSize: 13),
),
),
Expanded(
child: MapMetricsView(
options: MapOptions(
initStyle:
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
initCenter: Position(2.3522, 48.8566),
initZoom: 12,
),
onMapCreated: (controller) => mapController = controller,
onStyleLoaded: (style) async {
await style.addSource(
GeoJsonSource(
id: _sourceId,
data: _toGeoJson(),
cluster: true,
clusterRadius: 50,
clusterMaxZoom: 14,
),
);
// Unclustered points, rendered first so clusters sit on top
await style.addLayer(
const CircleStyleLayer(
id: 'unclustered-point',
sourceId: _sourceId,
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': '#e53935',
'circle-radius': 6,
'circle-stroke-width': 1,
'circle-stroke-color': '#ffffff',
},
),
);
// Cluster circles, colored/sized by point_count
await style.addLayer(
const CircleStyleLayer(
id: 'clusters',
sourceId: _sourceId,
filter: ['has', 'point_count'],
paint: {
'circle-color': [
'step',
['get', 'point_count'],
'#51bbd6',
10,
'#f1f075',
50,
'#f28cb1',
],
'circle-radius': [
'step',
['get', 'point_count'],
16,
10,
20,
50,
26,
],
},
),
);
// Cluster count labels
await style.addLayer(
const SymbolStyleLayer(
id: 'cluster-count',
sourceId: _sourceId,
filter: ['has', 'point_count'],
layout: {
'text-field': '{point_count_abbreviated}',
'text-font': ['Open Sans Semibold'],
'text-size': 12,
},
),
);
},
),
),
],
),
);
}
}Cluster Circle Sizing
The 'step' expressions above pick a color and radius based on point_count directly in the style — no Dart-side recomputation needed, and no custom bitmap icons to generate:
| Point count | Color | Radius |
|---|---|---|
| 1–9 | #51bbd6 (blue) | 16px |
| 10–49 | #f1f075 (yellow) | 20px |
| 50+ | #f28cb1 (pink) | 26px |
To use a custom image per cluster size instead of plain circles, swap CircleStyleLayer for a SymbolStyleLayer with an icon-image 'step' expression, after loading the images with StyleController.addImage/addImages.
Tap a Cluster to Zoom In
MapController doesn't expose a getClusterExpansionZoom lookup, so there's no way to ask "exactly what zoom breaks this cluster apart." A practical approximation is to zoom in by a fixed increment centered on the tap point, using MapEventClick from onEvent:
onEvent: (event) {
if (event case MapEventClick(:final point)) {
mapController?.animateCamera(
center: point,
zoom: (mapController?.camera?.zoom ?? 12) + 2,
);
}
},Because clusters and unclustered points share the same source, this zoom-in gesture works reasonably well for both — tapping an individual point just re-centers the map without much visual change.
Next Steps
- Add a Heatmap — Density visualization alternative to clusters
- Add a Popup — Show details when tapping a point
- Add a Polyline — Connect points with routes
Tip: Recalculate GeoJSON data with StyleController.updateGeoJsonSource only when the underlying data actually changes — clustering itself happens natively as the user zooms, so you don't need to recompute anything on camera move.