Freezing Real-Time Vehicle Position Layers for Tests
A vehicle layer is the hardest thing on a transit or logistics map to test and the most valuable, because it is the part the product is actually about. Positions arrive continuously, the number on screen depends on how fast the runner is, marker rotation is derived from a heading that changes with every update, and age-based fading is computed against a clock the test has already frozen. Each of those has a specific fix; applied together they turn a layer that has never captured the same way twice into one that is byte-identical across runs and engines.
This is a task within Live Data Overlay Stabilization, under Dynamic Element Masking & UI Stability. It assumes the clock is already pinned per Freezing Map Time with Playwright addInitScript.
Prerequisites
Step-by-step procedure
1. Trim the recording to the camera
A raw feed carries every vehicle in the network; the capture renders only those inside the camera’s bounds at its zoom. Trimming at fixture-build time typically drops the file by an order of magnitude and makes the remainder reviewable.
const inView = frames.filter(({ lon, lat }) =>
lon >= bounds.west && lon <= bounds.east &&
lat >= bounds.south && lat <= bounds.north
);
Keep a small margin beyond the bounds — a marker whose anchor is just outside still draws its icon inside — and record the margin in the fixture so a later camera change makes the mismatch obvious rather than silently dropping edge markers.
2. Replace the transport, and deliver everything at once
await context.addInitScript((frames) => {
class StubSocket extends EventTarget {
constructor(url) {
super();
this.url = url;
this.readyState = 1;
queueMicrotask(() => {
this.dispatchEvent(new Event('open'));
for (const f of frames) {
this.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(f) }));
}
globalThis.__vehiclesDrained = frames.length;
});
}
send() {}
close() { this.readyState = 3; }
}
globalThis.WebSocket = StubSocket;
}, VEHICLE_FRAMES);
Delivering in one microtask removes runner speed from the result entirely. The alternative — replaying on the recorded timings — reintroduces exactly the variance the fixture was built to remove.
3. Sort features by id before writing them to the source
If the layer writes features in arrival order, two overlapping markers can stack either way round. Sorting by a stable identifier at the point the source is updated fixes it, and it fixes a real product bug at the same time: an overlay whose z-order depends on network arrival order visibly flickers in production.
map.getSource('vehicles').setData({
type: 'FeatureCollection',
features: [...byId.values()].sort((a, b) =>
a.properties.vehicleId.localeCompare(b.properties.vehicleId)
),
});
4. Quantise derived bearings
Marker rotation is usually computed from consecutive positions, and floating-point noise in the last digits produces a rotation that differs by a hundredth of a degree between runs — enough to change the rasterised icon by a pixel along its edges. Quantise the bearing at the point it is derived.
const bearing = Math.round(rawBearing * 2) / 2; // half-degree steps
Half a degree is below what a reader can perceive on a 24-pixel icon and comfortably above the noise. Record the quantum in the fixture, because changing it later re-renders every marker.
5. Rewrite feed timestamps relative to the frozen epoch
With Date.now() pinned and the recording taken at another instant, every vehicle is either impossibly fresh or long expired, so age-based fading renders nothing or renders everything at full opacity. Rewriting the offsets makes the styling a deliberate property of the fixture.
const AGES = [5, 20, 45, 90, 240]; // seconds, chosen to straddle the fade
frames.forEach((f, i) => {
f.reportedAt = EPOCH - AGES[i % AGES.length] * 1000;
});
Choosing offsets that straddle the fade threshold is what makes the capture actually exercise the age styling rather than accidentally testing one branch of it.
6. Assert the rendered feature count before capturing
One line, and it catches a dropped message, an over-eager expiry rule and a silently failed parse — all of which otherwise produce a slightly different picture that a tolerance absorbs.
const rendered = await page.evaluate(() =>
window.__testMap.querySourceFeatures('vehicles').length
);
expect(rendered).toBe(EXPECTED_IN_VIEW);
7. Extend the readiness predicate to the feed
The capture gate must wait for the feed as well as the tiles, or it fires while the source is still being populated.
const settled = () =>
globalThis.__vehiclesDrained === EXPECTED_FRAMES &&
map.areTilesLoaded() && !map.isMoving() && quietFrames >= 2;
Verification
Confirm the procedure worked before wiring it into a blocking gate:
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| The layer renders empty even though the feed was stubbed | The recorded timestamps are far older than the frozen epoch, so every feature is past the expiry rule | Rewrite the fixture’s timestamps to fixed offsets from the epoch, as in step 5 |
| Vehicle count varies between runs on the same machine | The application hydrates over HTTP before subscribing, and only the socket was stubbed | Intercept the hydration endpoint as well; the two paths are almost always both present |
| Markers are in the right places but the icons differ by a pixel along their edges | Rotation is derived from consecutive positions and carries floating-point noise | Quantise the bearing to half-degree steps at the point it is derived, as in step 4 |
Frequently asked questions
Is one recorded window enough, or should each scenario have its own?
One well-chosen window per camera, because the set of vehicles in view is a function of the camera and a shared fixture would carry the union of every scenario’s vehicles. The windows compress well and overlap heavily, so the storage cost is small; the review cost of a fixture containing vehicles that never render is not.
Should the fixture use real vehicle identifiers?
No. Identifiers from a live fleet are operational data, and the layer renders identically with synthetic ones. Generate stable synthetic ids at fixture-build time — they need to be stable so the sort in step 3 is stable, which is the only property the rendering depends on.
What if vehicles are supposed to animate between positions?
Then the animation is the thing under test, and it needs a chosen frame rather than a settled state. Drive the interpolation to a named fraction — half way through the transition, say — assert that fraction, and capture there. This is the replay approach described in Live Data Overlay Stabilization, and it needs the animation clock pinned rather than merely frozen.
How does this interact with clustering?
Clustering runs on whatever feature set the layer holds, so freezing the feed is a prerequisite rather than an alternative: with a varying feature count, cluster badges and centroids vary too and the layer can never be stable. Once the set is frozen, the remaining work — pinning radius, maxZoom, integer zoom and insertion order — is covered in Marker Cluster Stability.
Related
- Up to Live Data Overlay Stabilization, and the section Dynamic Element Masking & UI Stability.
- Marker Cluster Stability — the determinism work that applies once the feature set is frozen.
- Freezing Map Time with Playwright addInitScript — the clock pin the age rewrite depends on.
- Intercepting Vector Tile Requests with Playwright route — the same interception applied to the basemap beneath the overlay.