Marker Cluster Stability
Marker clustering is recomputed on every frame from the current viewport bounds, zoom level, device pixel ratio, and the projection mathematics that map geographic coordinates to screen pixels. Change any one of those inputs by a fraction and the aggregation tree re-balances: a two-point cluster splits, a centroid drifts a few pixels, a count badge ticks from 12 to 11. A pixel comparator sees every one of those shifts as a regression, so a clustered map floods the report with false positives unless the clustering inputs are pinned to fixed values. This page is about making that aggregation step reproducible—turning cluster configuration into a versioned test artifact so that a diff over a clustered layer means a genuine change in the data or the symbology, not a re-run of a non-deterministic algorithm.
This page extends the concepts in Dynamic Element Masking & UI Stability: where the parent reference establishes the contract that separates deterministic cartographic rendering from non-deterministic UI, clustered markers sit in an awkward middle ground. The aggregation is logically deterministic—the same points at the same zoom always cluster the same way—but it is operationally fragile because its inputs are derived from the live viewport. The job here is to lock those inputs so the deterministic side of the contract actually holds.
What Cluster Stability Means
A marker cluster is the output of a spatial aggregation function that groups nearby point features into a single representative node once they fall within a pixel-space radius at a given zoom. The canonical client-side implementation, Supercluster, builds a hierarchical index: points are projected into the Web Mercator unit square, indexed in a flat KD-tree (KDBush), and greedily merged level by level from maxZoom down to minZoom. Querying the index for a viewport returns the clusters visible at that zoom, each with a generated cluster_id, a point_count, and a centroid computed as the weighted mean of its children.
Stability means that for a fixed input set and fixed parameters, every query returns byte-identical cluster geometry, counts, and IDs across runs, machines, and library versions. Three properties have to hold:
- Determinism of aggregation. Same points, same
radius/maxZoom/minPoints→ same cluster membership. Supercluster is deterministic by construction, but only if you do not feed it floating coordinate jitter or a re-ordered feature array, because tie-breaking on equal-distance merges can depend on insertion order. - Determinism of placement. A marker cluster’s screen position is
project(centroid)evaluated against a fixed viewport. The centroid is data-deterministic; the projection is only deterministic if the camera (center, zoom, bearing, pitch) and the device pixel ratio are frozen. - Determinism of decoration. The count badge, the marker-cluster icon, and any spiderfied expansion must render with a pinned font stack and disabled animation, or the badge text and halo anti-aliasing drift between engines.
The input data format matters because coordinate precision is a determinism input. Feeding fixtures that conform to the GeoJSON specification, RFC 7946, guarantees consistent topology parsing and a defined coordinate axis order across runners, so a fixture does not silently re-project differently on one engine.
Architecture: Clustering as a Versioned Test Artifact
The structural fix is to stop treating clustering parameters as runtime configuration inherited from a responsive app and start treating them as a manifest checked into the repository next to the map style and the GeoJSON fixtures. Production code may tune radius by breakpoint or device; the test build must override all of that with a single frozen profile so the aggregation tree is identical regardless of where the suite runs.
# cluster-fixture.yml — versioned next to the GeoJSON fixtures
fixture: city_incidents_2k
geojson: fixtures/city-incidents.geojson # RFC 7946, fixed feature order
cluster_profile:
radius: 60 # pixel merge radius — never inherited from responsive config
maxZoom: 16 # last zoom at which points still cluster
minZoom: 0
minPoints: 2
extent: 512 # tile extent; must match the renderer's tile size
camera:
center: [-73.9857, 40.7484]
zoom: 11
bearing: 0
pitch: 0
device_scale_factor: 1
The fixture drives a single pre-snapshot bootstrap rather than per-test setup. It loads the GeoJSON in a fixed order, constructs the index from the locked profile, applies the camera, and only then signals readiness. Pinning extent to the renderer’s tile size is easy to miss and is a frequent source of off-by-a-few-pixel centroid drift, because Supercluster’s projection granularity is tied to it.
Two design rules keep the artifact durable. First, the fixture’s feature array order is part of the contract—sort it canonically (for example by feature id) and never regenerate it from an unordered source, so equal-distance merge tie-breaks resolve identically. Second, tag each committed baseline image with the commit hash of the marker-cluster profile, so a parameter change forces an explicit baseline review rather than silently invalidating every clustered snapshot.
Step-by-Step Implementation
The procedure below assumes a headless run that has already locked its viewport and device pixel ratio, covered under Viewport & Zoom Sync Strategies. The flow forks only on whether your engine clusters on the main thread (Leaflet.markercluster) or inside the GL style (MapLibre/Mapbox GL cluster: true).
-
Load the fixture in a fixed feature order. Read the committed GeoJSON, do not fetch it from a live API, and do not shuffle it. Insertion order is part of the determinism contract for tie-broken merges.
const fixture = JSON.parse( fs.readFileSync("fixtures/city-incidents.geojson", "utf8"), ); // Canonical order so equal-distance merges resolve identically. fixture.features.sort((a, b) => a.id - b.id); -
Construct the index from the locked profile. Pass the parameters from the manifest explicitly; never let the constructor fall back to library defaults that can change across minor versions.
const index = new Supercluster({ radius: 60, // fixed pixel radius — never inherit from responsive config maxZoom: 16, minZoom: 0, minPoints: 2, extent: 512, // must match the renderer's tile size }); index.load(fixture.features); -
Apply the frozen camera and disable interaction. Set center, zoom, bearing, and pitch from the manifest, then disable every interaction handler so no stray inertia or scroll event nudges the viewport mid-capture.
map.jumpTo({ center: [-73.9857, 40.7484], zoom: 11, bearing: 0, pitch: 0 }); ["scrollZoom", "boxZoom", "dragRotate", "dragPan", "keyboard", "doubleClickZoom", "touchZoomRotate"].forEach((h) => map[h].disable()); -
Wait for the terminal clustering event, not a fixed timeout. Main-thread cluster engines emit a settle event (
animationend/clusterEndin Leaflet.markercluster); GL engines fireidleonce the style and source are fully rendered. Anchor the capture to that event so you never snapshot a half-rebalanced tree. The general async-render gating these events plug into is detailed in Handling Async Tile Loading.await page.evaluate(() => new Promise((res) => map.once("idle", res))); -
Suppress cluster expansion and zoom animation. Spiderfy animations, smooth cluster-merge transitions, and zoom interpolation must be frozen so the captured frame is the final state, not an intermediate one. The CSS overrides and JavaScript hooks for this live in Animation & Transition Suppression.
await page.addStyleTag({ content: `.leaflet-cluster-anim .leaflet-marker-icon, .leaflet-cluster-anim .leaflet-marker-shadow { transition: none !important; }`, }); -
Mask the volatile decoration, keep the geometry. A marker-cluster count badge that renders one frame late, or a hover tooltip over a marker cluster, is decoration—not cartography. Exclude it with a bounded rule rather than relaxing tolerance over the whole layer. The rule structure for this comes from Interactive Overlay Masking Rules, and the per-zone tolerance it pairs with is set in Dynamic Threshold Configuration.
await page.screenshot({ path: "baseline/clusters-z11.png", animations: "disabled", mask: [page.locator(".cluster-count-badge.is-loading")], maskColor: "#101820", }); -
Assert cluster invariants before comparing pixels. A pixel diff tells you that something moved; a structural assertion over the index tells you what. Snapshot the marker-cluster count and total point count at the test zoom as a cheap, fast guard that fails before the slower image comparison runs.
const clusters = index.getClusters([-74.05, 40.68, -73.9, 40.82], 11); expect(clusters.length).toBe(7); const total = clusters.reduce((n, c) => n + (c.properties.point_count || 1), 0); expect(total).toBe(2000);
Cross-Browser & Cross-Environment Considerations
The aggregation math is engine-independent—the same KDBush merge runs identically in any JavaScript runtime—but everything downstream of the marker-cluster centroid diverges across browsers. Chromium, WebKit, and Gecko rasterize the marker-cluster icon’s circular halo, the count-badge text, and any drop shadow with their own anti-aliasing and font-hinting pipelines, so a marker cluster that is geometrically identical can still produce sub-pixel colour differences at its edges. Standardize the environment before trusting any clustered baseline:
- Lock the viewport to a fixed resolution (for example
1280×800) and pindeviceScaleFactor: 1. A fractional DPR re-projects centroids to non-integer pixels and shifts the entire cluster layer. - Force CPU rasterization with
--use-gl=swiftshader --disable-gpuand--force-device-scale-factor=1to remove GPU-path variance in halo and shadow compositing. - Inject a fixed
@font-facestack so the count badge renders the same glyph metrics on every OS—OS font substitution is the most common cause of a badge that diffs without any data change. - Run the suite inside a single container image so the GL backend, font packages, and locale are byte-identical across CI runners and developer machines.
A marker-cluster layer that matches in CI but not locally is almost always a DPR or font-substitution mismatch, not a data change—reproduce it by running the same container locally before touching the fixture. The broader artifact-suppression strategy these flags feed into is covered in Noise Reduction for Map Artifacts, and the standardized automation contract they depend on is the W3C WebDriver Specification. When a deployment changes which icon assets the marker-cluster engine loads, pair this with Cache & CDN Invalidation Testing so a stale marker-cluster sprite does not masquerade as a regression.
Centroid drift tolerance
Even with the camera frozen, floating-point projection can land a centroid a fraction of a pixel apart between engines. Treat a marker cluster as positionally stable only when its rendered center stays within a small radius of the baseline center—formally, for baseline center
A tolerance of 1.5px absorbs sub-pixel projection rounding without admitting a genuine one-marker membership change, which moves a small cluster’s centroid by far more than a pixel. Gate the surviving image region with this geometric check before the pixel comparator runs.
Threshold & Parameter Reference
Pair a locked aggregation profile with region-specific comparison tolerance. The marker-cluster geometry is gated strictly; only the volatile decoration is relaxed or excluded.
| Parameter | Role in determinism | Recommended test value |
|---|---|---|
radius |
Pixel merge radius; sets cluster membership | 60 (fixed, never responsive) |
maxZoom |
Last zoom at which points still cluster | 16 |
minPoints |
Minimum points to form a marker cluster | 2 |
extent |
Projection granularity; must match tile size | 512 |
deviceScaleFactor |
Centroid → pixel projection scale | 1 |
| Centroid drift ( |
Allowed sub-pixel centroid movement | 1.5px |
| Basemap / static geometry | Per-pixel diff threshold | 0.0, SSIM 0.995 |
| Cluster halo / anti-aliased icon | Per-pixel diff threshold | 0.1–0.2, SSIM 0.98 |
| Count badge (loading state) | Disposition | Masked / excluded |
| Idle settle timeout | Slow fixture hydration before capture | 5s–15s |
A single global tolerance is the wrong tool here: set it loose enough to absorb halo anti-aliasing and it hides a real cluster split; set it tight and every anti-aliased edge fails. Encode tolerance per zone, and tune the per-layer values following Diff Algorithm Tuning for Cartography.
Common Pitfalls
- Inherited responsive
radius. The app tunesradiusby breakpoint, so the same fixture clusters differently depending on the runner’s window size, and baselines never reproduce. Overrideradius(and every other parameter) from the test manifest, ignoring app config entirely. - Re-ordered feature arrays. Regenerating the fixture from an unordered API response shuffles the feature order, which flips equal-distance merge tie-breaks and silently re-shapes small clusters. Sort the fixture canonically and commit it.
- Fractional device pixel ratio. A DPR of
1.25or2projects centroids to non-integer pixels, shifting the whole cluster layer by a fraction. PindeviceScaleFactor: 1for the baseline set. - Capturing mid-rebalance. Snapshotting on a fixed
setTimeoutinstead of the engine’s settle event grabs a half-spiderfied or mid-zoom frame. Anchor capture toidle/clusterEnd. extentmismatch. Leavingextentat a default that differs from the renderer’s tile size introduces a constant few-pixel centroid offset that looks like a real shift. Matchextentto the tile size in the manifest.
Establish a quarterly cluster-fixture audit: re-validate the marker-cluster count and point-count assertions against the current map-library version, recompute centroid drift tolerance from aggregated false-positive metrics, and confirm the committed fixture order still matches the canonical sort. The broader synchronization story these checks plug into is covered cross-domain in Screenshot Capture, Sync & Comparison Logic.
Frequently Asked Questions
Is Supercluster deterministic, or do I have to seed it?
Supercluster is deterministic by construction—there is no randomness in the KD-tree build or the greedy merge—so you do not seed it. What you must control are its inputs: pass the same parameters explicitly, feed features in a stable order, and project against a frozen camera and DPR. Determinism breaks not in the algorithm but in the surrounding viewport and fixture state.
Why does my cluster count change by one between runs?
Almost always because a centroid sits exactly on the radius boundary at the test zoom and a fractional DPR or a slightly different feature order tips it across. Pin deviceScaleFactor: 1, commit the fixture in a canonical order, and nudge the test zoom away from the boundary value so no cluster straddles the merge threshold.
Should I assert on cluster counts or just compare pixels?
Do both, in that order. A cheap structural assertion over getClusters() for the count and total point count fails fast and tells you exactly what changed; the pixel comparison then catches symbology and rendering regressions the count cannot see. Running the structural guard first saves the expensive image diff on obvious data changes.
How do I keep cluster baselines from going stale after a library upgrade?
Tag every committed cluster baseline with the commit hash of its parameter manifest, and run the count/point-count invariants as a pre-flight on every upgrade. A map-library bump that changes default extent or merge behaviour then fails the structural assertion immediately, forcing an explicit baseline review instead of silently shifting every clustered snapshot.
Related
- Dynamic Element Masking & UI Stability — the parent reference defining the contract between deterministic cartographic rendering and non-deterministic UI.
- Interactive Overlay Masking Rules — bounded exclusion rules for cluster popups, count badges, and hover chrome.
- Animation & Transition Suppression — freezing spiderfy and zoom interpolation so the captured cluster frame is final.
- Viewport & Zoom Sync Strategies — locking the camera and device pixel ratio the marker-cluster projection depends on.
- Diff Algorithm Tuning for Cartography — per-layer tolerance for the regions surrounding a clustered layer.