Freezing Animated Weather and Traffic Tile Layers for Tests
A weather-radar overlay animates through a rolling window of timestamped frames, and a live-traffic layer repaints as congestion data updates. Both pull tiles from a temporal endpoint whose path carries a time token — …/{time}/{z}/{x}/{y} — so the exact bytes returned depend on when the test runs. Point a screenshot comparator at that surface and every run captures a different radar sweep or a different traffic snapshot, producing a diff that has nothing to do with a code change. This guide gives a concrete procedure for pinning such a layer to one known frame, so the same reflectivity pattern or the same congestion state renders on every run and the comparison measures your map, not the weather.
This is one task inside Animated Tile Layer Stabilization, the guide covering time-driven map layers whose content mutates independently of your application. It sits under the broader discipline described in Dynamic Element Masking & UI Stability, and it pairs with the render-loop techniques in Animation & Transition Suppression — suppressing the loop stops the frames advancing, but you still have to decide which frame to hold, which is what this page is about.
Prerequisites
Step-by-step procedure
1. Identify the animation clock and timestamp source
Before you can freeze a frame you must find what drives it. Time-animated layers advance from one of three clocks: a JavaScript timer (setInterval/requestAnimationFrame) that increments a frame index, the wall clock read through Date.now() to pick “the latest available” timestamp, or an explicit time property the layer exposes (layer.setDate(), a time paint property, or a Leaflet TimeDimension cursor). Inspect the layer to see which applies.
// Inspect the animated layer's temporal state in the page context.
await page.evaluate(() => {
const map = window.__testMap;
const layer = map.getLayer ? map.getLayer('radar') : null;
return {
// GL: is the time carried as a paint/layout property or a source tile param?
source: map.getSource && map.getSource('radar'),
// Leaflet TimeDimension exposes a current time cursor:
tdTime: map.timeDimension && map.timeDimension.getCurrentTime()
};
});
Whichever clock you find determines which of the next steps do the real work: a timer needs stopping, a Date.now() read needs mocking, and an explicit property needs setting.
2. Pin the clock to a fixed epoch
If the layer reads the wall clock to choose its “current” frame, install a fake clock before the page script runs so every Date.now() and new Date() returns the same instant. Playwright’s clock API freezes time deterministically; a manual Date shim works in any driver. Pin to the exact epoch whose frame you want.
// Freeze wall-clock time to a fixed instant BEFORE app scripts run.
const FROZEN = Date.UTC(2026, 0, 15, 12, 0, 0); // 2026-01-15T12:00:00Z
await page.clock.install({ time: new Date(FROZEN) });
// Or, driver-agnostic, via an init script that shims Date:
await page.addInitScript((t) => {
const Fixed = Date;
const now = () => t;
// eslint-disable-next-line no-global-assign
Date = class extends Fixed {
constructor(...a) { super(...(a.length ? a : [t])); }
static now() { return now(); }
};
}, FROZEN);
With the clock pinned, any logic that computes a timestamp from “now” resolves to the same value on every run.
3. Step to a known frame index and stop the loop
If the layer animates through an array of frames with its own timer, mocking Date is not enough — you must select one index and halt advancement. Set the layer to the target frame explicitly, then cancel the interval or animation callback so it never advances again. This is the same render-loop halt described in Animation & Transition Suppression, applied to the layer’s frame cursor rather than to CSS.
await page.evaluate((frameIndex) => {
const player = window.__radarPlayer; // the layer's animation controller
player.pause(); // stop the loop
if (player._timer) clearInterval(player._timer);
player.setFrame(frameIndex); // jump to a fixed, known frame
// Leaflet TimeDimension equivalent:
if (window.__testMap.timeDimension) {
window.__testMap.timeDimension.setCurrentTime(
window.__testMap.timeDimension.getAvailableTimes()[frameIndex]
);
}
}, 6); // e.g. the 7th frame in the rolling window
Choose an index, not a timestamp relative to “now” — a relative offset drifts as the rolling window slides, and the frame you pinned yesterday is gone today.
4. Intercept the temporal tile URL and rewrite the time token
The clock and the frame cursor decide which URL the layer requests, but a robust freeze also pins the URL itself. Intercept the temporal tile pattern and rewrite whatever time token appears in the path to your fixed value, so even if a stray code path computes a live timestamp, the bytes fetched are identical every run. This is the load-bearing step: it makes the freeze independent of application internals.
// Force every temporal tile request to one fixed time token.
const FIXED_TIME = '2026-01-15T1200Z';
await context.route(/\/radar\/[^/]+\/\d+\/\d+\/\d+\.(png|webp)/, async (route) => {
const url = new URL(route.request().url());
// Path form: /radar/{time}/{z}/{x}/{y}.png — replace the {time} segment.
url.pathname = url.pathname.replace(
/\/radar\/[^/]+\//, `/radar/${FIXED_TIME}/`
);
await route.continue({ url: url.toString() });
});
The regex targets only the temporal layer’s tiles; the basemap and other sources pass through untouched. For a layer that carries the time as a query parameter (?time=…) rather than a path segment, rewrite url.searchParams.set('time', FIXED_TIME) instead.
5. If the layer cannot be frozen, mask its region
Some third-party animated overlays offer no clock hook, no frame API, and no interceptable URL — a cross-origin traffic widget rendered into its own canvas, for example. When a layer genuinely cannot be pinned, exclude its rectangle from the comparison instead. Compute the layer’s on-screen bounds and pass them as a mask so the comparator ignores those pixels while still diffing the rest of the map. The selector-region technique is covered in full in masking CSS selector regions for map popups.
// Fallback: exclude the un-freezable layer's region from the diff.
await page.locator('#map').screenshot({
animations: 'disabled',
mask: [page.locator('.traffic-overlay-canvas')],
maskColor: '#00000000'
});
Masking is the weaker option — it blinds the suite to real regressions inside that rectangle — so reach for it only after steps 2 through 4 have failed.
6. Verify the same frame renders every run
Freezing is only proven by repetition. Capture the pinned layer several times and confirm the frames are pixel-identical, then hand the image to the diff stage. Because the freeze removes temporal variance but not GPU compositing variance, gate capture on render completion first — the double-requestAnimationFrame settle described in WebGL Idle & Render-Completion Detection — so you are comparing settled frames, not frames caught mid-draw.
await page.evaluate(() => new Promise((resolve) => {
let stable = 0;
const tick = () => (++stable >= 2 ? resolve() : requestAnimationFrame(tick));
requestAnimationFrame(tick);
}));
const buffer = await page.locator('#map').screenshot({ animations: 'disabled' });
Verification
Confirm the freeze holds before trusting it in CI:
A correct setup gives a green repeat run and an unchanged frame a day later; if a same-day run passes but a next-day run fails, a live clock is still driving the layer.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Radar sweep differs on every run despite a mocked clock | The layer times its frames with its own setInterval, not Date.now(), so mocking Date never touches it |
Stop the loop and set an explicit frame index per step 3, then rewrite the tile time token per step 4 |
| Frame is stable within a session but changes the next day | You pinned a timestamp relative to “now” (last frame in the window) rather than a fixed epoch or index | Pin an absolute FIXED_TIME and an absolute frame index so the target never slides with the rolling window |
| Basemap freezes but the traffic layer still animates | The route pattern in step 4 did not match the temporal layer’s URL, or the layer is a cross-origin canvas with no interceptable request | Widen the regex to the exact temporal path, or fall back to masking the layer region |
Frequently asked questions
Should I mock Date or rewrite the tile URL to freeze a weather layer?
Do both where you can. Mocking Date (step 2) fixes any logic that derives “the latest frame” from the wall clock, but a layer that animates through an index with its own timer ignores it. Rewriting the temporal tile URL (step 4) is the load-bearing guarantee: it pins the actual bytes fetched regardless of how the frame was chosen, so even a stray live-timestamp code path returns identical tiles.
Why does my pinned frame change after a day even though the test passes each session?
You pinned a frame relative to the present — for example “the newest frame in the rolling window” — rather than an absolute epoch. As the window slides, that relative position maps to a different timestamp. Pin an absolute FIXED_TIME token and an absolute frame index so the target is the same instant on every run, today and next week.
The traffic overlay is a third-party canvas with no time API — what now?
When a layer exposes no clock hook, no frame method, and no interceptable request, exclude its rectangle from the comparison with a mask, as in step 5 and masking CSS selector regions for map popups. This is a last resort: the comparator goes blind to regressions inside that rectangle, so keep the mask as tight to the overlay bounds as possible.
Do I still need render-completion gating once the frame is frozen?
Yes. Freezing removes temporal variance — which frame — but not compositing variance — whether the GPU has finished drawing that frame. Capture can still fire mid-draw and produce a partially painted overlay. Gate on the double-requestAnimationFrame settle from WebGL Idle & Render-Completion Detection before the shutter, so you compare settled frames.
Related
- Up to the parent guide Animated Tile Layer Stabilization, and the broader Dynamic Element Masking & UI Stability.
- Animation & Transition Suppression — halting render loops and CSS transitions so the pinned frame holds still.
- WebGL Idle & Render-Completion Detection — confirming the frozen frame has actually finished drawing before capture.
- Masking CSS selector regions for map popups — the fallback when a layer cannot be frozen at all.
← Back to Dynamic Element Masking & UI Stability