Skip to content
Aggregate Values in Clusters

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.

Use the Maptoolkit Connector. Create an interactive map with clustered points where each cluster shows the summed capacity of its points rather than a count, and clicking a cluster zooms in to expand it.

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.