Skip to main content

Marker Clustering

Last updated: July 20, 2026 | 5 minutes read

This example demonstrates how to render thousands of markers loaded from a bundled GeoJSON file and cluster them natively using Maps SDK for Flutter. At low zoom the markers group into count "pill" bubbles; as you zoom in the clusters break apart into per-type coloured pins. Tapping a cluster zooms in to split it, and tapping a pin opens an info sheet.

Dependencies

This example requires Maps SDK for Flutter 3.1.10 or newer. Add it to the dependencies section of your pubspec.yaml:

dependencies:
magiclane_maps_flutter: ^3.1.10

Saving Assets

Before running the app, ensure that you save the campsite data and pin icons into the assets directory:

  • assets/campsites.geojson — the marker source data
  • assets/pin_bookable.png — red pin, bookable campsites
  • assets/pin_non_bookable.png — green pin, info-only campsites

Update your pubspec.yaml file to include these assets:

flutter:
assets:
- assets/campsites.geojson
- assets/pin_bookable.png
- assets/pin_non_bookable.png

How it works

The map uses two overlapping SDK-drawn marker collections. A marker that carries its own per-marker render settings suppresses the SDK's cluster count label, so the count and the coloured pins cannot come from a single collection:

  • Cluster layer — one point-marker per campsite with collection-level settings only. The SDK groups the points into density "pill" bubbles and paints the count on them; loose points draw a transparent image.
  • Detail layer — one coloured green/red pin per campsite added through the SDK's optimised bulk addList(...) path. Its group images are transparent, so it shows nothing while clustered and only its pins once the clusters split.

Both collections group at the same zoom level, so a pill never sits on top of a pin.

Clustered markers

UI and Map Integration

This code sets up the map screen, enables the (invisible) cursor so a tap can select a marker, and centers the camera on the initial view.

class MyApp extends StatelessWidget {
const MyApp({super.key});


Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Marker Clustering',
home: MapScreen(),
);
}
}

Future<void> _onMapCreated(GemMapController controller) async {
_controller = controller;

// Enable the (invisible) cursor so tap → marker selection works.
controller.preferences.enableCursor = true;
controller.preferences.enableCursorRender = false;

controller.centerOnCoordinates(_usCenter, zoomLevel: _usZoom);
controller.registerOnTouch(_onTouch);
}

Loading and parsing the GeoJSON

The bundled GeoJSON is read from assets and decoded on a background isolate (compute) so the multi-MB parse never janks the UI thread.

campsite.dartView on Github
class CampsiteLoader {
static Future<List<Campsite>> loadFromAssets(
[String asset = 'assets/campsites.geojson']) async {
final raw = await rootBundle.loadString(asset);
return compute(_parse, raw);
}

static List<Campsite> _parse(String raw) {
final root = jsonDecode(raw) as Map<String, dynamic>;
final features = (root['features'] as List?) ?? const [];
final result = <Campsite>[];
for (final feature in features) {
final f = feature as Map<String, dynamic>;
final geometry = f['geometry'] as Map<String, dynamic>?;
final coords = geometry?['coordinates'] as List?;
final props = f['properties'] as Map<String, dynamic>?;
if (coords == null || coords.length < 2 || props == null) continue;

result.add(Campsite(
id: _asInt(props['campsiteId'])!,
name: (props['name'] as String?) ?? '',
isBookable: props['bookable'] == true,
latitude: _asDouble(coords[1])!,
longitude: _asDouble(coords[0])!,
// ...
));
}
return result;
}
}

The cluster layer (count "pill" bubbles)

The first collection holds one point-marker per campsite with collection-level settings only. buildPointsGroupConfig plus the density images and count thresholds make the SDK group loose points into count-bearing pills. Note that labelGroupTextColor defaults to transparent — it must be set opaque or the count is invisible.

final clusterCollection = MarkerCollection(
markerType: MarkerType.point,
name: '$_collectionName-clustered',
);
for (final c in campsites) {
clusterCollection.add(
Marker.fromCoords(
[Coordinates(latitude: c.latitude, longitude: c.longitude)]),
);
}

final clusterSettings = MarkerCollectionRenderSettings(
pointsGroupingZoomLevel: _clusterZoom,
buildPointsGroupConfig: true,
lowDensityPointsGroupImage: assets.lowPill,
mediumDensityPointsGroupImage: assets.mediumPill,
highDensityPointsGroupImage: assets.highPill,
lowDensityPointsGroupMaxCount: 200,
mediumDensityPointsGroupMaxCount: 4000,
labelGroupTextSize: 2.7,
labelingMode: const {
MarkerLabelingMode.groupLabelVisible,
MarkerLabelingMode.groupCenter,
},
);
// Count colour defaults to transparent — set it opaque, or the count is invisible.
clusterSettings.labelGroupTextColor = const Color(0xFFFFFFFF);
clusterSettings.imageSize = 5.8;
clusterSettings.image = assets.transparent; // loose singles invisible

controller.preferences.markers
.add(clusterCollection, settings: clusterSettings);

The detail layer (coloured pins)

The second collection carries one green/red pin per campsite through the optimised bulk addList(...) path. Its group images are transparent, so while clustered it shows nothing and the cluster layer's pills show instead; once the clusters split its pins appear.

final markers = <MarkerWithRenderSettings>[];
for (final c in campsites) {
markers.add(
MarkerWithRenderSettings(
MarkerJson(
coords: [Coordinates(latitude: c.latitude, longitude: c.longitude)],
name: c.markerName,
),
MarkerRenderSettings(
image: assets.pinFor(bookable: c.isBookable),
imageSize: 6.0,
labelingMode: const {MarkerLabelingMode.iconBottomCenter},
),
),
);
}

final detailSettings = MarkerCollectionRenderSettings(
pointsGroupingZoomLevel: _clusterZoom,
// Transparent group images → nothing shown while clustered.
lowDensityPointsGroupImage: assets.transparent,
mediumDensityPointsGroupImage: assets.transparent,
highDensityPointsGroupImage: assets.transparent,
labelGroupTextSize: 0,
labelingMode: const {MarkerLabelingMode.iconBottomCenter},
);

await controller.preferences.markers.addList(
list: markers,
settings: detailSettings,
name: '$_collectionName-detail',
);

As you zoom in, the clusters break apart: smaller count pills give way to the individual green (info-only) and red (bookable) pins.

Clusters breaking apart into pins

Handling taps

On touch, the cursor position is set (awaited) before reading the selection. A coordinateGroup match means a cluster was tapped — zoom in toward the tap to break it apart. Otherwise a single campsite was tapped — center on it and show its info sheet.

Future<void> _onTouch(Point<int> pos) async {
await controller.setCursorScreenPosition(pos);
final matches = controller.cursorSelectionMarkers();
if (matches.isEmpty) return;

final isCluster =
matches.any((m) => m.type == MarkerMatchType.coordinateGroup);
if (isCluster) {
final target = (controller.zoomLevel + 12).clamp(0, 90).toInt();
controller.centerOnCoordinates(
controller.transformScreenToWgs(pos),
zoomLevel: target,
animation: GemAnimation(type: AnimationType.linear, duration: 500),
);
return;
}

// Single campsite → decode metadata and show the info sheet.
final marker = matches.first.marker;
final info = Campsite.decodeMarkerName(marker.name);
// ... center on it and setState to show _InfoSheet
}
Coloured pins and info sheet