Encoding Map Camera State in the URL Fragment

Part of the Dashboard State & URL Sharing guide.

Operative rule: parse the fragment before the map object exists and pass the result into the constructor — restoring a camera after construction always costs a wasted tile fetch and a visible jump.

Why the Fragment and Not the Query String

Camera state changes constantly and means nothing to a server. The fragment is the right home for exactly that combination: it never leaves the browser, it does not vary the cache key of a static page, and it can be rewritten hundreds of times a session without generating a single request.

A query string would do the opposite. On a static host it becomes part of the URL a CDN may key on, so every distinct pan position risks its own cache entry; and it travels to the server and into access logs, turning a reader’s browsing into recorded data for no benefit.

Fragment versus query string for camera state The fragment stays in the browser, never varies a cache key and never appears in server logs, which suits state that changes on every pan. The query string reaches the server, can fragment a CDN cache across pan positions and is recorded in logs, which suits only state a server must act on. Camera state changes constantly — put it where that is free property #fragment ?query reaches the server never always affects the cache key no — one cached page possibly — one per position appears in access logs no yes

Production-Ready Implementation

const DEFAULT = { lon: 10.0, lat: 51.0, zoom: 5.5, bearing: 0, pitch: 0 };

function precisionFor(zoom) {
  if (zoom >= 14) return 5;      // ≈ 1 m
  if (zoom >= 9) return 4;       // ≈ 10 m
  return 3;                      // ≈ 100 m
}

export function encodeCamera(c) {
  const p = precisionFor(c.zoom);
  const parts = [`${c.lon.toFixed(p)},${c.lat.toFixed(p)},${c.zoom.toFixed(2)}z`];
  if (Math.abs(c.bearing) > 0.5) parts.push(`${Math.round(c.bearing)}b`);
  if (Math.abs(c.pitch) > 0.5) parts.push(`${Math.round(c.pitch)}p`);
  return `#map=${parts.join("/")}`;
}

export function decodeCamera(hash) {
  const match = /(?:^#|&)map=([^&]+)/.exec(hash || "");
  if (!match) return { ...DEFAULT };
  const [position, ...rest] = match[1].split("/");
  const [lon, lat, zoom] = position.split(",");
  const camera = {
    lon: Number(lon), lat: Number(lat),
    zoom: Number(String(zoom).replace(/z$/, "")),
    bearing: 0, pitch: 0,
  };
  for (const token of rest) {
    if (token.endsWith("b")) camera.bearing = Number(token.slice(0, -1));
    if (token.endsWith("p")) camera.pitch = Number(token.slice(0, -1));
  }
  const valid =
    Number.isFinite(camera.lon) && Math.abs(camera.lon) <= 180 &&
    Number.isFinite(camera.lat) && Math.abs(camera.lat) <= 85.06 &&
    Number.isFinite(camera.zoom) && camera.zoom >= 0 && camera.zoom <= 24;
  return valid ? camera : { ...DEFAULT };     // a broken link still opens a map
}

// Restore BEFORE construction, then keep the fragment in step.
const camera = decodeCamera(window.location.hash);
const map = new maplibregl.Map({
  container: "map",
  style: "/style/style.json",
  center: [camera.lon, camera.lat],
  zoom: camera.zoom,
  bearing: camera.bearing,
  pitch: camera.pitch,
});

map.on("moveend", () => {
  const c = map.getCenter();
  const url = new URL(window.location.href);
  url.hash = encodeCamera({
    lon: c.lng, lat: c.lat, zoom: map.getZoom(),
    bearing: map.getBearing(), pitch: map.getPitch(),
  });
  window.history.replaceState(null, "", url);   // replace, never push
});

window.addEventListener("popstate", () => {
  const next = decodeCamera(window.location.hash);
  map.jumpTo({ center: [next.lon, next.lat], zoom: next.zoom,
               bearing: next.bearing, pitch: next.pitch });
});

Three details make this behave well in practice. Bearing and pitch are omitted when they are at their defaults, which keeps the common case short. Validation clamps to the projection’s real limits and falls back rather than throwing. And jumpTo rather than flyTo is used on popstate, because a reader pressing back expects to arrive, not to travel.

Precision, Length and Readability

Coordinate precision against ground accuracy Three decimal places resolve about one hundred metres and suit country-scale views. Four resolve about ten metres. Five resolve about one metre and are enough for street level. Six and beyond resolve centimetres, which no web map can display and which only lengthen the URL. Decimal places versus what the reader can actually see 3 dp — ≈ 100 m country and regional views — plenty 4 dp — ≈ 10 m district views — the middle of the range 5 dp — ≈ 1 m street level — the practical maximum 6+ dp — centimetres: invisible on any web map, and it lengthens every shared link

Replace or Push?

replaceState for movement, pushState for decisions Panning, zooming, rotating and tilting are continuous and should replace the current history entry. Toggling a layer, selecting a feature or applying a filter are discrete decisions and should push a new entry so the back button undoes them. The back button should step through decisions, not through pixels replaceState — continuous pan · zoom · rotate · tilt fires many times per second write on moveend, not on move one entry, endlessly updated pushState — discrete layer toggled · feature selected filter applied · view preset chosen one entry per decision back undoes exactly one action

Verification Steps

  • Round-trip a camera through encode and decode and assert equality to the encoded precision.
  • Open the page with #map=999,999,99z and confirm it opens at the default view without a console error.
  • Record the first second of a deep-link load and confirm the camera never moves.
  • Pan for ten seconds, press back once, and confirm you return to the previous decision rather than to a nearby pan position.
  • Confirm the fragment survives whatever redirect the deployment performs, including a trailing-slash or www redirect.

Common Errors & Fixes

The URL updates but nothing happens on back

There is no popstate listener. Add one and route it through the same camera application path as the initial load.

The map drifts slightly every time the page is reloaded

Coordinates are being written at full precision and re-read, and the map’s own rounding shifts them. Round on write and treat the rounded value as authoritative.

The fragment is empty for readers who never move the map

Nothing has written it yet. Write the initial camera on the map’s first idle event so a reader can share the default view without touching anything.

Two dashboards on one page fight over the fragment

Namespace the key — #map= for one, #inset= for the other — and have each read only its own token, as the regular expression above does.

A shared link is untrusted input in the same sense that any URL parameter is. It might have been edited by hand, truncated by a mail client, or created against a version of the dashboard whose coverage was different — and in each case the camera it names may fall outside where the map is now allowed to go.

Validating the numbers is only half the job. A longitude of 200 is obviously invalid and easy to reject, but a longitude of 30 may be perfectly valid and still outside a dashboard constrained to a single country. Restoring it produces a map that opens on empty ocean and then rubber-bands, which reads as a broken link rather than as an out-of-area request.

The fix is to clamp rather than reject: after parsing, constrain the centre to the current envelope and the zoom to the current floor and ceiling, then use the result. A link that points slightly outside the coverage then opens at the nearest legal view, which is almost always what its author meant. A link that points somewhere entirely different opens at the default, which is the honest answer.

It is worth logging when clamping occurs, at least during development. A steady trickle of clamped links usually means the coverage changed and old links are drifting out of range — useful information that is invisible if the clamp is silent.

Bearing, Pitch and Whether to Share Them

Not every camera property deserves to travel. Bearing and pitch are genuinely part of the view on a 3D or rotated map and belong in the link; on a conventional north-up dashboard they are almost always zero and including them lengthens every URL to express nothing.

The rule that works is to omit any property at its default and to include it otherwise, which is what the encoder above does. A reader who has rotated the map shares the rotation; everyone else shares a shorter link. The decoder then treats a missing token as the default rather than as an error, so both forms round-trip correctly.

The same principle extends to any camera property a particular renderer supports: encode the difference from the default, never the full state. It keeps links readable, keeps them short, and means adding a new camera capability later does not invalidate every link already in circulation.

Gotchas & Edge Cases

  • Some chat and mail clients treat a trailing punctuation mark as part of the URL; keeping the fragment free of characters that need escaping makes links survive being pasted mid-sentence.
  • A fragment written on every moveend still fires during an inertial glide, which produces several writes per gesture — throttle to the final settle if the history API becomes a hotspot.
  • Browsers cap history entries per session; a page that pushes rather than replaces will hit that cap and start silently discarding older entries.
  • A deep link opened in a new tab starts with an empty history, so the back button leaves the site entirely — never rely on it as the only way to undo a state change.
  • Fragments are not sent to the server and therefore never appear in analytics; if the shared view needs to be measurable, that measurement has to be explicit in the page.