# Marker Clustering

Last updated:

<!-- -->

October 18, 2026

<!-- -->

\| 5 minutes read

<br />

<br />

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[​](#dependencies "Direct link to Dependencies")

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

```yaml
dependencies:
  magiclane_maps_flutter: ^3.1.10

```

## Saving Assets[​](#saving-assets "Direct link to 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:

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

```

## How it works[​](#how-it-works "Direct link to 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](/docs/flutter/assets/images/example_flutter_marker_clustering1-847fa2c39914320e34893733b184fcd9.png)

**Clustered markers**

### UI and Map Integration[​](#ui-and-map-integration "Direct link to 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.

main.dart[View on Github](https://github.com/magiclane/magiclane-maps-sdk-examples-for-flutter/tree/main/marker_clustering/lib/main.dart?ref_type=heads#L37)

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

  @override
  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[​](#loading-and-parsing-the-geojson "Direct link to 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.dart[View on Github](https://github.com/magiclane/magiclane-maps-sdk-examples-for-flutter/tree/main/marker_clustering/lib/campsite.dart?ref_type=heads#L74)

```dart
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-cluster-layer-count-pill-bubbles "Direct link to 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.

main.dart[View on Github](https://github.com/magiclane/magiclane-maps-sdk-examples-for-flutter/tree/main/marker_clustering/lib/main.dart?ref_type=heads#L146)

```dart
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-detail-layer-coloured-pins "Direct link to 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.

main.dart[View on Github](https://github.com/magiclane/magiclane-maps-sdk-examples-for-flutter/tree/main/marker_clustering/lib/main.dart?ref_type=heads#L195)

```dart
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](/docs/flutter/assets/images/example_flutter_marker_clustering3-7ca79b348910ba70993e5ee4b302bf32.png)

**Clusters breaking apart into pins**

### Handling taps[​](#handling-taps "Direct link to 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.

main.dart[View on Github](https://github.com/magiclane/magiclane-maps-sdk-examples-for-flutter/tree/main/marker_clustering/lib/main.dart?ref_type=heads#L242)

```dart
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](/docs/flutter/assets/images/example_flutter_marker_clustering2-29474290a95b961c8905e06cf1c4ba13.png)

**Coloured pins and info sheet**
