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.
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
Replace or Push?
Verification Steps
- Round-trip a camera through encode and decode and assert equality to the encoded precision.
- Open the page with
#map=999,999,99zand 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
wwwredirect.
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.
Clamping a Restored Camera to the Legal Envelope
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
moveendstill 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.
Related
- Dashboard State & URL Sharing — the parent guide covering what else belongs in a link
- Restoring Layer Visibility from a Shared Link — the other half of a complete shared view
- Zoom & Pan Constraints & Boundaries for Geo-Dashboards — clamping a restored camera to the legal envelope
- Layer Management & Toggling — the state registry the fragment mirrors