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?

Which tier each piece of dashboard state belongs to Shareable state belongs in the URL: camera position, active layers, filter values, selected feature and time window. Session state belongs in local storage: theme choice, panel widths, whether an introduction has been dismissed. Ephemeral state stays in memory: hover target, open popup, in-flight requests and animation progress. Would this help a colleague opening the link? That is the whole test. shareable → the URL camera centre and zoom · active layer set · filter values · selected feature id · time window these describe WHAT IS BEING LOOKED AT — they are the content of the link session-local → localStorage theme choice · panel widths · dismissed introductions · preferred units these describe HOW THIS PERSON WORKS — imposing them on a colleague is rude ephemeral → memory only hover target · open popup · in-flight requests · animation progress · drag state writing any of these to the URL produces hundreds of history entries per minute

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.

Parse then construct, never construct then move In the correct order the URL is parsed, the map is constructed with the restored camera and layer set, and the first frame is already correct. In the incorrect order the map is constructed with defaults, paints the wrong view, then animates to the restored one, which reads as a glitch and re-fetches tiles for a view nobody wanted. Restoration should be invisible correct — parse, then construct read the fragment → build the map with that centre, zoom and layer set → first frame is right no wasted tile fetches, no movement, nothing for the reader to notice incorrect — construct, then move build with defaults → paint the wrong place → flyTo the restored view two tile fetches, a visible jump, and layer toggles that flash on and off The same rule applies to the theme and to any generated basemap choice — decide before the first paint, not after it.

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.

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.

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.

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.

What a shared link has to survive A link is created from the current state, pasted into a ticket or a message, and opened later by someone else — possibly after the dashboard has been redeployed, layers renamed and coverage extended. Identifiers, defaults and the schema version are what let it still mean what its author intended. Created once, opened much later, by someone else created encodes the difference from defaults shared pasted into a ticket or a message opened months later after redeploys and renames What has to still be true at the far end ◦ every identifier in the link still names the same thing, or an alias maps it to what does ◦ a layer added since then takes its default rather than being switched off ◦ an unrecognised schema version opens a working map instead of an error

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.

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.

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.

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.