Lesson 2 — Clustering and spiderfy
Lesson 1 ended with the manager’s national map and the Sydney dispatcher’s regional map both unreadable for the same reason: too many features at the same place. Job circles bled together into amorphous masses. FO trucks stacked on top of each other. The popups still worked on individual features, but visually the map had stopped communicating.
This lesson introduces clustering — MapLibre’s built-in mechanism for aggregating dense point sets into bubbles labelled by count, with bubbles splitting apart as the user zooms in until individual features become visible again. Same data, very different rendering.
Plus spiderfy for the edge case where points won’t split: when multiple FOs share the exact same coordinates (because we fabricated them as SA centroids), even maximum zoom won’t separate them. Spiderfy fans coincident points out into a spiral pattern on click.
How clustering works
MapLibre’s clustering is a property of the source, not the
layer. When you declare a source as clustered, MapLibre computes
clusters automatically based on viewport zoom — features close
to each other on screen get aggregated into a synthetic
“cluster” feature with a point_count property. As the user
zooms in, the clustering re-runs at the new zoom level: clusters
that were tight at zoom 5 might split into multiple smaller
clusters at zoom 8, and into individual features at zoom 12.
This is computed client-side by the MapLibre runtime. The server doesn’t change anything — it still ships the same GeoJSON. The aggregation happens in the browser, dynamically, based on what’s currently visible.
Practical implications:
- The source becomes “either a feature or a cluster” — layers need to handle both cases
- Clusters have synthetic properties (
point_count,cluster_id) that real features don’t - Clicking a cluster typically zooms in until it splits
- Style each layer to filter on
cluster(true for clusters, unset for individuals)
Activating clustering on the source
In Vera, clustering is a single keyword on m.source:
|
|
That’s it. With cluster: true, MapLibre treats the source’s
features as candidates for aggregation. A handful of additional
options tune the behaviour:
|
|
cluster_radius (default 50) — the pixel radius within which
points merge into a cluster. Larger means more aggressive
clustering; smaller means clusters split sooner.
cluster_max_zoom (default 14) — the zoom level above which
clustering stops entirely. At zoom 15 and beyond, every feature
renders individually regardless of density.
40 and 14 are reasonable defaults for our maps. Adjust if the cluster bubbles feel too aggressive or too sparse.
Layers for clustered sources
A clustered source typically needs three layers:
- The cluster bubbles themselves — circles styled by count
- The cluster count labels — text showing the number
- The unclustered (individual) features — rendered when
zoom is past
cluster_max_zoomor when a feature has no neighbours
Each layer uses MapLibre’s filter to render only the
appropriate subset. Clusters have a cluster: true property;
individual features don’t have that property at all (so the
filter ["!", ["has", "point_count"]] matches them).
In OperationsMap, replace the existing add_jobs method with
a clustered version:
|
|
A few things to walk through.
The ["step", ...] paint expression is MapLibre’s stepped-
threshold function. It reads “for each cluster, get its
point_count. If less than 50, return #a3e635. If 50-199,
return #84cc16. If 200-499, return #65a30d. Otherwise
return #4d7c0f.” Same pattern for circle_radius — clusters
grow as their count grows, so a 500-job cluster is visually
much larger than a 20-job cluster.
The colour range here is a green progression — light for small clusters, dark for large. Pick whatever ramp matches your visual language; the dispatch deck stays in the lime/emerald family because it’s distinct from the amber/blue/red used for job statuses (which a reader might confuse with a cluster otherwise).
filter: ["has", "point_count"] restricts the bubble layer
to clusters only. point_count is the auto-generated property
MapLibre adds to cluster features; individual features don’t
have it.
filter: ["!", ["has", "point_count"]] is the inverse —
“render this feature only if it doesn’t have point_count.”
Individual features pass through unchanged; clusters get
filtered out.
text_field: ["get", "point_count_abbreviated"] —
MapLibre auto-generates a human-friendly version of the count
(“1.2K” instead of “1234”). Good for cluster labels at any
scale.
cluster: true on the cluster layer is Vera’s affordance
for “clicks on this layer should zoom in to expand the cluster.”
The Stimulus controller wires up the click handler that calls
MapLibre’s getClusterExpansionZoom and animates the zoom.
The on_hover pattern is preserved across all three layers — clusters grow slightly on hover, individual circles grow slightly on hover. The visual feedback stays consistent.
Same treatment for field officers
The FO layer suffers from the same density problem. Apply the same clustering pattern:
|
|
The cluster colour ramp is amber (matching FO state colours loosely) so cluster bubbles don’t get confused with the green job clusters. Different feature category, different cluster visual identity.
The thresholds are smaller too (20, 100 vs 50, 200, 500) — there are fewer FOs than jobs, so cluster sizes top out lower. Tune to your data.
Look what we just did
Refresh the dispatcher’s dashboard for Sydney. The map looks fundamentally different.
At low zoom, the metro core shows three or four large green bubbles labelled “847”, “623”, “412” — clusters of jobs at different parts of the city. Amber bubbles for FOs cluster similarly, sized to count. The map reads cleanly.
Zoom in. Bubbles split into smaller bubbles. Zoom in more. Smaller bubbles split further. Eventually individual job circles and FO trucks emerge. The data was always there; the rendering adapts.
Switch to the manager’s dashboard. Same effect at national scale — the Sydney mass becomes one massive cluster, Melbourne another, Brisbane another, and the rest of the country shows individual SAs because the density is below the cluster threshold.
Click any cluster. The map animates a zoom-in until that
cluster expands into smaller clusters or individual features.
Vera wires this automatically because we declared cluster: true on the layer.
Spiderfy — for points that won’t split
There’s an edge case clustering doesn’t solve: features at identical coordinates. When two or more features have exactly the same lat/lng, no amount of zoom will separate them. The cluster contains them; clicking the cluster zooms in; the cluster contains them; nothing changes.
In our data this used to happen with FOs because they all sat at their SA’s centroid. We’ve added jitter at the service layer (small random offset) so this is rare, but not impossible — two FOs in the same SA could randomly land on near-identical points.
The pattern for handling this is spiderfy: when a cluster’s constituent points are too close to separate by zooming, fan them out in a spiral pattern visually so the user can click each one individually.
Vera supports it on the source declaration:
|
|
spiderfy: true enables spiderfy with sensible defaults. Or
pass options:
|
|
min_zoom_level — only spiderfy clusters at zoom 12 or above
(below that, normal cluster expansion still works). Prevents
tiny zoomed-out clusters from spiraling unnecessarily.
close_on_leaf_click — when the user clicks a spiderfied leaf
(an individual feature), close the spider. Useful when each
leaf opens a popup; the spider tidies itself up.
Spiderfy uses the @nazka/map-gl-js-spiderfy plugin under the
hood. Vera dynamically imports it the first time a spiderfy-
enabled map mounts, so pages without spiderfy don’t pay the
~30KB plugin cost.
For the dispatch deck’s FO source, enabling spiderfy is worthwhile: the centroid+jitter approach guarantees most clusters split via zoom, but the corner case of FOs in tiny or simple SAs (where the centroid is the only “anchor” position) might leave residual coincident points. Spiderfy handles them.
Update the FO source
Apply spiderfy to the FO source in OperationsMap:
|
|
Skip it on the jobs source — jobs are at customer locations, which are real geocoded addresses. They don’t have the synthetic-position problem; clustering alone handles their density.
Look what we just did, again
Refresh. Find a cluster of FOs at maximum zoom that doesn’t fully split. Click it. The constituent FOs fan out in a spiral pattern around the cluster’s anchor position, each truck icon clickable individually. Click an empty area; the spider collapses.
This is rare in practice because of the position jitter, but when it happens, the user has a way through. The map never hits a dead end.
A note on cluster behaviour at the manager scale
The manager’s national map demonstrates clustering at its most useful. ~14k active jobs across Australia would be unreadable as individual circles. As clusters, they become a small number of bubbles per metro area — Sydney, Melbourne, Brisbane, Perth — each labelled with its job count. The reader can see operational scale at a glance.
Hover any of these bubbles and the count is the number of features it represents. Click and the zoom level adjusts to reveal smaller clusters within. Each “drill down” is one click plus a smooth zoom animation.
The same dataset that was unreadable at the start of Lesson 1 is now legible. No new data, no new endpoints, no new service objects — just a different rendering technique.
Where this leaves us
The visualisation problem from Lesson 1 is solved for both the job and FO layers, on both the dispatcher’s regional map and the manager’s national map. Same code, different render.
A few things this introduced:
- Source-level clustering —
cluster: trueon the source declaration aggregates points dynamically by zoom - Three-layer pattern for clustered sources — bubbles + count labels + individual features, each filtered appropriately
["step", ...]paint expressions — discrete-threshold styling, useful beyond clustering for any “value falls in a range” colouring- Spiderfy — fan coincident points out into a spiral when zoom can’t separate them
cluster: trueon the layer — Vera’s affordance for “clicks on cluster bubbles zoom in”
The next lesson uses this dataset to write the first real
spatial join. Aggregating jobs by SA via ST_Contains will be
the first time we ask the database to do geometric work in a
user-facing query — and the first time we’ll feel what
happens when the supporting indexes aren’t there.