dash-leaflet2 — Leaflet 2 (alpha) mapping components for Plotly Dash 4. Wraps Leaflet 2 core directly instead of react-leaflet, exposing unified Pointer Events, BlanketOverlay canvas/WebGL layers, ES6-class subclassing, ResizeObserver sizing and map rotation as Dash components. By Pip Install Python.

dash-leaflet2 — Leaflet 2 maps for Dash

Leaflet 2 (alpha) mapping components for Plotly Dash 4, without react-leaflet.


dash-leaflet2 — Leaflet 2 maps for Dash

dash-leaflet2 wraps Leaflet 2 core directly — no react-leaflet — and

ships it as real Dash components. By Pip Install Python.

Overview

dash-leaflet is frozen on react-leaflet, which has no Leaflet 2 line, so it cannot move past Leaflet 1.9. This library skips that abstraction entirely and drives Leaflet 2 core itself, which is what puts v2's headline features inside reach of a Python callback:

pointerType, pressure and tiltX / tiltY reaching your callbacks

whole viewport, instead of the DOM layer system

The demo below is the whole claim in one page: Leaflet 2.0.0-alpha.1, rendering inside Dash 4, with no JavaScript build step.

Watch the introduction

Dash Leaflet 2.0: Drone Tracking, Image Overlays & Map Packages in Python — drone tracking, image overlays and map packages, built with this library.

<!-- component rendered from docs/home/video.py; source withheld by :code: false -->

Live demo

How it works (zero build step)

# app.py  — no JS build step
from dash import Dash, hooks, html

V = "2.0.0-alpha.1"  # WITH the dot; the dotless form 404s on unpkg
hooks.stylesheet([{"external_url": f"https://unpkg.com/leaflet@{V}/dist/leaflet.css",
                   "external_only": True}])
hooks.script([{"external_url": f"https://unpkg.com/leaflet@{V}/dist/leaflet-global.js",
               "external_only": True}])   # exposes window.leaflet (NOT window.L)

app = Dash(__name__)
app.layout = html.Div(className="leaflet2-map", **{"data-demo": "home"},
                      style={"height": "60vh"})

# assets/leaflet2_maps.js mounts the map:
#   const map = new leaflet.Map(el).setView([49.286, -123.12], 12);
#   new leaflet.TileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png").addTo(map);

Source

# File: docs/home/example.py

"""Home — prove Leaflet 2 alpha renders inside Dash 4."""

import dash_mantine_components as dmc
from dl2_shared import info_panel, map_div


component = dmc.Stack(
    [
        map_div("home"),
        info_panel(
            "What the other pages show",
            dmc.List(
                [
                    dmc.ListItem(
                        "Pointer Events — v2's unified pointer model (pen pressure, tilt)."
                    ),
                    dmc.ListItem(
                        "Canvas Renderer — thousands of points through one <canvas>."
                    ),
                    dmc.ListItem(
                        "ES6 Subclassing — custom Control + a BlanketOverlay canvas layer."
                    ),
                    dmc.ListItem(
                        "ResizeObserver Sizing — no more gray tiles in collapsible panels."
                    ),
                    dmc.ListItem(
                        "Vector Layers — Polygon / Polyline / Circle / CircleMarker."
                    ),
                    dmc.ListItem(
                        "Events → Python — map state round-tripped into @callback."
                    ),
                ]
            ),
        ),
    ],
    gap="md",
)

The helpers every example imports

Each example page publishes its own source, and those examples import three small modules that live beside them in the repository. They are page furniture rather than library API — dash-leaflet2 itself needs none of them — but an example lifted from this site will not run without them, so they are published here once rather than repeated on all 27 pages:

controls and readouts in, and map_div, the mount point the showcase JavaScript builds a map into.

…) and register_theme_swap, which is what makes every map follow the site's light/dark toggle.

center and helpers for placing things a known distance from it.

The three modules the examples import

# File: dl2_shared.py

"""Shared layout helpers so every example page looks consistent."""

import dash_mantine_components as dmc
from dash import html


def map_div(demo_id, height="60vh", **kwargs):
    """The mount point the JS runtime (assets/leaflet2_maps.js) builds into.

    `data-demo` selects which DEMOS[...] builder runs. We wrap it in a Paper so
    it matches the dash-leaflet test app's framing.
    """
    style = {"height": height}
    style.update(kwargs.pop("style", {}))
    return dmc.Paper(
        html.Div(
            className="leaflet2-map", style=style, **{"data-demo": demo_id}, **kwargs
        ),
        shadow="sm",
        radius="md",
        withBorder=True,
        style={"overflow": "hidden"},
    )


def header(title, desc, badge=None):
    bits = [dmc.Title(title, order=1)]
    if badge:
        bits = [
            dmc.Group(
                [
                    dmc.Title(title, order=1),
                    dmc.Badge(badge, color="green", variant="light"),
                ]
            )
        ]
    bits.append(dmc.Text(desc, c="dimmed"))
    return dmc.Stack(bits, gap=4)


def code_panel(title, code):
    return dmc.Paper(
        [dmc.Title(title, order=4, mb="sm"), dmc.Code(code, block=True)],
        shadow="sm",
        radius="md",
        p="md",
        withBorder=True,
    )


def info_panel(title, children):
    return dmc.Paper(
        [dmc.Title(title, order=4, mb="sm"), children],
        shadow="sm",
        radius="md",
        p="md",
        withBorder=True,
    )

The three modules the examples import

# File: dl2_tiles.py

"""Light/dark basemap pairs for the documentation examples.

Every live demo has to read correctly in both colour schemes. This module is
the registry that makes that a one-liner, and the single place the theme wiring
lives.

The bug this replaces
---------------------
Twelve example pages swapped their tile URL with::

    clientside_callback(
        "(checked) => (checked ? LIGHT : DARK)",
        Output("some-tile", "url"),
        Input("color-scheme-toggle", "checked"),   # <-- wrong prop
    )

``color-scheme-toggle`` is an ``ActionIcon`` (see ``components/header.py``); it
has no ``checked`` prop. The callback therefore always received ``undefined``,
``undefined ? LIGHT : DARK`` always took the dark branch, and every one of those
maps rendered its DARK basemap in light mode. The pattern was inherited from an
earlier build where the toggle really was a ``Switch``.

The source of truth is ``color-scheme-storage`` — the ``dcc.Store`` in
``components/appshell.py`` that holds the string ``"light"`` or ``"dark"``, is
persisted to localStorage, and already drives ``MantineProvider.forceColorScheme``.
:func:`register_theme_swap` reads that, so a page cannot get the polarity wrong.

Usage
-----
    from dl2_tiles import POSITRON, themed_tile, register_theme_swap

    dl2.Map(children=[themed_tile("my-tile", POSITRON)])
    register_theme_swap("my-tile", POSITRON)

`python dl2_tiles.py` prints the registry and audits which pages use which
pair, so a reviewer can see the variety at a glance.
"""
from __future__ import annotations

from dataclasses import dataclass

from _tile_catalog import PROVIDERS

# The Store in components/appshell.py holding "light" | "dark".
SCHEME_STORE_ID = "color-scheme-storage"


@dataclass(frozen=True)
class TilePair:
    """A basemap with a light and a dark form."""

    key: str
    label: str
    light: str          # provider slug in _tile_catalog
    dark: str           # provider slug in _tile_catalog
    note: str           # why this pair, for the page prose

    def _p(self, slug: str) -> dict:
        try:
            return PROVIDERS[slug]
        except KeyError as exc:  # a typo here would silently render a blank map
            raise KeyError(
                f"TilePair {self.key!r} references unknown provider {slug!r}"
            ) from exc

    def url(self, scheme: str = "light") -> str:
        return self._p(self.dark if scheme == "dark" else self.light)["url"]

    def attribution(self, scheme: str | None = None) -> str:
        """Credit for this pair.

        Called with no scheme (the normal case) this returns a **combined**
        credit naming both providers, because ``dl2.TileLayer.attribution`` is
        construction-only: the component builds the Leaflet layer once in
        ``useEffect([map])`` and only ``url`` / ``opacity`` / ``zIndex`` have
        update effects (see ``src/ts/components/TileLayer.tsx``). A theme swap
        therefore changes the tiles but can never change the credit — so the
        credit has to be true for both from the start. Pass an explicit scheme
        only when you genuinely want one side's string.
        """
        light = self._p(self.light)["attribution"]
        dark = self._p(self.dark)["attribution"]
        if scheme == "light":
            return light
        if scheme == "dark":
            return dark
        if light == dark:
            return light
        # Both are served depending on the reader's colour scheme, so both are
        # credited. Joined with a separator rather than concatenated so the two
        # provider links stay visually distinct in the attribution box.
        return f"{light} &middot; {dark}"

    def max_zoom(self) -> int:
        """The SMALLER of the two — the shared ceiling.

        Taking the light layer's max would let a user zoom past the dark
        layer's last level and hit blank tiles after a theme flip.
        """
        return min(self._p(self.light)["max_zoom"], self._p(self.dark)["max_zoom"])

    def kwargs(self, scheme: str = "light") -> dict:
        """Ready to splat into ``dl2.TileLayer(**pair.kwargs())``."""
        return {
            "url": self.url(scheme),
            # Combined credit, not this scheme's — see attribution().
            "attribution": self.attribution(),
            "maxZoom": self.max_zoom(),
        }


# ---------------------------------------------------------------------------
# The registry
#
# Pairs, not single basemaps, so every example can be read in either scheme.
# Where a genuine light/dark restyle of one cartography exists (CARTO's
# Positron/Dark Matter, Esri's Light/Dark Gray Canvas) we use it. Where it does
# not, the pair is two maps of the same character at the two ends of the
# brightness range — noted per entry, because that IS a design decision.
# ---------------------------------------------------------------------------

POSITRON = TilePair(
    "positron", "CARTO Positron / Dark Matter",
    "carto_positron", "carto_dark",
    "The reference pair — one cartography, two palettes. Neutral enough that "
    "overlaid data always wins.",
)
VOYAGER = TilePair(
    "voyager", "CARTO Voyager / Dark Matter",
    "carto_voyager", "carto_dark",
    "Voyager keeps road classes and land-use colour, so it reads as a real "
    "street map rather than a backdrop.",
)
OSM_CLASSIC = TilePair(
    "osm_classic", "OpenStreetMap / Dark Matter (no labels)",
    "osm_mapnik", "carto_dark_nolabels",
    "Standard OSM Mapnik. It has no dark form, so the dark side drops to "
    "CARTO's label-free dark base and lets the demo's own labels carry.",
)
ESRI_CANVAS = TilePair(
    "esri_canvas", "Esri Light Gray / Dark Gray Canvas",
    "esri_gray_canvas", "esri_dark_gray",
    "Esri's canvas pair — deliberately desaturated, designed as a substrate "
    "for data. The truest light/dark twin in the catalogue after Positron.",
)
ESRI_STREET = TilePair(
    "esri_street", "Esri World Street / Dark Gray Canvas",
    "esri_world_street", "esri_dark_gray",
    "Detailed street cartography in light, dropping to the muted dark canvas.",
)
TOPO = TilePair(
    "topo", "OpenTopoMap / Esri World Terrain",
    "opentopomap", "esri_world_terrain",
    "Contours and relief. Terrain is inherently light-toned, so the dark side "
    "uses Esri's flatter, darker terrain base.",
)
SATELLITE = TilePair(
    "satellite", "Esri World Imagery / USGS Imagery",
    "esri_world_imagery", "usgs_imagery",
    "Aerial imagery is photographic — it has no light or dark form. So this "
    "pair is two different sources rather than a restyle: Esri's global "
    "mosaic, and USGS's higher-contrast US imagery for the dark scheme. What "
    "really adapts on a satellite page is the map chrome — tooltips, popups "
    "and controls — via the liquid-glass theme.",
)
OCEAN = TilePair(
    "ocean", "Esri Ocean / Dark Gray Canvas",
    "esri_ocean", "esri_dark_gray",
    "Bathymetry and depth contours — the right substrate for anything marine.",
)
NATGEO = TilePair(
    "natgeo", "Esri NatGeo / Shaded Relief",
    "esri_natgeo", "esri_shaded_relief",
    "NatGeo's editorial cartography, dropping to plain shaded relief in dark "
    "where NatGeo's warm paper tone would fight the UI.",
)
PHYSICAL = TilePair(
    "physical", "Esri World Physical / Shaded Relief",
    "esri_world_physical", "esri_shaded_relief",
    "Landcover without any labels — pure backdrop.",
)
TRANSIT = TilePair(
    "transit", "ÖPNV Karte / Dark Matter",
    "opnv_karte", "carto_dark_nolabels",
    "Public-transport cartography: routes and stops promoted over roads.",
)
CYCLE = TilePair(
    "cycle", "CyclOSM / Dark Matter",
    "cyclosm", "carto_dark_nolabels",
    "Cycle infrastructure rendering — a good stress test for dense line work.",
)
USGS_TOPO = TilePair(
    "usgs_topo", "USGS Topo / USGS Imagery",
    "usgs_topo", "usgs_imagery",
    "The USGS quad sheet in light, its aerial counterpart in dark.",
)

ALL: tuple[TilePair, ...] = (
    POSITRON, VOYAGER, OSM_CLASSIC, ESRI_CANVAS, ESRI_STREET, TOPO,
    SATELLITE, OCEAN, NATGEO, PHYSICAL, TRANSIT, CYCLE, USGS_TOPO,
)

BY_KEY = {pair.key: pair for pair in ALL}


# ---------------------------------------------------------------------------
# Wiring
# ---------------------------------------------------------------------------

def themed_tile(tile_id: str, pair: TilePair, **kwargs):
    """A ``dl2.TileLayer`` starting on the pair's LIGHT form.

    Light is the right initial render: :func:`register_theme_swap` fires on
    page load (``prevent_initial_call`` is left off) and corrects to dark
    immediately when that is the stored scheme, whereas starting dark would
    flash a dark map at every light-mode visitor.
    """
    import dash_leaflet2 as dl2

    props = pair.kwargs("light")
    props.update(kwargs)
    return dl2.TileLayer(id=tile_id, **props)


def register_theme_swap(tile_id: str, pair: TilePair) -> None:
    """Swap ``tile_id``'s url + attribution when the colour scheme changes.

    Reads ``color-scheme-storage`` (``"light"`` | ``"dark"``) — NOT the header
    ActionIcon, which has no ``checked`` prop and silently pinned every map to
    its dark basemap. ``None`` (a first visit with nothing in localStorage yet)
    is treated as light, matching the appshell's own default.
    """
    import json

    from dash import Input, Output, clientside_callback

    # `url` ONLY. `attribution` is deliberately not an output: it is
    # construction-only in dl2.TileLayer, so writing it would set a Dash prop
    # the map never reads — a control that looks wired and does nothing.
    # :meth:`TilePair.attribution` credits both providers instead.
    #
    # json.dumps, NOT repr(). Attribution and URL strings can contain double
    # quotes, so a repr() + blanket `'`->`"` swap produces invalid JavaScript
    # and the callback silently becomes a syntax error — every map then keeps
    # whatever URL it first rendered with, which is exactly the failure this
    # module exists to fix. scripts/smoke_test.py node --check's every inline
    # script to keep that from recurring.
    clientside_callback(
        f"""
        function(scheme) {{
            return scheme === "dark"
                ? {json.dumps(pair.url("dark"))}
                : {json.dumps(pair.url("light"))};
        }}
        """,
        Output(tile_id, "url"),
        Input(SCHEME_STORE_ID, "data"),
    )


def _audit() -> None:
    """Print the registry and which example uses each pair."""
    import re
    from pathlib import Path

    root = Path(__file__).parent
    used: dict[str, list[str]] = {}
    for example in sorted((root / "docs").glob("*/example.py")):
        text = example.read_text()
        for group in re.findall(r"from dl2_tiles import ([A-Z_, \n()]+)", text):
            for symbol in (s.strip(" ()\n") for s in group.split(",")):
                if symbol in globals() and isinstance(globals()[symbol], TilePair):
                    used.setdefault(symbol, []).append(example.parent.name)

    print(f"{len(ALL)} light/dark pairs registered\n")
    for pair in ALL:
        pages = used.get(pair.key.upper(), [])
        print(f"  {pair.label:<44} {', '.join(pages) if pages else '— free'}")
        print(f"      light {pair.light:<22} dark {pair.dark:<22} maxZoom {pair.max_zoom()}")
    unused = [p for p in ALL if p.key.upper() not in used]
    print(f"\n{len(ALL) - len(unused)} in use, {len(unused)} free")


if __name__ == "__main__":
    _audit()

The three modules the examples import

# File: dl2_locations.py

"""Named map locations for the documentation examples.

Every live demo used to open on the same patch of Rockport, TX, which made the
documentation read as one map shown twenty-six times. Each example now opens
somewhere different, and this module is the registry that keeps it that way:
pick a `Location` here rather than pasting a literal `center=[lat, lon]`.

Why a registry and not just different literals
----------------------------------------------
Most examples draw *geometry* around their center — polygons, image-overlay
bounds, pan clamps, jittered point clouds. Moving a demo from 28°N to 49°N and
keeping the same degree offsets would squash every shape east-to-west, because a
degree of longitude is ~98 km at Rockport and only ~73 km in Vancouver. So the
helpers below take **kilometres** and convert, which keeps a "3 km box" the same
real-world size wherever it lands.

Usage
-----
    from dl2_locations import VANCOUVER

    dl2.Map(center=VANCOUVER.center, zoom=VANCOUVER.zoom, children=[...])

    # 2 km north, 3 km east of the center
    dl2.Marker(position=VANCOUVER.at(north_km=2, east_km=3))

    # a 12 x 16 km box centred on the city, as [[s, w], [n, e]]
    dl2.ImageOverlay(bounds=VANCOUVER.bounds(6, 8))

Adding an example? Take an unused location from :data:`ALL` — `python
-m dl2_locations` prints which ones are still free by scanning `docs/`.
"""
from __future__ import annotations

import math
from dataclasses import dataclass

# Mean length of a degree of latitude, in km. Good to ~0.1% anywhere, which is
# far tighter than any demo needs.
_KM_PER_DEG_LAT = 111.32


@dataclass(frozen=True)
class Location:
    """A named place a documentation example can open on."""

    key: str
    label: str          # "Vancouver, BC"
    lat: float
    lon: float
    zoom: int
    blurb: str          # one clause on what you're looking at, for page prose

    # ---- basics ----------------------------------------------------------
    @property
    def center(self) -> list[float]:
        """`[lat, lon]` — the shape Leaflet and every dl2 component want."""
        return [self.lat, self.lon]

    @property
    def lonlat(self) -> list[float]:
        """`[lon, lat]` — GeoJSON's axis order, which is the other way round."""
        return [self.lon, self.lat]

    # ---- offsets in real-world units --------------------------------------
    def at(self, north_km: float = 0.0, east_km: float = 0.0) -> list[float]:
        """A point `north_km` / `east_km` from the center, as `[lat, lon]`.

        Longitude is scaled by `cos(lat)`, so the same call describes the same
        ground distance at every location.
        """
        dlat = north_km / _KM_PER_DEG_LAT
        dlon = east_km / (_KM_PER_DEG_LAT * math.cos(math.radians(self.lat)))
        return [round(self.lat + dlat, 6), round(self.lon + dlon, 6)]

    def at_lonlat(self, north_km: float = 0.0, east_km: float = 0.0) -> list[float]:
        """:meth:`at`, in GeoJSON's `[lon, lat]` order."""
        lat, lon = self.at(north_km, east_km)
        return [lon, lat]

    def bounds(self, half_ns_km: float, half_ew_km: float) -> list[list[float]]:
        """A box centred here, as Leaflet's `[[south, west], [north, east]]`."""
        return [
            self.at(-half_ns_km, -half_ew_km),
            self.at(half_ns_km, half_ew_km),
        ]

    def ring(self, points: list[tuple[float, float]]) -> list[list[float]]:
        """Translate a list of `(north_km, east_km)` offsets into `[lat, lon]`.

        Handy for polygons and polylines: describe the shape once in kilometres
        and it renders identically wherever the demo is set.
        """
        return [self.at(n, e) for n, e in points]

    # ---- slippy tiles ------------------------------------------------------
    def tile(self, zoom: int) -> tuple[int, int]:
        """The XYZ tile `(x, y)` containing this location at `zoom`."""
        lat_rad = math.radians(self.lat)
        n = 2 ** zoom
        x = int((self.lon + 180.0) / 360.0 * n)
        y = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n)
        return x, y

    def tile_key(self, zoom: int) -> str:
        """`"z/x/y"` for this location — the key format the demos use."""
        x, y = self.tile(zoom)
        return f"{zoom}/{x}/{y}"

    def nested_tile_keys(self, start_zoom: int, levels: int = 3) -> list[str]:
        """`levels` tile keys where each is the NW child of the one before.

        `compare-lab` needs genuine ancestor/descendant nesting to exercise its
        cross-zoom association math, and the NW child of `(x, y)` is always
        `(2x, 2y)` — so walking down from one real tile guarantees it.
        """
        x, y = self.tile(start_zoom)
        keys = []
        for i in range(levels):
            keys.append(f"{start_zoom + i}/{x}/{y}")
            x, y = x * 2, y * 2
        return keys


# ---------------------------------------------------------------------------
# The registry
#
# One location per example. Coordinates sit on something worth looking at —
# a harbour, a river confluence, a downtown core — rather than a centroid in a
# suburb, because the demo is the first impression of the component.
# ---------------------------------------------------------------------------

VANCOUVER = Location(
    "vancouver", "Vancouver, BC", 49.2860, -123.1200, 12,
    "Coal Harbour and the downtown peninsula, with the North Shore mountains behind",
)
PORTLAND = Location(
    "portland", "Portland, OR", 45.5202, -122.6742, 12,
    "the Willamette cutting through downtown, bridges every few blocks",
)
NEW_YORK = Location(
    "new_york", "New York, NY", 40.7484, -73.9857, 12,
    "Midtown Manhattan, with the island's grid running to the rivers on both sides",
)
CHICAGO = Location(
    "chicago", "Chicago, IL", 41.8827, -87.6233, 12,
    "the Loop against the Lake Michigan shoreline",
)
DALLAS = Location(
    "dallas", "Dallas, TX", 32.7791, -96.8005, 12,
    "downtown Dallas inside the freeway ring",
)
HOUSTON = Location(
    "houston", "Houston, TX", 29.7589, -95.3677, 12,
    "downtown Houston where the bayou bends",
)
SEATTLE = Location(
    "seattle", "Seattle, WA", 47.6062, -122.3321, 12,
    "downtown between Elliott Bay and Lake Union",
)
SAN_FRANCISCO = Location(
    "san_francisco", "San Francisco, CA", 37.7955, -122.3937, 12,
    "the Embarcadero waterfront and the bay",
)
BOSTON = Location(
    "boston", "Boston, MA", 42.3601, -71.0589, 12,
    "the harbour and the tangle of streets that predate the grid",
)
DENVER = Location(
    "denver", "Denver, CO", 39.7392, -104.9903, 12,
    "downtown Denver with the Front Range to the west",
)
TORONTO = Location(
    "toronto", "Toronto, ON", 43.6426, -79.3871, 12,
    "the waterfront and the islands across the harbour",
)
MONTREAL = Location(
    "montreal", "Montréal, QC", 45.5017, -73.5673, 12,
    "Vieux-Montréal along the St. Lawrence",
)
MIAMI = Location(
    "miami", "Miami, FL", 25.7743, -80.1937, 12,
    "Biscayne Bay, the causeways and the beach barrier island",
)
SAN_DIEGO = Location(
    "san_diego", "San Diego, CA", 32.7157, -117.1611, 12,
    "the natural harbour, with Coronado closing it off",
)
PHILADELPHIA = Location(
    "philadelphia", "Philadelphia, PA", 39.9526, -75.1652, 12,
    "Center City between the Schuylkill and the Delaware",
)
MINNEAPOLIS = Location(
    "minneapolis", "Minneapolis, MN", 44.9778, -93.2650, 12,
    "downtown on the Mississippi, lakes scattered to the southwest",
)
PITTSBURGH = Location(
    "pittsburgh", "Pittsburgh, PA", 40.4406, -79.9959, 12,
    "the Golden Triangle where three rivers meet — unmistakable at any zoom",
)
HONOLULU = Location(
    "honolulu", "Honolulu, HI", 21.3069, -157.8583, 12,
    "Waikīkī, Diamond Head and the reef line",
)
NASHVILLE = Location(
    "nashville", "Nashville, TN", 36.1627, -86.7816, 12,
    "downtown inside the Cumberland's horseshoe bend",
)
AUSTIN = Location(
    "austin", "Austin, TX", 30.2672, -97.7431, 12,
    "downtown along Lady Bird Lake",
)
CHARLESTON = Location(
    "charleston", "Charleston, SC", 32.7833, -79.9333, 12,
    "the peninsula between the Ashley and the Cooper",
)
SAVANNAH = Location(
    "savannah", "Savannah, GA", 32.0776, -81.0912, 15,
    "the historic district's grid of squares — a genuinely walkable street plan",
)
SALT_LAKE_CITY = Location(
    "salt_lake_city", "Salt Lake City, UT", 40.7608, -111.8910, 12,
    "downtown with the Wasatch Range rising immediately east",
)
WASHINGTON_DC = Location(
    "washington_dc", "Washington, DC", 38.8899, -77.0091, 12,
    "the National Mall between the Capitol and the Potomac",
)
NEW_ORLEANS = Location(
    "new_orleans", "New Orleans, LA", 29.9511, -90.0715, 12,
    "the French Quarter inside the Mississippi's crescent",
)

# Not North America. `rotation-basic` has always opened on London — it is the
# canonical Leaflet example view, which is the right nod for the page that
# demonstrates v2's rotation. Registered so the audit knows it is taken.
LONDON = Location(
    "london", "London, UK", 51.5050, -0.0900, 12,
    "the City and the Thames — Leaflet's own canonical example view",
)

ALL: tuple[Location, ...] = (
    VANCOUVER, PORTLAND, NEW_YORK, CHICAGO, DALLAS, HOUSTON,
    SEATTLE, SAN_FRANCISCO, BOSTON, DENVER, TORONTO, MONTREAL,
    MIAMI, SAN_DIEGO, PHILADELPHIA, MINNEAPOLIS, PITTSBURGH, HONOLULU,
    NASHVILLE, AUSTIN, CHARLESTON, SAVANNAH, SALT_LAKE_CITY,
    WASHINGTON_DC, NEW_ORLEANS, LONDON,
)

BY_KEY = {loc.key: loc for loc in ALL}


def _audit() -> None:
    """Print which locations are used by which example, and which are free.

    Scans three places, because the demos are wired three different ways:
    the Python examples import from here, `usage.py` does too, and the
    hooks/CDN showcase pages are driven by a parallel `CITY` table inside
    `assets/leaflet2_maps.js`. Reporting only the first would call Vancouver
    "free" while the home page is sitting on it.
    """
    import re
    from pathlib import Path

    root = Path(__file__).parent
    used: dict[str, list[str]] = {}

    def mark(symbol: str, where: str) -> None:
        if symbol in globals() and where not in used.setdefault(symbol, []):
            used[symbol].append(where)

    for example in sorted((root / "docs").glob("*/example.py")):
        for group in re.findall(r"from dl2_locations import ([A-Z_, ]+)", example.read_text()):
            for symbol in (s.strip() for s in group.split(",")):
                mark(symbol, example.parent.name)

    usage = root / "usage.py"
    if usage.exists():
        for group in re.findall(r"from dl2_locations import ([A-Z_, ]+)", usage.read_text()):
            for symbol in (s.strip() for s in group.split(",")):
                mark(symbol, "usage.py")

    # The JS half: `CITY.<camelCase>` referenced inside a DEMOS builder. Map
    # each camelCase key back to this module's SNAKE_CASE symbol.
    js = root / "assets" / "leaflet2_maps.js"
    if js.exists():
        text = js.read_text()
        demo = "?"
        for line in text.splitlines():
            found = re.search(r'^\s{4}"?([a-zA-Z-]+)"?\(el, L\)', line)
            if found:
                demo = found.group(1)
            for key in re.findall(r"CITY\.([a-zA-Z]+)", line):
                snake = re.sub(r"(?<!^)(?=[A-Z])", "_", key).upper()
                mark(snake, f"{demo} (js)")

    print(f"{len(ALL)} locations registered\n")
    for loc in ALL:
        pages = used.get(loc.key.upper(), [])
        print(f"  {loc.label:<22} {', '.join(pages) if pages else '— free'}")

    free = [loc for loc in ALL if loc.key.upper() not in used]
    print(f"\n{len(ALL) - len(free)} in use, {len(free)} free")
    if free:
        print("Free for a new example: " + ", ".join(loc.key.upper() for loc in free))


if __name__ == "__main__":
    _audit()

Source: /

Note for AI agents: This is the static, prerendered view of an interactive Dash application served because we detected a non-JS user agent. Full prose docs: