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 is

Near a tile boundary the value inside the floor sits a hair below an integer, so a difference of degrees can flip to the next column — one extra tile enters the frame, coverage at the opposite edge drops out, and the diff fires. Rounding to a fixed number of decimals collapses that jitter to a stable value. Six decimals of longitude is roughly 0.1 m at the equator, far finer than any tile edge, and three decimals of zoom is well inside a single integer level.

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);
From live camera fields to a deterministic tile grid A left-to-right flow of four stages. First, the live map exposes five raw camera fields — center longitude, center latitude, zoom, bearing, and pitch — as full-precision doubles. Second, each field is rounded to fixed decimals and written to a JSON camera fixture that is committed to the repository. Third, on the next run the fixture is applied with jumpTo followed by map.stop, setting the camera in one frame with no interpolation. Fourth, the fixed camera resolves the same set of z, x, y tiles every run, producing a deterministic tile grid under the shutter. 1 · Live camera center lng / lat zoom bearing · pitch full-precision doubles 2 · Fixture round to fixed decimals write camera.json committed to repo 3 · Restore jumpTo(camera) map.stop() one frame, no interpolation 4 · Tile grid same z/x/y every run

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.