Stabilizing Marker-Cluster Expansion in Visual Tests

Your suite captures the same map twice and the marker cluster bubbles disagree: one run shows a “24” badge where the other shows a “23” and a lone pin, or two overlapping clusters swap position between frames. Marker clustering is a spatial aggregation that depends on point order, a radius, an active zoom, and — for animated plugins — a running expansion tween. Any one of those left unpinned makes cluster membership non-deterministic, and a comparator then flags a genuine but meaningless difference on every run. This guide gives a concrete procedure for freezing a marker-clustering layer (Supercluster, or the Leaflet.markercluster plugin) so the same points always aggregate into the same bubbles in the same pixels, capture after capture. Note that “marker cluster” here means the map feature that groups nearby pins, not any organizational term.

This is one task inside Marker Cluster Stability, the guide covering deterministic aggregation layers, and it sits under the broader Dynamic Element Masking & UI Stability area that governs how moving UI is held still for capture. It assumes the camera is already pinned per Viewport & Zoom Sync Strategies — an unlocked camera changes the active zoom, which changes cluster membership, which makes any of the steps below meaningless.

Prerequisites

Step-by-step procedure

1. Freeze the point dataset

Clustering is a pure function of its inputs, so the first input to pin is the point set itself. A dataset fetched live can differ by one record, arrive reordered, or carry a jittered coordinate, and each of those reshuffles the whole aggregation. Load points from a committed fixture and intercept the network so no live endpoint can leak in.

import { readFileSync } from 'node:fs';

const points = JSON.parse(
  readFileSync(new URL('./fixtures/incidents.geojson', import.meta.url))
);

await page.route('**/api/incidents**', (route) =>
  route.fulfill({ contentType: 'application/json', body: JSON.stringify(points) })
);

Round coordinates to a fixed precision in the fixture itself (six decimal places is ~0.1 m and far below one screen pixel at test zooms). A coordinate that varies in its seventh decimal will not move a pin visibly, but it can flip which side of a marker cluster radius boundary a point falls on and change a badge count.

2. Pin the marker cluster radius, maxZoom, and any seed

The aggregation geometry is governed by two numbers: radius (the pixel distance within which points merge at a given zoom) and maxZoom (the zoom past which clustering stops). Both have library defaults that can change across versions, so set them explicitly rather than inheriting them. Supercluster is deterministic given identical options and input order, but only if you fix those options.

import Supercluster from 'supercluster';

const index = new Supercluster({
  radius: 60,      // pixels — pin explicitly, never inherit the default
  maxZoom: 16,     // stop clustering past this zoom
  minPoints: 2,    // clusters need at least two points
  extent: 512,     // tile extent used for the internal grid
});
index.load(points.features);

If your pipeline uses any stochastic layout — a jitter to separate co-located pins, or a randomized tie-break — seed the generator so the “random” sequence is identical every run. An unseeded Math.random() is the single most common cause of a marker cluster layer that diffs clean on nine runs and fails on the tenth.

3. Lock an integer zoom so cluster membership is fixed

Cluster membership is recomputed per zoom level. At a fractional zoom the renderer interpolates between two discrete cluster trees, so the badge counts and bubble positions are a blend that never settles identically twice. Jump the camera to an integer zoom and stop any easing before you read the clusters, exactly as the camera contract in Viewport & Zoom Sync Strategies requires.

await page.evaluate((cam) => {
  const map = window.__testMap;
  map.jumpTo({ center: cam.center, zoom: cam.zoom, bearing: 0, pitch: 0 });
  map.stop();               // cancel any in-flight easing
}, { center: [-73.9857, 40.7484], zoom: 12 });   // integer zoom only

Reading clusters at the integer zoom z means calling index.getClusters(bbox, z) with the same z the camera is locked to. If the map is at zoom 12.4, Math.round it before querying, or the query tree and the rendered tree disagree.

4. Enforce stable marker ordering

Supercluster assigns each marker cluster the id and position of a representative point, and it resolves ties — two candidate representatives at the same distance — by input order. If the input order is not stable, the representative flips, the badge sits over a different pin, and z-ordering of overlapping bubbles swaps. Sort the features by their stable id before loading so tie-breaks resolve identically every run.

const ordered = [...points.features].sort((a, b) =>
  String(a.properties.id).localeCompare(String(b.properties.id))
);
index.load(ordered);

For Leaflet.markercluster, the equivalent is to add layers in a sorted, deterministic sequence rather than in fetch-completion order, since the plugin’s internal spatial tree is order-sensitive too. Never rely on object-key or Set iteration order to feed the layer.

5. Disable spiderfy and expansion animations

The Leaflet.markercluster plugin animates two things that a screenshot can catch mid-frame: the zoom-in expansion tween when a marker cluster splits, and spiderfy, the fan-out of overlapping markers on click. Both must be off, or capture races a moving sprite. Turn them off at construction.

const clusterGroup = L.markerClusterGroup({
  animate: false,               // no expansion/collapse tween
  animateAddingMarkers: false,  // no per-marker add animation
  spiderfyOnMaxZoom: false,     // no fan-out at max zoom
  zoomToBoundsOnClick: false,   // no camera move on cluster click
  maxClusterRadius: 60,         // match the radius pinned in step 2
});

This is the clustering-specific case of the general rule in Animation & Transition Suppression: any tween on a captured element must be frozen, not merely fast. If a spiderfied fan or an open popup still needs to be present but must not jitter, mask its region with a selector rule for interactive overlays rather than trying to time the capture.

6. Capture after the marker cluster layout settles

With the dataset, radius, zoom, order, and animations all pinned, the layout is deterministic — but the GPU still needs one frame to paint the final bubbles. Wait for the map’s idle event so the last cluster sprites are composited, then capture with animations disabled as a belt-and-braces guard.

await page.evaluate(() => new Promise((resolve) => {
  const map = window.__testMap;
  const check = () => { if (map.areTilesLoaded()) { map.off('idle', check); resolve(); } };
  map.on('idle', check);
  check();                 // handle the already-idle case
}));

const buffer = await page.locator('#map').screenshot({ animations: 'disabled' });

The diagram below shows the transformation the pinned parameters enforce: the same points, aggregated on the same fixed grid at the same zoom, always collapse to the same stable bubbles.

From loose points to stable cluster bubbles via a fixed radius and locked zoom Three panels connected left to right. Panel one, Frozen points, shows nine scattered pins loaded from a fixture and sorted by id. Panel two, Fixed grid at locked zoom, overlays a regular grid whose cell size is the pinned cluster radius at the locked integer zoom; points falling in the same cell merge. Panel three, Stable bubbles, shows three cluster bubbles with fixed badge counts of four, three, and two, each in a fixed position. An arrow between panels one and two is labelled pin radius and maxZoom; an arrow between panels two and three is labelled sort by id, disable animation. Because every input is pinned, the same points always yield the same bubbles. 1 · Frozen points 2 · Fixed grid at locked zoom 3 · Stable bubbles loaded from fixture, sorted by id cell size = pinned radius 4 3 2 fixed counts, fixed positions pin radius,maxZoom sort by id,no anim

Verification

Confirm the marker cluster layer is genuinely deterministic before trusting it in the gate:

A correct setup gives a green stability run and an identical cluster array under throttling. If a fast run passes but a slow one fails, capture is racing an animation tween rather than reading a settled layout.

Troubleshooting

Symptom Likely cause Fix
Badge count flips by one between runs A jittered coordinate or an extra/dropped point crosses a radius boundary Freeze and round the fixture (step 1) and pin radius/maxZoom explicitly (step 2)
Two overlapping bubbles swap position or z-order Unstable input order flips the marker cluster representative and tie-break Sort features by stable id before load() (step 4)
Cluster caught mid-expansion or mid-spiderfy An animation tween was still running when the shutter fired Set animate: false and spiderfyOnMaxZoom: false, then gate on idle (steps 5–6)

Frequently asked questions

Why does the badge count change even though the point data is identical?

Almost always a fractional zoom or an unpinned radius. Cluster membership is recomputed per zoom level, so at zoom 12.4 the renderer blends two discrete cluster trees and the count you capture is an interpolation that never settles the same way twice. Jump to an integer zoom (step 3) and set radius and maxZoom explicitly (step 2) so the aggregation is a fixed function of a fixed input.

Do I really need to sort the points if the dataset never changes?

Yes, if the points can arrive in a different order — from parallel fetches, object-key iteration, or a Set. Supercluster resolves representative-point ties by input order, so a reordered but otherwise identical dataset can put the badge over a different pin and swap the z-order of overlapping bubbles. Sorting by a stable id before load() makes the tie-break resolve identically every run.

How do I keep spiderfied markers or an open popup in the shot without jitter?

Disabling the expansion animation stops the layout from moving, but a fan-out or popup that still needs to appear is better masked than timed. Suppress the tween per Animation & Transition Suppression, and if a dynamic region must be excluded from comparison, apply a selector rule for interactive overlays rather than trying to catch the frame at the right instant.

Should I test clustering at a fractional zoom to match production?

No. Production users pan and zoom freely, but a visual baseline must be reproducible, and only integer zooms give stable cluster membership. Test the aggregation at a set of pinned integer zooms that bracket the behavior you care about — one below maxZoom, one at it — and rely on the camera contract in Viewport & Zoom Sync Strategies to hold each one exactly.