MapSnippets logoMapSnippets logo

Point Clustering with MapLibre GL JS

MapLibre GL JS

Group nearby points into clusters using the built-in GeoJSON source clustering in MapLibre GL JS -- no plugins needed.

progress_activityLoading map...
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://api.maptiler.com/maps/streets-v4/style.json?key=YOUR_MAPTILER_KEY',
  center: [15.5, 49.8],
  zoom: 6
});
const pts = Array.from({ length: 300 }, () => ({
  type: 'Feature',
  geometry: { type: 'Point', coordinates: [12 + Math.random() * 7, 48.5 + Math.random() * 2.5] }
}));
map.on('load', () => {
  map.addSource('pts', { type: 'geojson', data: { type: 'FeatureCollection', features: pts }, cluster: true, clusterMaxZoom: 14, clusterRadius: 50 });
  map.addLayer({ id: 'clusters', type: 'circle', source: 'pts', filter: ['has', 'point_count'],
    paint: { 'circle-color': ['step', ['get', 'point_count'], '#51bbd6', 50, '#f1f075', 200, '#f28cb1'], 'circle-radius': ['step', ['get', 'point_count'], 18, 50, 25, 200, 35] } });
  map.addLayer({ id: 'cluster-count', type: 'symbol', source: 'pts', filter: ['has', 'point_count'],
    layout: { 'text-field': '{point_count_abbreviated}', 'text-font': ['Noto Sans Regular'], 'text-size': 12 } });
  map.addLayer({ id: 'single', type: 'circle', source: 'pts', filter: ['!', ['has', 'point_count']],
    paint: { 'circle-color': '#11b4da', 'circle-radius': 6, 'circle-stroke-width': 1, 'circle-stroke-color': '#fff' } });
  map.on('click', 'clusters', (e) => {
    const cid = e.features[0].properties.cluster_id;
    map.getSource('pts').getClusterExpansionZoom(cid, (err, z) => { if (!err) map.easeTo({ center: e.features[0].geometry.coordinates, zoom: z }); });
  });
});

How it works

MapLibre has built-in clustering on GeoJSON sources: no plugins required. Set cluster: true on the source along with clusterMaxZoom (the zoom level beyond which points are not clustered) and clusterRadius (the pixel radius for grouping points).

The source automatically adds point_count and point_count_abbreviated properties to cluster features. Use filter: ['has', 'point_count'] to style clusters separately from individual points.

Clicking a cluster calls getClusterExpansionZoom() to find the zoom level that expands that cluster, then eases the map there.

Key APIs

API Description Docs
cluster: true Enable GeoJSON source clustering maplibre.org docs
clusterMaxZoom Max zoom for clustering maplibre.org docs
getClusterExpansionZoom() Get zoom to expand a cluster maplibre.org docs

FAQ

Can I customize cluster aggregation?

Yes. Use clusterProperties on the source to define custom aggregated values, like sum or average of a property across clustered points.

What happens when two points overlap at max zoom?

Beyond clusterMaxZoom, all points render individually even if they overlap. Adjust the value based on your data density.

Common mistakes

  • Forgetting cluster: true on the source: points render individually without grouping
  • Using DOM markers instead of circle layers for clustered data: defeats the purpose of GPU-rendered clustering
  • Not filtering layers by point_count: clusters and single points render on the same layer
auto_awesome

Build maps using MapLibre GL JS with AI

Generate code like this point clustering with maplibre gl js example using the MapLibre GL JS skill in your IDE.