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.