Dashboard State & URL Sharing
Part of the Python-to-Web Generation Workflows guide.
A dashboard that cannot be linked to is a dashboard that gets described in prose. “Zoom into the north-east, turn off the coverage layer, look at the third cluster of incidents” is how teams communicate about maps that have no shareable state — and it is slow, ambiguous and impossible to put in a ticket. Encoding the view in the URL turns that sentence into a link. For generated maps it also solves a second problem: the page is static, so the URL is the only place where per-reader state can live at all.
Prerequisites
Step 1 — Separate Shareable State from Interface State
Not everything the reader touches should travel in a link. The test is simple: if a colleague opened this URL, would this piece of state help them see what I see, or would it be noise from my session?
The boundary is not always obvious. A selected feature is shareable — it is the subject of the conversation. An open popup is ephemeral, even though it looks like the same thing, because it is a transient rendering of the selection rather than the selection itself. Getting this distinction right is what keeps the URL short and the back button usable.
Step 2 — Design a Compact, Stable Encoding
URLs get pasted into chat, tickets and email clients that wrap and truncate. Compactness is therefore a usability property, not an aesthetic one. Three decisions do most of the work: round coordinates to the precision the zoom justifies, use short stable keys rather than descriptive ones, and omit anything that is at its default.
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class ViewState:
"""The shareable state of a dashboard, encoded compactly for a URL."""
lon: float
lat: float
zoom: float
layers: tuple[str, ...] = ()
selected: str | None = None
schema: int = 1
@staticmethod
def _precision_for(zoom: float) -> int:
"""Decimal places worth keeping: ~1 m at street zoom, coarser above."""
if zoom >= 14:
return 5
if zoom >= 9:
return 4
return 3
def to_fragment(self) -> str:
p = self._precision_for(self.zoom)
parts = [
f"v={self.schema}",
f"@={self.lon:.{p}f},{self.lat:.{p}f},{self.zoom:.1f}",
]
if self.layers:
parts.append("l=" + ",".join(sorted(self.layers)))
if self.selected:
parts.append(f"f={self.selected}")
return "#" + "&".join(parts)
@classmethod
def from_fragment(cls, fragment: str, default: "ViewState") -> "ViewState":
"""Parse a fragment, falling back to the default on anything malformed."""
raw = fragment.lstrip("#")
if not raw:
return default
pairs = dict(
part.split("=", 1) for part in raw.split("&") if "=" in part
)
try:
schema = int(pairs.get("v", "1"))
if schema != 1:
return default # migrate old links, never guess
lon_s, lat_s, zoom_s = pairs["@"].split(",")
layers = tuple(
x for x in pairs.get("l", "").split(",") if x
)
return cls(
lon=float(lon_s),
lat=float(lat_s),
zoom=float(zoom_s),
layers=layers,
selected=pairs.get("f") or None,
schema=schema,
)
except (KeyError, ValueError):
return default # a broken link must still open a map
Two details in that code are load-bearing. Sorting the layer list makes two readers who enabled the same layers in a different order produce byte-identical URLs, which matters for deduplication and for caching. And every parse failure falls back to the default view rather than raising — a truncated link pasted from an email client must still open a working map, not an error page.
Step 3 — Restore Before First Paint
State restored after the map has rendered produces a visible jump: the reader sees the default view, then the map flies somewhere else. Worse, if the restore uses an animated camera method the jump takes a second and looks like a bug.
Parse the URL first, then construct the map with the restored values as its initial camera. Restoration then costs nothing and is invisible — which is the goal, because a deep link should feel like the page was always that way.
Step 4 — Write Back Without Flooding History
Writing state back is where dashboards break the browser. A camera move fires continuously; calling pushState on each one adds a history entry per frame, and the back button becomes useless within seconds.
The rule that works: replaceState during continuous interaction, pushState on discrete decisions. Panning and zooming replace; toggling a layer, selecting a feature or applying a filter push. Readers then get a back button that steps through decisions rather than through pixels.
// Continuous interaction: replace, never push. Throttle to idle.
map.on("moveend", () => {
const url = new URL(window.location.href);
url.hash = encodeViewState(readStateFromMap(map));
window.history.replaceState(null, "", url);
});
// Discrete decision: push, so the back button undoes it.
function setLayerVisible(id, visible) {
applyToMap(id, visible);
const url = new URL(window.location.href);
url.hash = encodeViewState(readStateFromMap(map));
window.history.pushState(null, "", url);
}
// And listen, so back and forward actually work.
window.addEventListener("popstate", () => {
applyStateToMap(map, decodeViewState(window.location.hash));
});
The popstate listener is the part that is most often missing. Without it the URL changes when the reader presses back but the map does not, which is worse than having no history integration at all.
Step 5 — Version the Schema and Keep Old Links Alive
Deep links outlive releases. Someone will open a link from a year-old ticket after the layer identifiers have changed, and the honest options are to migrate it or to fall back cleanly — never to misinterpret it. A single v= field makes both possible: unknown versions fall back to the default view, and known older versions can be mapped forward by an explicit migration function.
This is the same durability concern that makes versioned artifact paths worthwhile in static and dynamic export pipelines: the thing readers keep is not the build, it is the reference to it.
Step 6 — Saved Views When the URL Gets Too Long
Encoding works beautifully up to a point. Past a few hundred characters — a dozen active filters, a polygon drawn by hand, a list of forty selected features — the URL stops being something a person can paste into a message and starts being an artifact that email clients wrap and chat apps truncate. At that point the right move is indirection: store the state and share a short key that points at it.
For a statically hosted dashboard this need not mean running a service. A saved view can be a small JSON object written to the same object storage as the tiles, named by a hash of its content, with the URL carrying only that hash. The page fetches it on load exactly as it fetches its data manifest, and because the name is a content hash the object is immutable and cacheable forever — the same property that makes versioning tile URLs with content hashes work for tiles.
The trade is that a saved view is opaque. A reader cannot look at the link and see where it points, and a state that fails to load leaves the dashboard with nothing to fall back on. Two habits soften both problems. Keep the camera in the fragment even when the rest of the state moves to a saved view, so a broken key still opens the right place. And write the saved view with an expiry policy that matches how long links are expected to live — indefinitely for published references, a few months for ad-hoc sharing — so the storage does not accumulate one object per pan.
There is also a middle path worth knowing: compressing the state before encoding it. A filter set that occupies six hundred characters as readable key-value pairs often fits in under two hundred once serialised compactly and base64url-encoded. That keeps links self-contained and inspectable-by-machine while staying paste-safe, at the cost of being unreadable by humans — which for a state blob is usually an acceptable trade, provided the schema version stays outside the compressed blob so old links can still be recognised.
Step 7 — Give Readers an Obvious Way to Copy the Link
State in the URL is only useful if readers know it is there. Most will not think to copy the address bar of what looks like a static page, and on mobile the address bar is often hidden entirely while the map is in use.
A single visible control — a share button that copies the current URL and confirms it did — closes that gap. Place it near the map rather than in a page header, because it is a property of the view rather than of the site. Confirm the copy visibly and briefly; a silent copy leaves readers pressing the button repeatedly, unsure whether it worked.
Two refinements are worth the extra hour. First, normalise the state before copying — sort layers, round coordinates — so that the link a reader shares is the canonical form of that view rather than whatever the last pan happened to produce. Second, if the current state exceeds the length at which links survive paste, transparently write a saved view and copy the short form instead, so the reader never has to know that a threshold existed.
For dashboards embedded in another page, the share control has to reach outward: the URL that matters is the host page’s, not the frame’s. Post the state to the parent and let it update its own address, using the message discipline described in Iframe Embedding & Isolation. A frame that silently updates only its own URL produces links that open a bare map with no surrounding dashboard, which is worse than offering no share control at all.
One further habit is worth adopting early: write a test that round-trips a fully-populated state through the encoder and decoder and asserts equality. It is three lines, it runs in milliseconds, and it catches every encoding regression before a reader finds it in a link they cannot reopen.
The lifecycle of a shared link
A link is created once and opened later — sometimes much later, by someone who was not there when it was made. Following that lifecycle end to end shows where the durability requirements come from and why identifiers, defaults and schema versions all matter.
Verification & Smoke-Test
- Round trip — encode a state, decode it, and assert the result is identical including layer order.
- Malformed input — open the page with a truncated fragment and confirm a working default map, no console error.
- No flash — record the first second of load with a deep link and confirm the camera never moves.
- History sanity — pan for ten seconds, then press back once; the map should return to the previous decision, not to a slightly different pan position.
- Length — check the URL for a fully-specified view stays under about 200 characters so it survives email clients.
Troubleshooting
The URL updates but the back button does nothing
There is no popstate listener, so history entries exist but nothing applies them. Add the listener and route it through the same state-application path as the initial load.
Deep links open at the right place but with the wrong layers
The layer identifiers changed between builds. Identifiers in URLs are a public contract: keep a mapping from retired ids to current ones, and apply it during parsing.
Two readers share links that look different for the same view
The layer list is not being sorted, or coordinates are being written at full float precision. Normalise before encoding — same view, same string.
The fragment disappears after a redirect
Some hosting redirects drop fragments. If your deployment redirects — for example from a bare domain to www, or to add a trailing slash — verify the fragment survives, and prefer canonical links that avoid the redirect entirely.
Shared links behave differently for different readers
Something outside the URL is influencing the view — usually a stored preference such as a saved layer set or a remembered region. Anything that changes what the map shows must live in the link, not in storage; storage is only for how an individual likes to work, never for what they are looking at.
Gotchas & Edge Cases
- A link shared into a chat application is often fetched by a preview crawler before a human opens it; make sure that fetch cannot mutate anything, which is another reason camera state belongs in the fragment.
- Fragments are not sent to the server, which is exactly why they are right for camera state and wrong for anything the server must act on.
- A selected feature id must be stable across rebuilds; a row-number id makes every old link point at the wrong feature after the next data refresh.
- Time windows should be encoded as absolute instants, not as “last 7 days”, or a link shared today means something different tomorrow.
- Very long filter sets are better represented by a short saved-view key than by encoding every value, once the URL passes a few hundred characters.
- Restoring a state whose layer no longer exists must degrade gracefully — ignore the unknown id, keep the rest, and do not throw.
Related
- Python-to-Web Generation Workflows — the section this state layer belongs to
- Layer Management & Toggling — the registry whose identifiers appear in the URL
- Static vs Dynamic Map Export Methods — why a static artifact makes the URL the only place state can live
- Iframe Embedding & Isolation for Python-Generated Maps — passing state across a frame boundary instead of through the URL
- Popups, Tooltips & Feature Interaction — the selection that a shareable link points at