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 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:

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:

dart
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 countColorRadius
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:

dart
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 ​


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.