Aggregate Values in Clusters in Maptoolkit Maps JS
A cluster labelled with a point count reduces clutter and answers nothing. A cluster labelled with the total revenue, the combined capacity or the average rating answers a question before anyone clicks. clusterProperties computes that in the source, and clicking a cluster zooms to the level where it breaks apart.
const API_KEY = 'YOUR_API_KEY';
// Stand-in for your own data: each point carries a capacity.
const DATA = {
type: 'FeatureCollection',
features: Array.from({ length: 400 }, (_, i) => ({
type: 'Feature',
properties: { capacity: 10 + ((i * 37) % 190) },
geometry: {
type: 'Point',
coordinates: [
11.2 + ((i * 7919) % 1000) / 1000 * 0.9,
47.15 + ((i * 6271) % 1000) / 1000 * 0.35
]
}
}))
};
const map = new maptoolkit.Map({
container: 'map',
apiKey: API_KEY,
style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
center: [11.65, 47.32],
zoom: 8.5,
attributionControl: { compact: false }
});
map.addControl(new maptoolkit.NavigationControl(), 'top-right');
map.on('load', () => {
map.addSource('sites', {
type: 'geojson',
data: DATA,
cluster: true,
clusterRadius: 60,
clusterMaxZoom: 13,
// Aggregations run in the clustering worker, one entry per derived property.
clusterProperties: {
// [operator, input]. The accumulator is the running total.
total: ['+', ['get', 'capacity']],
largest: ['max', ['get', 'capacity']]
}
});
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'sites',
filter: ['has', 'point_count'],
paint: {
'circle-color': ['step', ['get', 'total'], '#74c476', 2000, '#fd8d3c', 6000, '#e6550d'],
'circle-radius': ['interpolate', ['linear'], ['get', 'total'], 0, 16, 12000, 40],
'circle-opacity': 0.85,
'circle-stroke-color': '#fff',
'circle-stroke-width': 2
}
});
map.addLayer({
id: 'cluster-label',
type: 'symbol',
source: 'sites',
filter: ['has', 'point_count'],
layout: {
// Round for display. The aggregate is a number, not a string.
'text-field': ['number-format', ['get', 'total'], { 'max-fraction-digits': 0 }],
'text-font': ['Roboto Regular'],
'text-size': 12
},
paint: { 'text-color': '#fff' }
});
map.addLayer({
id: 'unclustered',
type: 'circle',
source: 'sites',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-radius': ['interpolate', ['linear'], ['get', 'capacity'], 10, 4, 200, 12],
'circle-color': '#31a354',
'circle-stroke-color': '#fff',
'circle-stroke-width': 1.5
}
});
map.on('click', 'clusters', (e) => {
const cluster = e.features[0];
map.getSource('sites')
.getClusterExpansionZoom(cluster.properties.cluster_id)
.then((zoom) => {
map.easeTo({ center: cluster.geometry.coordinates, zoom });
});
});
for (const id of ['clusters', 'unclustered']) {
map.on('mouseenter', id, () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', id, () => { map.getCanvas().style.cursor = ''; });
}
map.on('click', 'unclustered', (e) => {
new maptoolkit.Popup()
.setLngLat(e.features[0].geometry.coordinates)
.setHTML(`Capacity: ${e.features[0].properties.capacity}`)
.addTo(map);
});
});<!DOCTYPE html>
<html lang="en">
<head>
<title>Aggregate Values in Clusters - Maptoolkit Maps JS</title>
<meta property="og:description" content="Sum a property across clustered points." />
<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';
// Stand-in for your own data: each point carries a capacity.
const DATA = {
type: 'FeatureCollection',
features: Array.from({ length: 400 }, (_, i) => ({
type: 'Feature',
properties: { capacity: 10 + ((i * 37) % 190) },
geometry: {
type: 'Point',
coordinates: [
11.2 + ((i * 7919) % 1000) / 1000 * 0.9,
47.15 + ((i * 6271) % 1000) / 1000 * 0.35
]
}
}))
};
const map = new maptoolkit.Map({
container: 'map',
apiKey: API_KEY,
style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
center: [11.65, 47.32],
zoom: 8.5,
attributionControl: { compact: false }
});
map.addControl(new maptoolkit.NavigationControl(), 'top-right');
map.on('load', () => {
map.addSource('sites', {
type: 'geojson',
data: DATA,
cluster: true,
clusterRadius: 60,
clusterMaxZoom: 13,
// Aggregations run in the clustering worker, one entry per derived property.
clusterProperties: {
// [operator, input]. The accumulator is the running total.
total: ['+', ['get', 'capacity']],
largest: ['max', ['get', 'capacity']]
}
});
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'sites',
filter: ['has', 'point_count'],
paint: {
'circle-color': ['step', ['get', 'total'], '#74c476', 2000, '#fd8d3c', 6000, '#e6550d'],
'circle-radius': ['interpolate', ['linear'], ['get', 'total'], 0, 16, 12000, 40],
'circle-opacity': 0.85,
'circle-stroke-color': '#fff',
'circle-stroke-width': 2
}
});
map.addLayer({
id: 'cluster-label',
type: 'symbol',
source: 'sites',
filter: ['has', 'point_count'],
layout: {
// Round for display. The aggregate is a number, not a string.
'text-field': ['number-format', ['get', 'total'], { 'max-fraction-digits': 0 }],
'text-font': ['Roboto Regular'],
'text-size': 12
},
paint: { 'text-color': '#fff' }
});
map.addLayer({
id: 'unclustered',
type: 'circle',
source: 'sites',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-radius': ['interpolate', ['linear'], ['get', 'capacity'], 10, 4, 200, 12],
'circle-color': '#31a354',
'circle-stroke-color': '#fff',
'circle-stroke-width': 1.5
}
});
map.on('click', 'clusters', (e) => {
const cluster = e.features[0];
map.getSource('sites')
.getClusterExpansionZoom(cluster.properties.cluster_id)
.then((zoom) => {
map.easeTo({ center: cluster.geometry.coordinates, zoom });
});
});
for (const id of ['clusters', 'unclustered']) {
map.on('mouseenter', id, () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', id, () => { map.getCanvas().style.cursor = ''; });
}
map.on('click', 'unclustered', (e) => {
new maptoolkit.Popup()
.setLngLat(e.features[0].geometry.coordinates)
.setHTML(`Capacity: ${e.features[0].properties.capacity}`)
.addTo(map);
});
});
</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
clusterProperties is where the aggregation happens, and it runs in the clustering worker
rather than in your code, so it costs nothing per frame. Each entry is
name: [operator, input], where the operator is an expression applied pairwise as points are
merged. ['+', ['get', 'capacity']] sums, ['max', …] keeps the largest.
An average needs two entries and a division at draw time, because there is no mean operator:
sum the value, sum a constant 1 for the count, then divide the two in the text-field
expression. Trying to average pairwise gives the wrong answer, since the mean of means is not
the mean.
Aggregates only exist on cluster features. Unclustered points carry their original
properties and nothing else, which is why every layer here is filtered on ['has', 'point_count'] or its negation. A layer that reads total without that filter renders
nothing for the individual points and reports no error.
getClusterExpansionZoom returns a promise in current MapLibre, not a callback. Older
examples pass a callback as a second argument and simply never fire, which looks like the
click handler is broken. It answers the question “at what zoom does this cluster split”,
which is the only sensible target for a click: guessing zoom + 2 either overshoots or
leaves the cluster intact.
number-format formats the aggregate for display. Without it, a summed float renders with
its full decimal tail inside the circle.
clusterMaxZoom is the zoom above which nothing clusters. Setting it close to the map’s max
zoom keeps points merged until they are genuinely distinguishable; setting it too low
produces a field of overlapping single points.
Next steps
The aggregate is more useful when it is chooseable. Letting the reader switch between total
capacity, count and maximum is one setPaintProperty call plus a text-field swap, because
all three aggregates can be declared in clusterProperties at once and selected at draw
time.
Colouring clusters by their aggregate rather than their count is what
a legend then has to explain, and
the step stops in the paint expression are exactly the rows it needs.