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)
  ),
});
Two overlapping vehicles, drawn in arrival order and in sorted order Two panels show the same pair of overlapping vehicle markers. In the arrival-order panel the two markers are drawn in whichever sequence the socket delivered them, so on one run the first vehicle's icon is on top and on the next run the second is, producing a diff in the overlap region with no change in the data. In the sorted panel the features are ordered by vehicle identifier before being written to the source, so the same marker is always on top. A caption notes that this also fixes a visible flicker in production, where the stacking changes on every update. Arrival order is not a rendering contract arrival order sorted by id stacking flips between runs the same marker is always on top the sort also removes a visible flicker in production, where stacking changes on every update

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.

Vehicle ages placed deliberately on both sides of the fade and expiry thresholds An age axis runs from zero seconds on the left to four minutes on the right, with two marked thresholds: a fade threshold at sixty seconds beyond which markers draw at reduced opacity, and an expiry threshold at three minutes beyond which they are removed entirely. Five vehicles from the fixture are placed along the axis at five, twenty, forty-five, ninety and two hundred and forty seconds, so two render at full opacity, one renders faded, and one is expired. A caption notes that a fixture whose ages all land in one band silently tests only one branch of the styling. Place the ages, do not inherit them from the recording date fade at 60 s expire at 180 s 5 s20 s90 s240 s full opacity faded removed a fixture whose ages all land in one band tests one branch of the styling and reports full coverage

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;
Five sources of variance in a vehicle layer, and the step that removes each Five variance sources are listed against the step that removes them. How many updates arrived before capture is removed by delivering every recorded frame in one microtask. Which marker draws on top is removed by sorting features by identifier before writing to the source. Marker rotation jitter is removed by quantising the derived bearing to half-degree steps. Age-based fading is removed by rewriting the fixture's timestamps to fixed offsets from the frozen epoch. A silently dropped feature is caught by asserting the rendered feature count before the shutter. Five variances, five specific removals how many updates arrived one microtask delivery which marker is on top sort by vehicle id marker rotation jitter quantise the bearing age-based fading rewrite timestamps a silently dropped feature assert the rendered count

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.