Serializing and Restoring Map Camera State for Tests
Two runs of the same test point the map at “the same place,” yet the saved frames differ: a label reflows at the viewport edge, one extra coverage tile appears in the corner, the diff lights up. The cause is almost never the map — it is the camera. A center of -122.41941 and -122.41940999997 describe the same intent to a human but can select a different tile column at the boundary, and an animated flyTo leaves the camera a few thousandths of a degree short of where it was last week. This guide gives a concrete procedure for capturing the camera as an exact, rounded record, writing it to a JSON fixture, and restoring it byte-for-byte on every run so the same tile set renders and the baseline stays valid.
This is one task within Viewport & Zoom Sync Strategies, the guide that governs how the camera is locked before capture, and it sits under the broader pipeline described in Screenshot Capture, Sync & Comparison Logic. Pin the camera first; everything downstream — tile counting, idle detection, diffing — assumes the frame under the shutter is reproducible.
Prerequisites
Step-by-step procedure
1. Read the camera from the live map
The authoritative camera is the one the renderer reports, not the one you passed at construction — plugins, fitBounds, or a deep-link handler may have moved it. Read the five fields that fully determine the projected view: center longitude and latitude, zoom, bearing, and pitch. Pull them in the page context and return a plain object to the test scope.
const rawCamera = await page.evaluate(() => {
const map = window.__testMap;
const c = map.getCenter();
return {
center: [c.lng, c.lat],
zoom: map.getZoom(),
bearing: map.getBearing(),
pitch: map.getPitch()
};
});
These five numbers are the complete state. Anything else — the container size, DPR, style — is fixed separately and is not part of the camera fixture.
2. Round to fixed decimal precision
getZoom() and getCenter() return full IEEE-754 doubles whose trailing digits are meaningless noise from the last easing frame. That noise is not cosmetic: it changes which tiles the renderer selects. For the Web Mercator grid, the tile column for a longitude at zoom
Near a tile boundary the value inside the floor sits a hair below an integer, so a difference of
const round = (n, dp) => Number(n.toFixed(dp));
const camera = {
center: [round(rawCamera.center[0], 6), round(rawCamera.center[1], 6)],
zoom: round(rawCamera.zoom, 3),
bearing: round(rawCamera.bearing, 2),
pitch: round(rawCamera.pitch, 2)
};
Round once, here, at serialization time — never round on restore. The fixture is the single source of truth, and every consumer must read the identical rounded numbers.
3. Serialize to a JSON fixture
Write the rounded camera to a committed JSON file keyed by the test name. Keeping the camera out of the test body means a deliberate re-baseline is a reviewable diff on the fixture, not a buried literal, and the same file feeds both the browser (to restore) and the diff stage (to key the baseline).
import { writeFileSync } from 'node:fs';
const fixture = {
name: 'downtown-sf-z12',
camera,
renderer: 'maplibre-gl@4.7.0'
};
writeFileSync(
'tests/fixtures/cameras/downtown-sf-z12.json',
JSON.stringify(fixture, null, 2) + '\n'
);
Serialize with a trailing newline and stable key order so the file is diff-friendly and does not churn in version control between otherwise-identical runs.
4. Restore with jumpTo, then stop the map
On the next run, read the fixture and apply it with jumpTo, which sets the camera in a single frame with no interpolation. Never use flyTo or easeTo: an animated move is captured mid-ease unless you also wait it out, and even then it can land fractionally off the target. Immediately call map.stop() to cancel any easing the app may have queued during load, so nothing nudges the camera after you set it.
await page.evaluate((cam) => {
const map = window.__testMap;
map.jumpTo({
center: cam.center,
zoom: cam.zoom,
bearing: cam.bearing,
pitch: cam.pitch
});
map.stop(); // cancel any in-flight easing
}, fixture.camera);
With the camera jumped and easing cancelled, the tile grid is fixed. From here the run hands off to tile-hydration gating covered in Handling Async Tile Loading and, for WebGL engines, the frame-completion checks in WebGL Idle & Render-Completion Detection.
5. Hash the camera for the baseline key
The baseline image is only valid for the exact camera it was captured under. Derive a short, stable hash from the canonical serialization of the rounded camera and fold it into the baseline filename. When someone re-frames the shot, the hash changes, the harness looks for a baseline that does not exist, and you get an explicit “new baseline” rather than a silent comparison against a stale golden.
import { createHash } from 'node:crypto';
const canonical = JSON.stringify(fixture.camera); // rounded fields, stable order
const cameraHash = createHash('sha256')
.update(canonical)
.digest('hex')
.slice(0, 12);
const baselineKey = `${fixture.name}.${cameraHash}.png`;
// -> downtown-sf-z12.a1b2c3d4e5f6.png
Because the hash is taken over the rounded fields from step 2, two runs that agree to the recorded precision produce the same key — the whole point of rounding before hashing.
6. Assert the restored state matches the fixture
Before capturing, prove the restore actually took. Read the camera back out and compare it to the fixture at the recorded precision. A mismatch here means a plugin or a late resize moved the camera after jumpTo, and it is far cheaper to fail on the assertion than to chase a pixel diff later.
const restored = await page.evaluate(() => {
const map = window.__testMap;
const c = map.getCenter();
return {
center: [round(c.lng, 6), round(c.lat, 6)],
zoom: round(map.getZoom(), 3),
bearing: round(map.getBearing(), 2),
pitch: round(map.getPitch(), 2)
};
});
expect(restored).toEqual(fixture.camera);
Verification
Confirm the round-trip is stable before you trust it as a gate:
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| One extra tile appears at a viewport edge between runs | Center or zoom carries float noise that flips the floor at a tile boundary | Round center to 6 dp and zoom to 3 dp in step 2, and hash the rounded values so the key changes only on a real re-frame |
| Restored frame is subtly off despite a correct fixture | Restore used flyTo/easeTo, so capture caught the camera mid-ease |
Apply the camera with jumpTo and call map.stop() (step 4); never animate a restore |
| Step 6 assertion fails intermittently | A plugin, deep-link handler, or resize moved the camera after jumpTo |
Restore after the map’s load event, disable the mover during tests, and re-assert the readback against the fixture |
Frequently asked questions
Why round the camera at all instead of storing full precision?
Full-precision doubles record easing noise from the last animation frame, and that noise selects tiles. Two runs that stored 12.000000001 and 12.0 can resolve different {z}/{x}/{y} sets at a boundary. Rounding to a fixed precision — 6 dp for center, 3 dp for zoom — collapses the noise to one stable value that every run and the baseline hash agree on, which is finer than any tile edge anyway.
Why jumpTo and not flyTo or setCenter?
flyTo and easeTo animate over several frames, so a screenshot fired during load catches the camera mid-move, and the landing point can be fractionally off the target. jumpTo sets center, zoom, bearing, and pitch in a single frame with no interpolation. Pair it with map.stop() to cancel any easing the app queued, so nothing nudges the camera after you set it.
What belongs in the camera fixture?
Only the five fields that determine the projected view: center longitude and latitude, zoom, bearing, and pitch. Container size, device pixel ratio, and the style are fixed separately and are not part of the camera. Keep the fixture minimal so its hash changes only when the framing genuinely changes, and record the renderer version alongside it for traceability.
How does the camera hash relate to the baseline image?
The hash is taken over the canonical, rounded camera and folded into the baseline filename. A baseline is only valid for the exact camera it was shot under, so when the camera changes the hash changes and the harness looks for a baseline that does not exist — surfacing an explicit “new baseline” instead of silently diffing against a stale golden.
Related
- Up to the parent guide Viewport & Zoom Sync Strategies, and the broader pipeline Screenshot Capture, Sync & Comparison Logic.
- Capturing consistent map states across network conditions — holding the frame steady once the camera is pinned.
- Handling Async Tile Loading — gating capture on the tile grid the fixed camera resolves.
- WebGL Idle & Render-Completion Detection — confirming the frame is painted before the shutter fires.