Create and Style Clusters in Maptoolkit Maps JS
GeoJSON clustering in Maptoolkit Maps JS is enabled by setting cluster: true on the source, which automatically groups nearby points into cluster features with a point_count property. You then add separate layers for clusters, cluster count labels, and individual unclustered points to handle each case visually. Use clustering whenever you have a large number of point features that would overlap at lower zoom levels, such as locations, events, or search results.
const API_KEY = 'YOUR_API_KEY';
const map = new maptoolkit.Map({
container: 'map',
apiKey: API_KEY,
style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
center: [-103.59179687498357, 40.66995747013945],
zoom: 3,
attributionControl: { compact: false }
});
map.addControl(new maptoolkit.NavigationControl(), 'top-right');
map.on('load', () => {
map.addSource('earthquakes', {
type: 'geojson',
data: 'https://maplibre.org/maplibre-gl-js/docs/assets/earthquakes.geojson',
cluster: true,
clusterMaxZoom: 14, // Max zoom to cluster points on
clusterRadius: 50 // Radius of each cluster when clustering points (defaults to 50)
});
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'earthquakes',
filter: ['has', 'point_count'],
paint: {
// Use step expressions (https://maplibre.org/maplibre-gl-js/docs/overview/expressions/#step)
// with three steps to implement three types of circles:
// * Blue, 20px circles when point count is less than 100
// * Yellow, 30px circles when point count is between 100 and 750
// * Pink, 40px circles when point count is greater than or equal to 750
'circle-color': [
'step', ['get', 'point_count'],
'#51bbd6', 100,
'#f1f075', 750,
'#f28cb1'
],
'circle-radius': [
'step', ['get', 'point_count'],
20, 100,
30, 750,
40
]
}
});
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: 'earthquakes',
filter: ['has', 'point_count'],
layout: {
'text-field': '{point_count_abbreviated}',
'text-size': 12
}
});
map.addLayer({
id: 'unclustered-point',
type: 'circle',
source: 'earthquakes',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': '#11b4da',
'circle-radius': 4,
'circle-stroke-width': 1,
'circle-stroke-color': '#fff'
}
});
// Inspect a cluster on click
map.on('click', 'clusters', async (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: ['clusters'] });
const clusterId = features[0].properties.cluster_id;
const zoom = await map.getSource('earthquakes').getClusterExpansionZoom(clusterId);
map.easeTo({ center: features[0].geometry.coordinates, zoom });
});
map.on('mouseenter', 'clusters', () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', 'clusters', () => { map.getCanvas().style.cursor = ''; });
});<!DOCTYPE html>
<html lang="en">
<head>
<title>Create and Style Clusters - Maptoolkit Maps JS</title>
<meta property="og:description" content="Cluster nearby points using GeoJSON clustering and style by cluster size." />
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.3/dist/maptoolkit.js"></script>
<link rel="stylesheet" href="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.3/dist/maptoolkit.css" />
<style>
html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
#map { width: 100%; height: 100%; }
</style>
</head>
<body>
<div id="map"></div>
<script>
const API_KEY = 'YOUR_API_KEY';
const map = new maptoolkit.Map({
container: 'map',
apiKey: API_KEY,
style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
center: [-103.59179687498357, 40.66995747013945],
zoom: 3,
attributionControl: { compact: false }
});
map.addControl(new maptoolkit.NavigationControl(), 'top-right');
map.on('load', () => {
map.addSource('earthquakes', {
type: 'geojson',
data: 'https://maplibre.org/maplibre-gl-js/docs/assets/earthquakes.geojson',
cluster: true,
clusterMaxZoom: 14, // Max zoom to cluster points on
clusterRadius: 50 // Radius of each cluster when clustering points (defaults to 50)
});
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'earthquakes',
filter: ['has', 'point_count'],
paint: {
// Use step expressions (https://maplibre.org/maplibre-gl-js/docs/overview/expressions/#step)
// with three steps to implement three types of circles:
// * Blue, 20px circles when point count is less than 100
// * Yellow, 30px circles when point count is between 100 and 750
// * Pink, 40px circles when point count is greater than or equal to 750
'circle-color': [
'step', ['get', 'point_count'],
'#51bbd6', 100,
'#f1f075', 750,
'#f28cb1'
],
'circle-radius': [
'step', ['get', 'point_count'],
20, 100,
30, 750,
40
]
}
});
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: 'earthquakes',
filter: ['has', 'point_count'],
layout: {
'text-field': '{point_count_abbreviated}',
'text-size': 12
}
});
map.addLayer({
id: 'unclustered-point',
type: 'circle',
source: 'earthquakes',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': '#11b4da',
'circle-radius': 4,
'circle-stroke-width': 1,
'circle-stroke-color': '#fff'
}
});
// Inspect a cluster on click
map.on('click', 'clusters', async (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: ['clusters'] });
const clusterId = features[0].properties.cluster_id;
const zoom = await map.getSource('earthquakes').getClusterExpansionZoom(clusterId);
map.easeTo({ center: features[0].geometry.coordinates, zoom });
});
map.on('mouseenter', 'clusters', () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', 'clusters', () => { map.getCanvas().style.cursor = ''; });
});
</script>
</body>
</html>Use the prompt below with any LLM to get the same result. Make sure the Maptoolkit MCP server is connected first — check out AI Integration & MCP to get started.
How it works
cluster: true on the source does the grouping. The renderer replaces nearby points with
cluster features carrying a point_count property, and you style those separately from the
unclustered points.
That is why this needs three layers over one source: one for clusters, one for the cluster
count label, and one for points that did not get clustered. Each uses a filter on
has('point_count') to select its half of the data.
clusterRadius is in pixels, not meters, so clustering is a screen-space decision and
changes as you zoom. clusterMaxZoom is where clustering stops and individual points appear.
getClusterExpansionZoom is what makes a cluster clickable: it returns the zoom at which that
cluster splits, so clicking can zoom exactly far enough to break it open.
Clustering only applies to GeoJSON sources. Vector tile sources cannot be clustered client-side, because the data arrives already tiled.
Next steps
Clicking a cluster to zoom into it is the interaction people expect, and it is the difference between clustering as a rendering trick and clustering as navigation.
Cluster properties are the step after: summing or averaging a value across the points in each group so the circle shows a total rather than a count. That is where clustering starts answering questions instead of only reducing clutter.