Skip to content

Cluster

Clustering groups nearby points into a single bubble at low zoom levels. As users zoom in, clusters expand to reveal individual features. MapLibre handles clustering natively, with no JavaScript or server-side code needed.

200 random points around Paris. Clusters show a count and collapse/expand as you zoom. Tap one to zoom to the level where it splits.

Add sources and layers after the style loads

Every call below needs a loaded style. Run them from onStyleLoadedCallback, not from onMapCreated, and run them there again after a style change: a new style discards every source and layer you added. See Constraints and gotchas.

How clustering works

flowchart LR
    LOW["Low zoom<br/><small>points group into<br/>large clusters</small><br/>● 142  ● 38  ● 20"]
    MID["Mid zoom<br/><small>clusters split into<br/>smaller clusters</small><br/>● 45  ● 97  ● 12  ● 26"]
    HIGH["High zoom<br/><small>individual points<br/>appear</small><br/>· · · · ·  · · · · ·"]
    LOW -- "zoom in" --> MID -- "zoom in" --> HIGH

Clustering is a source-level feature, you enable it on GeojsonSourceProperties, then add separate layers for clusters and individual points.

Full setup

// 1. Add source with clustering enabled, passing the data inline.
await controller.addSource(
  'events',
  GeojsonSourceProperties(
    data: featureCollection,   // inline GeoJSON map, or a URL string
    cluster: true,
    clusterMaxZoom: 14,   // stop clustering above this zoom
    clusterRadius: 50,    // pixel radius to group into one cluster
  ),
);

// 2. Cluster circles: sized by point count
await controller.addCircleLayer(
  'events', 'cluster-circles',
  CircleLayerProperties(
    circleRadius: [
      Expressions.step,
      [Expressions.get, 'point_count'],
      18,        // base size
      10,  24,   // >= 10 points
      50,  32,   // >= 50 points
      100, 42,   // >= 100 points
    ],
    circleColor: [
      Expressions.step,
      [Expressions.get, 'point_count'],
      '#51bbd6',
      10,  '#f1f075',
      50,  '#f28cb1',
      100, '#E74C3C',
    ],
    circleOpacity: 0.85,
    circleStrokeWidth: 2,
    circleStrokeColor: '#ffffff',
  ),
  filter: ['has', 'point_count'],  // only cluster features
);

// 3. Cluster count label
await controller.addSymbolLayer(
  'events', 'cluster-count',
  SymbolLayerProperties(
    textField: [Expressions.get, 'point_count_abbreviated'],
    textSize: 13,
    textColor: '#1a1a2e',
    textAllowOverlap: true,
  ),
  filter: ['has', 'point_count'],
);

// 4. Individual (unclustered) points
await controller.addCircleLayer(
  'events', 'unclustered-point',
  const CircleLayerProperties(
    circleRadius: 5,
    circleColor: '#296CA8',
    circleStrokeWidth: 1.5,
    circleStrokeColor: '#ffffff',
  ),
  filter: ['!', ['has', 'point_count']],  // only non-cluster features
);

Cluster properties on GeoJSON source

Property Default Description
cluster false Enable clustering
clusterMaxZoom one less than maxzoom (17 by default) Zoom at which clustering stops
clusterRadius 50 Pixel radius to group points

Cluster feature properties

When clustering is enabled, cluster features have extra properties:

Property Description
point_count Number of points in the cluster
point_count_abbreviated Abbreviated count: "142", "1.2k"
cluster_id Internal cluster ID
cluster Always true for cluster features

Inspect a cluster

Three calls read a cluster back from the source, all keyed on the cluster_id property of the cluster feature. They work on Android, iOS and web.

// Tap a cluster: zoom to exactly where it splits, instead of guessing zoom + 2.
Future<void> onTap(math.Point<double> point, LatLng coordinates) async {
  final features = await controller.queryRenderedFeatures(
    point, ['cluster-circles'], null,
  );
  if (features.isEmpty) return;

  final feature = features.first as Map;
  final properties = feature['properties'] as Map?;
  // A num: an int from the native channels, a double from the JS interop on web.
  final clusterId = (properties?['cluster_id'] as num?)?.toInt();
  if (clusterId == null) return;

  final zoom = await controller.getClusterExpansionZoom('events', clusterId);
  // Centre on the cluster rather than on the tap: on a large bubble the two
  // are far enough apart to leave the split half off screen.
  final coords = (feature['geometry'] as Map)['coordinates'] as List;
  await controller.animateCamera(
    CameraUpdate.newLatLngZoom(
      LatLng((coords[1] as num).toDouble(), (coords[0] as num).toDouble()),
      zoom.toDouble(),
    ),
  );
}

Wiring that up needs one extra option. Layers are interactive by default, so a tap that lands on a cluster circle is reported as a feature tap, and onMapClick is not called at all: the handler above would never run.

MapLibreMap(
  featureTapsTriggersMapClick: true, // otherwise cluster taps never reach onMapClick
  onMapClick: onTap,
  // ...
)
// The original points behind the bubble, paginated.
final pointCount = (properties['point_count'] as num).toInt();
final leaves = await controller.getClusterLeaves(
  'events', clusterId, limit: pointCount,
);

// The next zoom level's clusters and points. A child may itself be a cluster.
final children = await controller.getClusterChildren('events', clusterId);
Method Returns
getClusterExpansionZoom(sourceId, clusterId) The zoom at which the cluster splits
getClusterLeaves(sourceId, clusterId, {limit, offset}) The cluster's original points, as GeoJSON features
getClusterChildren(sourceId, clusterId) The cluster's immediate children, as GeoJSON features

For a GeoJSON source that is not clustered, or a clusterId that is not one of its current clusters, every platform answers 0 or an empty list. An unknown source id, or one that is not a GeoJSON source, raises a PlatformException instead.

Filters for cluster vs. point layers

// Show only cluster bubbles
filter: ['has', 'point_count']

// Show only individual points
filter: ['!', ['has', 'point_count']]

Always add these filters. Without them, both layers apply to all features.

Update cluster data

// Refresh cluster data (e.g., after fetching from API)
await controller.setGeoJsonSource('events', newFeatureCollection);
// Clusters recalculate automatically

Key APIs