# dash-leaflet2 — Leaflet 2 maps for Dash — full corpus

## Access policy

- Terms: these documents are free to fetch. A free account unlocks any gated document.
- Identity: agents may present a key by appending `?key=<value>` to any document URL. Get one: https://2plot.ai
- Rate: prefer ONE `/llms-full.txt` fetch over N per-page fetches. On 429, honour `Retry-After` and back off exponentially.
- Coordination: start at https://2plot.dev/llms.txt — one index enumerates every site; do not rediscover the network by crawling it.
- Crawler policy (mirrors /robots.txt): allowed: GPTBot, ClaudeBot, CCBot, Google-Extended, FacebookBot, Omgili, ByteSpider, Amazonbot, Applebot-Extended, meta-externalagent, AI2Bot, Diffbot, Timpibot, ImagesiftBot, ChatGPT-User, Claude-User, Claude-SearchBot, PerplexityBot, OAI-SearchBot, Perplexity-User, Googlebot, Bingbot, Slurp, DuckDuckBot, GoogleOther, Google-InspectionTool, Storebot-Google, AdsBot-Google.
- Accounting: every document read is logged with the requesting vendor (verified against published IP ranges where the operator publishes them). See https://2plot.dev/llms.txt

> 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.

Generated from 31 pages. The page index lives at https://leaflet.2plot.dev/llms.txt and a compact briefing at https://leaflet.2plot.dev/llms-small.txt; each page also serves its own document at `<page>/llms.txt`.

---

<!-- / — https://leaflet.2plot.dev/llms.txt -->

# 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](https://github.com/2plotai).

### 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:

- **Unified Pointer Events** — one event model for mouse, touch and stylus, with
  `pointerType`, `pressure` and `tiltX` / `tiltY` reaching your callbacks
- **`BlanketOverlay` canvas / WebGL layers** — your own renderer across the
  whole viewport, instead of the DOM layer system
- **ES6-class subclassing** — extend a Leaflet 2 class and mount the result
- **`ResizeObserver` sizing** — no grey tiles for a map born in a hidden tab
- **Map rotation** — `bearing` as a first-class, two-way prop

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](https://youtu.be/Wlmw98JrJZI) — 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)

```python
# 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


```python
# 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:

- **`dl2_shared`** — `info_panel`, the titled `Paper` the demos put their
  controls and readouts in, and `map_div`, the mount point the showcase
  JavaScript builds a map into.
- **`dl2_tiles`** — the named basemaps (`POSITRON`, `VOYAGER`, `SATELLITE`,
  …) and `register_theme_swap`, which is what makes every map follow the
  site's light/dark toggle.
- **`dl2_locations`** — the cities the demos centre on, each with a
  `center` and helpers for placing things a known distance from it.


**The three modules the examples import**

```python
# 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**

```python
# 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**

```python
# 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: /*

---

<!-- /api — https://leaflet.2plot.dev/api/llms.txt -->

# API reference

## dash_leaflet2

### AttributionControl

AttributionControl adds an explicitly-controlled attribution box to the map. Place
it as a child of `dl2.Map` with `attributionControl=False` to take over from the
bundled default; both `position` and `prefix` are two-way (mutable from Python
callbacks). Pass `prefix=False` to hide the "Leaflet" link.

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | The ID used to identify this component in Dash callbacks. |
| `className` | string |  | Often-used CSS class name(s) for the root element. |
| `position` | one of 'topleft', 'topright', 'bottomleft', 'bottomright' | 'bottomright' | Map control position. Default 'bottomright'. [MUTABLE] |
| `prefix` | string \| bool |  | HTML shown before the layer attributions. Default Leaflet's "Leaflet" link. Pass `False` (or empty string) to hide the prefix entirely. [MUTABLE] |
| `style` | dict |  | Inline style for the root element. For Map, this is where you set height. |

### BaseLayer

BaseLayer wraps a layer (typically a TileLayer) and registers it as a base layer in the
parent LayersControl. Bases are mutually exclusive (radio). Place it as a child of
LayersControl, with a single layer component (e.g. TileLayer) as its own child.

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | The ID used to identify this component in Dash callbacks. |
| `checked` | bool | false | Initially selected base layer? Exactly one base is active at a time. |
| `children` | node |  | The Leaflet layer (typically a dl2.TileLayer) controlled by this entry. |
| `className` | string |  | Often-used CSS class name(s) for the root element. |
| `name` | string | 'Base' | Display name shown in the LayersControl (also the radio's identity). |
| `style` | dict |  | Inline style for the root element. For Map, this is where you set height. |

### Circle

Circle draws a circle with a radius in meters (it grows/shrinks with zoom). For a
fixed-pixel circle use CircleMarker. Place it as a child of Map. Wraps Leaflet 2's Circle.

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | The ID used to identify this component in Dash callbacks. |
| `center` | tuple | [51.505, -0.09] | Center as [lat, lng]. [MUTABLE] |
| `children` | node |  | Popup / Tooltip children. |
| `className` | string |  | Often-used CSS class name(s) for the root element. |
| `color` | string | '#3388ff' | Stroke color. [MUTABLE] |
| `fillColor` | string |  | Fill color (defaults to stroke color). [MUTABLE] |
| `fillOpacity` | number | 0.2 | Fill opacity, 0..1. [MUTABLE] |
| `n_clicks` | number |  | Times the circle has been clicked. [READONLY] |
| `radius` | number | 100 | Radius in METERS (geographic). [MUTABLE] |
| `style` | dict |  | Inline style for the root element. For Map, this is where you set height. |
| `weight` | number | 3 | Stroke width in pixels. [MUTABLE] |

### CircleMarker

CircleMarker draws a circle with a fixed pixel radius (it stays the same size at every
zoom). For a metric radius use Circle. Place it as a child of Map. Wraps Leaflet 2's
CircleMarker.

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | The ID used to identify this component in Dash callbacks. |
| `center` | tuple | [51.505, -0.09] | Center as [lat, lng]. [MUTABLE] |
| `children` | node |  | Popup / Tooltip children. |
| `className` | string |  | Often-used CSS class name(s) for the root element. |
| `color` | string | '#3388ff' | Stroke color. [MUTABLE] |
| `fillColor` | string |  | Fill color (defaults to stroke color). [MUTABLE] |
| `fillOpacity` | number | 0.2 | Fill opacity, 0..1. [MUTABLE] |
| `interactive` | bool |  | Whether the circle captures pointer events (fires clicks, blocks the map click underneath). Set false for a non-interactive decoration / context overlay so it never intercepts clicks meant for the map. Construction-only. @default true |
| `n_clicks` | number |  | Times the marker has been clicked. [READONLY] |
| `radius` | number | 10 | Radius in PIXELS (fixed; does not scale with zoom). [MUTABLE] |
| `style` | dict |  | Inline style for the root element. For Map, this is where you set height. |
| `weight` | number | 3 | Stroke width in pixels. [MUTABLE] |

### EasyButton

EasyButton adds a single-icon control to the map. Use it for quick map-level actions
(open a panel, locate, zoom-home, etc.); the click is reported back to Dash as n_clicks.
Icons come from Iconify (any of the 200k+ icons), e.g. "mdi:emoticon-happy-outline".
Place it as a child of dl2.Map.

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | The ID used to identify this component in Dash callbacks. |
| `className` | string |  | Often-used CSS class name(s) for the root element. |
| `icon` | string | 'mdi:circle-medium' | Iconify icon name, e.g. "mdi:emoticon-happy-outline" or "mdi:crosshairs-gps". |
| `iconSize` | number | 18 | Icon size in pixels. |
| `n_clicks` | number | 0 | Number of times the button has been clicked. [READONLY] |
| `n_dblclicks` | number | 0 | Number of times the button has been double-clicked. [READONLY] |
| `position` | string | 'topleft' | "topleft" \| "topright" \| "bottomleft" \| "bottomright". |
| `style` | dict |  | Inline style for the root element. For Map, this is where you set height. |
| `title` | string |  | Tooltip text shown on hover. |

### EditControl

EditControl renders a Leaflet draw/edit toolbar (native v2 — leaflet-draw is Leaflet
1-only). Place it as a child of dl2.Map. Shapes are kept in an internal FeatureGroup and
surfaced via the `geojson` prop. The Edit section appears only when at least one shape
exists. A contextual sub-toolbar appears while a tool is active (Finish / Delete last
point / Cancel during draw; Save / Cancel during edit; Clear all / Cancel during remove).

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | The ID used to identify this component in Dash callbacks. |
| `action` | dict |  | The most recent action, dash-leaflet-shaped:   {layer_type: 'polygon', type: 'created'\|'edited'\|'deleted', n_actions: int} Bumps every time something happens — useful as a sole Input for "anything changed". [READONLY] |
| `activeMode` | one of 'edit', 'remove' |  | The currently active edit/remove mode, or null. [READONLY] |
| `activeTool` | one of 'text', 'circle', 'marker', 'polyline', 'polygon', 'rectangle', 'circlemarker' |  | The currently active draw tool, or null. Emitted whenever a tool is activated or cleared — pages can use this to open a popover when the user clicks a draw icon (mirrors the `/easy-button` popover-on-button-click pattern). [READONLY] |
| `className` | string |  | Often-used CSS class name(s) for the root element. |
| `draw` | objectOf |  | Per-tool enable/disable for the Draw section, e.g. {rectangle: False, marker: True}. Tools not listed default to enabled. |
| `drawToolbar` | dict |  | Python -> control: setting this prop activates a draw tool or dispatches an action on the currently active tool. Bump `n_clicks` to ensure the prop registers as changed. Shape: {mode?: tool name, action?: 'finish'\|'cancel'\|'delete last point', n_clicks: int}. [MUTABLE] |
| `edit` | objectOf |  | Per-mode enable/disable for the Edit section, e.g. {remove: False}. Modes not listed default to enabled. The Edit section only appears once at least one shape exists. |
| `editToolbar` | dict |  | Python -> control: enter edit/remove mode or dispatch save/cancel/clear-all. Bump `n_clicks` to ensure the prop changes. Shape: {mode?: 'edit'\|'remove', action?: 'save'\|'cancel'\|'clear all', n_clicks: int}. [MUTABLE] |
| `featureClick` | dict |  | The most recent feature click. Only fires while `editMode === 'edit'` — clicks on features in normal view mode do NOT emit this. The click is NOT propagated to the map (we set `bubblingMouseEvents: false`) so a Map.clickData callback only fires on empty-map clicks. Shape: {id, layerType, n_clicks}. [READONLY] |
| `featureUpdate` | dict |  | Python -> control: update or remove a single feature by its _dl2_id. Bump `n_clicks` each call to ensure the prop registers as changed. Shape:   {id, style?, properties?, remove?, n_clicks}   - style:      Leaflet path style options to apply via setStyle (color, weight, fillOpacity, ...)   - properties: merged into the feature's properties (e.g. {name: "Lighthouse"})   - remove:     drop the feature from the FeatureGroup [MUTABLE] |
| `geojson` | object |  | All currently drawn shapes as a GeoJSON FeatureCollection. [READONLY] |
| `lastAction` | object |  | Brief metadata for the most recent draw / delete event (legacy shape). [READONLY] |
| `measurementSystem` | one of 'metric', 'imperial' | 'metric' | Unit system for the live drawing previews — drives the radius readout in the circle tool and the area readout in the rectangle tool.  - 'metric' (default): meters / kilometers for distance; m² / hectares / km² for area. - 'imperial' (US customary): feet / miles for distance; ft² / acres / mi² for area.  Each formatter auto-picks the largest readable unit for the current magnitude (e.g. a 5 km radius reads "5.00 km"; a 50 m radius reads "50 m"). [MUTABLE] |
| `n_drawn` | number |  | Total shapes drawn since mount (decrements on delete). [READONLY] |
| `position` | string | 'topleft' | Control position: "topleft" \| "topright" \| "bottomleft" \| "bottomright". |
| `shapeOptions` | object | { color: '#2f9e44', weight: 3, fillOpacity: 0.2 } | Path style applied to drawn vectors (color, weight, fillOpacity, ...). |
| `showMeasurementTooltips` | bool | false | When true, every committed shape gets a permanent Leaflet tooltip showing its measured area (rectangle / circle / polygon) or length (polyline), formatted with the configured `measurementSystem`. [READONLY] |
| `style` | dict |  | Inline style for the root element. For Map, this is where you set height. |

### FeatureGroup

FeatureGroup is like LayerGroup but extends `leaflet.FeatureGroup` — it can
emit a combined GeoJSON of its vector children and broadcasts a single
`click` event no matter which child was clicked. Use it when grouping
shapes you want to treat as one unit (typical companion for `EditControl`).
Wraps Leaflet 2's FeatureGroup.

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | The ID used to identify this component in Dash callbacks. |
| `children` | node |  | Any number of layer children (Marker, Polygon, Circle, ...). |
| `className` | string |  | Often-used CSS class name(s) for the root element. |
| `geojson` | object |  | Combined GeoJSON FeatureCollection of all children (vectors only). [READONLY] |
| `n_clicks` | number |  | Number of times any child layer has been clicked. [READONLY] |
| `n_layers` | number |  | Number of times the group's children were modified. [READONLY] |
| `style` | dict |  | Inline style for the root element. For Map, this is where you set height. |

### FullScreenControl

FullScreenControl adds a single button to the map that toggles the map
container in/out of the browser's native fullscreen mode. Leaflet 2 doesn't
ship a fullscreen control — this maps the browser's `requestFullscreen()`
API onto a small `Control` subclass, matching the dash-leaflet (and
`Leaflet.fullscreen` plugin) API shape.

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | The ID used to identify this component in Dash callbacks. |
| `className` | string |  | Often-used CSS class name(s) for the root element. |
| `fullscreen` | bool |  | Whether the map is currently in fullscreen mode. [READONLY] |
| `n_clicks` | number |  | Number of times the button has been clicked. [READONLY] |
| `position` | one of 'topleft', 'topright', 'bottomleft', 'bottomright' | 'topleft' | "topleft" \| "topright" \| "bottomleft" \| "bottomright". Default "topleft". [MUTABLE] |
| `style` | dict |  | Inline style for the root element. For Map, this is where you set height. |
| `title` | string | 'Full Screen' | Tooltip text when entering fullscreen. Default "Full Screen". |
| `titleCancel` | string | 'Exit Full Screen' | Tooltip text when leaving fullscreen. Default "Exit Full Screen". |

### GeoJSON

GeoJSON renders a GeoJSON object — typically fed from a Python callback via the `data`
prop. Set `cluster=True` to collapse dense point sets via SuperCluster (the same backend
dash-leaflet 1's clustering uses). Custom `pointToLayer` / `clusterToLayer` JS strings
plus a `hideout` passthrough let you style features without round-tripping through Python.
Place it as a child of Map. Wraps Leaflet 2's GeoJSON layer.

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | The ID used to identify this component in Dash callbacks. |
| `children` | node |  | Popup / Tooltip children bound to the whole layer. |
| `className` | string |  | Often-used CSS class name(s) for the root element. |
| `clickFeature` | object |  | `properties` of the most recently clicked feature. [READONLY] |
| `cluster` | bool | false | Turn on supercluster-based point clustering. Markers within `superClusterOptions.radius` pixels collapse into a single cluster bubble; zooming in expands them. Only point geometries cluster; vector features (LineString, Polygon) are passed through unchanged. |
| `clusterToLayer` | string |  | JavaScript source for a function that builds the layer shown in place of a SuperCluster cluster. Signature: `(feature, latlng, index, ctx) => Layer`. The default is a small DivIcon with the cluster's point count. |
| `data` | object |  | A GeoJSON FeatureCollection / Feature / geometry object. [MUTABLE] |
| `hideout` | objectOf |  | Arbitrary pass-through data made available to `pointToLayer` / `clusterToLayer` as `ctx.hideout`. Use it to ship colour maps, label dictionaries, or threshold values from Python without re-evaluating the JS function. [MUTABLE] |
| `n_clicks` | number |  | Number of times any feature has been clicked. [READONLY] |
| `pointToLayer` | string |  | JavaScript source for a function that converts an individual point feature into a layer. Signature: `(feature, latlng, ctx) => Layer`, where `ctx = { hideout, leaflet, map }`. Pass the function body as a string; it is wrapped in `new Function(...)` at construction time. The default uses the bundled DEFAULT_ICON. |
| `spiderfyOnMaxZoom` | bool | false | Reserved for future support — at max zoom, "spiderfy" overlapping markers into a ring so each is individually selectable. Currently a no-op (clicking the cluster at maxZoom still triggers zoomToBoundsOnClick). |
| `style` | object \| dict |  | Path style applied to all vector features, e.g. {color, weight, fillOpacity}. [MUTABLE]   Inline style for the root element. For Map, this is where you set height. |
| `superClusterOptions` | objectOf |  | Tuning for the underlying SuperCluster index: `{ radius, minPoints, maxZoom, minZoom, extent }`. Defaults: `{ radius: 80, minPoints: 2, maxZoom: 16, minZoom: 0, extent: 512 }`. See https://github.com/mapbox/supercluster#options for the full list. |
| `zoomToBoundsOnClick` | bool | true | If true, clicking a cluster fits the map to that cluster's children's bounds. Default true. |

### ImageOverlay

ImageOverlay drapes a single static image over a geographic bounding box. With `editable`
it gains a TextMarker-style transform control system: click to select, drag to move, a corner
handle to resize (scaling the bounds about the `anchor`), and a top handle to rotate (a visual
CSS rotation pivoting at the `anchor`). The white anchor dot marks where the image is pinned.
`bounds`, `rotation`, and `selected` round-trip back to Dash. Wraps Leaflet 2's ImageOverlay.

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | The ID used to identify this component in Dash callbacks. |
| `alt` | string |  | Alt-text / title for the image element. |
| `anchor` | one of 'center', 'left', 'right', 'bottom', 'top', 'top-left', 'top-right', 'bottom-left' … | 'center' | Which point of the image is the rotation pivot + resize anchor + where the white anchor dot is drawn. One of center \| top-left \| top \| top-right \| left \| right \| bottom-left \| bottom \| bottom-right. @default "center". [MUTABLE] |
| `bounds` | objectOf | [[0, 0], [0, 0]] | Geographic bounds the image is stretched to, `[[south, west], [north, east]]`. Two-way when `editable`: dragging / resizing writes it back. [MUTABLE] |
| `className` | string |  | Often-used CSS class name(s) for the root element. |
| `crossOrigin` | string |  | Adds the `crossOrigin` attribute to the img element. Pass "anonymous" to make the image load CORS-mode so canvas captures (map screenshots / html2canvas) can read the pixels. The host must answer with Access-Control-Allow-Origin or the image fails to load entirely — leave unset for hosts you don't control. "use-credentials" and "" are also valid. Construction-time only. |
| `editable` | bool | false | Enable the on-map transform controls: click to select, then drag to move, drag the corner handle to resize, and the top handle to rotate. @default false. |
| `interactive` | bool | false | If true, the image is wrapped in an interactive layer that fires click events. Forced on when `editable`. |
| `n_clicks` | number |  | Number of times the image has been clicked. [READONLY] |
| `n_transforms` | number |  | Bumped on each drag-move / resize commit. [READONLY] |
| `opacity` | number | 1 | Layer opacity, 0..1. [MUTABLE] |
| `rotation` | number | 0 | Visual rotation in degrees, CW. Applied as a CSS transform pivoting at the `anchor` (Leaflet's ImageOverlay has no native geographic rotation, so the image's `bounds` stay axis-aligned and only the rendered pixels rotate). The rotate handle writes it back. [MUTABLE] |
| `selected` | bool |  | Whether the transform chrome (outline + resize/rotate handles) is shown. Two-way: clicking the image selects it, a map-background click clears it. Only meaningful when `editable`. [MUTABLE] |
| `style` | dict |  | Inline style for the root element. For Map, this is where you set height. |
| `url` | string | '' | URL of the image. [MUTABLE] |
| `zIndex` | number |  | Explicit z-index for the overlay pane. [MUTABLE] |

### KeyboardControl

KeyboardControl installs a window-level keyboard listener that drives map
rotation and pan. Place it as a child of <Map>. No DOM is rendered — it's a
pure side-effect component.
*
Default behavior:
  - Arrow keys rotate the map bearing (5° / press by default)
  - Cmd / Ctrl + Arrow keys pan the map (Leaflet's built-in arrow-key panning
    is suppressed by `map.keyboard.disable()` so the two don't both fire)
*
This makes the page feel like a flight sim: the arrows turn the camera, the
modifier is the "manual pan" escape hatch. Pages can flip the bindings by
passing a custom `keymap`.
*
Listens on `window`, not the map container — so a user pressing arrows while
the map div doesn't have focus still rotates. Pages with form inputs should
either set `enabled=false` while the form is focused or override the keymap.

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | The ID used to identify this component in Dash callbacks. |
| `bearingStep` | number | 5 | Degrees of map bearing change per ArrowLeft / ArrowRight keypress. Default 5. |
| `className` | string |  | Often-used CSS class name(s) for the root element. |
| `enabled` | bool | true | Whether keyboard input is processed. When `false`, no key handler is installed. Useful for disabling controls while a modal/form is focused. [MUTABLE] |
| `keymap` | objectOf |  | Direction map: each property holds the action ('rotate-cw', 'rotate-ccw', 'pan-up', 'pan-down', 'pan-left', 'pan-right') triggered by a given key + modifier combination. Defaults to:    ArrowLeft        → rotate-ccw  (turn camera left)   ArrowRight       → rotate-cw   (turn camera right)   ArrowUp          → rotate-ccw  (same — feels natural for flight sims)   ArrowDown        → rotate-cw   Cmd\|Ctrl+ArrowLeft   → pan-left   Cmd\|Ctrl+ArrowRight  → pan-right   Cmd\|Ctrl+ArrowUp     → pan-up   Cmd\|Ctrl+ArrowDown   → pan-down  Pages can override individual entries (e.g. flight sims that want ArrowUp/Down to be throttle, not rotation) by passing a partial object. |
| `lastKey` | dict |  | The most recent key + action processed, as { key, action, modifier, ts }. [READONLY] |
| `n_pans` | number |  | Number of pan keypresses processed. [READONLY] |
| `n_rotations` | number |  | Number of bearing changes emitted (each rotate keypress increments). Useful as the sole Input for "did the user rotate?". [READONLY] |
| `panStep` | number | 80 | Pixels of map pan per Cmd+Arrow / Ctrl+Arrow keypress. Default 80 (matches Leaflet's own keyboard panOffset). |
| `style` | dict |  | Inline style for the root element. For Map, this is where you set height. |

### LayerGroup

LayerGroup bundles N layers so they can be added/removed together. Drop child
layers (Marker, Polygon, Circle, GeoJSON, ...) inside it; each is added to a
shared `leaflet.LayerGroup` instead of the map directly. Place it as a child
of `dl2.Map` — or of `dl2.Overlay` inside a LayersControl, to toggle the
whole group as one entry. Wraps Leaflet 2's LayerGroup.

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | The ID used to identify this component in Dash callbacks. |
| `children` | node |  | Any number of layer children (Marker, Polygon, Circle, GeoJSON, ...). |
| `className` | string |  | Often-used CSS class name(s) for the root element. |
| `style` | dict |  | Inline style for the root element. For Map, this is where you set height. |

### LayersControl

LayersControl renders a Leaflet control that lets the user pick one of N base layers and
toggle M overlays. Place dl2.BaseLayer and dl2.Overlay as its children; LayersControl
itself must be a child of dl2.Map.

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | The ID used to identify this component in Dash callbacks. |
| `activeBase` | string |  | Name of the active base layer. Two-way: reflects user choice + accepts callback. [MUTABLE] |
| `activeOverlays` | list of string |  | Names of currently visible overlays. Two-way. [MUTABLE] |
| `children` | node |  | BaseLayer + Overlay children. |
| `className` | string |  | Often-used CSS class name(s) for the root element. |
| `collapsed` | bool | true | If true, show only the toggle handle until the pointer enters. |
| `position` | string | 'topright' | "topright" \| "topleft" \| "bottomright" \| "bottomleft". |
| `style` | dict |  | Inline style for the root element. For Map, this is where you set height. |

### Map

Map is the root Leaflet 2 map container. It owns the Leaflet map instance and
provides it to child layers (TileLayer, Marker) through React context. Set the
height via the `style` prop.

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | The ID used to identify this component in Dash callbacks. |
| `attributionControl` | bool | true | Whether Leaflet 2's built-in attribution control is added to the map. Default True (matches Leaflet's default). Set False when you want to mount a `dl2.AttributionControl` child and control position / prefix yourself — same convention as dash-leaflet's `attributionControl=False` + `dl.AttributionControl(...)` pairing. Constructor-only — changing it after the map is built has no effect. |
| `bearing` | number |  | Map rotation in degrees (CW from north). 0 = north up. Implemented as a CSS `transform: rotate()` on the leaflet map pane — the same technique `leaflet-rotate` uses on Leaflet 1.x. Leaflet 2 has no native rotation, so we build it the same way.  **Important caveat (CSS rotation, not coordinate-correct rotation):** Tiles, markers, polygons, and zoom math are computed in Leaflet's un-rotated coordinate space and the whole pane is then rotated visually. That works perfectly for read-only views and follow-camera flight/walk sims (you look at the map; the camera tracks the player). It breaks subtly for INTERACTIVE drawing at non-zero bearing — a click lands at the visually-rotated screen position, which is no longer the same latlng Leaflet would resolve from the bare event coords. For drawing, keep bearing = 0. A fully coordinate-correct rotation is a much larger project (essentially porting leaflet-rotate's coord math to v2).  [MUTABLE] |
| `boxZoom` | bool | true | Shift-drag box-zoom selection. Default true. [MUTABLE] |
| `center` | tuple | [51.505, -0.09] | Initial map center as [lat, lng]. Updating it from a callback re-centers the map. [MUTABLE] |
| `children` | node |  | Child layers (TileLayer, Marker, ...) rendered into this map. |
| `className` | string |  | Often-used CSS class name(s) for the root element. |
| `clickData` | dict |  | Data from the most recent map click: { latlng: [lat, lng] }. [READONLY] |
| `doubleClickZoom` | bool | true | Double-click-to-zoom. Default true. [MUTABLE] |
| `dragging` | bool | true | Mouse / pointer drag panning. Default true. [MUTABLE] |
| `flyTo` | dict |  | Python -> map: trigger a smooth viewport transition. The map calls the Leaflet 2 method indicated by `transition` and ignores the prop until `n_clicks` bumps again (matching `drawToolbar` / `editToolbar` / `featureUpdate` — needed so consecutive identical payloads still register as changes).  Shape:   {     transition: 'setView' \| 'flyTo' \| 'panTo' \| 'fitBounds' \| 'flyToBounds' \| 'panInsideBounds',     center?: [lat, lng],          # for setView / flyTo / panTo     zoom?:   number,              # optional zoom target (setView / flyTo)     bounds?: [[s, w], [n, e]],    # for fitBounds / flyToBounds / panInsideBounds     options?: object,             # passed straight to the Leaflet method                                   # (duration, easeLinearity, animate, paddingTopLeft, ...)     n_clicks: int,   }  `flyTo` / `flyToBounds` give the smooth glide-and-zoom motion; `setView` is instant; `panTo` glides without changing zoom. See the /flyto showcase. [MUTABLE] |
| `keyboard` | bool | true | Whether the map can be panned / zoomed with the keyboard (arrow keys + `+`/`-`). Default true. [MUTABLE] |
| `maxBounds` | tuple |  | Geographic bounds the map's view is constrained inside, as `[[south, west], [north, east]]`. Panning past the edges is bounced back. [MUTABLE] |
| `maxZoom` | number |  | Maximum zoom level the user can zoom in to. When a TileLayer also sets `maxZoom`, Leaflet uses the *smaller* of the two. [MUTABLE] |
| `minZoom` | number |  | Minimum zoom level the user can zoom out to. When a TileLayer also sets `minZoom`, Leaflet uses the *larger* of the two (the most restrictive value wins). [MUTABLE] |
| `n_moveend` | number |  | Counter bumped on every `moveend` event (a pan / flyTo / fit completes). `n_moveend < n_movestart` means a transition is currently running. [READONLY] |
| `n_movestart` | number |  | Counter bumped on every `movestart` event (a pan / flyTo / fit begins). Pair with `n_moveend` to drive a "flying…" indicator. [READONLY] |
| `pinchZoom` | bool | true | Pinch-to-zoom on touch devices. Default true. In Leaflet 1.x this was called `touchZoom`; in v2 it's `pinchZoom`. [MUTABLE] |
| `preferCanvas` | bool | false | If true, render all vector layers through the Canvas renderer (preferred for dense point sets). [READONLY] |
| `scrollWheelZoom` | bool | true | Mouse-wheel zoom. Default true. [MUTABLE] |
| `style` | dict |  | Inline style for the root element. For Map, this is where you set height. |
| `tapHold` | bool |  | Mobile-safari tap-hold-to-contextmenu emulation. Defaults to true on mobile Safari only. [MUTABLE] |
| `viewport` | dict |  | Current view state, written back by the map on every moveend/zoomend as { center: [lat, lng], zoom, bearing, bounds: { north, south, east, west } }. Read this in callbacks. [READONLY] |
| `zoom` | number | 13 | Initial zoom level. Updating it from a callback changes the zoom. [MUTABLE] |
| `zoomControl` | bool | true | Whether Leaflet's built-in +/- zoom buttons control is added. Default true. Constructor-only — changing it after the map is built has no effect. |

### Marker

Marker displays an icon at a position and can host Popup/Tooltip children. The icon can
be the default pin, a custom image (`icon`), an `emoji`, or any Iconify icon (`iconify`,
e.g. "mdi:home"). Draggable markers write their new `position` back to Dash. Wraps
Leaflet 2's Marker. Place it as a child of Map.

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | The ID used to identify this component in Dash callbacks. |
| `children` | node |  | Popup / Tooltip children bound to this marker. |
| `className` | string |  | Often-used CSS class name(s) for the root element. |
| `draggable` | bool | false | Whether the marker can be dragged with the pointer. [MUTABLE] |
| `emoji` | string |  | A single emoji to use as the marker, e.g. "🛥️". |
| `icon` | object |  | Custom image icon as Leaflet Icon options, e.g. {iconUrl, iconSize:[w,h], iconAnchor:[x,y]}. |
| `iconAnchor` | tuple |  | [x, y] icon anchor for emoji / iconify / iconOptions markers. Default bottom-center. |
| `iconColor` | string |  | CSS color for monochrome iconify icons. |
| `iconOptions` | object |  | Full Leaflet DivIcon options escape hatch ({html, className, iconSize, iconAnchor}). |
| `iconSize` | number | 32 | Pixel size for emoji / iconify markers. Default 32. |
| `iconify` | string |  | An Iconify icon name, e.g. "mdi:home" or "twemoji:sailboat" (loads from the Iconify API). |
| `n_clicks` | number |  | Number of times the marker has been clicked. [READONLY] |
| `n_drags` | number |  | Number of times the marker has been dragged. [READONLY] |
| `opacity` | number | 1 | Marker opacity, 0..1. [MUTABLE] |
| `popup` | string |  | Convenience popup text. For rich content, use a <Popup> child instead. |
| `position` | tuple | [51.505, -0.09] | Marker position as [lat, lng]. Updating it moves the marker; dragging writes it back. [MUTABLE] |
| `rotateWithMap` | bool | false | When `true`, the marker icon rotates together with the map — useful for vehicles, aircraft, walking characters, compass arrows, anything whose orientation is tied to the world. The icon's visual screen rotation is `bearing + rotationAngle`.  When `false` (default), the icon stays in a fixed screen orientation regardless of map bearing — useful for pins, labels, and the typical "marker should always look upright" case. The icon's visual screen rotation is just `rotationAngle`.  Implementation: when `false` we apply `rotationAngle - bearing` to the icon, which cancels the map pane's rotation contribution. When `true` we apply `rotationAngle` and let the pane's CSS rotation carry the icon. |
| `rotationAngle` | number | 0 | Marker rotation in degrees (CW from north). Useful for vehicle / aircraft / character sprites that need to point in a direction. Applied via a CSS `rotate` on the icon DOM. [MUTABLE] |
| `style` | dict |  | Inline style for the root element. For Map, this is where you set height. |
| `tooltip` | string |  | Convenience tooltip text. For rich content, use a <Tooltip> child instead. |
| `zIndexOffset` | number | 0 | z-index offset relative to other markers. [MUTABLE] |

### MiniMap

MiniMap adds a small overview map in a corner of the main map. The overview tracks
the main map's center + zoom (with a configurable offset) and draws a rectangle showing
the main viewport. Click the corner toggle to collapse/expand. Place it as a child of
dl2.Map.
*
Native Leaflet 2 — `leaflet-minimap` (the Leaflet 1 plugin) does not run on v2.

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | The ID used to identify this component in Dash callbacks. |
| `aimingRectOptions` | object | {         color: '#3388ff',         weight: 1,         fillColor: '#3388ff',         fillOpacity: 0.15,         interactive: false,     } | Leaflet path options for the aiming rectangle that shows the main map's viewport bounds on the minimap. Defaults to a translucent blue stroke. |
| `attribution` | string | '' | Attribution shown by the inner minimap. Empty by default — the main map already attributes. |
| `centerFixed` | tuple |  | When set to `[lat, lng]`, the inner minimap anchors on this point instead of tracking the main map's center. The aiming rectangle still reflects the main map's bounds — so the rectangle drifts off-minimap if the main map is panned far from the fixed point. Pass `null` (or omit) to follow the main map. Useful for "return-home" style affordances, where the minimap pins on a player / marker and clicking it (see `n_clicks`) snaps the main map back to them. [MUTABLE] |
| `className` | string |  | Often-used CSS class name(s) for the root element. |
| `height` | number | 150 | Expanded height in pixels. Default 150. |
| `minimized` | bool |  | Whether the minimap starts (or currently is) minimized. Two-way: setting it from a Python callback collapses/expands the minimap; the user clicking the toggle button also writes it back. [MUTABLE] |
| `n_clicks` | number | 0 | Number of times the user has clicked anywhere on the inner minimap (excluding the corner expand/collapse toggle). Increments per click — pair with `prevent_initial_call=True` to use the minimap as a button. [READONLY] |
| `position` | one of 'topleft', 'topright', 'bottomleft', 'bottomright' | 'bottomright' | Control position: 'topleft' \| 'topright' \| 'bottomleft' \| 'bottomright'. Default 'bottomright'. |
| `style` | dict |  | Inline style for the root element. For Map, this is where you set height. |
| `toggleDisplay` | bool | true | Show the [⤡] toggle button. Default true. |
| `url` | string | 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' | Tile URL template for the inner minimap basemap. Defaults to OSM. |
| `width` | number | 150 | Expanded width in pixels. Default 150. |
| `zoomLevelOffset` | number | -5 | Zoom-level offset from the main map (negative = zoomed out further than the main). Default -5: a 150x150 minimap shows the main map's neighbourhood. |

### Overlay

Overlay wraps any layer and registers it as a toggleable overlay in the parent
LayersControl (checkbox). Place it as a child of LayersControl, with a single layer
component as its own child.

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | The ID used to identify this component in Dash callbacks. |
| `checked` | bool | false | Initially checked? Overlays are independent. |
| `children` | node |  | The Leaflet layer (TileLayer, GeoJSON, Marker, ...) controlled by this entry. |
| `className` | string |  | Often-used CSS class name(s) for the root element. |
| `name` | string | 'Overlay' | Display name shown in the LayersControl (also the checkbox's identity). |
| `style` | dict |  | Inline style for the root element. For Map, this is where you set height. |

### Polygon

Polygon draws a filled, closed shape from a list of [lat, lng] points. Place it as a
child of Map. Wraps Leaflet 2's Polygon.

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | The ID used to identify this component in Dash callbacks. |
| `children` | node |  | Popup / Tooltip children. |
| `className` | string |  | Often-used CSS class name(s) for the root element. |
| `color` | string | '#3388ff' | Stroke color. [MUTABLE] |
| `fillColor` | string |  | Fill color (defaults to stroke color). [MUTABLE] |
| `fillOpacity` | number | 0.2 | Fill opacity, 0..1. [MUTABLE] |
| `n_clicks` | number |  | Times the polygon has been clicked. [READONLY] |
| `opacity` | number | 1 | Stroke opacity, 0..1. [MUTABLE] |
| `positions` | list of tuple | [] | Ring vertices as a list of [lat, lng] points (auto-closed). [MUTABLE] |
| `style` | dict |  | Inline style for the root element. For Map, this is where you set height. |
| `weight` | number | 3 | Stroke width in pixels. [MUTABLE] |

### Polyline

Polyline draws a multi-segment line from a list of [lat, lng] points. Place it as a
child of Map. Wraps Leaflet 2's Polyline.

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | The ID used to identify this component in Dash callbacks. |
| `children` | node |  | Popup / Tooltip children. |
| `className` | string |  | Often-used CSS class name(s) for the root element. |
| `color` | string | '#3388ff' | Stroke color. [MUTABLE] |
| `dashArray` | string |  | Dash pattern, e.g. "5,10". [MUTABLE] |
| `interactive` | bool |  | Whether the line captures pointer events (fires clicks, blocks the map click underneath). Set false for a non-interactive decoration / context overlay so it never intercepts clicks meant for the map. Construction-only. @default true |
| `n_clicks` | number |  | Times the line has been clicked. [READONLY] |
| `opacity` | number | 1 | Stroke opacity, 0..1. [MUTABLE] |
| `positions` | list of tuple | [] | Vertices as a list of [lat, lng] points. [MUTABLE] |
| `style` | dict |  | Inline style for the root element. For Map, this is where you set height. |
| `weight` | number | 3 | Stroke width in pixels. [MUTABLE] |

### Popup

Popup shows content in a balloon bound to its parent layer (Marker, Polygon, ...).
Children are rendered through a React portal, so any Dash component works as popup
content. Wraps Leaflet 2's Popup.

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | The ID used to identify this component in Dash callbacks. |
| `autoClose` | bool |  | If true, opening a popup closes other popups. Leaflet defaults to true; set False to allow multiple popups open simultaneously. |
| `children` | node |  | Popup content — any Dash/HTML children, rendered live via a React portal. |
| `className` | string |  | Often-used CSS class name(s) for the root element. |
| `closeButton` | bool | true | Show the close (×) button. |
| `closeOnClick` | bool |  | If true, clicking the map closes the popup. Leaflet defaults to true; set False for form popups that should stay open while the user is interacting. |
| `maxWidth` | number | 300 | Max width in pixels. |
| `minWidth` | number | 50 | Min width in pixels. |
| `opened` | bool |  | Controlled open state — when set, the popup follows this prop (True → open, False → closed) instead of waiting for a click on the parent layer. [MUTABLE] |
| `style` | dict |  | Inline style for the root element. For Map, this is where you set height. |

### Rectangle

Rectangle draws an axis-aligned box from geographic bounds. Place it as a child of Map.
Wraps Leaflet 2's Rectangle.

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | The ID used to identify this component in Dash callbacks. |
| `bounds` | tuple | [[0, 0], [0, 0]] | Geographic bounds as [[south, west], [north, east]]. [MUTABLE] |
| `children` | node |  | Popup / Tooltip children. |
| `className` | string |  | Often-used CSS class name(s) for the root element. |
| `color` | string | '#3388ff' | Stroke color. [MUTABLE] |
| `fillColor` | string |  | Fill color (defaults to stroke color). [MUTABLE] |
| `fillOpacity` | number | 0.2 | Fill opacity, 0..1. [MUTABLE] |
| `n_clicks` | number |  | Times the rectangle has been clicked. [READONLY] |
| `style` | dict |  | Inline style for the root element. For Map, this is where you set height. |
| `weight` | number | 3 | Stroke width in pixels. [MUTABLE] |

### ScaleControl

ScaleControl shows a metric and/or imperial scale bar in a map corner.
Wraps Leaflet 2's built-in `Control.Scale` (lives on the Control namespace
but not exported by the ESM — we reach in through `Control.Scale`).

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | The ID used to identify this component in Dash callbacks. |
| `className` | string |  | Often-used CSS class name(s) for the root element. |
| `imperial` | bool | false | Show imperial (mi/ft) bar. Default false. |
| `maxWidth` | number | 100 | Maximum bar width in pixels. Default 100. |
| `metric` | bool | true | Show metric (km/m) bar. Default true. |
| `position` | one of 'topleft', 'topright', 'bottomleft', 'bottomright' | 'bottomleft' | "topleft" \| "topright" \| "bottomleft" \| "bottomright". Default "bottomleft". [MUTABLE] |
| `style` | dict |  | Inline style for the root element. For Map, this is where you set height. |
| `updateWhenIdle` | bool | false | Only redraw the bar when the map stops moving. Default false. |

### TextMarker

TextMarker is editable, draggable, styleable text placed on the map like a Marker. Give it
a `position` and `text`; drag to move, double-click to edit, and (when `selected`) use the
on-canvas resize / rotate handles and the contextual toolbar to restyle it. Position, text,
rotation, font size, and color all round-trip back to Dash. When `position` is omitted the
label spawns at the center of the current viewport. Place it as a child of dl2.Map.

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | The ID used to identify this component in Dash callbacks. |
| `anchor` | one of 'center', 'left', 'right', 'bottom', 'top', 'top-left', 'top-right', 'bottom-left' … | 'center' | Which point of the text box sits on `position`. One of center \| top-left \| top \| top-right \| left \| right \| bottom-left \| bottom \| bottom-right. @default "center". |
| `backgroundColor` | string | 'transparent' | Box background behind the text ("transparent" for none). Editable from the toolbar. [MUTABLE] |
| `borderRadius` | number | 6 | Corner radius of the background pill in px. @default 6. |
| `className` | string |  | Often-used CSS class name(s) for the root element. |
| `color` | string | '#111827' | Text color (any CSS color / Mantine var). Editable from the toolbar. [MUTABLE] |
| `draggable` | bool | true | Whether the label can be dragged to a new position. @default true. |
| `editable` | bool | true | Whether double-click enters inline text edit. @default true. |
| `fontFamily` | string | 'system-ui, sans-serif' | Font family stack, e.g. "Inter, system-ui, sans-serif". [MUTABLE] |
| `fontSize` | number | 24 | Font size in screen px (the resize handle changes this). [MUTABLE] |
| `fontStyle` | one of 'normal', 'italic' | 'normal' | Font style: "normal" \| "italic". [MUTABLE] |
| `fontWeight` | string \| number | 600 | Font weight (400 / 600 / 700 / "bold" …). [MUTABLE] |
| `n_clicks` | number |  | Number of times the label has been clicked. [READONLY] |
| `n_drags` | number |  | Number of times the label has been dragged. [READONLY] |
| `n_edits` | number |  | Bumped on each committed text edit (fires a Dash Input even for identical text). [READONLY] |
| `opacity` | number | 1 | Caption opacity, 0..1 — fade the whole label in/out (e.g. keyframed transitions). @,default,1 [MUTABLE] |
| `padding` | number | 6 | Box padding in px (only visible when `backgroundColor` is set). [MUTABLE] |
| `position` | tuple |  | Text anchor as [lat, lng]. Dragging the label writes it back. When omitted, the label is created at the current center of the map viewport (and that position is emitted back so Python has it). [MUTABLE] |
| `referenceZoom` | number |  | The zoom level at which `fontSize` is the literal screen px (only used when `scaleWithZoom`). Defaults to the map's zoom when the label is created. [MUTABLE] |
| `rotateWithMap` | bool | false | When true the label rotates together with a rotated map (`Map.bearing`); when false (default) it stays upright on screen regardless of map bearing — like a Marker. |
| `rotation` | number | 0 | Rotation in degrees, CW from upright. The rotate handle changes this. [MUTABLE] |
| `scaleWithZoom` | bool | false | Geographic sizing. When false (default) the label is a constant screen-size HUD caption: `fontSize` is literal screen px at every zoom (like a Tooltip). When true the label scales with the map — its on-screen size grows/shrinks by 2^(zoom − referenceZoom) so it keeps a fixed *ground* footprint as the camera flies (like a polygon's edge). [MUTABLE] |
| `selected` | bool |  | Show selection chrome (resize / rotate handles + the style toolbar). Two-way: clicking the label sets it true and a map-background click clears it, so a host can also drive selection from the outside. [MUTABLE] |
| `showToolbar` | bool | true | Whether the contextual style toolbar is shown while selected. @default true. |
| `style` | dict |  | Inline style for the root element. For Map, this is where you set height. |
| `text` | string | 'Text' | The caption string. Double-click the label to edit it inline; the committed text (on blur / Enter) is written back with `n_edits` bumped. [MUTABLE] |

### TileLayer

TileLayer loads and displays a raster tile basemap. Place it as a child of
Map. Wraps Leaflet 2's TileLayer.

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | The ID used to identify this component in Dash callbacks. |
| `attribution` | string | '&copy; OpenStreetMap contributors' | Attribution HTML shown in the bottom-right of the map. |
| `bounds` | tuple |  | Geographic bounds outside of which no tiles are requested, as `[[south, west], [north, east]]`. Same as Leaflet's `LatLngBounds`. Cheaper than server-side 404s for out-of-area requests. |
| `className` | string |  | Often-used CSS class name(s) for the root element. |
| `crossOrigin` | string |  | Adds the `crossOrigin` attribute to every tile img element. Pass "anonymous" to make tile loads CORS-mode so canvas captures (map screenshots / html2canvas) can read the pixels. The tile host must answer with Access-Control-Allow-Origin or the tiles fail to load entirely — leave unset for hosts you don't control. "use-credentials" and "" are also valid. Construction-time only. |
| `detectRetina` | bool | false | If true, request tiles at 2x resolution on hi-DPI displays (loads twice as many tiles but renders sharper). |
| `errorTileUrl` | string |  | URL of an image shown in place of any tile that fails to load. A 1x1 transparent PNG data URL is the common "hide broken tiles" trick. |
| `maxNativeZoom` | number |  | Maximum zoom level that the tile source actually has tiles for. Leaflet upscales tiles from this zoom when the map zooms in past it (instead of 404-ing). Useful for overlays whose cache caps below the map's max zoom — e.g. USGS Hydro is only cached to z16; set `maxNativeZoom=16` and the z16 tile will be shown at z17/z18. |
| `maxZoom` | number | 19 | Maximum zoom level for this tile layer. |
| `minZoom` | number | 0 | Minimum zoom level at which this tile layer is visible. Below this zoom Leaflet stops requesting tiles entirely (no 404 thrash on out-of-range historical / harbor-cropped pyramids). Default 0. |
| `opacity` | number | 1 | Layer opacity, 0..1. [MUTABLE] |
| `style` | dict |  | Inline style for the root element. For Map, this is where you set height. |
| `subdomains` | string \| list of string |  | Subdomains substituted into the URL `{s}` placeholder. Accepts an array like `['a','b','c']` or a string `'abc'` (each character is a subdomain). |
| `tms` | bool | false | If true, inverts Y coordinates so this layer works with TMS-shaped tile pyramids (Leaflet defaults to XYZ). |
| `url` | string | 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' | Tile URL template, e.g. "https://tile.openstreetmap.org/{z}/{x}/{y}.png". Updating it swaps the basemap. [MUTABLE] |
| `zIndex` | number |  | Explicit z-index for the tile layer's DOM pane. Higher = renders on top. Useful when stacking multiple tile layers and DOM mount order alone is insufficient. [MUTABLE] |

### TileSelector

TileSelector adds a toggle button to the map. While active, the cursor becomes a
crosshair, a dashed outline tracks the tile under the cursor at the current map zoom,
and clicking a tile adds/removes it from the multi-select. Holding Shift while dragging
captures every tile inside the resulting box. Selections persist across zooms (each
tile is keyed by z/x/y). Place it as a child of dl2.Map.

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | The ID used to identify this component in Dash callbacks. |
| `className` | string |  | Often-used CSS class name(s) for the root element. |
| `hoverColor` | string | '#fa5252' | Color of the hover outline. |
| `position` | string | 'topleft' | "topleft" \| "topright" \| "bottomleft" \| "bottomright". |
| `selectedColor` | string | '#228be6' | Stroke + fill color of selected-tile rectangles (and the box-drag preview). |
| `selectedTiles` | list of dict | [] | Currently selected tiles, each `{z, x, y, url, bounds: [s, w, n, e]}`. Two-way: clicks + shift-drag add/remove tiles (component → Python); Python callbacks can also push (e.g. a Clear button writes `[]`). [MUTABLE] |
| `style` | dict |  | Inline style for the root element. For Map, this is where you set height. |
| `tileUrl` | string | 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' | Tile URL template — same `{s}/{z}/{x}/{y}` form as a TileLayer URL. |

### Tooltip

Tooltip shows a small label on hover (or permanently), bound to its parent layer
(Marker, Polygon, ...). Children render through a React portal, so any Dash component
works as content. Wraps Leaflet 2's Tooltip.

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | The ID used to identify this component in Dash callbacks. |
| `children` | node |  | Tooltip content — any Dash/HTML children, rendered live via a React portal. |
| `className` | string |  | Often-used CSS class name(s) for the root element. |
| `direction` | string | 'auto' | Placement: "right" \| "left" \| "top" \| "bottom" \| "center" \| "auto". |
| `opacity` | number | 0.9 | Tooltip opacity, 0..1. |
| `permanent` | bool | false | If true, the tooltip stays open instead of showing only on hover. |
| `style` | dict |  | Inline style for the root element. For Map, this is where you set height. |

---

<!-- /attribution — https://leaflet.2plot.dev/attribution/llms.txt -->

# Attribution

> explicit control over the attribution box.

---



### Overview

Mirrors dash-leaflet 1.x: pass `attributionControl=False` to the Map to suppress
Leaflet 2's built-in attribution control, then add a `dl2.AttributionControl`
as a child to position it explicitly and customize the prefix.

  • `position` ∈ {'topleft','topright','bottomleft','bottomright'} — [MUTABLE]
  • `prefix`   — string of HTML (any anchor / icon / text), or `False` to hide
                 the Leaflet link entirely. [MUTABLE]

Bonus: a Switch toggles whether the `dl2.AttributionControl` is mounted at all —
which proves the `attributionControl=False` map option does suppress the bundled
default (without our component, no box appears).

### Live demo


### The shape


**`attributionControl=False` suppresses Leaflet 2's built-in box**

```python
# File: docs/attribution/example.py  (region: map)

dl2.Map(
    id="attr-map",
    center=BOSTON.center,
    zoom=10,
    attributionControl=False,
    style={"height": "60vh"},
    children=[
        dl2.TileLayer(
            id="attr-tile", url=TILE_URL, attribution=ATTR
        ),
        dl2.Marker(
            position=BOSTON.center,
            iconify="mdi:lighthouse-on",
            iconColor="#e8590c",
            iconSize=32,
        ),
        # The component is rendered into a Dash child by the
        # "mount" callback below — Python toggles whether it's
        # in the children list at all.
        html.Div(id="attr-mount"),
    ],
),
```



**dl2.AttributionControl — explicit positioning + custom prefix**

```python
# File: docs/attribution/example.py  (region: control)

return dl2.AttributionControl(
    id="attr-ctl",
    position=position or "bottomright",
    prefix=prefix,
)
```


### Source


```python
# File: docs/attribution/example.py

"""
AttributionControl — explicit control over the attribution box.

Mirrors dash-leaflet 1.x: pass `attributionControl=False` to the Map to suppress
Leaflet 2's built-in attribution control, then add a `dl2.AttributionControl`
as a child to position it explicitly and customize the prefix.

  • `position` ∈ {'topleft','topright','bottomleft','bottomright'} — [MUTABLE]
  • `prefix`   — string of HTML (any anchor / icon / text), or `False` to hide
                 the Leaflet link entirely. [MUTABLE]

Bonus: a Switch toggles whether the `dl2.AttributionControl` is mounted at all —
which proves the `attributionControl=False` map option does suppress the bundled
default (without our component, no box appears).
"""

import dash_leaflet2 as dl2
import dash_mantine_components as dmc
from dash import (
    Input,
    Output,
    State,
    callback,
    clientside_callback,
    dcc,
    html,
    no_update,
)
from dash_iconify import DashIconify
from dl2_tiles import VOYAGER, register_theme_swap
from dl2_locations import BOSTON
from dl2_shared import info_panel

# Basemap pair for this page. dl2_tiles owns the light/dark wiring so
# every example themes the same way — see register_theme_swap below.
TILES = VOYAGER
TILE_URL = TILES.url("light")
ATTR = TILES.attribution()

# Three sample prefixes the user can flip between with a SegmentedControl. The
# "Custom" branch reads the live TextInput value instead.
PREFIX_PRESETS = {
    "leaflet": (
        '<a target="_blank" href="https://leafletjs.com" '
        'title="A JavaScript library for interactive maps">Leaflet</a>'
    ),
    "branded": (
        '<a href="https://pipinstallpython.com" target="_blank" '
        'style="display:inline-flex;align-items:center;'
        'text-decoration:none;color:inherit;">'
        "🛰️&nbsp;<b>dash-leaflet2</b></a>"
    ),
    "none": False,
}



component = dmc.Stack(
    [
        dmc.Grid(
            [
                dmc.GridCol(
                    dmc.Paper(
                        # region map
                        dl2.Map(
                            id="attr-map",
                            center=BOSTON.center,
                            zoom=10,
                            attributionControl=False,
                            style={"height": "60vh"},
                            children=[
                                dl2.TileLayer(
                                    id="attr-tile", url=TILE_URL, attribution=ATTR
                                ),
                                dl2.Marker(
                                    position=BOSTON.center,
                                    iconify="mdi:lighthouse-on",
                                    iconColor="#e8590c",
                                    iconSize=32,
                                ),
                                # The component is rendered into a Dash child by the
                                # "mount" callback below — Python toggles whether it's
                                # in the children list at all.
                                html.Div(id="attr-mount"),
                            ],
                        ),
                        # endregion
                        shadow="sm",
                        radius="md",
                        withBorder=True,
                        style={"overflow": "hidden"},
                    ),
                    span={"base": 12, "md": 8},
                ),
                dmc.GridCol(
                    dmc.Stack(
                        [
                            info_panel(
                                "Mounted?",
                                dmc.Stack(
                                    [
                                        dmc.Switch(
                                            id="attr-mounted",
                                            label="Render dl2.AttributionControl",
                                            description="Off → map's attributionControl=False shows nothing.",
                                            checked=True,
                                            size="sm",
                                        ),
                                    ],
                                    gap="xs",
                                ),
                            ),
                            info_panel(
                                "Position",
                                dmc.SegmentedControl(
                                    id="attr-position",
                                    data=[
                                        {"value": "topleft", "label": "TL"},
                                        {"value": "topright", "label": "TR"},
                                        {"value": "bottomleft", "label": "BL"},
                                        {"value": "bottomright", "label": "BR"},
                                    ],
                                    value="bottomright",
                                    size="xs",
                                    fullWidth=True,
                                ),
                            ),
                            info_panel(
                                "Prefix",
                                dmc.Stack(
                                    [
                                        dmc.SegmentedControl(
                                            id="attr-prefix-preset",
                                            data=[
                                                {
                                                    "value": "leaflet",
                                                    "label": "Leaflet link",
                                                },
                                                {
                                                    "value": "branded",
                                                    "label": "Branded",
                                                },
                                                {"value": "none", "label": "Hidden"},
                                                {"value": "custom", "label": "Custom"},
                                            ],
                                            value="branded",
                                            size="xs",
                                            fullWidth=True,
                                        ),
                                        dmc.Textarea(
                                            id="attr-prefix-custom",
                                            placeholder='HTML allowed, e.g. <a href="...">my site</a>',
                                            minRows=2,
                                            maxRows=5,
                                            autosize=True,
                                            size="xs",
                                            value='🌐 <a href="https://example.com">my site</a>',
                                        ),
                                        dmc.Text(
                                            "Picking 'Custom' wires the textarea live to "
                                            "AttributionControl.prefix.",
                                            size="xs",
                                            c="dimmed",
                                        ),
                                    ],
                                    gap="xs",
                                ),
                            ),
                            info_panel(
                                "Live state",
                                dmc.Stack(
                                    [
                                        dmc.Text("position", size="xs", c="dimmed"),
                                        dmc.Code(
                                            id="attr-state-position",
                                            children="bottomright",
                                        ),
                                        dmc.Text(
                                            "prefix HTML (None = hidden)",
                                            size="xs",
                                            c="dimmed",
                                        ),
                                        dmc.Code(
                                            id="attr-state-prefix",
                                            block=True,
                                            style={
                                                "fontSize": "11px",
                                                "whiteSpace": "pre-wrap",
                                            },
                                        ),
                                    ],
                                    gap=4,
                                ),
                            ),
                        ],
                        gap="md",
                    ),
                    span={"base": 12, "md": 4},
                ),
            ]
        ),
        # Internal store: holds the resolved prefix value (string or False).
        dcc.Store(id="attr-resolved-prefix", data=PREFIX_PRESETS["branded"]),
    ],
    gap="md",
)


# ---- prefix preset/custom -> resolved prefix value -------------------------
@callback(
    Output("attr-resolved-prefix", "data"),
    Input("attr-prefix-preset", "value"),
    Input("attr-prefix-custom", "value"),
)
def resolve_prefix(preset, custom):
    if preset == "custom":
        # Empty custom -> False (hide); otherwise the raw HTML string.
        return custom or False
    return PREFIX_PRESETS.get(preset, PREFIX_PRESETS["leaflet"])


# ---- mount / unmount the AttributionControl child --------------------------
@callback(
    Output("attr-mount", "children"),
    Input("attr-mounted", "checked"),
    Input("attr-position", "value"),
    Input("attr-resolved-prefix", "data"),
)
def render_attribution_control(mounted, position, prefix):
    if not mounted:
        return []
    # region control
    return dl2.AttributionControl(
        id="attr-ctl",
        position=position or "bottomright",
        prefix=prefix,
    )
    # endregion


# ---- live readouts ----------------------------------------------------------
@callback(
    Output("attr-state-position", "children"),
    Output("attr-state-prefix", "children"),
    Input("attr-position", "value"),
    Input("attr-resolved-prefix", "data"),
)
def state_readouts(position, prefix):
    if prefix is False:
        return (position or "bottomright"), "False  (prefix hidden)"
    return (position or "bottomright"), prefix or "(empty string — prefix hidden)"


# ---- light/dark tile swap (existing pattern) -------------------------------
register_theme_swap("attr-tile", TILES)
```


---

*Source: /attribution*

---

<!-- /canvas-overlay — https://leaflet.2plot.dev/canvas-overlay/llms.txt -->

# Canvas Renderer

> dense point clouds through one <canvas>.

---



### Overview

This page demonstrates Canvas Renderer.

### Live demo


### Canvas-backed markers

```javascript
// preferCanvas routes every CircleMarker through the Canvas renderer:
// one <canvas> instead of N SVG nodes — the substrate for live vessel
// positions, sensor swarms and heatmaps.
const map = new leaflet.Map(el, {preferCanvas: true}).setView(center, 11);
for (let i = 0; i < 8000; i++) {
    new leaflet.CircleMarker(points[i], {radius: 3, stroke: false}).addTo(group);
}
```

### Source


```python
# File: docs/canvas-overlay/example.py

"""Canvas Renderer — dense point clouds through one <canvas>."""

import dash_mantine_components as dmc
from dash import html
from dl2_shared import info_panel, map_div


component = dmc.Stack(
    [
        info_panel(
            "Render benchmark",
            html.Div(id="canvas-hud", className="dl2-hud", children="rendering…"),
        ),
        map_div("canvas-overlay"),
    ],
    gap="md",
)
```


---

*Source: /canvas-overlay*

---

<!-- /changelog — https://leaflet.2plot.dev/changelog/llms.txt -->

# Changelog

> Version history of dash-leaflet2. The timeline on this page is rendered from `CHANGELOG.md`, reproduced below.

---

All notable changes to **dash-leaflet2** are documented in this file.

The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Because
the project tracks `leaflet@2.0.0-alpha.1`, the **0.x** line is itself alpha — APIs
will move until v2 leaves alpha upstream.

---

## [Unreleased]

The fleet's x402 instrumentation sync (1.3.x) — measurement only, no payment
code — followed by the **sign-in gate pilot**, which this site runs first for
the network. Documentation site and network wiring only; no `dl2.*` component
changed, and `pip install dash-leaflet2` is untouched by any of it.

### Added

- **The sitemap stopped lying.** Every entry used to claim the page changed
  today, regenerated on every crawl — a sitemap asserting that 27 pages change
  daily is one search engines learn to discard wholesale. Each page now
  publishes the real date its prose last changed, and a page that declares no
  date gets no date: truth or silence.

- **Google now sees this site's own icons.** The crawler document carried no
  favicon at all — browsers got six, Googlebot got zero, which is why search
  showed a generic globe. It also had no social image and described every
  documentation page as an untyped generic web page. All three now match what
  a browser gets. (Requires `dash-improve-my-llms` 2.6.0, which discovers the
  icons from `assets/favicon_io/` with nothing declared.)

- **A live "active now" figure** on the hub's dashboard, from a lightweight
  presence beacon alongside the existing hourly rollup. Display-only — the
  daily numbers still come from the rollup, which now reports every 15 minutes
  rather than hourly.

- **The network directory caught up with the fleet**: muicharts, flexlayout
  and llms.2plot.dev added, and pannellum/emojimart restored now that they
  resolve — twelve peers, no dead links.

- **A sign-in gate, shipped dark.** Documentation pages can now require an
  account. Nothing is gated yet: the site deploys with the gate wired and
  every verdict answering "allow", so the whole path runs in production
  before the single environment variable (`PAGE_DEFAULT_TIER=auth`) that
  turns it on — and setting that variable back is the entire rollback.

  A signed-out visitor on a gated page gets a **sign-in card at HTTP 200**,
  not a redirect and not a 404: the URL stays shareable, and "Create free
  account" now carries the current page in its return trip, so a visitor
  lands back where they started instead of on the primary's home page. That
  return leak is the one user-visible bug this pass fixes today.

  **Machine surfaces stay open.** `/<page>/llms.txt`, the crawler document
  and the prerender keep serving prose to agents and crawlers while humans
  meet the card — a deliberate 30-day posture, switched network-wide later
  with `LLMS_PUBLIC_DEFAULT=0` rather than per-page edits.

- **`GET /api/agent-key`** — the person-to-agent handoff. Copying a page's
  `llms.txt` URL while signed in now carries a key, so the link still
  resolves when it is pasted into an assistant, whose fetch arrives with no
  session cookie. Signed out, the copy button behaves exactly as before.

- **The network's page-tier ceiling.** 2plot.dev can now restrict a page
  across the network; this site may lock a page down further but can never
  open one the network gated. A hub outage changes nothing for a signed-in
  reader — sessions resolve locally — and resolves to "gated" for anyone
  else, never to publishing restricted prose and never to a dead site.

### Added

- **A `/changelog` page** — this file, rendered as a timeline, and served to
  agents as its own llms.txt.

- **An `/api` page** — every `dl2.*` component's props, types, defaults and
  descriptions in one table per component, generated from the package's own
  build metadata rather than written by hand, so it cannot drift from the
  code. The same tables are served as Markdown to agents.

- **An "Other Apps" menu in the top bar**, listing the 2plot network's primary
  sites from the shared registry, and a footer carrying the copyright, the
  GitHub profile, Discord and YouTube.

### Removed

- **The R&D sync script.** `scripts/sync_from_rnd.py` pulled documentation
  forward from a sibling checkout on the maintainer's machine. That checkout
  turned out to hold nothing this repository did not already have, and a good
  deal less: running the pull would have stripped the real "last updated"
  dates from 26 pages, replaced the home page with a placeholder, and
  reverted 15 examples to an older shape. A tool whose only remaining effect
  is to undo work is not worth keeping behind a warning, so it is gone.
  Nothing replaces it — this repository is simply the repository.

### Changed

- **The sidebar is built from each page's frontmatter, not a hand-kept list.**
  Section names and their order are unchanged — Start here, v2 capabilities,
  Layers, Markers, Controls, Rotation & Sims, Dash integration — and so is
  every page's position within them. What changed is that adding a page no
  longer means editing the navbar: it declares its `category:` and appears.
  Home and Changelog sit above the sections, API and Resources below them.

- **Resources is now third-party only** — Dash Mantine Components and Leaflet
  itself. The GitHub, Discord and YouTube links moved to the top bar and the
  footer, where they are not repeated three times.

- **The admin pages are no longer in anyone else's page.** `/admin/traffic`
  and `/admin/control-board` used to be rendered into every visitor's
  navigation and hidden with CSS; now they are added per request, only for an
  administrator. An anonymous visitor's page contains no admin link at all.

- **Long code blocks and wide prop tables no longer stretch the page on a
  phone.** They scroll inside their own box instead.

- **`human_hits` will DROP and `bot_hits` will RISE on the day this ships, and
  that is the number becoming true.** The visitor tracker carried its own
  User-Agent list for a year; it filed ClaudeBot — Anthropic's *training*
  crawler — under "search", still named the retired `anthropic-ai` /
  `claude-web` tokens, and counted every UA-less or library client (`httpx`,
  `Go-http-client`, `node-fetch`, an empty User-Agent) as a person. The list
  is gone: classification now delegates to `dash-improve-my-llms`'
  `classify()`, the same vendor registry `robots.txt` is already rendered
  from, so what this site SAYS about a vendor and what it COUNTS finally
  agree. Those clients move from human to crawler, so the hub's day-over-day
  view will show a step. Nothing regressed; the old numbers were wrong.

- **The site now keeps a read ledger, and can show it to its owner.** With
  `dash-improve-my-llms` 2.8.0, the package hands over one event per corpus
  document it serves — tier, verdict, bytes, verified vendor — which used to
  be discarded at the app boundary. Those rows are kept as a second table in
  the same analytics file (never summed into the visit counts), folded into
  the hourly rollup as an additive `vendors[]` block, and rendered on the new
  owner-only `/admin/traffic`: vendor × day, vendor → tier, and the paths each
  vendor pulled. `verified` there means the request came from an IP range the
  vendor publishes — Anthropic publishes none, so ClaudeBot reads `n/a`, which
  is a property of the vendor and not a defect on this host.

- **A push to `main` is no longer a deploy.** Render now watches a `release`
  branch that only CD writes, fast-forward, after the full CI matrix is green;
  `main` is where CI judges. Previously the platform built `main` directly, so
  a red commit could be live minutes before its own CI run said so, and the
  post-deploy verification could report green against the *previous* build.
  `main` ahead of `release` now means an uncertified push is pending — never
  drift, and never a reason to deploy by hand.

- **One access system instead of two.** `tier:` and `visibility:` in a page's
  frontmatter were independent fields naming the same four values, so a page
  could declare one tier and be enforced at another, with a control-board row
  that quietly disagreed. `tier:` is now canonical, `visibility:` is an
  accepted alias, and one declared value feeds both. `PAGE_DEFAULT_TIER` is
  likewise the canonical spelling of `PAGE_DEFAULT_VISIBILITY`, which is
  still read so the running service does not change posture underneath a
  deploy.

  The control board keeps everything it did — live toggles, four tiers, and
  overrides that outlive a deploy — and its override is still the most
  authoritative local word on a page. What moved out of it is the decision
  itself, into `lib/access.py`.

- **Admin surfaces now fail closed everywhere.** Documentation still falls
  open when Clerk is unavailable — it must never brick over a missing
  credential — but the retired resolver fell open for admin pages too. Only
  `/admin/control-board`'s own double gate stopped that mattering.

- **Analytics: Gen-1 single-module tracker retired for the boilerplate's
  trio.** `lib/analytics_tracker.py` (per-request JSON ledger),
  `lib/traffic_rollup.py` (the hub's own daily v2+v3 definitions — its
  `_SKIP` tuple stays byte-identical to the boilerplate's, the fleet's
  one-measurement rule) and `lib/satellite_reporter.py` (hourly signed POST
  to 2plot.ai). The ledger moves from a JSONL file to
  `TRAFFIC_ANALYTICS_FILE` (JSON, `visitor_analytics.json`); the old ledger
  is left on disk untouched — the data window starts fresh. The Gen-1 SPA
  page-view beacon (`/api/pageview`) and per-session sign-in beacon
  (`POST /api/satellite/auth`) had no trio counterpart and were dropped:
  request-only counting is what makes this app's numbers comparable
  fleet-wide. `/healthz` now lives in `lib/health.py` (all three backends)
  and keeps its deployed payload shape. `SATELLITE_APP_ID` is retired;
  the trio reads `SATELLITE_APP_KEY` only.
- **Hard boot guard.** `lib.constants.require_owned_base_url()` replaces the
  warn-only `base_url_misconfigured()`: on Render (or `APP_ENV=production`)
  the app now REFUSES to boot without an owned base URL — unset, a
  platform-generated hostname, or a loopback origin all raise instead of
  logging one line into a wall of boot output.
- **dash-improve-my-llms floor 2.3.4 → 2.5.1** (the Tier-B SEO standard +
  tiered corpus documents), and `run.py` now registers `/llms-small.txt` /
  `/llms-full.txt` tiers from `LLMS_SMALL_TIER` / `LLMS_FULL_TIER` via the
  ported `lib/page_tiers.py`.
- **dash-clerk-auth 1.0.0 → 1.0.2, and a cryptography security floor.** This
  site renders the Clerk menu (`components/header.py`), so it was directly
  exposed to the avatar race 1.0.2 fixes: the injected script resolved the menu
  with `getElementById` the moment `Clerk.load()` resolved, but Dash mounts that
  menu from a separate `/_dash-layout` fetch — so whenever Clerk won the race a
  signed-in user sat behind a placeholder avatar and a "Sign In" menu for the
  life of the page, while `current_user()` and `clerk-auth-store` were both
  correct. Separately, `clerk-backend-api` moves `>=5.0.0,<6` → `>=7.0.0,<8`
  with a new `cryptography>=50.0.0` floor: SDK 5.x caps `cryptography` at
  `<47.0.0`, holding it on 46.0.7, below the fix for GHSA-537c-gmf6-5ccf,
  PYSEC-2026-3552, PYSEC-2026-3553 and PYSEC-2026-3554. Pinning `cryptography`
  alone returns `ResolutionImpossible`, which is why dash-clerk-auth 1.0.1 had
  to widen its own cap to `<8` first — the library carries the compatibility
  range, `requirements.txt` carries the security floor.
- **Version claims are derived, never written**: `lib/versions.py` ported and
  wired into `pages/markdown.py`, so docs prose can state
  `{{VERSION:dash-leaflet2}}` and always publish the installed version.
- **render.yaml matches the dashboard**: `plan: starter` (upgraded
  2026-08-16), a 1 GB disk at `/var/data` holding the analytics ledger and
  the control board's visibility overrides (both now survive deploys), and
  the corpus-tier knobs.

### Added

- `tests/test_traffic_rollup.py` — the boilerplate's 15-test suite over the
  v3 rollup semantics, copied verbatim.

---

## [0.2.2] — 2026-08-05

The rest of the 2plot network standard, from the checklist's "found on the
email pass" — the items that each bit a satellite which already looked
finished. Documentation site and network wiring only; no `dl2.*` component
changed.

> **Deploy note.** `og:image` now declares 1200×630, and the battery reads the
> CDN file's real pixels after every deploy. The new card
> (`scripts/make_social_card.py`) must be uploaded to
> `cdn.2plot.ai/github_assets/leaflet.2plot.dev.png` **before** this ships, or
> `social_card_real_pixels` fails the deploy — deliberately.

### Fixed

- **The network bulletin was never wired.** The hub publishes announcements and
  tips at `2plot.dev/api/network/bulletin`, and every satellite renders them in
  its llms.txt viewer header. This host had no `lib/bulletin.py` at all, so it
  showed "No announcements." and one generic tip where the hub publishes two —
  and an unwired host still renders both panels, which is why nobody noticed.
  Note that `dash_improve_my_llms/bulletin.py` never reads
  `NETWORK_BULLETIN_URL`: setting that variable without this code does nothing,
  silently. `run.py` now prints which of the two states it booted in.
- **The social card was the wrong shape, and the wrong image.** 1280×515
  (2.49:1) is wider than both the Open Graph ideal and Twitter's 2:1 slot, so
  every platform cropped it — and the file was the 2plot network wordmark
  rather than a card for this site. Replaced with a generated 1200×630 card,
  and the battery now reads the served PNG's IHDR so a re-upload at a different
  size cannot pass while every offline test stays green.
- **`dash-clerk-auth` 0.9.0 renders a dead avatar on satellites** — the header
  control appears and never resolves the signed-in user. This host is a
  satellite of the 2plot.ai primary, so it is exactly the affected shape.
  Vendored 0.9.1.
- **`markdown2dash` was installed without `--no-deps` in two places** —
  `scripts/compat_matrix.py` and the README quickstart. In the matrix that
  meant every per-Dash-version venv booted an app with no documentation pages,
  so the compatibility run measured nothing.
- **`AD_APP_ID` was the package name, not the directory key.** The hub lists
  `dash-leaflet2` under `legacy_ids` and folds it in at ingest specifically
  "until leaflet's own network-standard pass sets `AD_APP_ID=leaflet`". It now
  does, and `SATELLITE_APP_KEY` is set alongside `SATELLITE_APP_ID`.

### Added

- **The Control Board appears in the nav, to admins only** — its own section in
  both the desktop navbar and the mobile drawer, hidden by default and revealed
  server-side by the same predicate the page itself uses. The link is cosmetic:
  `/admin/control-board` gates itself on every render and again in its mutating
  callback, and fails closed without Clerk.
- `lib/bulletin.py`, `scripts/make_social_card.py`, `tests/test_bulletin.py`,
  `tests/test_admin_nav.py`, and `social_card_real_pixels` in the battery.
- `SITE_SHORT_NAME` (with `PAGE_TITLE_PREFIX` derived from it rather than typed
  twice) and `OG_IMAGE_TYPE`.

### Changed

- **A hosted deploy advertising `http://localhost` now says so, loudly.**
  Production was serving `/llms.txt`, `/sitemap.xml` and every canonical link
  pointing at `http://localhost:8050`, and nothing looked wrong: the site
  rendered, `/healthz` returned 200, and `tests/test_network_surfaces.py`
  passed because it asserts sitemap URLs start with `BASE_URL` — comparing the
  deployed value against itself, which is just as true when both sides are
  localhost. The code default was never the problem (it is already
  `https://leaflet.2plot.dev`); a loopback value can only come from
  `APP_BASE_URL` or `DASH_LEAFLET2_BASE_URL` being *explicitly* set to one, and
  `.env.example` ships exactly those values uncommented for local use.
  Three changes, none of which self-heal — auto-filling Render's
  `RENDER_EXTERNAL_URL` would just swap one wrong canonical origin
  (`*.onrender.com`) for another: `lib.constants.base_url_misconfigured()`
  returns an actionable message when a hosted service resolves BASE_URL to a
  loopback origin, naming which of the two variables is at fault; `run.py`
  prints the resolved base URL at boot and that warning after it; and
  `/healthz` now reports `base_url`, so the origin a satellite *advertises* is
  checkable from outside it with one curl. `.env.example` says plainly that its
  values are local-only.
- **`BASE_URL` accepts `APP_BASE_URL` first**, falling back to this repo's
  `DASH_LEAFLET2_BASE_URL`. An alias, never a rename — both are set in
  `render.yaml`, because removing one of two env names from a live service is
  how a host starts advertising the wrong canonical origin and deindexes
  itself quietly.
- **`dash-emoji-mart` and `flexlayout-dash` install from PyPI**, replacing the
  vendored tarballs now that their working builds are published. Both keep a
  load-bearing floor — `dash-emoji-mart>=0.0.5` (0.0.3 errors on init) and
  `flexlayout-dash>=1.1.0` (1.1.0 renamed the import to `flexlayout_dash`, which
  `docs/walking-sim/example.py` imports directly) — so a too-old resolve fails
  at install rather than at page render. They also re-enter CI's `pip-audit`
  job, which skips `./vendor/` lines because pip-audit can only assess PyPI
  dists. `vendor/` is down to the single Clerk tarball.
- **`dash-clerk-auth` 0.9.1 → 1.0.0**, and `lib/auth.py` stops hand-patching the
  satellite. Both fixes it used to inject are upstream: 0.9.1 stamps
  `data-clerk-domain` onto the ClerkJS script tag, and 0.9.2 replaced the
  `Clerk.openSignIn()` modal — which ClerkJS forbids on a satellite — with a
  navigation to the primary. What stays is one *delegated* capture-phase
  listener on `#clerk-login-button`: the package binds that id inside its
  `DOMContentLoaded` handler, so the header control is covered but the sign-in
  card in `lib/page_visibility.py`, which a page callback renders later, would
  otherwise have no listener at all. It now defers to the package's own
  `window.dashClerkAuth.buildSatelliteRedirect()` (0.9.2's page-JS surface,
  opt-in via `CLERK_SATELLITE_SIGN_IN_REDIRECT`) and falls back to the same
  `redirectToSignIn()` call upstream makes.

  1.0.0 raises `requires-python` to `>=3.10` — `clerk-backend-api` 5.x
  publishes no 3.9 build, so the old `>=3.9` claim was never installable. That
  binds the **docs site** only: Docker is 3.12 and the CI docs matrix is
  3.10/3.12/3.13. The `dash_leaflet2` package keeps `requires-python >=3.9`,
  which the `package-python-range` CI job proves against the built wheel.

---

## [0.2.1] — 2026-07-31

Brings this satellite onto the **2plot network standard** that 2plot.ai (root),
2plot.dev (hub) and `dash-documentation-boilerplate` (the template) now ship.
No `dl2.*` component changed; everything here is the documentation site, its
analytics and its CI.

### Fixed

- **Every page shipped an empty `og:image`.** Dash emits `og:image` and
  `twitter:image` for each page and leaves them `content=""` when it can find
  no image, which unfurls as a *blank* preview card on Facebook, Twitter/X,
  Slack, Discord and LinkedIn — strictly worse than declaring no image at all.
  `register_page(image_url=...)` now supplies the real absolute URL, served
  from the 2plot CDN so a sleeping free-tier container never costs a preview.
  `templates/index.html` deliberately declares only the auxiliaries Dash omits
  (`og:image:width` / `height` / `alt` / `type` / `secure_url`,
  `twitter:image:alt`), so it cannot duplicate the URL.
- **The web app manifest could never have offered an install.** Its `name` and
  `short_name` were empty strings — which disqualifies a manifest outright —
  and its icon `src` paths pointed at `/android-chrome-192x192.png` at the site
  root, where nothing is served; the files live under `/assets/favicon_io/`.
  Nothing linked to it either. Fixed, linked, and joined by
  `apple-touch-icon` (iOS ignores the manifest and uses that for Add to Home
  Screen) and the `msapplication-*` tiles.
- **Crawler traffic was never counted.** The per-request tracker was a Flask
  `before_request` handler registered *after* `add_llms_routes`, and
  dash-improve-my-llms' bot middleware answers every crawler with prerendered
  HTML — which short-circuits the remaining `before_request` handlers. No
  crawler request ever reached the ledger, so this site reported
  `bot_hits: 0` to 2plot.ai structurally, for every day it has been live,
  with nothing visibly broken. The tracker now wraps the WSGI/ASGI callable
  instead (`_wsgi_tracker` / `_asgi_tracker`), which sits outside the whole
  application and cannot be short-circuited. Registration order was not a
  usable fix: Flask runs `before_request` handlers first-registered-first,
  while Starlette makes the last-added middleware outermost, so no single
  ordering is correct on all three backends.
- **The ad fetch and the traffic rollup polluted the hub's ledgers.** Both
  server-to-server calls left as `python-requests/2.x`, which 2plot.dev and
  2plot.ai classify as a bot — so every docs page view here inflated the
  hub's `bot_hits`. Both now send the network's internal-traffic User-Agent.
- **A control-board toggle could rename the site.** `apply_llms_state`
  re-registers a page's metadata whenever a visibility verdict changes, using
  the name the markdown loader recorded — `"Home"` for this site's root. One
  flip of the home page's llms.txt switch would have overwritten the site
  brand at runtime, silently degrading the published identity to a generic
  word. `lib.page_visibility.published_name` now pins the root to
  `SITE_BRAND`.
- **gunicorn was pinned under a security floor.** `gunicorn>=21.2,<22` was
  holding the production server on a line carrying two HTTP request-smuggling
  CVEs (CVE-2024-6827, CVE-2024-1135), because `markdown2dash` 0.1.2 declares
  `gunicorn<22`. markdown2dash is now installed with `--no-deps` (its real
  dependencies moved into `requirements.txt`, carrying its own version ranges)
  and the floor is `gunicorn>=23.0.0`, asserted inside the built image by CI.

### Added

- **Explicit site identity.** `lib.constants.SITE_BRAND` —
  *"dash-leaflet2 — Leaflet 2 maps for Dash"* — is now the one string on every
  surface: `Dash(title=)`, `register_page_metadata(path="/")`, the home
  markdown's H1 and the README. This matters because the home page is
  registered as `"Home"`, which `resolve_site_title` skips as generic; without
  the explicit registration the site published a framework fallback.
- **An introduction video** on the home page and in the README —
  [*Dash Leaflet 2.0: Drone Tracking, Image Overlays & Map Packages in
  Python*](https://youtu.be/Wlmw98JrJZI). Embedded from
  `youtube-nocookie.com`, so the player sets no visitor-tracking cookies on a
  site that otherwise counts nothing beyond an anonymised page view, and
  accompanied by a plain link — an agent reading `/llms.txt` never sees an
  iframe, and neither does anyone whose browser blocks the embed.
- **`scripts/network_smoke.py`** — the network's named-check battery, run
  against the CI container and against production with identical check names.
  Proves identity, the agent-facing document surfaces, the robots fingerprint,
  hidden-page 404s and content negotiation.
- **`scripts/smoke_live.py`** — post-deploy checks: every canonical, every
  crawler body, and every peer `llms.txt` in the directory. Peer failures warn
  rather than fail, because gating a deploy on somebody else's certificate is
  shared fate.
- **`tests/`** — a secretless in-process suite (80 tests) covering site
  identity, the internal-traffic contract in both directions, the agent and
  crawler surfaces, the social card and manifest, and *the smoke scripts
  themselves*, so a battery that has rotted into a silent pass fails here
  first.
- Two live battery checks for the surfaces above — `social_card_is_shareable`
  (the image is declared once, is not empty, and actually resolves) and
  `installable_as_an_app` (the manifest is linked, named, and its icons
  resolve). Both fail invisibly in production otherwise: nobody sees their own
  link previews, and no browser explains why it declined to offer an install.
- **`.github/workflows/cd.yml`** — deploy plus live verification, waiting for
  five consecutive healthy responses after a 120s settle rather than a single
  200 (Render swaps instances, so the old build answers throughout).
- **`.github/dependabot.yml`** — weekly pip with a `dash-network` group,
  weekly npm, monthly actions and Docker.

### Changed

- **`dash-improve-my-llms>=2.3.4`** (from 2.3.3), the network floor: 2.3.4 adds
  `resolve_site_title`, without which the `/llms.txt` H1 and the llms viewer's
  brand chip fall back to `app.title`.
- **CI on the network baseline**: `permissions: contents: read`,
  `timeout-minutes` on every job, an `actionlint` step (an invalid workflow
  file is the one defect CI structurally cannot report), a real Docker
  build → boot → battery job with buildx GHA caching, version fingerprints
  asserted inside the image, and an advisory `pip-audit`. CI now runs on
  pull requests and `workflow_call` only — `main` belongs to CD, which calls
  it. The existing wheel and Dash-compatibility jobs are unchanged.
- **The home page** is no longer the generated scaffold: it opens with the site
  brand and describes what the library actually is.
- `templates/index.html` no longer publishes `pip-install-python.com` as this
  site's Organization URL, author URL or footer link — it is not a 2plot
  network host. Those now point at https://github.com/2plotai.
- The README's assets are served from `cdn.2plot.ai` rather than
  `raw.githubusercontent.com`, so they render on PyPI (where the README is the
  long description) as well as on GitHub.

---

## [0.2.0] — 2026-07-28

First public release: the project splits into a private R&D checkout and this
public mirror, which is what ships to PyPI and to https://leaflet.2plot.dev.

### Added — public release preparation

The project is split into a private R&D checkout and this **public mirror**, which
is what ships to PyPI and to https://leaflet.2plot.dev.

- **`/tile-selector` rewritten** as a lean, self-contained page documenting the
  `dl2.TileSelector` component — click / shift-drag selection, the
  `{z, x, y, url, bounds}` data boundary, and the `[MUTABLE]` round-trip that lets
  a Clear button write `selectedTiles` back from Python. The previous 3,700-line
  AI tile-generation lab stays internal.
- **`scripts/smoke_test.py`** — headless suite driving the app through the backend's
  test client (no socket, no browser): page registration with duplicate-path
  detection, layout construction plus JSON serialisation of every example, and an
  HTTP sweep of every route, `/_dash-layout`, `/_dash-dependencies`, `/healthz`,
  `/llms.txt`, `/robots.txt` and `/sitemap.xml`.
- **`scripts/compat_matrix.py`** — builds a throwaway virtualenv per Dash version
  (4.1.0 / 4.2.0 / 4.3.0 / 4.4.1 by default), installs the docs site into each, runs
  the smoke suite, and writes `COMPATIBILITY.md`. Optional `--browser` leg boots each
  venv for real and collects console errors with Playwright. This is what turns the
  `dash>=4.1` claim into evidence.
- **`scripts/sync_from_rnd.py`** — pulls R&D work forward into the mirror behind an
  explicit denylist. Pull, not push: a new R&D docs page surfaces as NEW for approval
  rather than leaking by being forgotten upstream.
- **2plot network integration**, all dormant without environment keys:
  `lib/ad_client.py` (2plot.dev ad slots in the docs aside), the Gen-1
  satellite traffic module — since retired for the analytics trio, see the
  Unreleased entry — (signed traffic rollups to 2plot.ai, `/healthz`, SPA
  page-view beacon),
  `lib/auth.py` (Clerk satellite of the 2plot.ai primary, including the two
  dash-clerk-auth 0.9.0 satellite fixes), and `lib/page_visibility.py` +
  `pages/control_board.py` (four-tier page visibility re-checked on every render,
  editable live at `/admin/control-board`).
- **Deployment**: `Dockerfile`, `.dockerignore`, `render.yaml` and `DEPLOYMENT.md`
  for `leaflet.2plot.dev`.
- **`vendor/`** — the two docs-only packages that are not on PyPI
  (`dash_emoji_mart` 0.0.5, `flexlayout_dash` 1.1.0) are committed here so
  `pip install -r requirements.txt` works from a clean clone. Neither is needed by
  the `dash_leaflet2` package, which still requires only `dash>=4.1`.

### Changed

- `app.py` → **`run.py`**, with `HOST` / `PORT` / `DASH_DEBUG` read from the
  environment so the compatibility matrix can run several Dash versions side by side.
- `requirements.txt` rewritten: the vendored packages install from relative
  `./vendor/` paths instead of absolute `file:///Users/...` URLs, and the Dash pin
  carries a `# COMPAT-MATRIX: dash` tag the matrix script strips per run.
- README rebuilt for the public release; `pyproject.toml` gained full trove
  classifiers and project URLs pointing at the documentation site.

---

## [0.1.0] — 2026-07-04

### Added — `crossOrigin` on TileLayer + ImageOverlay

`dl2.TileLayer` and `dl2.ImageOverlay` surface Leaflet's `crossOrigin` option
(`"anonymous" | "use-credentials" | ""`). Setting it makes the underlying `<img>`
loads CORS-mode so canvas captures (map screenshots / html2canvas thumbnails) can
read the pixels without tainting the canvas — requested by SailsBoard's
save-time-thumbnail pipeline. **Opt-in with no default**: a CORS-mode img fails to
load entirely against a host that doesn't answer `Access-Control-Allow-Origin`, so
leave it unset for tile providers you don't control. Construction-time only (for
`ImageOverlay`, `setUrl` and the editable drag/resize/rotate transforms reuse the
same img element, so the attribute set at construction persists).

### Added — dash-leaflet 1.x parity work (compiled `dl2.*` package)

Closes the surface gap downstream projects (SailsBoard's harbor map being the
canonical one) hit when migrating off `dash-leaflet` 1.x. Every item below is
verified end-to-end in a new live showcase page under `/docs/<slug>/`.

**TileLayer pro props** — `dl2.TileLayer` gains `minZoom`, `bounds`, `errorTileUrl`,
`zIndex`, `subdomains`, `detectRetina`, `tms`. `opacity` + `zIndex` are `[MUTABLE]`
via `setOpacity` / `setZIndex`. Lets downstream apps clip tile requests to a
geographic box, hide 404 tiles with a transparent PNG, stack multiple tile layers
explicitly, and shard CDN load across `{s}` subdomains. Showcase: `/tilelayer-pro-props`.

**Map pro props** — `dl2.Map` gains `minZoom`, `maxZoom`, `maxBounds`, `zoomControl`,
`keyboard`, plus the 6 interaction-disable handlers: `dragging`, `scrollWheelZoom`,
`doubleClickZoom`, `boxZoom`, `pinchZoom` (v2's name for v1's `touchZoom`), and
`tapHold`. All of zoom/bounds/keyboard/the 5 user-flippable handlers are `[MUTABLE]`
— a callback can lock dragging while a walkthrough plays, kill scroll-wheel zoom in a
detail-preview panel, etc. `pinchZoom` writes through to both `pinchZoom` (v2) and
`touchZoom` (v1 alias) so the prop name stays stable as Leaflet 2 evolves. Showcase:
`/map-pro-props`.

**GeoJSON clustering** — `dl2.GeoJSON` adds the dash-leaflet 1.x clustering surface
backed by [SuperCluster v8](https://github.com/mapbox/supercluster): `cluster`,
`superClusterOptions`, `pointToLayer`, `clusterToLayer`, `hideout`,
`zoomToBoundsOnClick`, `spiderfyOnMaxZoom`. `pointToLayer` / `clusterToLayer` accept
a JS source string compiled via `new Function(...)` at construction time; both
receive a `ctx = { hideout, leaflet, map }` argument so user code can build any
Leaflet 2 layer without depending on a global. Non-point features (LineString,
Polygon) pass through unclustered. Showcase: `/geojson-cluster`.

**LayerGroup + FeatureGroup** — new `dl2.LayerGroup` and `dl2.FeatureGroup`
components. Children of either attach to the group instead of the map via a
forwarding `LeafletMapContext` proxy (`makeForwardingMapProxy` in
`layersControl-shared.ts`) that intercepts `addLayer`/`removeLayer` but transparently
forwards every other map method (`latLngToLayerPoint`, `on`, `getCenter`, ...) to the
real map — required for layers like Marker whose rotation effect needs the real
projection. FeatureGroup additionally emits a combined `geojson` of its vector
children plus an aggregate `n_clicks` and `n_layers` counter. Showcase: `/layer-group`.

**ScaleControl** — `dl2.ScaleControl` wraps Leaflet 2's `Control.Scale` (lives on the
`Control` namespace but is not ESM-exported by `leaflet@2.0.0-alpha.1`, so we reach
through `(Control as any).Scale`). Props: `position` (mutable), `metric`, `imperial`,
`maxWidth`, `updateWhenIdle`. Showcase: `/scale-fullscreen-image`.

**FullScreenControl** — `dl2.FullScreenControl` is a thin custom `Control` that wraps
the browser's native `requestFullscreen()` / `exitFullscreen()` API around the map
container (Leaflet 2 itself does not ship a fullscreen control). Round-trips
`fullscreen` (boolean) and `n_clicks` to Dash so a callback can react when the user
enters or leaves fullscreen. Showcase: `/scale-fullscreen-image`.

**ImageOverlay** — `dl2.ImageOverlay` wraps `leaflet.ImageOverlay`. Mutable `url`,
`bounds`, `opacity`, `zIndex`; optional `interactive=True` lets the image fire
`n_clicks`. Useful for previewing a raster scan before slicing it into tiles, draping
a single static image onto a geographic box, or showing a non-tiled overlay. Showcase:
`/scale-fullscreen-image`.

- **Editable transform controls** — set `editable=True` for a TextMarker-style control
  system: click to select, drag the body to **move** (translates `bounds`), drag the corner
  dot to **resize** (scales `bounds` about the `anchor`, which stays pinned), and the top dot
  to **rotate** (a CSS-transform visual rotation pivoting at the `anchor` — `bounds` stay
  axis-aligned since Leaflet's ImageOverlay has no native geo-rotation). The white anchor dot
  sits at the chosen `anchor`. New props `editable`, `selected` (two-way), `rotation`
  (two-way), `anchor`; `bounds` becomes two-way and `n_transforms` counts move/resize commits.

**TextMarker** — new `dl2.TextMarker`: editable, draggable, styleable text placed on
the map like a Marker (implements Route A of the `text-caption-marker-proposal`). It is
a Leaflet 2 `Marker` whose icon is a content-sized, optionally-`contentEditable` text box
rendered into the icon via a React portal, reusing Marker's drag lifecycle + the
transform-reprojection trick (so rotation survives Leaflet's constant transform rewrites).
- **Placement**: anchored to `[lat, lng]`; when `position` is omitted it spawns at the
  **center of the current viewport** and writes that position back. `anchor` (9 positions)
  picks which point of the box sits on the latlng — that point is also the rotation pivot.
- **Direct manipulation**: drag to move (writes `position` + `n_drags`), double-click to
  edit inline (writes `text` + `n_edits`), and when `selected` a corner **resize** handle
  (→ `fontSize`) and a **rotate** handle (→ `rotation`, Shift-snaps to 15°) appear.
- **Style**: `color`, `backgroundColor`, `fontFamily`, `fontSize`, `fontWeight`,
  `fontStyle`, `padding`, `borderRadius`, `rotation`, `rotateWithMap` — all `[MUTABLE]`
  two-way (the contextual glass toolbar that shows while selected edits them and round-trips
  every change to Dash, so a host can also drive style from props). `selected` is two-way
  (clicking the label selects it; a map-background click deselects); `showToolbar` hides the
  built-in toolbar for hosts that supply their own.
- **Two size models** via `scaleWithZoom` (+ `referenceZoom`): `false` (default) is a
  constant screen-size HUD caption (`fontSize` is literal px at every zoom); `true` is
  geographic sizing — the on-screen size scales by `2^(zoom − referenceZoom)` so the caption
  keeps a fixed ground footprint as the camera flies. `referenceZoom` defaults to the zoom at
  which the label was created.
- Anchor offset is applied via the icon's **margin** (not baked into the transform) so
  Leaflet's own mid-drag positioning and ours never disagree; a post-handle-drag `click` is
  swallowed so resize/rotate don't deselect.
- The white selection dot (which doubles as the resize grip) is drawn at the chosen `anchor`
  point — `bottom` → bottom-center, `top-left` → top-left, … (`center` → bottom-right so it
  never covers the text) — so you can see where the label is pinned. Resize now references the
  box center (the dot sits at the anchor, so an anchor-referenced ratio would divide by ~0).
- `selected` is **uncontrolled when omitted**: the marker self-manages selection (click to
  select, two-stage map-click to deselect) and a map-event bus keeps only one TextMarker
  selected at a time. Pass an explicit `selected` to drive it from the host. The anchor model
  + dot positioning now live in the shared `src/ts/anchor.ts` (used by the editable ImageOverlay too).

**EditControl `text` tool (proposal Route B)** — `dl2.EditControl` gains a `text` tool
alongside `marker` / `polyline` / `polygon` / …. Picking it and clicking the map drops an
inline-editable caption that round-trips through the **same `geojson` channel** as every other
shape — a GeoJSON `Point` carrying `kind:"text"` + the caption style (`text`, `color`,
`fontSize`, `fontFamily`, `fontWeight`) in `properties`. In edit mode the caption is draggable
and double-click re-opens the inline editor; cancel/revert rebuilds captions as text icons (not
pins). Enable per-tool with `draw={"text": True}`. Showcase: `/text-marker`.

### Added — docs site (`docs/<slug>/`)

Five new markdown-driven showcase pages, each with a focused "limited working
example" `example.py` next to the markdown:

- `/tilelayer-pro-props` — two stacked tile layers (OSM base with `subdomains` +
  `detectRetina`; CARTO labels-only overlay clipped to a Rockport, TX `bounds` box
  with a transparent `errorTileUrl`). Sliders drive `opacity` + `zIndex` live.
- `/map-pro-props` — 6-handler Switch panel + zoom RangeSlider + `maxBounds` toggle
  + live viewport readback. Flipping a Switch immediately disables the matching
  Leaflet handler on the live map.
- `/geojson-cluster` — 200 synthetic vessel positions colored by category via a JS
  `pointToLayer` reading a Python-shipped `hideout` color map; cluster bubbles take
  the dominant category's color. Cluster-radius slider tunes
  `superClusterOptions.radius` live.
- `/layer-group` — two maps: one `LayerGroup` of three markers behind a single
  Switch (the whole group toggles together), one `FeatureGroup` wrapping four shapes
  and emitting combined `geojson` + bumping `n_clicks` on any child click.
- `/scale-fullscreen-image` — one map with the scale bar (position + metric/imperial
  Switches), the fullscreen button (reports `fullscreen` + `n_clicks`), and a
  swappable `ImageOverlay` with opacity slider.
- `/text-marker` — a selected `TextMarker` you drag / edit / resize / rotate / restyle
  on the map (or drive from the right column: text, color, font size, rotation, anchor,
  `scaleWithZoom`), a second caption with `scaleWithZoom=True` that holds its ground size,
  and the `EditControl` `text` tool wired in (click the T, click the map, type — the
  caption shows up in `EditControl.geojson` as a `kind:"text"` Point). Live readback panel.

### Added — supporting work

- New runtime dependency: `supercluster@^8.0.1` (bundled into `dash_leaflet2.js`).
  The 0.0.1 wheel sat at ~261 KiB; with clustering + the four new components the
  bundle is now ~263 KiB.
- `src/ts/types/leaflet.d.ts` — extended for `Map.setMinZoom` / `setMaxZoom` /
  `setMaxBounds` / `getMinZoom` / `getMaxZoom`, `Map.keyboard`, `TileLayer.setOpacity`
  / `setZIndex`, `ImageOverlay`, plus a minimal ambient `supercluster` module.
- `src/ts/layersControl-shared.ts` — new `makeForwardingMapProxy(onAdd, onRemove,
  getRealMap)` builds a JS-`Proxy`-based map stand-in that intercepts
  `addLayer`/`removeLayer` but forwards every other property access to the real map.
  Used by `LayerGroup` and `FeatureGroup`; the existing thin `makeMapProxy` is kept
  for `BaseLayer`/`Overlay` where forwarding is unwanted.
- `src/ts/theme.css` — cluster-bubble glass styling (`.dl2-cluster-bubble` and
  `.dl2-cluster-{32,40,48,56}` sizes) + fullscreen-button styling
  (`.dl2-fullscreen-control`, `.dl2-fullscreen-button`).

### Added — earlier in Unreleased

- **`dl2.TileSelector`** — a map control that turns the map into a tile picker:
  click or shift-drag to select tiles, which round-trip to Python as
  `{z, x, y, url, bounds}`, keyed by `z/x/y` so selections survive pan and zoom.
- **Compare Lab** (`/compare-lab`) — tileset comparison surface: an `EasyButton` +
  Popover + `dash_mui_charts.TreeViewPro` driving a clientside reconciler over a
  stack of `TileLayer` overlays (visibility, opacity, z-order, deletion), seeded
  with synthetic SVG overlays so every interaction responds in under a second.
- **Walking Sim** (`/walking-sim`) — Esri Imagery + NatGeo layered basemaps with a
  street-tile minimap; flyTo between WALK / EXPLORE modes.
- **Sub-toolbar + live drawing feedback** in `dl2.EditControl` — vertex-handle previews,
  cursor-following guide tooltip, dashed rubber-band, context-sensitive fly-out actions
  (Finish / Delete-last-point / Cancel during draw; Save / Cancel during edit).

### Fixed
- **Cross-zoom prompt engineering** — the AI was pasting descendant references as
  visible rectangular insets with duplicated features and a seam. Rewrote SOURCE +
  CROSS-ZOOM REFERENCE labels and the addendum to forbid pasting/insets and to assert
  the source tile as the geometric ground truth for all four quadrants.
- **Tileset comparison overlay layering** — z15 (later-added, larger) was covering z16
  at every viewport zoom. Added zoom-meets-tile filtering: among overlapping tree-checked
  tiles, only the deepest zoom the viewport has met shows (`z15` at vz=13–15, `z16` at
  vz=16, `z17` at vz=17+). Standalone tiles unaffected. Set `zIndex = 400 + tileZ` so
  any transient overlap keeps the finer tile on top.
- **EasyButton popover toggle** — `dmc.Popover.opened` is not pushed through `setProps`
  after internal state changes; switched to a DOM-read clientside pattern reading
  `.mantine-Popover-dropdown` `offsetParent`.
- **`MUI TreeView` overlay flicker** — refactored to "mount-everything-hide-via-opacity"
  with `transition: opacity 120ms ease`; checkbox-row double-click bounces no longer
  tear overlays off the map.

---

## [0.0.1] — 2026-05-22

First **alpha** release. Build a wheel from source (`python -m build`); not yet on PyPI.

### Added — components shipped in the wheel

| Component | Wraps | Notes |
|---|---|---|
| `dl2.Map` | `leaflet.Map` | `viewport` + `clickData` round-trip; React-context bridge replaces react-leaflet |
| `dl2.TileLayer` | `leaflet.TileLayer` | `url`, `attribution`, `maxZoom`, `opacity` |
| `dl2.Marker` | `leaflet.Marker` | default / `icon` / `emoji` / `iconify` / full `iconOptions` icon modes; bundled marker images (base64) |
| `dl2.Polyline`, `dl2.Polygon`, `dl2.Rectangle`, `dl2.Circle`, `dl2.CircleMarker` | corresponding `leaflet.*` | vector path props + click round-trip |
| `dl2.GeoJSON` | `leaflet.GeoJSON` | `data`, `style`, `clickFeature`; `pointToLayer` sets the bundled default icon to dodge v2's stale `Icon.Default()` trap |
| `dl2.Popup`, `dl2.Tooltip` | `leaflet.Popup`, `leaflet.Tooltip` | render arbitrary Dash content through React portals |
| `dl2.LayersControl` + `dl2.BaseLayer` + `dl2.Overlay` | custom (`Control` subclass) | v2's `Layers` class is not ESM-exported; ships our own with `RegisterContext` |
| `dl2.EditControl` | native v2 toolbar `Control` | leaflet-draw is v1-only; our native replacement draws marker / polyline / polygon / rectangle / circle + delete with GeoJSON round-trip |
| `dl2.EasyButton` | `leaflet.Control` | Iconify icon, `n_clicks` / `n_dblclicks` |
| `dl2.AttributionControl` | `leaflet.Control.Attribution` | `prefix`, custom `attribution` |
| `dl2.KeyboardControl` | custom (`Control`) | DOM key listeners → `lastKey` / `n_events` |
| `dl2.MiniMap` | custom (`Control`) | second `leaflet.Map` instance pinned to a corner |
| `dl2.TileSelector` | custom (`Control`) | hover-highlight, click-toggle, shift+drag box-select; `selectedTiles` round-trip with `{z, x, y, url, bounds}` |
| `dl2.Tooltip`, `dl2.Popup` | (see above) | bind to any layer via React portal |

### Added — hooks/CDN showcase (`run.py`)
- 20+ pages under `docs/` demonstrating v2 features through the `dash.hooks` API with
  no build step: pointer events, canvas overlay, ES6 subclassing, `ResizeObserver`
  sizing, vector layers, emoji/iconify markers, layers control, draw + edit + measure,
  easy button, MiniMap, basic rotation, flight sim, walking sim, events→Python, flyTo,
  attribution control, tile-layers-pro, tile-selector, compare-lab.
- DMC AppShell + sidebar + dark-mode toggle; FastAPI backend by default
  (`DASH_BACKEND=flask python run.py` to fall back).

### Added — developer tooling
- `.claude/` directory: 1 subagent (`leaflet2-component-author`), 2 skills
  (`build-and-verify`, `new-component`), 3 path-scoped rules
  (`leaflet2-v2-api.md`, `dash-components.md`, `showcase-pages.md`).
- Webpack + `dash-generate-components` build pipeline; Python classes generated from TS
  JSDoc; default marker icons inlined as base64 to dodge v2's CSS-path detection.

### Known gotchas
- v2's UMD global is **`window.leaflet`** (not `window.L`).
- No lowercase factories — `new Marker(...)`, not `L.marker(...)`.
- v2 fires **pointer events** (`pointermove`/`pointerdown`), not mouse events.
- `BlanketOverlay._onMoveEnd()` clears the canvas after drawing — Canvas renderer
  workaround: `requestAnimationFrame(() => renderer._update())` after `moveend`/`zoomend`.
- v2 ships no TypeScript types — minimal ambient declarations at
  `src/ts/types/leaflet.d.ts`.

[Unreleased]: https://github.com/pip-install-python/dash-leaflet2/compare/v0.1.0...HEAD
[0.1.0]: https://github.com/pip-install-python/dash-leaflet2/compare/v0.0.1...v0.1.0
[0.0.1]: https://github.com/pip-install-python/dash-leaflet2/releases/tag/v0.0.1

---

<!-- /compare-lab — https://leaflet.2plot.dev/compare-lab/llms.txt -->

# Compare Lab

> stack, reorder and cross-fade tile overlays from a TreeViewPro popover.

---



### Overview

A tileset **comparison** surface: a `dl2.EasyButton` opens a Mantine `Popover`
holding a MUI TreeViewPro, and every interaction in that tree reconciles a stack
of `dl2.TileLayer` overlays on the map underneath — visibility, per-layer
opacity, z-order and deletion.

The interesting part is the **reconciler**. Overlays are never unmounted and
remounted as the tree changes; a clientside callback diffs the desired state
against what is already on the map and mutates only what differs. That is what
keeps a slider drag smooth instead of tearing down and rebuilding a TileLayer on
every frame.

The overlays here are synthetic — coloured SVG tiles seeded by a button — so the
page is a self-contained demonstration of the pattern rather than a dependency on
any particular imagery source.

### What this page demonstrates

1. **Consistent initial state.** The tree shows Esri World Imagery checked at
   80 % opacity on first paint, *and* the layer is actually on the map. Getting
   this right means the store's initial value must agree with the tree's initial
   `selectedItems` — if the reducer is `prevent_initial_call=True` and the store
   says `source_visible: False` while the tree says checked, the layer will not
   appear until the user jiggles a control. Initialise both to the same truth.
2. **Light / dark basemap.** The basemap swaps between CARTO Positron and CARTO
   Dark Matter with the app shell's colour-scheme toggle, driven by a clientside
   callback so the change is instant.
3. **Multi-zoom overlays.** "Seed 3 fake gens" drops tiles at matching z14 / z15
   / z16 coordinates over Salt Lake City, so cross-zoom relationships line up and
   the association-by-zoom logic has something real to chew on.
4. **Every tree interaction wired** — selection toggles overlay visibility,
   sliders drive opacity, the kebab menu's Remove deletes both the tree leaf and
   its overlay, and items are reorderable to drive z-order.

### Live demo


### Source


```python
# File: docs/compare-lab/example.py

"""
Compare Lab — stack, reorder and cross-fade tile overlays from a
TreeViewPro popover.

A `dl2.EasyButton` opens a Mantine Popover holding a MUI TreeViewPro; every
interaction in that tree reconciles a stack of `dl2.TileLayer` overlays on the
map beneath it — visibility, per-layer opacity, z-order and deletion.

The interesting part is the reconciler. Overlays are never unmounted and
remounted as the tree changes; a clientside callback diffs the desired state
against what is already on the map and mutates only what differs. That is what
keeps a slider drag smooth instead of rebuilding a TileLayer every frame.

Overlays here are SYNTHETIC — coloured SVG placeholders seeded by a button — so
the page is self-contained and every interaction responds in under a second.

What this page demonstrates:

  1. **Consistent initial state.** The tree shows Esri World Imagery checked at
     80 % opacity on first paint AND the layer is actually on the map. The
     store's initial value has to agree with the tree's initial `selectedItems`:
     with a `prevent_initial_call=True` reducer, a store saying
     `source_visible=False` while the tree says checked leaves the layer
     invisible until the user jiggles a control. Both are initialised to the
     same truth here (`source_visible: True`), so render-source-layer fires
     correctly on first paint.
  2. **Light/dark basemap.** The basemap auto-swaps between CARTO Positron
     (light) and CARTO Dark Matter (dark) with the app-shell
     `color-scheme-toggle`, driven by a clientside callback so it is instant.
  3. **Multi-zoom overlays.** "Seed 3 fake gens" drops three coloured SVG tiles
     at matching z14 / z15 / z16 coordinates over Salt Lake City, so cross-zoom
     relationships line up and the association-by-zoom math has real input.
  4. **All TreeViewPro interactions wired** — selection toggles overlay
     visibility, sliders drive opacity, the kebab "Remove" deletes the leaf and
     its overlay, and items are reorderable to drive z-order.
"""

from __future__ import annotations

import base64
import os
import time

import dash_leaflet2 as dl2
import dash_mantine_components as dmc
from dash import (
    ALL,
    Input,
    Output,
    State,
    callback,
    clientside_callback,
    ctx,
    dcc,
    html,
    no_update,
)
from dash_iconify import DashIconify
from dl2_tiles import POSITRON, register_theme_swap
from dl2_locations import SALT_LAKE_CITY
from dash_mui_charts import TreeViewPro

# ---------------------------------------------------------------------
# Constants — tile URLs, attributions, MUI key.
# ---------------------------------------------------------------------

# CARTO basemap (no `{s}` subdomain so the URL is stable for the
# clientside light/dark swap below — the swap just substitutes one URL
# template for the other).
# Basemap pair for this page. dl2_tiles owns the light/dark wiring so
# every example themes the same way — see register_theme_swap below.
TILES = POSITRON
CARTO_LIGHT = TILES.url("light")
CARTO_DARK = TILES.url("dark")
CARTO_ATTR = (
    '&copy; <a href="https://openstreetmap.org/copyright">'
    "OpenStreetMap</a> &copy; "
    '<a href="https://carto.com/attributions">CARTO</a>'
)

ESRI_SAT = (
    "https://server.arcgisonline.com/ArcGIS/rest/services/"
    "World_Imagery/MapServer/tile/{z}/{y}/{x}"
)
ESRI_ATTR = (
    "Tiles &copy; Esri &mdash; Esri, Maxar, Earthstar Geographics, "
    "and the GIS User Community"
)

MUI_PRO_LICENSE_KEY = os.environ.get("MUI_PRO_API_KEY", "")

# The three synthetic generation coords. `nested_tile_keys` walks down from
# the real z14 tile containing the city, and the NW child of (x, y) is always
# (2x, 2y) — so the cross-zoom nesting the association math is testing is
# genuine, and the tiles actually sit over the city the map opens on.
SYNTH_KEYS = SALT_LAKE_CITY.nested_tile_keys(14, levels=3)
SYNTH_COLOR = ["#e64980", "#7950f2", "#15aabf"]  # one color per zoom


# ---------------------------------------------------------------------
# Tiny shared helpers (copied — not imported — to keep the lab fully
# self-contained).
# ---------------------------------------------------------------------


def parse_key(key: str) -> tuple[int, int, int]:
    z, x, y = key.split("/")
    return int(z), int(x), int(y)


def _synth_data_url(label: str, color: str) -> str:
    """A 256x256 SVG square — used as the synthetic overlay image.
    Inline data URL so no network fetch happens at render time."""
    svg = (
        f"<svg xmlns='http://www.w3.org/2000/svg' width='256' height='256'>"
        f"<rect width='256' height='256' fill='{color}' opacity='0.88'/>"
        f"<text x='128' y='128' text-anchor='middle' "
        f"dominant-baseline='middle' fill='white' "
        f"font-family='monospace' font-size='22' font-weight='700'>"
        f"{label}</text></svg>"
    )
    return f"data:image/svg+xml;base64,{base64.b64encode(svg.encode()).decode()}"


def _synth_gen(key: str, idx: int) -> dict:
    z, x, y = parse_key(key)
    return {
        "data_url": _synth_data_url(f"z{z}", SYNTH_COLOR[idx % len(SYNTH_COLOR)]),
        "model": "synthetic",
        "n": 1,
        "source": key,
        "accepted": True,
        "ts": time.time() + idx,
    }


def _build_tree(generations: dict) -> list[dict]:
    """Build the TreeViewPro model — Custom
    Tileset group first (one leaf per generation, sorted by z, x, y),
    Source Tileset group second (one leaf: Esri World Imagery)."""
    keys = list((generations or {}).keys())

    def _zxy(k: str):
        try:
            return (0, *parse_key(k))
        except Exception:
            return (1, k, 0, 0)

    keys.sort(key=_zxy)
    custom_children: list[dict] = []
    for key in keys:
        gen = (generations or {}).get(key)
        if not (gen and gen.get("data_url")):
            continue
        z, x, y = parse_key(key)
        custom_children.append({"id": f"cust:{key}", "label": f"z{z} · {x}/{y}"})
    if not custom_children:
        custom_children.append(
            {"id": "cust:empty", "label": "(no generations — click Seed)"}
        )
    n_real = len([c for c in custom_children if c["id"] != "cust:empty"])
    return [
        {
            "id": "grp.custom",
            "label": f"Custom Tileset ({n_real})",
            "children": custom_children,
        },
        {
            "id": "grp.source",
            "label": "Source Tileset",
            "children": [
                {"id": "src:esri", "label": "Esri World Imagery (satellite)"},
            ],
        },
    ]


KEBAB_MENU = [
    {"label": "Remove", "value": "delete", "icon": "Delete"},
]


# ---------------------------------------------------------------------
# Popover builder
# ---------------------------------------------------------------------


def _compare_popover():
    return dmc.Popover(
        id="cl-compare-popover",
        opened=False,
        position="right-start",
        offset=6,
        withArrow=True,
        arrowSize=10,
        shadow="lg",
        radius="md",
        closeOnClickOutside=True,
        closeOnEscape=True,
        keepMounted=True,
        children=[
            # Anchor — invisible 1×30 strip over the EasyButton.
            dmc.PopoverTarget(
                html.Div(id="cl-compare-anchor"),
                boxWrapperProps={
                    "style": {
                        "position": "absolute",
                        "top": "60px",
                        "left": "46px",
                        "width": "1px",
                        "height": "30px",
                        "pointerEvents": "none",
                        "zIndex": 600,
                    }
                },
            ),
            dmc.PopoverDropdown(
                dmc.Stack(
                    [
                        dmc.Group(
                            [
                                DashIconify(
                                    icon="mdi:layers-search-outline",
                                    width=16,
                                    color="var(--mantine-color-blue-6)",
                                ),
                                dmc.Text("Tileset comparison", fw=600, size="sm"),
                                dmc.Badge(
                                    "LAB",
                                    color="violet",
                                    variant="light",
                                    size="xs",
                                    ml="auto",
                                ),
                            ],
                            gap="xs",
                            wrap="nowrap",
                        ),
                        dmc.Divider(),
                        dmc.Text(
                            "Synthetic playground — selection / slider / "
                            "kebab / drag-reorder all update the map in real "
                            "time. No AI calls.",
                            size="xs",
                            c="dimmed",
                        ),
                        html.Div(
                            TreeViewPro(
                                id="cl-compare-tree",
                                items=_build_tree({}),
                                defaultExpandedItems=["grp.source", "grp.custom"],
                                multiSelect=True,
                                checkboxSelection=True,
                                # MATCH the initial store value below — this is the
                                # fix for the "Source Tileset reads checked but
                                # isn't on the map" mismatch described in the module docstring.
                                selectedItems=["src:esri"],
                                isItemEditable=False,
                                itemsReordering=True,
                                showItemControls=True,
                                controlsItems=["src:esri"],
                                sliderValues={"src:esri": 80},
                                sliderMin=0,
                                sliderMax=100,
                                sliderStep=1,
                                sliderColor="blue",
                                kebabMenuItems=KEBAB_MENU,
                                licenseKey=MUI_PRO_LICENSE_KEY,
                                expandIcon="ChevronRight",
                                collapseIcon="ExpandMore",
                                itemChildrenIndentation="14px",
                                sx={
                                    "& .MuiTreeItem-content": {"paddingY": "3px"},
                                    "& .MuiTreeItem-label": {
                                        "fontSize": "12px",
                                        "width": "100%",
                                    },
                                    "& .MuiSlider-root": {
                                        "height": "2px",
                                        "padding": "8px 0",
                                    },
                                },
                            ),
                            className="tlp-tree",  # reuse /tile-layers-pro tree polish
                        ),
                    ],
                    gap=10,
                ),
                p="md",
                style={"width": "360px"},
            ),
        ],
    )


# ---------------------------------------------------------------------
# Layout
# ---------------------------------------------------------------------

component = dmc.Stack(
    [
        dmc.Group(
            [
                dmc.Stack(
                    [
                        dmc.Title("Compare Lab", order=2, mb=0),
                        dmc.Text(
                            "Isolated harness for the Tileset Comparison popover. "
                            "Initial render shows Esri satellite at 80 % on top of "
                            "CARTO (light or dark per the app theme). Seed synthetic "
                            "generations and exercise the TreeViewPro controls; the "
                            "map reflects every change immediately.",
                            size="sm",
                            c="dimmed",
                        ),
                    ],
                    gap=2,
                ),
                dmc.Badge(
                    "polished pattern testing",
                    color="violet",
                    variant="light",
                    size="lg",
                ),
            ],
            justify="space-between",
            wrap="nowrap",
            align="start",
        ),
        dmc.Group(
            [
                dmc.Button(
                    "Seed 3 fake gens (z14 / z15 / z16)",
                    id="cl-seed",
                    leftSection=DashIconify(icon="mdi:auto-fix", width=16),
                    color="violet",
                ),
                dmc.Button(
                    "Clear all",
                    id="cl-clear",
                    variant="light",
                    color="red",
                    leftSection=DashIconify(icon="mdi:trash-can-outline", width=16),
                ),
                dmc.Tooltip(
                    label=(
                        "Synthetic overlays at z14/z15/z16, nested as ancestor/"
                        "descendant of each other so cross-zoom math is real. "
                        "Toggle / slider / kebab reorder + delete all wired."
                    ),
                    multiline=True,
                    w=320,
                    withArrow=True,
                    position="bottom",
                    children=DashIconify(
                        icon="mdi:information-outline",
                        width=18,
                        color="var(--mantine-color-dimmed)",
                    ),
                ),
            ],
            gap="xs",
        ),
        html.Div(
            # Position-relative so the popover's absolute anchor lands over
            # the EasyButton on the map.
            style={"position": "relative", "height": "72vh"},
            children=[
                dmc.Paper(
                    dl2.Map(
                        id="cl-map",
                        center=SALT_LAKE_CITY.center,
                        zoom=11,
                        style={"height": "100%", "width": "100%"},
                        children=[
                            # Basemap — URL controlled by the clientside
                            # light/dark callback below.
                            dl2.TileLayer(
                                id="cl-basemap", url=CARTO_LIGHT, attribution=CARTO_ATTR
                            ),
                            # Source Tileset slot — a Python callback fills
                            # this with the Esri TileLayer when source_visible
                            # is True (which it IS at page load — that's the
                            # fix the lab is demonstrating).
                            html.Div(id="cl-source-layer-slot"),
                            # Compare popover trigger.
                            dl2.EasyButton(
                                id="cl-compare-btn",
                                position="topleft",
                                icon="mdi:layers-search-outline",
                                iconSize=20,
                                title="Tileset comparison",
                            ),
                        ],
                    ),
                    shadow="sm",
                    radius="md",
                    withBorder=True,
                    style={"overflow": "hidden", "height": "100%"},
                ),
                _compare_popover(),
            ],
        ),
        # ---- Stores ----
        # Synthetic generations dict — the shape a real generation pipeline would emit.
        dcc.Store(id="cl-generations", data={}, storage_type="memory"),
        # Compare-popover state. CRITICAL: source_visible STARTS at True so
        # the render-source-layer callback paints the Esri layer on the very
        # first run — without this, the tree shows Esri checked but the map
        # doesn't reflect it until the user nudges the slider or toggle.
        dcc.Store(
            id="cl-compare-state",
            data={
                "source_visible": True,
                "source_opacity": 80,
                "visible_customs": [],
                "custom_opacities": {},
            },
            storage_type="memory",
        ),
        # Dummy output for the clientside overlay reconciler — Dash requires
        # a concrete Output target. Nothing reads it.
        dcc.Store(id="cl-overlay-trigger", data=0, storage_type="memory"),
        # Canonical open/closed state for the compare popover. See
        # `manage_popover_state` below for why we bother with a separate
        # Store instead of just using popover.opened directly.
        dcc.Store(id="cl-popover-open", data=False, storage_type="memory"),
    ],
    gap="md",
)


# =====================================================================
# Callbacks
# =====================================================================

# Basemap light/dark swap — the app's `color-scheme-toggle` is True when
# the LIGHT theme is active (see app.py header()), so checked=True → light.
register_theme_swap("cl-basemap", TILES)


# Source-layer slot. Critically NOT `prevent_initial_call` so it fires on
# page load with the initial source_visible=True and the Esri tile layer
# mounts right away.
@callback(
    Output("cl-source-layer-slot", "children"),
    Input("cl-compare-state", "data"),
)
def render_source_layer(state):
    state = state or {}
    if not state.get("source_visible"):
        return []
    opacity = state.get("source_opacity", 80) / 100.0
    return [
        dl2.TileLayer(
            url=ESRI_SAT,
            attribution=ESRI_ATTR,
            opacity=opacity,
            maxZoom=19,
        )
    ]


# EasyButton → popover toggle.
#
# Two clientside callbacks, no cycle:
#   1. Button click reads the popover's CURRENT visibility from the DOM
#      and flips the store. Reading the DOM at click time gives accurate
#      state even after a click-outside or Escape close (Mantine's
#      `opened` State prop is unreliable — verified empirically — but
#      the rendered DOM always reflects the truth).
#   2. Store → popover.opened (one-way drive).
#
# DomEvent.disableClickPropagation on the EasyButton stops the click
# from bubbling to document, so Mantine's outside-click handler doesn't
# fire on button clicks — the DOM read is "is the dropdown currently
# visible" and the answer is the truth at the moment we want to act.
clientside_callback(
    """
    (n) => {
      if (!n) return window.dash_clientside.no_update;
      const dd = document.querySelector('.mantine-Popover-dropdown');
      const isOpen = !!(dd && dd.offsetParent !== null);
      return !isOpen;
    }
    """,
    Output("cl-popover-open", "data"),
    Input("cl-compare-btn", "n_clicks"),
    prevent_initial_call=True,
)

clientside_callback(
    "(opened) => !!opened",
    Output("cl-compare-popover", "opened"),
    Input("cl-popover-open", "data"),
)


# TreeViewPro selection + sliders → compare-state.
# The reducer keeps visible_customs as a list, NOT
# a single boolean, so each custom leaf toggles independently). No-op
# guard skips identity-different-but-structurally-equal emissions to
# keep the cascade quiet.
@callback(
    Output("cl-compare-state", "data"),
    Input("cl-compare-tree", "selectedItems"),
    Input("cl-compare-tree", "sliderValues"),
    State("cl-compare-state", "data"),
    prevent_initial_call=True,
)
def reduce_state(selected, sliders, state):
    prev = dict(state or {})
    new = dict(prev)
    sel = set(selected or [])
    sliders = sliders or {}
    new["source_visible"] = "src:esri" in sel
    new["visible_customs"] = sorted(
        s[len("cust:") :] for s in sel if s.startswith("cust:") and s != "cust:empty"
    )
    if "src:esri" in sliders:
        new["source_opacity"] = int(sliders["src:esri"])
    new["custom_opacities"] = {
        k[len("cust:") :]: int(v)
        for k, v in sliders.items()
        if k.startswith("cust:") and k != "cust:empty"
    }
    if new == prev:
        return no_update
    return new


# Derive tree items + slider seeds from generations. The `items` no-op
# guard is what stops MUI from re-rendering the tree on every
# generations update (which would briefly reset selectedItems → the
# "vanishing tile" flicker).
@callback(
    Output("cl-compare-tree", "items"),
    Output("cl-compare-tree", "controlsItems"),
    Output("cl-compare-tree", "sliderValues"),
    Input("cl-generations", "data"),
    State("cl-compare-state", "data"),
    State("cl-compare-tree", "items"),
)
def derive_tree(generations, state, current_items):
    state = state or {}
    items = _build_tree(generations or {})
    if items == (current_items or []):
        return no_update, no_update, no_update
    opacities = state.get("custom_opacities") or {}
    controls = ["src:esri"]
    sliders = {"src:esri": state.get("source_opacity", 80)}
    for grp in items:
        if grp["id"] == "grp.custom":
            for leaf in grp.get("children") or []:
                if leaf["id"] == "cust:empty":
                    continue
                controls.append(leaf["id"])
                key = leaf["id"][len("cust:") :]
                sliders[leaf["id"]] = opacities.get(key, 90)
    return items, controls, sliders


# Auto-select new generations into selectedItems so a freshly-seeded
# overlay shows up on the map without the user opening the popover.
@callback(
    Output("cl-compare-tree", "selectedItems", allow_duplicate=True),
    Input("cl-generations", "data"),
    State("cl-compare-tree", "selectedItems"),
    prevent_initial_call=True,
)
def auto_select(gens, selected):
    selected = list(selected or [])
    sel_set = set(selected)
    changed = False
    for key, gen in (gens or {}).items():
        if not (gen and gen.get("data_url")):
            continue
        leaf_id = f"cust:{key}"
        if leaf_id not in sel_set:
            selected.append(leaf_id)
            sel_set.add(leaf_id)
            changed = True
    return selected if changed else no_update


# Seed three synthetic generations at the known nested coords.
@callback(
    Output("cl-generations", "data"),
    Input("cl-seed", "n_clicks"),
    State("cl-generations", "data"),
    prevent_initial_call=True,
)
def seed_gens(_, gens):
    gens = dict(gens or {})
    for i, key in enumerate(SYNTH_KEYS):
        gens[key] = _synth_gen(key, i)
    return gens


# Clear → wipe generations + reset selection + reset state to initial.
@callback(
    Output("cl-generations", "data", allow_duplicate=True),
    Output("cl-compare-tree", "selectedItems", allow_duplicate=True),
    Output("cl-compare-state", "data", allow_duplicate=True),
    Input("cl-clear", "n_clicks"),
    prevent_initial_call=True,
)
def clear_all(_):
    return (
        {},
        ["src:esri"],
        {
            "source_visible": True,
            "source_opacity": 80,
            "visible_customs": [],
            "custom_opacities": {},
        },
    )


# Kebab — "Remove" is the only action this page needs.
@callback(
    Output("cl-generations", "data", allow_duplicate=True),
    Output("cl-compare-tree", "selectedItems", allow_duplicate=True),
    Input("cl-compare-tree", "kebabAction"),
    State("cl-generations", "data"),
    State("cl-compare-tree", "selectedItems"),
    prevent_initial_call=True,
)
def kebab(action, gens, selected):
    if not action:
        return no_update, no_update
    item_id = action.get("itemId", "")
    what = action.get("action")
    if not item_id.startswith("cust:") or item_id == "cust:empty" or what != "delete":
        return no_update, no_update
    key = item_id[len("cust:") :]
    new_gens = dict(gens or {})
    new_gens.pop(key, None)
    new_sel = [s for s in (selected or []) if s != item_id]
    return new_gens, new_sel


# Clientside overlay reconciler — same mount-everything-hide-via-opacity
# reconciler pattern: EVERY generation stays mounted; visibility
# is purely a `setOpacity` toggle based on visible_customs membership
# AND zoom match. Immune to the MUI selectedItems flicker.
clientside_callback(
    """
    (state, generations) => {
      const log = (msg, data) => {
        try { console.log('[compare-lab] ' + msg, data ?? ''); } catch (e) {}
      };
      const root = document.getElementById('cl-map');
      const map  = root && root.__dl2_map;
      if (!map) { log('skip: no map handle'); return window.dash_clientside.no_update; }
      const L = window.leaflet || window.L;
      if (!L)  { log('skip: no Leaflet');    return window.dash_clientside.no_update; }

      const visible   = new Set((state && state.visible_customs) || []);
      const opacities = (state && state.custom_opacities) || {};
      if (!window._cl_overlays_by_key) window._cl_overlays_by_key = {};
      const reg = window._cl_overlays_by_key;
      const TILE = 256;

      // Latest state for the zoomend listener (attached once but needs
      // fresh values on every map zoom).
      window._cl_compare_visible   = visible;
      window._cl_compare_opacities = opacities;

      // 1) Remove only when the underlying generation is gone.
      let removed = 0;
      for (const key of Object.keys(reg)) {
        if (!generations || !generations[key] || !generations[key].data_url) {
          try { reg[key].overlay.remove(); } catch (e) {}
          delete reg[key];
          removed += 1;
        }
      }
      // 2) Mount or update every generation (at opacity 0 — applyVis
      //    sets the right value next).
      let added = 0, updated = 0;
      for (const [key, gen] of Object.entries(generations || {})) {
        if (!gen || !gen.data_url) continue;
        const parts = key.split('/').map(Number);
        if (parts.length !== 3 || parts.some(isNaN)) continue;
        const [z, x, y] = parts;
        const baseOpacity = ((opacities[key] ?? 90)) / 100;
        if (!reg[key]) {
          const nw = map.unproject([x * TILE, y * TILE], z);
          const se = map.unproject([(x + 1) * TILE, (y + 1) * TILE], z);
          const bounds = [[se.lat, nw.lng], [nw.lat, se.lng]];
          const overlay = new L.ImageOverlay(gen.data_url, bounds, {
            opacity: 0, interactive: false,
            className: 'cl-compare-overlay',
          });
          overlay.addTo(map);
          overlay._clKey = key;
          reg[key] = { overlay, dataUrl: gen.data_url,
                       baseOpacity, tileZ: z };
          added += 1;
        } else {
          const entry = reg[key];
          if (entry.dataUrl !== gen.data_url) {
            try { entry.overlay.setUrl(gen.data_url); } catch (e) {}
            entry.dataUrl = gen.data_url;
            updated += 1;
          }
          if (entry.baseOpacity !== baseOpacity) {
            entry.baseOpacity = baseOpacity;
            updated += 1;
          }
        }
      }

      window._cl_overlays = Object.values(reg).map(e => {
        const o = e.overlay;
        o._clTileZ       = e.tileZ;
        o._clBaseOpacity = e.baseOpacity;
        return o;
      });

      // Visibility = visible_customs membership only. No strict zoom
      // matching — that confuses users (toggling a
      // leaf at the wrong zoom looked like "nothing happened"). Geo-
      // graphic bounds keep each overlay at its TRUE scale, so smaller
      // tiles naturally nest inside larger ones (Russian doll), and
      // the user can compare across zooms simultaneously.
      const applyVis = () => {
        const vis = window._cl_compare_visible   || new Set();
        const ops = window._cl_compare_opacities || {};
        for (const o of window._cl_overlays) {
          const baseOp = (ops[o._clKey] ?? 90) / 100;
          o.setOpacity(vis.has(o._clKey) ? baseOp : 0);
        }
      };
      applyVis();
      // (zoomend listener no longer needed — visibility is zoom-
      // independent now.)

      log('done', {added, updated, removed,
                   mounted: window._cl_overlays.length,
                   visible: Array.from(visible)});
      return window._cl_overlays.length;
    }
    """,
    Output("cl-overlay-trigger", "data"),
    Input("cl-compare-state", "data"),
    Input("cl-generations", "data"),
)
```


---

*Source: /compare-lab*

---

<!-- /easy-button — https://leaflet.2plot.dev/easy-button/llms.txt -->

# Easy Button

> Easy Button + marker creation flow with emoji picker, form popup, and view mode.

---



### Overview

Click the smile button on the map (top-left) to enter create mode → a dmc.Popover opens
directly to the right of the button with a DashEmojiMart picker. Click the map to place
the marker; a form popup opens above it with name/type fields. Pick an emoji at any time
to update the marker's icon live. Create finalizes the marker (pushes to a markers store,
exits create mode, closes everything); Cancel discards.

(We use dmc.Popover, not dmc.HoverCard. HoverCard is hover-triggered only — it would close
the moment the user moves their mouse off the button toward the map to place a marker.
Popover supports click-triggered + controlled `opened`, same visual styling.)

### Live demo


### The shape


**dl2.EasyButton + marker-creation pattern**

```python
# File: docs/easy-button/example.py  (region: map)

dl2.Map(
    id="eb-map",
    center=DENVER.center,
    zoom=12,
    style={
        "height": "100%"
    },  # fills the dmc.Paper, which fills the flex row
    children=[
        dl2.TileLayer(
            id="eb-tile", url=TILE_URL, attribution=ATTR
        ),
        dl2.EasyButton(
            id="eb-add",
            position="topleft",
            icon="mdi:emoticon-happy-outline",
            iconSize=20,
            title="Add an emoji marker",
        ),
        html.Div(id="eb-pending-container"),
        html.Div(id="eb-markers-container"),
    ],
),
```


### Source


```python
# File: docs/easy-button/example.py

"""
Easy Button + marker creation flow with emoji picker, form popup, and view mode.

Click the smile button on the map (top-left) to enter create mode → a dmc.Popover opens
directly to the right of the button with a DashEmojiMart picker. Click the map to place
the marker; a form popup opens above it with name/type fields. Pick an emoji at any time
to update the marker's icon live. Create finalizes the marker (pushes to a markers store,
exits create mode, closes everything); Cancel discards.

(We use dmc.Popover, not dmc.HoverCard. HoverCard is hover-triggered only — it would close
the moment the user moves their mouse off the button toward the map to place a marker.
Popover supports click-triggered + controlled `opened`, same visual styling.)
"""

import dash
import dash_leaflet2 as dl2
import dash_mantine_components as dmc
from dash import Input, Output, State, callback, clientside_callback, ctx, dcc, html
from dash_emoji_mart import DashEmojiMart
from dash_iconify import DashIconify
from dl2_tiles import ESRI_CANVAS, register_theme_swap
from dl2_locations import DENVER
from dl2_shared import info_panel

# CARTO Positron (light) + Dark Matter — the standard light/dark pair the rest of the
# showcase already uses (see assets/leaflet2_maps.js). The tile URL is swapped at
# runtime via a clientside callback driven by the app's color-scheme toggle.
# Basemap pair for this page. dl2_tiles owns the light/dark wiring so
# every example themes the same way — see register_theme_swap below.
TILES = ESRI_CANVAS
TILE_URL = TILES.url("light")
ATTR = TILES.attribution()

MARKER_TYPES = ["Market", "Event", "Bounty", "Other"]
TYPE_COLORS = {"Market": "green", "Event": "blue", "Bounty": "grape", "Other": "gray"}



# ---- helpers ---------------------------------------------------------------


def form_layout(pending):
    """The form rendered INSIDE the leaflet popup above the pending marker."""
    return html.Div(
        style={"minWidth": "260px", "padding": "2px"},
        children=dmc.Stack(
            [
                dmc.TextInput(
                    id="eb-name",
                    placeholder="Marker name",
                    value=(pending or {}).get("name", ""),
                    size="xs",
                ),
                dmc.SegmentedControl(
                    id="eb-type",
                    data=MARKER_TYPES,
                    value=(pending or {}).get("type", "Market"),
                    size="xs",
                    fullWidth=True,
                ),
                dmc.Group(
                    [
                        dmc.Button(
                            "Cancel",
                            id="eb-cancel",
                            size="xs",
                            variant="light",
                            color="gray",
                        ),
                        dmc.Button(
                            "Create",
                            id="eb-create",
                            size="xs",
                            color="green",
                            leftSection=DashIconify(icon="mdi:check", width=14),
                        ),
                    ],
                    gap="xs",
                    grow=True,
                ),
            ],
            gap="xs",
        ),
    )


# ---- layout ----------------------------------------------------------------

component = dmc.Stack(
    [
        # Toggle button for the side panel. Mirrors /resize-observer's UX so this page
        # gets the same "expand for full-screen map / collapse to see context" affordance.
        dmc.Button(
            "Toggle side panel",
            id="eb-panel-toggle",
            color="green",
            variant="light",
            leftSection=DashIconify(icon="mdi:panel-right-open", width=16),
        ),
        # Flex row: relative-positioned map wrapper on the left, collapsible info panel on
        # the right. Starts OPEN (the Mode badge + marker list are useful context while
        # creating markers); the user can collapse it for a full-width map. We bump the
        # panel width via --dl2-resize-panel-w because the code snippet inside is wider
        # than what /resize-observer's default 320px shows comfortably.
        html.Div(
            id="eb-row",
            className="dl2-resize-row open",
            style={"height": "62vh", "--dl2-resize-panel-w": "380px"},
            children=[
                html.Div(
                    # Relative container so the Popover anchor div positions absolutely over
                    # the EasyButton location inside the Paper. `dl2-resize-main` makes this
                    # the flex-grow main area (height: 100%, flex: 1 — see assets/style.css).
                    className="dl2-resize-main",
                    style={"position": "relative"},
                    children=[
                        dmc.Paper(
                            # region map
                            dl2.Map(
                                id="eb-map",
                                center=DENVER.center,
                                zoom=12,
                                style={
                                    "height": "100%"
                                },  # fills the dmc.Paper, which fills the flex row
                                children=[
                                    dl2.TileLayer(
                                        id="eb-tile", url=TILE_URL, attribution=ATTR
                                    ),
                                    dl2.EasyButton(
                                        id="eb-add",
                                        position="topleft",
                                        icon="mdi:emoticon-happy-outline",
                                        iconSize=20,
                                        title="Add an emoji marker",
                                    ),
                                    html.Div(id="eb-pending-container"),
                                    html.Div(id="eb-markers-container"),
                                ],
                            ),
                            # endregion
                            shadow="sm",
                            radius="md",
                            withBorder=True,
                            style={"overflow": "hidden", "height": "100%"},
                        ),
                        # Popover anchor: invisible Dash element positioned over the EasyButton.
                        # Leaflet anchors top-left controls 10px from the map edges, button is
                        # 30px wide → anchor at left:46px puts the popover dropdown right of it.
                        dmc.Popover(
                            id="eb-popover",
                            opened=False,
                            position="right-start",
                            offset=6,
                            withArrow=True,
                            arrowSize=10,
                            shadow="lg",
                            radius="md",
                            # Fully suppress the "outside click closes me" behavior — we control
                            # `opened` from the picker-open store. Listening to no events means
                            # the popover never tries to auto-close.
                            closeOnClickOutside=False,
                            closeOnEscape=False,
                            clickOutsideEvents=[],
                            keepMounted=True,
                            children=[
                                # PopoverTarget wraps its child in a dmc.Box that drops inline
                                # absolute styles on the inner element — so position the Box
                                # itself via boxWrapperProps instead.
                                dmc.PopoverTarget(
                                    html.Div(id="eb-popover-anchor"),
                                    boxWrapperProps={
                                        "style": {
                                            "position": "absolute",
                                            "top": "10px",
                                            "left": "46px",
                                            "width": "1px",
                                            "height": "30px",
                                            "pointerEvents": "none",
                                            "zIndex": 600,
                                        }
                                    },
                                ),
                                dmc.PopoverDropdown(
                                    DashEmojiMart(
                                        id="eb-emoji",
                                        theme="auto",
                                        perLine=8,
                                        emojiSize=22,
                                        emojiButtonSize=30,
                                        previewPosition="none",
                                    ),
                                    p=4,
                                ),
                            ],
                        ),
                    ],
                ),
                # Side panel — holds everything that used to live in the right grid column
                # and the bottom code panel: Mode readout, Created markers list, and the
                # snippet showing the dl2.EasyButton + marker-creation pattern.
                html.Div(
                    className="dl2-resize-panel",
                    children=dmc.Stack(
                        [
                            info_panel(
                                "Mode",
                                dmc.Group(
                                    [
                                        dmc.Badge(
                                            id="eb-mode-badge",
                                            color="gray",
                                            variant="light",
                                            children="view",
                                        ),
                                        DashIconify(
                                            id="eb-mode-icon",
                                            icon="mdi:eye-outline",
                                            width=20,
                                            color="var(--mantine-color-dimmed)",
                                        ),
                                    ],
                                    gap="xs",
                                ),
                            ),
                            info_panel(
                                "Created markers",
                                html.Div(
                                    id="eb-list",
                                    children=dmc.Text(
                                        "Click the smile button on the map to create your first marker.",
                                        size="sm",
                                        c="dimmed",
                                    ),
                                ),
                            ),
                        ],
                        gap="md",
                    ),
                ),
            ],
        ),
        # State stores
        dcc.Store(id="eb-mode", data="view"),
        dcc.Store(id="eb-pending", data=None),
        dcc.Store(id="eb-markers", data=[]),
        dcc.Store(id="eb-picker-open", data=False),
    ],
    gap="md",
)


# ---- callbacks --------------------------------------------------------------


# 1) EasyButton click → toggle create mode + picker.
@callback(
    Output("eb-mode", "data"),
    Output("eb-picker-open", "data"),
    Output("eb-pending", "data"),
    Input("eb-add", "n_clicks"),
    State("eb-mode", "data"),
    prevent_initial_call=True,
)
def toggle_create(_, mode):
    if mode == "create":
        # Toggling off while in create mode: bail out without finalizing.
        return "view", False, None
    return "create", True, None


# 2) Picker open state → DMC Popover.opened
@callback(Output("eb-popover", "opened"), Input("eb-picker-open", "data"))
def reflect_picker(opened):
    return bool(opened)


# 3) Mode badge readout.
@callback(
    Output("eb-mode-badge", "children"),
    Output("eb-mode-badge", "color"),
    Output("eb-mode-icon", "icon"),
    Output("eb-mode-icon", "color"),
    Input("eb-mode", "data"),
)
def mode_readout(mode):
    if mode == "create":
        return "create", "green", "mdi:pencil-outline", "var(--mantine-color-green-6)"
    return "view", "gray", "mdi:eye-outline", "var(--mantine-color-dimmed)"


# 4) Picking an emoji updates pending (creating a stub if none yet) + resets the picker.
@callback(
    Output("eb-pending", "data", allow_duplicate=True),
    Output("eb-emoji", "value"),
    Input("eb-emoji", "value"),
    State("eb-mode", "data"),
    State("eb-pending", "data"),
    prevent_initial_call=True,
)
def pick_emoji(emoji, mode, pending):
    if not emoji or mode != "create":
        return dash.no_update, dash.no_update
    base = pending or {}
    return {**base, "emoji": emoji}, ""


# 5) Map click while in create mode → place the pending marker (only first click).
@callback(
    Output("eb-pending", "data", allow_duplicate=True),
    Input("eb-map", "clickData"),
    State("eb-mode", "data"),
    State("eb-pending", "data"),
    prevent_initial_call=True,
)
def place_pending(click, mode, pending):
    if mode != "create" or not click:
        return dash.no_update
    if pending and "lat" in pending:
        return dash.no_update  # already placed; further clicks ignored (drag to move)
    lat, lng = click["latlng"]
    base = pending or {}
    return {
        **base,
        "lat": lat,
        "lng": lng,
        "name": base.get("name", ""),
        "type": base.get("type", "Market"),
    }


# 6) Cancel → discard pending + exit create mode.
@callback(
    Output("eb-mode", "data", allow_duplicate=True),
    Output("eb-pending", "data", allow_duplicate=True),
    Output("eb-picker-open", "data", allow_duplicate=True),
    Output("eb-emoji", "value", allow_duplicate=True),
    Input("eb-cancel", "n_clicks"),
    prevent_initial_call=True,
)
def cancel(n):
    # Cancel/Create are dynamically mounted via render_pending; Dash fires their
    # callbacks on first mount with n_clicks=None despite prevent_initial_call=True.
    # Guard against the mount-fire so it doesn't immediately close the popover.
    if not n:
        return dash.no_update, dash.no_update, dash.no_update, dash.no_update
    return "view", None, False, ""


# 7) Create → finalize pending (push to markers) + exit.
@callback(
    Output("eb-markers", "data"),
    Output("eb-mode", "data", allow_duplicate=True),
    Output("eb-pending", "data", allow_duplicate=True),
    Output("eb-picker-open", "data", allow_duplicate=True),
    Output("eb-emoji", "value", allow_duplicate=True),
    Input("eb-create", "n_clicks"),
    State("eb-pending", "data"),
    State("eb-name", "value"),
    State("eb-type", "value"),
    State("eb-markers", "data"),
    prevent_initial_call=True,
)
def create(n, pending, name, type_, markers):
    # Same mount-fire guard as cancel — without it, the Create button mounting fires
    # this callback with n=None and closes everything before the user can interact.
    if not n:
        return (
            dash.no_update,
            dash.no_update,
            dash.no_update,
            dash.no_update,
            dash.no_update,
        )
    if not pending or "lat" not in pending:
        return dash.no_update, "view", None, False, ""
    final = {**pending, "name": name or "", "type": type_ or "Market"}
    return list(markers or []) + [final], "view", None, False, ""


# 8) Render the pending marker (+ form popup) into the map.
@callback(Output("eb-pending-container", "children"), Input("eb-pending", "data"))
def render_pending(pending):
    if not pending or "lat" not in pending:
        return []
    emoji = pending.get("emoji")
    return dl2.Marker(
        id="eb-pending-marker",
        position=[pending["lat"], pending["lng"]],
        emoji=emoji,
        iconSize=34 if emoji else None,
        draggable=True,
        children=dl2.Popup(
            id="eb-pending-popup",
            opened=True,
            closeOnClick=False,
            autoClose=False,
            closeButton=False,
            maxWidth=320,
            minWidth=260,
            children=form_layout(pending),
        ),
    )


# 9) Drag the pending marker → update its position in pending.
@callback(
    Output("eb-pending", "data", allow_duplicate=True),
    Input("eb-pending-marker", "position"),
    State("eb-pending", "data"),
    prevent_initial_call=True,
)
def drag_pending(pos, pending):
    if not pending or not pos:
        return dash.no_update
    if pos[0] == pending.get("lat") and pos[1] == pending.get("lng"):
        return dash.no_update
    return {**pending, "lat": pos[0], "lng": pos[1]}


# 10) Render finalized markers (view mode).
@callback(
    Output("eb-markers-container", "children"),
    Output("eb-list", "children"),
    Input("eb-markers", "data"),
)
def render_markers(markers):
    markers = markers or []
    if not markers:
        empty = dmc.Text(
            "Click the smile button on the map to create your first marker.",
            size="sm",
            c="dimmed",
        )
        return [], empty
    children = []
    for i, m in enumerate(markers):
        emoji = m.get("emoji")
        type_ = m.get("type", "Other")
        name = m.get("name") or "(unnamed)"
        children.append(
            dl2.Marker(
                position=[m["lat"], m["lng"]],
                emoji=emoji,
                iconSize=34 if emoji else None,
                children=[
                    dl2.Tooltip(children=name),
                    dl2.Popup(
                        children=html.Div(
                            style={"minWidth": "180px"},
                            children=dmc.Stack(
                                [
                                    dmc.Text(name, fw=600, size="sm"),
                                    dmc.Badge(
                                        type_,
                                        color=TYPE_COLORS.get(type_, "gray"),
                                        variant="light",
                                        size="sm",
                                    ),
                                ],
                                gap=4,
                            ),
                        )
                    ),
                ],
            )
        )
    listing = dmc.Stack(
        [
            dmc.Group(
                [
                    dmc.Text(m.get("emoji") or "📍", size="lg"),
                    dmc.Stack(
                        [
                            dmc.Text(m.get("name") or "(unnamed)", size="sm", fw=600),
                            dmc.Badge(
                                m.get("type", "Other"),
                                color=TYPE_COLORS.get(m.get("type", "Other"), "gray"),
                                variant="light",
                                size="xs",
                            ),
                        ],
                        gap=0,
                    ),
                ],
                gap="sm",
            )
            for m in markers
        ],
        gap="sm",
    )
    return children, listing


# ---- side-panel toggle (mirrors /resize-observer) --------------------------
# Toggle the .open class on the flex row; the panel slides in/out via CSS
# transition (assets/style.css → .dl2-resize-row + .dl2-resize-panel). Because
# the map's container is a flex item, Leaflet 2's ResizeObserver picks up the
# width change and reflows the tiles automatically — no invalidateSize() needed.
clientside_callback(
    """
    (n) => {
        const r = document.getElementById('eb-row');
        if (r) r.classList.toggle('open');
        return window.dash_clientside.no_update;
    }
    """,
    Output("eb-panel-toggle", "id"),
    Input("eb-panel-toggle", "n_clicks"),
    prevent_initial_call=True,
)


# ---- theme sync (light/dark) ------------------------------------------------
# The header toggle (`color-scheme-toggle.checked`) drives the app's color scheme —
# checked=True means light. Mirror it to the dl2.TileLayer URL (CARTO Positron vs
# Dark Matter) and to DashEmojiMart's `theme` prop so the picker UI follows along
# too. Same pattern the /emoji-iconify page uses.
register_theme_swap("eb-tile", TILES)

clientside_callback(
    # Same fix as the tile swap: read the STORE, not the header ActionIcon.
    "(scheme) => (scheme === 'dark' ? 'dark' : 'light')",
    Output("eb-emoji", "theme"),
    Input("color-scheme-storage", "data"),
)
```


---

*Source: /easy-button*

---

<!-- /edit-control — https://leaflet.2plot.dev/edit-control/llms.txt -->

# Draw & Edit

> native v2 drawing/editing toolbar with full dash-leaflet-style API parity.

---



### Overview

Mirrors dash-leaflet's EditControl prop shape (`draw`, `edit`, `drawToolbar`, `editToolbar`,
`action`) so callbacks compose the same way: bump n_clicks to dispatch from Python; read
`action` to react to any change. The contextual sub-toolbar (Finish / Delete last point /
Cancel during draw; Save / Cancel during edit; Clear all during remove) appears alongside
the icon strip while a tool/mode is active. The Edit section appears only when shapes exist.

### Live demo


### The shape


**dl2.EditControl pattern (dash-leaflet API parity)**

```python
# File: docs/edit-control/example.py  (region: map)

dl2.Map(
    id="ec-map",
    center=TORONTO.center,
    zoom=12,
    style={"height": "62vh"},
    children=[
        dl2.TileLayer(
            id="ec-tile", **TILES.kwargs("light")
        ),
        dl2.EditControl(
            id="ec",
            position="topleft",
            shapeOptions={
                "color": "#2f9e44",
                "weight": 3,
                "fillOpacity": 0.2,
            },
        ),
    ],
),
```


### Source


```python
# File: docs/edit-control/example.py

"""
Edit Control — native v2 drawing/editing toolbar with full dash-leaflet-style API parity.

Mirrors dash-leaflet's EditControl prop shape (`draw`, `edit`, `drawToolbar`, `editToolbar`,
`action`) so callbacks compose the same way: bump n_clicks to dispatch from Python; read
`action` to react to any change. The contextual sub-toolbar (Finish / Delete last point /
Cancel during draw; Save / Cancel during edit; Clear all during remove) appears alongside
the icon strip while a tool/mode is active. The Edit section appears only when shapes exist.
"""

import json

import dash
import dash_leaflet2 as dl2
import dash_mantine_components as dmc
from dash import Input, Output, State, callback, ctx
from dl2_tiles import TRANSIT, register_theme_swap
from dl2_locations import TORONTO
from dl2_shared import info_panel

# Basemap pair for this page — dl2_tiles owns the light/dark wiring.
TILES = TRANSIT

TILE_URL = TILES.url("light")
ATTR = TILES.attribution()



def _btn(label, _id, color="gray"):
    return dmc.Button(label, id=_id, size="xs", variant="light", color=color)


component = dmc.Stack(
    [
        dmc.Grid(
            [
                dmc.GridCol(
                    dmc.Paper(
                        # region map
                        dl2.Map(
                            id="ec-map",
                            center=TORONTO.center,
                            zoom=12,
                            style={"height": "62vh"},
                            children=[
                                dl2.TileLayer(
                                    id="ec-tile", **TILES.kwargs("light")
                                ),
                                dl2.EditControl(
                                    id="ec",
                                    position="topleft",
                                    shapeOptions={
                                        "color": "#2f9e44",
                                        "weight": 3,
                                        "fillOpacity": 0.2,
                                    },
                                ),
                            ],
                        ),
                        # endregion
                        shadow="sm",
                        radius="md",
                        withBorder=True,
                        style={"overflow": "hidden"},
                    ),
                    span=8,
                ),
                dmc.GridCol(
                    dmc.Stack(
                        [
                            info_panel(
                                "Counter / last action",
                                dmc.Code(
                                    id="ec-status",
                                    block=True,
                                    style={
                                        "minHeight": "80px",
                                        "whiteSpace": "pre-wrap",
                                    },
                                ),
                            ),
                            info_panel(
                                "action (single Input for 'anything changed')",
                                dmc.Code(id="ec-action", block=True),
                            ),
                            info_panel(
                                "Python → control",
                                dmc.Stack(
                                    [
                                        dmc.Text(
                                            "Start a draw tool from Python:",
                                            size="sm",
                                            c="dimmed",
                                        ),
                                        dmc.Group(
                                            [
                                                _btn(
                                                    "Marker", "ec-draw-marker", "green"
                                                ),
                                                _btn(
                                                    "Polyline",
                                                    "ec-draw-polyline",
                                                    "green",
                                                ),
                                                _btn(
                                                    "Polygon",
                                                    "ec-draw-polygon",
                                                    "green",
                                                ),
                                                _btn(
                                                    "Rectangle",
                                                    "ec-draw-rectangle",
                                                    "green",
                                                ),
                                            ]
                                        ),
                                        dmc.Text(
                                            "Dispatch an action on the active tool:",
                                            size="sm",
                                            c="dimmed",
                                            mt="xs",
                                        ),
                                        dmc.Group(
                                            [
                                                _btn(
                                                    "Finish", "ec-act-finish", "green"
                                                ),
                                                _btn(
                                                    "Delete last point",
                                                    "ec-act-del",
                                                    "yellow",
                                                ),
                                                _btn("Cancel", "ec-act-cancel", "gray"),
                                            ]
                                        ),
                                        dmc.Text(
                                            "Edit / Remove:",
                                            size="sm",
                                            c="dimmed",
                                            mt="xs",
                                        ),
                                        dmc.Group(
                                            [
                                                _btn(
                                                    "Enter edit mode", "ec-edit", "blue"
                                                ),
                                                _btn("Clear all", "ec-clear", "red"),
                                            ]
                                        ),
                                        dmc.Divider(my="xs"),
                                        dmc.Group(
                                            [
                                                dmc.Switch(
                                                    id="ec-disable-rect",
                                                    checked=False,
                                                    size="sm",
                                                    label="Disable rectangle tool (draw= prop)",
                                                ),
                                            ]
                                        ),
                                        # See /edit-control-measurement for the measurementSystem prop, a
                                        # popover-driven color picker, click-to-recolor in edit mode, and
                                        # per-shape area / radius tooltips (showMeasurementTooltips=True).
                                    ],
                                    gap="xs",
                                ),
                            ),
                        ],
                        gap="md",
                    ),
                    span=4,
                ),
            ]
        ),
        info_panel(
            "GeoJSON FeatureCollection",
            dmc.Code(
                id="ec-geojson",
                block=True,
                style={
                    "maxHeight": "260px",
                    "overflow": "auto",
                    "fontSize": "11px",
                    "whiteSpace": "pre",
                },
            ),
        ),
    ],
    gap="md",
)


# --- Readouts ----------------------------------------------------------------
@callback(
    Output("ec-status", "children"),
    Output("ec-geojson", "children"),
    Output("ec-action", "children"),
    Input("ec", "geojson"),
    Input("ec", "n_drawn"),
    Input("ec", "lastAction"),
    Input("ec", "action"),
)
def show_state(geo, n, last, action):
    if not geo:
        return "Pick a tool (top-left) or use the Python buttons →", "—", "—"
    feats = (geo or {}).get("features", [])
    status = f"n_drawn: {n or 0}\nfeatures: {len(feats)}\nlastAction: {last}"
    return status, json.dumps(geo, indent=2), json.dumps(action or {}, indent=1)


# --- Python -> control: drawToolbar ------------------------------------------
@callback(
    Output("ec", "drawToolbar"),
    Input("ec-draw-marker", "n_clicks"),
    Input("ec-draw-polyline", "n_clicks"),
    Input("ec-draw-polygon", "n_clicks"),
    Input("ec-draw-rectangle", "n_clicks"),
    Input("ec-act-finish", "n_clicks"),
    Input("ec-act-del", "n_clicks"),
    Input("ec-act-cancel", "n_clicks"),
    prevent_initial_call=True,
)
def drive_draw(m, l, p, r, fin, dl, cn):
    t = ctx.triggered_id
    n = sum(x or 0 for x in (m, l, p, r, fin, dl, cn))  # always-increasing tick
    if t == "ec-draw-marker":
        return {"mode": "marker", "n_clicks": n}
    if t == "ec-draw-polyline":
        return {"mode": "polyline", "n_clicks": n}
    if t == "ec-draw-polygon":
        return {"mode": "polygon", "n_clicks": n}
    if t == "ec-draw-rectangle":
        return {"mode": "rectangle", "n_clicks": n}
    if t == "ec-act-finish":
        return {"action": "finish", "n_clicks": n}
    if t == "ec-act-del":
        return {"action": "delete last point", "n_clicks": n}
    if t == "ec-act-cancel":
        return {"action": "cancel", "n_clicks": n}
    return dash.no_update


# --- Python -> control: editToolbar ------------------------------------------
@callback(
    Output("ec", "editToolbar"),
    Input("ec-edit", "n_clicks"),
    Input("ec-clear", "n_clicks"),
    prevent_initial_call=True,
)
def drive_edit(e, c):
    t = ctx.triggered_id
    n = (e or 0) + (c or 0)
    if t == "ec-edit":
        return {"mode": "edit", "n_clicks": n}
    if t == "ec-clear":
        return {"mode": "remove", "action": "clear all", "n_clicks": n}
    return dash.no_update


# --- draw= prop gating: disable rectangle tool when the switch is on ---------
@callback(Output("ec", "draw"), Input("ec-disable-rect", "checked"))
def gate(disabled):
    return {"rectangle": not bool(disabled)}


# Light/dark basemap, driven off the color-scheme store.
register_theme_swap("ec-tile", TILES)
```


---

*Source: /edit-control*

---

<!-- /edit-control-measurement — https://leaflet.2plot.dev/edit-control-measurement/llms.txt -->

# Edit Control + Measurement

> popover-driven drawing, recoloring, and per-feature labels.

---



### Overview

Builds on top of /edit-control. Three states (view / create / edit) and a single
dmc.Popover anchored next to the EditControl icon strip — the same anchor pattern as
/easy-button, but driven by EditControl's `activeTool` + `featureClick` instead of an
EasyButton click. ColorPicker is live in BOTH phases:

- create (after clicking a draw icon, before drawing) → updates EditControl.shapeOptions
  so the NEXT shape is drawn in that color
- create (after drawing, before Create/Cancel) → applies via featureUpdate to the just-
  drawn shape so the user can tweak the color before finalizing
- edit (after clicking a feature in edit mode) → applies via featureUpdate to that
  feature live

Every committed shape gets a permanent Leaflet tooltip with its area / radius / length
(showMeasurementTooltips=True on the EditControl); the Metric / Imperial toggle in the
right rail drives the unit system.

### Live demo


### The shape


**Pattern**

```python
# File: docs/edit-control-measurement/example.py  (region: map)

dl2.Map(
    id="ecm-map",
    center=MONTREAL.center,
    zoom=12,
    style={"height": "62vh"},
    children=[
        dl2.TileLayer(
            id="ecm-tile",
            url=TILE_URL,
            attribution=ATTR,
        ),
        dl2.EditControl(
            id="ecm-ec",
            position="topleft",
            showMeasurementTooltips=True,
            measurementSystem="metric",
            shapeOptions={
                "color": DEFAULT_COLOR,
                "weight": 3,
                "fillOpacity": 0.25,
            },
            # Disable EditControl's built-in remove mode —
            # delete happens via our popover Delete button.
            edit={"remove": False},
        ),
    ],
),
```


### Source


```python
# File: docs/edit-control-measurement/example.py

"""
Edit Control + Measurement — popover-driven drawing, recoloring, and per-feature labels.

Builds on top of /edit-control. Three states (view / create / edit) and a single
dmc.Popover anchored next to the EditControl icon strip — the same anchor pattern as
/easy-button, but driven by EditControl's `activeTool` + `featureClick` instead of an
EasyButton click. ColorPicker is live in BOTH phases:

- create (after clicking a draw icon, before drawing) → updates EditControl.shapeOptions
  so the NEXT shape is drawn in that color
- create (after drawing, before Create/Cancel) → applies via featureUpdate to the just-
  drawn shape so the user can tweak the color before finalizing
- edit (after clicking a feature in edit mode) → applies via featureUpdate to that
  feature live

Every committed shape gets a permanent Leaflet tooltip with its area / radius / length
(showMeasurementTooltips=True on the EditControl); the Metric / Imperial toggle in the
right rail drives the unit system.
"""

import json

import dash_leaflet2 as dl2
import dash_mantine_components as dmc
from dash import (
    Input,
    Output,
    State,
    callback,
    clientside_callback,
    ctx,
    dcc,
    html,
    no_update,
)
from dash_iconify import DashIconify
from dl2_tiles import OSM_CLASSIC, register_theme_swap
from dl2_locations import MONTREAL
from dl2_shared import info_panel

# Basemap pair for this page. dl2_tiles owns the light/dark wiring so
# every example themes the same way — see register_theme_swap below.
TILES = OSM_CLASSIC
TILE_URL = TILES.url("light")
ATTR = TILES.attribution()

DEFAULT_COLOR = "#2f9e44"
PRESET_COLORS = [
    "#2f9e44",
    "#1971c2",
    "#e8590c",
    "#9c36b5",
    "#d6336c",
    "#fab005",
    "#0ca678",
    "#495057",
]



# ---- form layouts (rendered into the popover dropdown) ---------------------


def form_create(name, color):
    """Popover form for create mode: name + color + Cancel / Create."""
    return dmc.Stack(
        [
            dmc.Group(
                [
                    DashIconify(
                        icon="mdi:plus-circle-outline",
                        width=18,
                        color="var(--mantine-color-green-6)",
                    ),
                    dmc.Text("Add a shape", size="sm", fw=600),
                ],
                gap=6,
            ),
            dmc.TextInput(
                id="ecm-name",
                value=name or "",
                placeholder="Name (optional)",
                size="xs",
            ),
            dmc.ColorPicker(
                id="ecm-color",
                value=color or DEFAULT_COLOR,
                format="hex",
                swatches=PRESET_COLORS,
                size="xs",
                fullWidth=True,
            ),
            dmc.Group(
                [
                    dmc.Button(
                        "Cancel",
                        id="ecm-cancel",
                        size="xs",
                        variant="light",
                        color="gray",
                    ),
                    dmc.Button(
                        "Create",
                        id="ecm-create",
                        size="xs",
                        color="green",
                        leftSection=DashIconify(icon="mdi:check", width=14),
                    ),
                ],
                gap="xs",
                grow=True,
            ),
        ],
        gap="xs",
        style={"minWidth": "240px", "maxWidth": "260px"},
    )


def form_edit(name, color):
    """Popover form for edit-selected mode: name + color + Delete / Done."""
    return dmc.Stack(
        [
            dmc.Group(
                [
                    DashIconify(
                        icon="mdi:pencil-outline",
                        width=18,
                        color="var(--mantine-color-blue-6)",
                    ),
                    dmc.Text("Edit shape", size="sm", fw=600),
                ],
                gap=6,
            ),
            dmc.TextInput(
                id="ecm-name",
                value=name or "",
                placeholder="Name (optional)",
                size="xs",
            ),
            dmc.ColorPicker(
                id="ecm-color",
                value=color or DEFAULT_COLOR,
                format="hex",
                swatches=PRESET_COLORS,
                size="xs",
                fullWidth=True,
            ),
            dmc.Group(
                [
                    dmc.Button(
                        "Delete",
                        id="ecm-delete",
                        size="xs",
                        color="red",
                        variant="light",
                        leftSection=DashIconify(icon="mdi:delete-outline", width=14),
                    ),
                    dmc.Button(
                        "Done", id="ecm-done", size="xs", variant="light", color="gray"
                    ),
                ],
                gap="xs",
                grow=True,
            ),
        ],
        gap="xs",
        style={"minWidth": "240px", "maxWidth": "260px"},
    )


# ---- layout ----------------------------------------------------------------

component = dmc.Stack(
    [
        dmc.Grid(
            [
                dmc.GridCol(
                    html.Div(
                        # Relative wrapper so the Popover anchor positions absolutely over the
                        # EditControl icon strip — exactly the easy_button.py pattern.
                        style={"position": "relative"},
                        children=[
                            dmc.Paper(
                                # region map
                                dl2.Map(
                                    id="ecm-map",
                                    center=MONTREAL.center,
                                    zoom=12,
                                    style={"height": "62vh"},
                                    children=[
                                        dl2.TileLayer(
                                            id="ecm-tile",
                                            url=TILE_URL,
                                            attribution=ATTR,
                                        ),
                                        dl2.EditControl(
                                            id="ecm-ec",
                                            position="topleft",
                                            showMeasurementTooltips=True,
                                            measurementSystem="metric",
                                            shapeOptions={
                                                "color": DEFAULT_COLOR,
                                                "weight": 3,
                                                "fillOpacity": 0.25,
                                            },
                                            # Disable EditControl's built-in remove mode —
                                            # delete happens via our popover Delete button.
                                            edit={"remove": False},
                                        ),
                                    ],
                                ),
                                # endregion
                                shadow="sm",
                                radius="md",
                                withBorder=True,
                                style={"overflow": "hidden"},
                            ),
                            # Popover anchor — 1px-wide invisible div parked just to the right
                            # of the EditControl icon strip (~36px strip width + 10px map
                            # padding + 6px gap ≈ 52px). Height covers the strip so the popover
                            # opens at a sensible y when any draw icon is clicked.
                            dmc.Popover(
                                id="ecm-popover",
                                opened=False,
                                position="right-start",
                                offset=10,
                                withArrow=True,
                                arrowSize=10,
                                shadow="lg",
                                radius="md",
                                # Fully controlled — open/close only via callbacks, never
                                # auto-close (clicking the map needs to STAY interactive
                                # during create mode for drawing).
                                closeOnClickOutside=False,
                                closeOnEscape=False,
                                clickOutsideEvents=[],
                                keepMounted=True,
                                children=[
                                    dmc.PopoverTarget(
                                        html.Div(id="ecm-anchor"),
                                        boxWrapperProps={
                                            "style": {
                                                "position": "absolute",
                                                "top": "10px",
                                                "left": "52px",
                                                "width": "1px",
                                                "height": "30px",
                                                "pointerEvents": "none",
                                                "zIndex": 600,
                                            }
                                        },
                                    ),
                                    dmc.PopoverDropdown(
                                        html.Div(id="ecm-popover-content"),
                                        p="sm",
                                    ),
                                ],
                            ),
                        ],
                    ),
                    span=8,
                ),
                dmc.GridCol(
                    dmc.Stack(
                        [
                            info_panel(
                                "Mode",
                                dmc.Group(
                                    [
                                        dmc.Badge(
                                            id="ecm-mode-badge",
                                            color="gray",
                                            variant="light",
                                            children="view",
                                        ),
                                        DashIconify(
                                            id="ecm-mode-icon",
                                            icon="mdi:eye-outline",
                                            width=20,
                                            color="var(--mantine-color-dimmed)",
                                        ),
                                    ],
                                    gap="xs",
                                ),
                            ),
                            info_panel(
                                "Units (measurementSystem)",
                                dmc.SegmentedControl(
                                    id="ecm-units",
                                    value="metric",
                                    data=[
                                        {"value": "metric", "label": "Metric"},
                                        {"value": "imperial", "label": "Imperial"},
                                    ],
                                    size="xs",
                                    fullWidth=True,
                                ),
                            ),
                            info_panel(
                                "Last action",
                                dmc.Code(
                                    id="ecm-action",
                                    block=True,
                                    style={"minHeight": "60px", "fontSize": "11px"},
                                ),
                            ),
                            info_panel(
                                "Features",
                                html.Div(
                                    id="ecm-list",
                                    children=dmc.Text(
                                        "Click a draw tool (top-left of the map) to start.",
                                        size="sm",
                                        c="dimmed",
                                    ),
                                ),
                            ),
                        ],
                        gap="md",
                    ),
                    span=4,
                ),
            ]
        ),
        # State stores
        dcc.Store(id="ecm-mode", data="view"),  # 'view' | 'create' | 'edit'
        dcc.Store(
            id="ecm-pending-id", data=None
        ),  # id of just-drawn shape (create mode)
        dcc.Store(id="ecm-selected-id", data=None),  # id of selected shape (edit mode)
        dcc.Store(id="ecm-features", data={}),  # id -> {name, color, type}
        dcc.Store(id="ecm-current-color", data=DEFAULT_COLOR),
        dcc.Store(id="ecm-current-name", data=""),
        dcc.Store(id="ecm-drawtool-tick", data=0),  # n_clicks tick for drawToolbar
        dcc.Store(id="ecm-name-wired"),  # dummy: signals one-time listener wired
        # Dedicated keystroke-bridge store: a clientside delegate writes the current
        # value of the name input here on every `input` event. Kept SEPARATE from
        # `ecm-current-name` because that store is an Output of the orchestrator,
        # and clientside set_props writes to a prop that has a server callback as
        # Output don't reliably retrigger other Input callbacks in Dash 4.
        dcc.Store(id="ecm-name-bridge", data=""),
    ],
    gap="md",
)


# ---- orchestrator: maps EditControl + map signals to mode/selection state --
#
# One callback handles all the state transitions. The five Inputs are the
# "things that happen on the map":
#   activeTool / activeMode / action / featureClick (from EditControl) +
#   clickData (from Map; only fires for clicks outside any feature in edit mode
#   because EditControl sets bubblingMouseEvents:false on layers there).


@callback(
    Output("ecm-mode", "data"),
    Output("ecm-pending-id", "data"),
    Output("ecm-selected-id", "data"),
    Output("ecm-popover", "opened"),
    Output("ecm-features", "data"),
    Output("ecm-current-color", "data"),
    Output("ecm-current-name", "data"),
    Input("ecm-ec", "activeTool"),
    Input("ecm-ec", "activeMode"),
    Input("ecm-ec", "action"),
    Input("ecm-ec", "featureClick"),
    Input("ecm-map", "clickData"),
    State("ecm-mode", "data"),
    State("ecm-pending-id", "data"),
    State("ecm-selected-id", "data"),
    State("ecm-features", "data"),
    State("ecm-current-color", "data"),
    prevent_initial_call=True,
)
def orchestrate(
    active_tool,
    active_mode,
    action,
    feature_click,
    map_click,
    mode,
    pending_id,
    selected_id,
    features,
    current_color,
):
    # When multiple Inputs change in the same tick (e.g. clickData fires WITH
    # action.created on every shape commit, because the second mouse click that
    # finishes the shape also propagates as a Map click), `ctx.triggered[0]`
    # only reports one of them. We need to inspect the whole triggered set and
    # pick a branch by priority — action-events FIRST so a 'created' is never
    # masked by the commit click.
    triggered_props = {t["prop_id"] for t in ctx.triggered}
    features = dict(features or {})
    nu = [no_update] * 7

    # Priority 1: action events carry state-changing payloads (id + measurements).
    if "ecm-ec.action" in triggered_props and action:
        a_type = action.get("type")
        a_id = action.get("id")
        if a_type == "created" and a_id:
            features[a_id] = {
                "name": "",
                "color": current_color or DEFAULT_COLOR,
                "type": action.get("layer_type") or "shape",
                "area_m2": float(action.get("area_m2") or 0),
                "length_m": float(action.get("length_m") or 0),
            }
            return (no_update, a_id, no_update, True, features, no_update, "")
        if a_type in ("restyled", "geometry-changed") and a_id and a_id in features:
            features[a_id] = {
                **features[a_id],
                "area_m2": float(
                    action.get("area_m2") or features[a_id].get("area_m2", 0)
                ),
                "length_m": float(
                    action.get("length_m") or features[a_id].get("length_m", 0)
                ),
            }
            return (
                no_update,
                no_update,
                no_update,
                no_update,
                features,
                no_update,
                no_update,
            )
        if a_type == "deleted" and a_id:
            features.pop(a_id, None)
            return (
                no_update,
                no_update,
                no_update,
                no_update,
                features,
                no_update,
                no_update,
            )
        # Action fired but with nothing actionable — fall through to other branches.

    # Priority 2: activeTool change — user clicked a draw icon in EditControl.
    if "ecm-ec.activeTool" in triggered_props:
        if active_tool:
            return ("create", None, None, True, features, no_update, "")
        # activeTool → None: don't auto-exit (could be commit OR cancel; the
        # popover Cancel/Create button handles the explicit transition).

    # Priority 3: activeMode change.
    if "ecm-ec.activeMode" in triggered_props:
        if active_mode == "edit":
            return ("edit", None, None, False, features, no_update, no_update)
        if active_mode is None and mode == "edit":
            return ("view", None, None, False, features, no_update, no_update)

    # Priority 4: feature click in edit mode → select.
    if "ecm-ec.featureClick" in triggered_props and mode == "edit" and feature_click:
        sid = feature_click.get("id")
        f = features.get(sid, {})
        return (
            no_update,
            no_update,
            sid,
            True,
            features,
            f.get("color") or DEFAULT_COLOR,
            f.get("name") or "",
        )

    # Priority 5: empty-map click in edit mode → deselect.
    if "ecm-map.clickData" in triggered_props and mode == "edit" and selected_id:
        return (no_update, no_update, None, False, features, no_update, no_update)

    return nu


# ---- popover content (varies by mode + selection) --------------------------
@callback(
    Output("ecm-popover-content", "children"),
    Input("ecm-mode", "data"),
    Input("ecm-pending-id", "data"),
    Input("ecm-selected-id", "data"),
    State("ecm-features", "data"),
    State("ecm-current-color", "data"),
)
def render_popover_content(mode, pending_id, selected_id, features, current_color):
    features = features or {}
    if mode == "create":
        f = features.get(pending_id) if pending_id else None
        return form_create(
            (f or {}).get("name", ""),
            (f or {}).get("color") or current_color or DEFAULT_COLOR,
        )
    if mode == "edit" and selected_id:
        f = features.get(selected_id) or {}
        return form_edit(f.get("name", ""), f.get("color") or DEFAULT_COLOR)
    # view mode (or edit-without-selection): render nothing — popover is hidden anyway.
    return html.Div()


# ---- color picker change ---------------------------------------------------
#
# Three branches:
#   1. mode='create' AND no pending id  -> update EditControl.shapeOptions so the
#      NEXT drawn shape uses this color
#   2. mode='create' WITH pending id    -> apply featureUpdate.style to the just-
#      drawn shape (lets the user tweak the color before clicking Create)
#   3. mode='edit'  WITH selected id    -> apply featureUpdate.style to that
#      shape live


@callback(
    Output("ecm-ec", "shapeOptions"),
    Output("ecm-ec", "featureUpdate"),
    Output("ecm-features", "data", allow_duplicate=True),
    Output("ecm-current-color", "data", allow_duplicate=True),
    Input("ecm-color", "value"),
    State("ecm-mode", "data"),
    State("ecm-pending-id", "data"),
    State("ecm-selected-id", "data"),
    State("ecm-features", "data"),
    State("ecm-ec", "featureUpdate"),
    State("ecm-current-color", "data"),
    prevent_initial_call=True,
)
def on_color_change(
    color, mode, pending_id, selected_id, features, last_fu, current_color
):
    if not color:
        return no_update, no_update, no_update, no_update
    # Guard against mount-fire echoes (popover content remounts → ColorPicker
    # value Input fires with the same color we just stored).
    if color == current_color and not (pending_id or selected_id):
        return no_update, no_update, no_update, no_update
    target_id = pending_id or selected_id
    features = dict(features or {})
    if target_id:
        last_n = (last_fu or {}).get("n_clicks", 0)
        features[target_id] = {**features.get(target_id, {}), "color": color}
        return (
            no_update,
            {"id": target_id, "style": {"color": color}, "n_clicks": last_n + 1},
            features,
            color,
        )
    if mode == "create":
        # No pending shape yet — set the NEXT draw color via shapeOptions.
        return (
            {"color": color, "weight": 3, "fillOpacity": 0.25},
            no_update,
            no_update,
            color,
        )
    return no_update, no_update, no_update, color


# ---- name input change -----------------------------------------------------
# DMC TextInput in v2.7 / Mantine v8 doesn't surface every keystroke through
# the Dash `value` prop (`Input("ecm-name", "value")` only fires on blur, not
# on input). We bridge it manually with a delegated `input` listener on the
# popover-content node — it catches keystrokes from whichever name input is
# currently mounted (the popover content swaps between create / edit forms
# that both contain a `#ecm-name`) and writes into `ecm-current-name.data`.
clientside_callback(
    """
    () => {
        const root = document.getElementById('ecm-popover-content');
        if (root && !root._dl2_name_wired) {
            root._dl2_name_wired = true;
            root.addEventListener('input', (e) => {
                if (e.target && e.target.id === 'ecm-name') {
                    try {
                        window.dash_clientside.set_props(
                            'ecm-name-bridge', { data: e.target.value || '' }
                        );
                    } catch (err) {
                        console.error('[ecm-name bridge] set_props failed', err);
                    }
                }
            });
            return 'wired';
        }
        return window.dash_clientside.no_update;
    }
    """,
    Output("ecm-name-wired", "data"),
    Input("ecm-popover-content", "id"),
)


@callback(
    Output("ecm-ec", "featureUpdate", allow_duplicate=True),
    Output("ecm-features", "data", allow_duplicate=True),
    Output("ecm-current-name", "data", allow_duplicate=True),
    Input("ecm-name-bridge", "data"),
    State("ecm-pending-id", "data"),
    State("ecm-selected-id", "data"),
    State("ecm-features", "data"),
    State("ecm-ec", "featureUpdate"),
    prevent_initial_call=True,
)
def on_name_change(name, pending_id, selected_id, features, last_fu):
    target_id = pending_id or selected_id
    features = dict(features or {})
    if not target_id:
        return no_update, no_update, no_update
    # Echo guard: skip if value matches what we already stored.
    if (features.get(target_id, {}) or {}).get("name") == (name or ""):
        return no_update, no_update, no_update
    last_n = (last_fu or {}).get("n_clicks", 0)
    features[target_id] = {**features.get(target_id, {}), "name": name or ""}
    return (
        {"id": target_id, "properties": {"name": name or ""}, "n_clicks": last_n + 1},
        features,
        name or "",
    )


# ---- Cancel button (create mode) -------------------------------------------
@callback(
    Output("ecm-mode", "data", allow_duplicate=True),
    Output("ecm-pending-id", "data", allow_duplicate=True),
    Output("ecm-popover", "opened", allow_duplicate=True),
    Output("ecm-ec", "drawToolbar"),
    Output("ecm-ec", "featureUpdate", allow_duplicate=True),
    Output("ecm-features", "data", allow_duplicate=True),
    Output("ecm-drawtool-tick", "data"),
    Input("ecm-cancel", "n_clicks"),
    State("ecm-pending-id", "data"),
    State("ecm-drawtool-tick", "data"),
    State("ecm-ec", "featureUpdate"),
    State("ecm-features", "data"),
    prevent_initial_call=True,
)
def on_cancel(n, pending_id, dt_tick, last_fu, features):
    # Mount-fire guard: dynamic mount → Dash fires n_clicks=None despite
    # prevent_initial_call. Without this, Cancel triggers on popover open.
    if not n:
        return [no_update] * 7
    dt = (dt_tick or 0) + 1
    if pending_id:
        last_n = (last_fu or {}).get("n_clicks", 0)
        features = dict(features or {})
        features.pop(pending_id, None)
        return (
            "view",
            None,
            False,
            {"action": "cancel", "n_clicks": dt},
            {"id": pending_id, "remove": True, "n_clicks": last_n + 1},
            features,
            dt,
        )
    return (
        "view",
        None,
        False,
        {"action": "cancel", "n_clicks": dt},
        no_update,
        no_update,
        dt,
    )


# ---- Create button (create mode) -------------------------------------------
@callback(
    Output("ecm-mode", "data", allow_duplicate=True),
    Output("ecm-pending-id", "data", allow_duplicate=True),
    Output("ecm-popover", "opened", allow_duplicate=True),
    Input("ecm-create", "n_clicks"),
    State("ecm-pending-id", "data"),
    prevent_initial_call=True,
)
def on_create(n, pending_id):
    if not n:
        return no_update, no_update, no_update
    # Whether a shape was drawn or not, "Create" finishes the create flow.
    return "view", None, False


# ---- Delete button (edit mode, selected feature) ---------------------------
@callback(
    Output("ecm-selected-id", "data", allow_duplicate=True),
    Output("ecm-popover", "opened", allow_duplicate=True),
    Output("ecm-ec", "featureUpdate", allow_duplicate=True),
    Output("ecm-features", "data", allow_duplicate=True),
    Input("ecm-delete", "n_clicks"),
    State("ecm-selected-id", "data"),
    State("ecm-ec", "featureUpdate"),
    State("ecm-features", "data"),
    prevent_initial_call=True,
)
def on_delete(n, selected_id, last_fu, features):
    if not n or not selected_id:
        return [no_update] * 4
    last_n = (last_fu or {}).get("n_clicks", 0)
    features = dict(features or {})
    features.pop(selected_id, None)
    return (
        None,
        False,
        {"id": selected_id, "remove": True, "n_clicks": last_n + 1},
        features,
    )


# ---- Done button (edit mode, selected) -------------------------------------
@callback(
    Output("ecm-selected-id", "data", allow_duplicate=True),
    Output("ecm-popover", "opened", allow_duplicate=True),
    Input("ecm-done", "n_clicks"),
    prevent_initial_call=True,
)
def on_done(n):
    if not n:
        return no_update, no_update
    return None, False


# ---- units toggle ----------------------------------------------------------
@callback(Output("ecm-ec", "measurementSystem"), Input("ecm-units", "value"))
def set_units(v):
    return v or "metric"


# ---- mode badge readout ----------------------------------------------------
@callback(
    Output("ecm-mode-badge", "children"),
    Output("ecm-mode-badge", "color"),
    Output("ecm-mode-icon", "icon"),
    Output("ecm-mode-icon", "color"),
    Input("ecm-mode", "data"),
)
def mode_readout(mode):
    if mode == "create":
        return (
            "create",
            "green",
            "mdi:plus-circle-outline",
            "var(--mantine-color-green-6)",
        )
    if mode == "edit":
        return "edit", "blue", "mdi:pencil-outline", "var(--mantine-color-blue-6)"
    return "view", "gray", "mdi:eye-outline", "var(--mantine-color-dimmed)"


# ---- last action readout ---------------------------------------------------
@callback(Output("ecm-action", "children"), Input("ecm-ec", "action"))
def action_readout(a):
    return json.dumps(a, indent=1) if a else "—"


# ---- area / length formatting (mirrors src/ts/components/EditControl.tsx) --
# Python-side formatting lets us re-format already-emitted measurements when
# the Metric/Imperial toggle flips, without round-tripping back through the
# TS layer. The constants match the TS helpers character-for-character.

MI2_IN_M2 = 2_589_988.110336  # 1 mi²
ACRE_IN_M2 = 4046.8564224  # 1 acre = 43,560 ft²
M2_TO_FT2 = 10.7639104  # 1 m²   = 10.7639 ft²
HA_IN_M2 = 10_000  # 1 ha
KM2_IN_M2 = 1_000_000  # 1 km²
M_TO_FT = 3.28084
MI_IN_M = 1609.344


def fmt_area(m2: float, units: str) -> str:
    if not m2 or m2 <= 0:
        return "—"
    if units == "imperial":
        if m2 >= MI2_IN_M2:
            return f"{m2 / MI2_IN_M2:.2f} mi²"
        if m2 >= ACRE_IN_M2:
            return f"{m2 / ACRE_IN_M2:.2f} acres"
        return f"{round(m2 * M2_TO_FT2):,} ft²"
    if m2 >= KM2_IN_M2:
        return f"{m2 / KM2_IN_M2:.2f} km²"
    if m2 >= HA_IN_M2:
        return f"{m2 / HA_IN_M2:.2f} ha"
    return f"{round(m2):,} m²"


def fmt_length(m: float, units: str) -> str:
    if not m or m <= 0:
        return "—"
    if units == "imperial":
        return f"{m / MI_IN_M:.2f} mi" if m >= MI_IN_M else f"{round(m * M_TO_FT):,} ft"
    return f"{m / 1000:.2f} km" if m >= 1000 else f"{round(m):,} m"


# ---- features list readout -------------------------------------------------
@callback(
    Output("ecm-list", "children"),
    Input("ecm-features", "data"),
    Input("ecm-units", "value"),
)
def features_readout(features, units):
    features = features or {}
    units = units or "metric"
    if not features:
        return dmc.Text(
            "Click a draw tool (top-left of the map) to start.", size="sm", c="dimmed"
        )
    rows = []
    total_m2 = 0.0
    for fid, f in features.items():
        name = f.get("name") or "(unnamed)"
        area_m2 = float(f.get("area_m2") or 0)
        length_m = float(f.get("length_m") or 0)
        # Pick the right measurement for this shape: area for filled shapes,
        # length for polylines, em-dash for shapes with no metric (markers).
        measure = (
            fmt_area(area_m2, units)
            if area_m2 > 0
            else (fmt_length(length_m, units) if length_m > 0 else "—")
        )
        total_m2 += area_m2
        rows.append(
            dmc.Group(
                [
                    html.Div(
                        style={
                            "width": 14,
                            "height": 14,
                            "borderRadius": 3,
                            "border": "1px solid var(--mantine-color-default-border)",
                            "background": f.get("color") or DEFAULT_COLOR,
                        }
                    ),
                    dmc.Text(
                        measure,
                        size="xs",
                        c="dimmed",
                        ff="monospace",
                        style={"width": 86},
                    ),
                    dmc.Text(name, size="sm", fw=500, truncate=True),
                ],
                gap="xs",
                wrap="nowrap",
            )
        )
    # Append a divider + total area row (only counts shapes that have area —
    # polylines / markers are excluded from the sum).
    if total_m2 > 0:
        rows.append(dmc.Divider(my=4))
        rows.append(
            dmc.Group(
                [
                    html.Div(style={"width": 14, "height": 14}),  # spacer for alignment
                    dmc.Text(
                        "Total", size="xs", c="dimmed", fw=600, style={"width": 86}
                    ),
                    dmc.Text(
                        fmt_area(total_m2, units),
                        size="sm",
                        fw=700,
                        c="var(--mantine-color-green-6)",
                    ),
                ],
                gap="xs",
                wrap="nowrap",
            )
        )
    return dmc.Stack(rows, gap=4)


# ---- theme sync (light/dark) — mirrors /easy-button -----------------------
register_theme_swap("ecm-tile", TILES)
```


---

*Source: /edit-control-measurement*

---

<!-- /emoji-iconify — https://leaflet.2plot.dev/emoji-iconify/llms.txt -->

# Emoji & Iconify

> a live DashEmojiMart picker + a full Iconify catalogue search,

---



### Overview

both driving a Leaflet 2 DivIcon marker and both following the app's light/dark scheme.

Mirrors dash-leaflet's emoji_marker.py, on Leaflet 2, using the exact DivIcon technique
baked into the compiled dl2.Marker (emoji / iconify modes). The emoji picker is the real
DashEmojiMart component (>= 0.0.5; PyPI 0.0.3 is broken in this Dash 4 / React 18.2 setup).
The Iconify picker searches the full 200k+ catalogue via the Iconify API
(https://api.iconify.design/search). Selecting from either swaps the marker's icon at
runtime via clientside callbacks that call into the JS-mounted map (DL2.emojiIconify).

### Live demo


### How the icon is built

```python
# The same DivIcon technique the compiled dl2.Marker uses:
emoji   -> <div style="font-size:40px">{emoji}</div>
iconify -> <iconify-icon icon="mdi:lighthouse-on" width="40"></iconify-icon>

# With the compiled package it is just a prop:
dl2.Marker(position=[56, 10], emoji="🛥️", iconSize=40)
dl2.Marker(position=[56, 10], iconify="mdi:lighthouse-on", iconColor="#e8590c")
```

### Source


```python
# File: docs/emoji-iconify/example.py

"""
Emoji & Iconify markers — a live DashEmojiMart picker + a full Iconify catalogue search,
both driving a Leaflet 2 DivIcon marker and both following the app's light/dark scheme.

Mirrors dash-leaflet's emoji_marker.py, on Leaflet 2, using the exact DivIcon technique
baked into the compiled dl2.Marker (emoji / iconify modes). The emoji picker is the real
DashEmojiMart component (>= 0.0.5; PyPI 0.0.3 is broken in this Dash 4 / React 18.2 setup).
The Iconify picker searches the full 200k+ catalogue via the Iconify API
(https://api.iconify.design/search). Selecting from either swaps the marker's icon at
runtime via clientside callbacks that call into the JS-mounted map (DL2.emojiIconify).
"""

import dash_mantine_components as dmc
from dash import Input, Output, clientside_callback, dcc, html
from dash_emoji_mart import DashEmojiMart
from dash_iconify import DashIconify
from dl2_shared import map_div


component = dmc.Stack(
    [
        dmc.Grid(
            [
                dmc.GridCol(map_div("emoji-iconify", height="58vh"), span=7),
                dmc.GridCol(
                    dmc.Stack(
                        [
                            dmc.Paper(
                                [
                                    dmc.Group(
                                        [
                                            dmc.Text("Icon size", size="sm", fw=600),
                                            dmc.Text(
                                                id="ei-readout",
                                                size="sm",
                                                c="green",
                                                ff="monospace",
                                            ),
                                        ],
                                        justify="space-between",
                                    ),
                                    dmc.Slider(
                                        id="ei-size",
                                        min=20,
                                        max=72,
                                        value=40,
                                        step=2,
                                        mb="sm",
                                        marks=[
                                            {"value": 20, "label": "20"},
                                            {"value": 72, "label": "72"},
                                        ],
                                    ),
                                    dmc.Divider(label="Iconify catalogue", my="xs"),
                                    dmc.Group(
                                        [
                                            dmc.TextInput(
                                                id="ei-iconify",
                                                value="anchor",
                                                placeholder="Search icons…",
                                                leftSection=DashIconify(
                                                    icon="mdi:magnify"
                                                ),
                                                style={"flex": 1},
                                            ),
                                            DashIconify(
                                                id="ei-preview",
                                                icon="mdi:map-marker",
                                                width=28,
                                            ),
                                        ],
                                        align="center",
                                        mb="xs",
                                    ),
                                    html.Div(
                                        id="ei-iconify-grid",
                                        className="ei-iconify-grid",
                                        children="Type to search 200k+ Iconify icons…",
                                    ),
                                    dmc.Divider(label="Emoji", my="xs"),
                                    DashEmojiMart(
                                        id="ei-emoji",
                                        theme="auto",
                                        perLine=8,
                                        emojiSize=22,
                                        emojiButtonSize=30,
                                        previewPosition="none",
                                    ),
                                ],
                                shadow="sm",
                                radius="md",
                                p="md",
                                withBorder=True,
                            ),
                        ],
                        gap="md",
                    ),
                    span=5,
                ),
            ]
        ),
        dcc.Store(id="ei-iconify-status"),
    ],
    gap="md",
)


# Emoji picked in DashEmojiMart -> update the marker DivIcon + readout.
clientside_callback(
    """
    function(emoji) {
        var ctx = window.DL2 && window.DL2.emojiIconify;
        if (!emoji || !ctx) return window.dash_clientside.no_update;
        ctx.setEmoji(emoji);
        return 'emoji ' + emoji;
    }
    """,
    Output("ei-readout", "children"),
    Input("ei-emoji", "value"),
    prevent_initial_call=True,
)

# Iconify search -> query the Iconify API and render the catalogue grid. Each result button
# calls DL2.iconifyPick(name) which updates the marker + readout + preview.
clientside_callback(
    """
    async function(query) {
        var grid = document.getElementById('ei-iconify-grid');
        if (!grid) return window.dash_clientside.no_update;
        if (!query || query.trim().length < 2) {
            grid.innerHTML = 'Type to search 200k+ Iconify icons…';
            return 0;
        }
        try {
            var url = 'https://api.iconify.design/search?query=' + encodeURIComponent(query.trim()) + '&limit=60';
            var data = await (await fetch(url)).json();
            var icons = (data && data.icons) || [];
            if (!icons.length) { grid.innerHTML = 'No icons found for “' + query + '”'; return 0; }
            grid.innerHTML = icons.map(function(name) {
                return '<button class="ei-icon-btn" title="' + name + '" ' +
                       'onclick="window.DL2.iconifyPick(&quot;' + name + '&quot;)">' +
                       '<iconify-icon icon="' + name + '" width="22"></iconify-icon></button>';
            }).join('');
            return icons.length;
        } catch (e) {
            grid.innerHTML = 'Iconify search failed';
            return -1;
        }
    }
    """,
    Output("ei-iconify-status", "data"),
    Input("ei-iconify", "value"),
)

# Size slider -> re-apply the current size to whichever icon is active.
clientside_callback(
    """
    function(size) {
        var ctx = window.DL2 && window.DL2.emojiIconify;
        if (!ctx) return window.dash_clientside.no_update;
        ctx.setSize(size);
        return ctx.last.type + ' ' + ctx.last.val + ' @ ' + size + 'px';
    }
    """,
    Output("ei-readout", "children", allow_duplicate=True),
    Input("ei-size", "value"),
    prevent_initial_call=True,
)

# Sync DashEmojiMart's theme to the app's color scheme (the header toggle: checked=light).
clientside_callback(
    # Same fix as the tile swap: read the STORE, not the header ActionIcon.
    "(scheme) => (scheme === 'dark' ? 'dark' : 'light')",
    Output("ei-emoji", "theme"),
    Input("color-scheme-storage", "data"),
)
```


---

*Source: /emoji-iconify*

---

<!-- /events-python — https://leaflet.2plot.dev/events-python/llms.txt -->

# Events → Python

> full JS→Python round-trip via dcc.Store.

---



### Overview

This page demonstrates Events → Python.

### Live demo


### set_props bridge

```javascript
// JS -> Python bridge uses Dash 4's clientside set_props into a dcc.Store,
// which an ordinary @callback then reads.
map.on("moveend zoomend", () => {
    const c = map.getCenter();
    window.dash_clientside.set_props("ev-store",
        {data: {lat: c.lat, lng: c.lng, zoom: map.getZoom(),
                bounds: map.getBounds()}});
});
map.on("click", (e) => window.dash_clientside.set_props(
    "ev-click-store", {data: {lat: e.latlng.lat, lng: e.latlng.lng}}));
```

### Source


```python
# File: docs/events-python/example.py

"""Events → Python — full JS→Python round-trip via dcc.Store."""

import dash_mantine_components as dmc
from dash import Input, Output, callback, dcc
from dl2_shared import info_panel, map_div


component = dmc.Stack(
    [
        dmc.Grid(
            [
                dmc.GridCol(map_div("events-python"), span=8),
                dmc.GridCol(
                    dmc.Stack(
                        [
                            info_panel(
                                "View state (moveend / zoomend)",
                                dmc.Code(
                                    id="ev-view",
                                    block=True,
                                    style={"minHeight": "120px"},
                                ),
                            ),
                            info_panel(
                                "Last click", dmc.Code(id="ev-click", block=True)
                            ),
                        ],
                        gap="md",
                    ),
                    span=4,
                ),
            ]
        ),
        dcc.Store(id="ev-store"),
        dcc.Store(id="ev-click-store"),
    ],
    gap="md",
)


@callback(Output("ev-view", "children"), Input("ev-store", "data"))
def show_view(d):
    if not d:
        return "pan or zoom the map…"
    b = d["bounds"]
    return (
        f"center: {d['lat']}, {d['lng']}\n"
        f"zoom:   {d['zoom']}\n"
        f"bounds: N {b['n']}  S {b['s']}\n"
        f"        E {b['e']}  W {b['w']}"
    )


@callback(
    Output("ev-click", "children"),
    Input("ev-click-store", "data"),
    prevent_initial_call=True,
)
def show_click(d):
    return f"{d['lat']}, {d['lng']}" if d else "—"
```


---

*Source: /events-python*

---

<!-- /flight-sim — https://leaflet.2plot.dev/flight-sim/llms.txt -->

# Flight Sim

> single-player, keyboard + touch-joystick, rAF physics loop.

---



### Overview

Rotation model (revised, matching DashEcommerce/pages/map/fly.py):

  * The MAP STAYS NORTH-UP — `bearing` is left at 0 on this page. The previous
    iteration rotated the camera with the heading; the user wanted the fly.py
    pattern instead, where the player gets the orientation cue from the SPRITE.
  * The MARKER ROTATES — `rotateWithMap=False` plus `rotationAngle = heading`
    drives the airplane sprite to face the direction of travel. The sprite is
    the top-down airplane PNG, intrinsically pointing UP (north) — so a
    rotationAngle of 90° = nose pointing east, 180° = south, etc.

  * The dl2.Map(bearing=…) machinery still EXISTS and the dl2-rotation-wrapper
    is still there — we just don't use it from this page. Rotation-basic still
    demonstrates the camera-rotation capability.

Controls

  * Keyboard (desktop): ArrowLeft/Right turn the plane, ArrowUp throttles,
    ArrowDown brakes, Space hard-stops, Cmd/Ctrl+Arrow pans the camera.
  * Touch joystick (mobile, auto-shown via @media (hover: none)
    and (pointer: coarse)): pushing horizontally turns, pushing vertically
    throttles/brakes — same semantic as the arrow keys but as a continuous
    analog signal. Joystick state is read by the rAF tick alongside keyboard
    state, so both work simultaneously and either input alone is enough.

The rAF physics loop stays unchanged — frame-rate-independent integration,
self-throttling to display refresh, paused when the tab is hidden.

### Live demo


### The shape


**Pattern**

```python
# File: docs/flight-sim/example.py  (region: map)

dl2.Map(
    id="fs-map",
    center=START,
    zoom=START_ZOOM,
    bearing=0,
    style={"height": "100%"},
    children=[
        dl2.TileLayer(
            id="fs-sat",
            url=SAT,
            attribution=ATTR,
            opacity=0.55,
        ),
        dl2.TileLayer(
            id="fs-tile", url=TILE_URL, opacity=0.7
        ),
        dl2.Marker(
            id="fs-aircraft",
            position=START,
            icon={
                "iconUrl": AIRPLANE_SRC,
                "iconSize": [
                    AIRPLANE_SIZE,
                    AIRPLANE_SIZE,
                ],
                "iconAnchor": [
                    AIRPLANE_SIZE // 2,
                    AIRPLANE_SIZE // 2,
                ],
            },
            rotateWithMap=False,
            rotationAngle=0,
        ),
        dl2.Polyline(
            id="fs-trail",
            positions=[START],
            color="#2f9e44",
            weight=2,
            opacity=0.6,
        ),
    ],
),
```


### Source


```python
# File: docs/flight-sim/example.py

"""
Flight Sim — single-player, keyboard + touch-joystick, rAF physics loop.

Rotation model (revised, matching DashEcommerce/pages/map/fly.py):

  * The MAP STAYS NORTH-UP — `bearing` is left at 0 on this page. The previous
    iteration rotated the camera with the heading; the user wanted the fly.py
    pattern instead, where the player gets the orientation cue from the SPRITE.
  * The MARKER ROTATES — `rotateWithMap=False` plus `rotationAngle = heading`
    drives the airplane sprite to face the direction of travel. The sprite is
    the top-down airplane PNG, intrinsically pointing UP (north) — so a
    rotationAngle of 90° = nose pointing east, 180° = south, etc.

  * The dl2.Map(bearing=…) machinery still EXISTS and the dl2-rotation-wrapper
    is still there — we just don't use it from this page. Rotation-basic still
    demonstrates the camera-rotation capability.

Controls

  * Keyboard (desktop): ArrowLeft/Right turn the plane, ArrowUp throttles,
    ArrowDown brakes, Space hard-stops, Cmd/Ctrl+Arrow pans the camera.
  * Touch joystick (mobile, auto-shown via @media (hover: none)
    and (pointer: coarse)): pushing horizontally turns, pushing vertically
    throttles/brakes — same semantic as the arrow keys but as a continuous
    analog signal. Joystick state is read by the rAF tick alongside keyboard
    state, so both work simultaneously and either input alone is enough.

The rAF physics loop stays unchanged — frame-rate-independent integration,
self-throttling to display refresh, paused when the tab is hidden.
"""

import dash_leaflet2 as dl2
import dash_mantine_components as dmc
from dash import Input, Output, State, callback, clientside_callback, dcc, html
from dash_iconify import DashIconify
from dl2_tiles import ESRI_STREET, register_theme_swap
from dl2_locations import MIAMI
from dl2_shared import info_panel

# Basemap pair for this page. dl2_tiles owns the light/dark wiring so
# every example themes the same way — see register_theme_swap below.
TILES = ESRI_STREET
TILE_URL = TILES.url("light")
SAT = (
    "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/"
    "MapServer/tile/{z}/{y}/{x}"
)
ATTR = TILES.attribution()

START = MIAMI.center
START_ZOOM = 15

# Physics — deg/s for the integrator (the rAF tick multiplies by dt in seconds).
MIN_SPEED = 0.0
MAX_SPEED = 0.0006
ACCEL = 0.0006  # ≈ 1 sec to top speed
BRAKE = 0.0012
TURN_RATE = 90.0  # deg/sec of heading change while turning

# Top-down green bomber sprite with built-in drop shadow. The image is ~512px,
# square, with the nose pointing UP — matches our north-up convention so the
# rotationAngle = heading mapping in the rAF loop reads naturally (heading 90°
# → sprite rotated 90° CW → nose pointing east).
AIRPLANE_SRC = "/assets/sprites/airplane_with_shadow.webp"
AIRPLANE_SIZE = 68  # bumped from 56 to give the propellers + stars room to read



def _joystick_div(prefix: str):
    """Render the joystick base + controller. CSS in style.css hides this on
    non-touch displays. The base/controller IDs are wired up in the rAF loop's
    setup JS using a unique class prefix."""
    return html.Div(
        className="dl2-joystick",
        children=[
            html.Div(
                className="dl2-joystick-base",
                id=f"{prefix}-joystick-base",
                children=html.Div(
                    className="dl2-joystick-controller",
                    id=f"{prefix}-joystick-controller",
                ),
            ),
            html.Div(
                className="dl2-joystick-hint",
                children=[
                    DashIconify(icon="mdi:gesture-tap", width=14),
                    html.Span(" Drag to fly / brake"),
                ],
            ),
        ],
    )


component = dmc.Stack(
    [
        dmc.Grid(
            [
                # base 12 (stacks under map) on mobile, 8 on md+ (side-by-side).
                dmc.GridCol(
                    html.Div(
                        style={"position": "relative"},
                        children=[
                            # Height is set by the .dl2-sim-map-paper CSS class (70vh
                            # on desktop, 55vh on mobile) so the responsive height
                            # doesn't require an inline style dict.
                            dmc.Paper(
                                # region map
                                dl2.Map(
                                    id="fs-map",
                                    center=START,
                                    zoom=START_ZOOM,
                                    bearing=0,
                                    style={"height": "100%"},
                                    children=[
                                        dl2.TileLayer(
                                            id="fs-sat",
                                            url=SAT,
                                            attribution=ATTR,
                                            opacity=0.55,
                                        ),
                                        dl2.TileLayer(
                                            id="fs-tile", url=TILE_URL, opacity=0.7
                                        ),
                                        dl2.Marker(
                                            id="fs-aircraft",
                                            position=START,
                                            icon={
                                                "iconUrl": AIRPLANE_SRC,
                                                "iconSize": [
                                                    AIRPLANE_SIZE,
                                                    AIRPLANE_SIZE,
                                                ],
                                                "iconAnchor": [
                                                    AIRPLANE_SIZE // 2,
                                                    AIRPLANE_SIZE // 2,
                                                ],
                                            },
                                            rotateWithMap=False,
                                            rotationAngle=0,
                                        ),
                                        dl2.Polyline(
                                            id="fs-trail",
                                            positions=[START],
                                            color="#2f9e44",
                                            weight=2,
                                            opacity=0.6,
                                        ),
                                    ],
                                ),
                                # endregion
                                className="dl2-sim-map-paper",
                                shadow="sm",
                                radius="md",
                                withBorder=True,
                                style={"overflow": "hidden"},
                            ),
                            # Joystick overlay (mobile only — hidden by CSS otherwise).
                            _joystick_div("fs"),
                        ],
                    ),
                    span={"base": 12, "md": 8},
                ),
                dmc.GridCol(
                    dmc.Stack(
                        [
                            info_panel(
                                "HUD",
                                dmc.Stack(
                                    [
                                        dmc.Group(
                                            [
                                                dmc.Stack(
                                                    [
                                                        dmc.Text(
                                                            "HEADING",
                                                            size="xs",
                                                            c="dimmed",
                                                        ),
                                                        dmc.Badge(
                                                            id="fs-heading",
                                                            color="green",
                                                            variant="light",
                                                            size="lg",
                                                            children="0°",
                                                        ),
                                                    ],
                                                    gap=2,
                                                ),
                                                dmc.Stack(
                                                    [
                                                        dmc.Text(
                                                            "THROTTLE",
                                                            size="xs",
                                                            c="dimmed",
                                                        ),
                                                        dmc.Badge(
                                                            id="fs-throttle",
                                                            color="orange",
                                                            variant="light",
                                                            size="lg",
                                                            children="0%",
                                                        ),
                                                    ],
                                                    gap=2,
                                                ),
                                            ],
                                            justify="space-between",
                                        ),
                                        dmc.Progress(
                                            id="fs-throttle-bar",
                                            value=0,
                                            color="orange",
                                            size="sm",
                                            striped=True,
                                            animated=True,
                                        ),
                                        dmc.Group(
                                            [
                                                dmc.Text(
                                                    "POSITION", size="xs", c="dimmed"
                                                ),
                                                dmc.Code(
                                                    id="fs-position",
                                                    children="...",
                                                    style={"fontSize": "11px"},
                                                ),
                                            ],
                                            justify="space-between",
                                        ),
                                    ],
                                    gap="sm",
                                ),
                            ),
                            info_panel(
                                "Controls",
                                dmc.Stack(
                                    [
                                        dmc.Group(
                                            [
                                                dmc.Kbd("←"),
                                                dmc.Kbd("→"),
                                                dmc.Text("turn aircraft", size="sm"),
                                            ],
                                            gap="xs",
                                        ),
                                        dmc.Group(
                                            [
                                                dmc.Kbd("↑"),
                                                dmc.Text("throttle up", size="sm"),
                                            ],
                                            gap="xs",
                                        ),
                                        dmc.Group(
                                            [
                                                dmc.Kbd("↓"),
                                                dmc.Text("brake", size="sm"),
                                            ],
                                            gap="xs",
                                        ),
                                        dmc.Group(
                                            [
                                                dmc.Kbd("Space"),
                                                dmc.Text("hard stop", size="sm"),
                                            ],
                                            gap="xs",
                                        ),
                                        dmc.Divider(),
                                        dmc.Group(
                                            [
                                                dmc.Kbd("⌘"),
                                                dmc.Text("+", size="sm"),
                                                dmc.Kbd("←/→/↑/↓"),
                                                dmc.Text("pan camera", size="sm"),
                                            ],
                                            gap="xs",
                                        ),
                                        dmc.Divider(),
                                        dmc.Group(
                                            [
                                                DashIconify(
                                                    icon="mdi:gesture-tap", width=16
                                                ),
                                                dmc.Text(
                                                    "touch joystick auto-shows on mobile",
                                                    size="sm",
                                                    c="dimmed",
                                                ),
                                            ],
                                            gap="xs",
                                        ),
                                    ],
                                    gap=6,
                                ),
                            ),
                            info_panel(
                                "State",
                                dmc.Code(
                                    id="fs-state-readout",
                                    block=True,
                                    style={"fontSize": "11px", "minHeight": "100px"},
                                ),
                            ),
                        ],
                        gap="md",
                    ),
                    span={"base": 12, "md": 4},
                ),
            ]
        ),
        dcc.Store(id="fs-tick", data=0),
    ],
    gap="md",
)


# ---- rAF physics loop (installed once on page mount) -----------------------
clientside_callback(
    f"""
    (mapId) => {{
        const root = document.getElementById('fs-map');
        if (!root || root.dataset.fsLoopRunning) {{
            return window.dash_clientside.no_update;
        }}
        root.dataset.fsLoopRunning = '1';

        const MIN_SPEED = {MIN_SPEED};
        const MAX_SPEED = {MAX_SPEED};
        const ACCEL = {ACCEL};
        const BRAKE = {BRAKE};
        const TURN_RATE = {TURN_RATE};

        const state = {{
            lat: {START[0]}, lng: {START[1]},
            heading: 0,
            speed: 0,
            trail: [[{START[0]}, {START[1]}]],
            keys: new Set(),
            lastFrame: performance.now(),
        }};
        // Shared joystick state — written by the touch handlers below, read by
        // the rAF tick. Magnitude on each axis is normalized to [-1, +1].
        window._dl2_joystick = window._dl2_joystick || {{ x: 0, y: 0, active: false }};
        const joy = window._dl2_joystick;

        // --- keyboard ---
        // CRITICAL: disable Leaflet's built-in keyboard handler. Without this,
        // once the user clicks the map (giving it focus), Leaflet intercepts
        // ArrowLeft/Right/Up/Down via its own keyboard module and calls
        // stopPropagation — meaning the window-level listener we install
        // below never sees the event. Result: arrow keys appear to do nothing
        // on desktop (the map briefly pans, then snaps back via the rAF
        // re-centering). Disabling Leaflet's keyboard module lets the events
        // propagate up to window where our listener handles them.
        //
        // dl2.Map sets root.__dl2_map inside its mount effect (children run
        // first in React); the property may not exist yet when this setup
        // callback fires. Poll until it appears.
        const disableLeafletKeyboard = () => {{
            const m = root.__dl2_map;
            if (m && m.keyboard && typeof m.keyboard.disable === 'function') {{
                try {{ m.keyboard.disable(); }} catch (e) {{}}
                return;
            }}
            setTimeout(disableLeafletKeyboard, 80);
        }};
        disableLeafletKeyboard();

        const PAN_STEP_PX = 80;
        const panBy = (dx, dy) => {{
            const m = root.__dl2_map;
            if (m && typeof m.panBy === 'function') m.panBy([dx, dy]);
        }};
        const onDown = (e) => {{
            const t = e.target;
            if (t && /input|textarea|select/i.test(t.tagName)) return;
            if (e.metaKey || e.ctrlKey) {{
                if (e.key === 'ArrowLeft')  {{ panBy(-PAN_STEP_PX, 0); e.preventDefault(); }}
                if (e.key === 'ArrowRight') {{ panBy(PAN_STEP_PX, 0);  e.preventDefault(); }}
                if (e.key === 'ArrowUp')    {{ panBy(0, -PAN_STEP_PX); e.preventDefault(); }}
                if (e.key === 'ArrowDown')  {{ panBy(0, PAN_STEP_PX);  e.preventDefault(); }}
                return;
            }}
            if (['ArrowLeft','ArrowRight','ArrowUp','ArrowDown',' ','Space'].includes(e.key)) {{
                state.keys.add(e.key);
                e.preventDefault();
            }}
        }};
        const onUp = (e) => state.keys.delete(e.key);
        window.addEventListener('keydown', onDown);
        window.addEventListener('keyup', onUp);

        // --- touch joystick ---
        // Hand-rolled (no dash_gauge dep). Base is a circle anchored bottom-center;
        // dragging the controller updates joy.x/joy.y in [-1, +1]. Released =>
        // controller springs back to center, joy values zero out.
        const base = document.getElementById('fs-joystick-base');
        const ctrl = document.getElementById('fs-joystick-controller');
        if (base && ctrl && !base.dataset.wired) {{
            base.dataset.wired = '1';
            const reset = () => {{
                ctrl.style.transform = 'translate(-50%, -50%)';
                joy.x = 0; joy.y = 0; joy.active = false;
            }};
            reset();
            let activePtr = null;
            const onPtrDown = (e) => {{
                if (activePtr !== null) return;
                activePtr = e.pointerId;
                base.setPointerCapture(e.pointerId);
                joy.active = true;
                e.preventDefault();
            }};
            const onPtrMove = (e) => {{
                if (e.pointerId !== activePtr) return;
                const r = base.getBoundingClientRect();
                const radius = r.width / 2;
                const dx = e.clientX - (r.left + radius);
                const dy = e.clientY - (r.top + radius);
                const mag = Math.sqrt(dx * dx + dy * dy);
                // Clamp the visual controller offset to the base radius.
                const k = mag > radius ? radius / mag : 1;
                const cx = dx * k, cy = dy * k;
                ctrl.style.transform = `translate(calc(-50% + ${{cx}}px), calc(-50% + ${{cy}}px))`;
                joy.x = cx / radius;
                joy.y = cy / radius;
            }};
            const onPtrUp = (e) => {{
                if (e.pointerId !== activePtr) return;
                try {{ base.releasePointerCapture(e.pointerId); }} catch (err) {{}}
                activePtr = null;
                reset();
            }};
            base.addEventListener('pointerdown', onPtrDown);
            base.addEventListener('pointermove', onPtrMove);
            base.addEventListener('pointerup', onPtrUp);
            base.addEventListener('pointercancel', onPtrUp);
        }}

        // --- rAF tick ---
        const tick = (now) => {{
            const dt = Math.min(0.1, (now - state.lastFrame) / 1000);
            state.lastFrame = now;

            const k = state.keys;
            // Turn input: keyboard arrows give ±1, joystick gives [-1, +1].
            // Sum them and clamp so holding the keyboard AND pushing joystick
            // doesn't double-spin.
            let turn = 0;
            if (k.has('ArrowLeft'))  turn -= 1;
            if (k.has('ArrowRight')) turn += 1;
            if (joy.active) turn += joy.x;
            turn = Math.max(-1, Math.min(1, turn));
            state.heading = (state.heading + turn * TURN_RATE * dt + 360) % 360;

            // Throttle / brake input.
            let thr = 0;
            if (k.has('ArrowUp'))   thr += 1;
            if (k.has('ArrowDown')) thr -= 1;
            if (joy.active) thr += -joy.y;  // joystick UP (-y) = throttle
            thr = Math.max(-1, Math.min(1, thr));
            if (thr > 0)      state.speed = Math.min(MAX_SPEED, state.speed + thr * ACCEL * dt * 60);
            else if (thr < 0) state.speed = Math.max(MIN_SPEED, state.speed + thr * BRAKE * dt * 60);

            if (k.has(' ') || k.has('Space')) state.speed = 0;

            if (state.speed > 0) {{
                const hd = state.heading * Math.PI / 180;
                state.lat += state.speed * Math.cos(hd) * (dt * 60);
                state.lng += state.speed * Math.sin(hd) * (dt * 60);
                if (state.trail.length === 0 ||
                    Math.hypot(state.lat - state.trail[state.trail.length-1][0],
                               state.lng - state.trail[state.trail.length-1][1]) > 0.0008) {{
                    state.trail.push([state.lat, state.lng]);
                    if (state.trail.length > 300) state.trail.shift();
                }}
            }}

            const pos = [state.lat, state.lng];
            dash_clientside.set_props('fs-aircraft', {{
                position: pos,
                rotationAngle: state.heading,   // ← marker rotates to face heading
            }});
            dash_clientside.set_props('fs-map',   {{ center: pos }});
            dash_clientside.set_props('fs-trail', {{ positions: state.trail }});

            const pct = Math.round((state.speed / MAX_SPEED) * 100);
            dash_clientside.set_props('fs-heading',      {{ children: Math.round(state.heading) + '°' }});
            dash_clientside.set_props('fs-throttle',     {{ children: pct + '%' }});
            dash_clientside.set_props('fs-throttle-bar', {{ value: pct }});
            dash_clientside.set_props('fs-position',     {{ children: pos[0].toFixed(4) + ', ' + pos[1].toFixed(4) }});

            if (!document.getElementById('fs-map')) return;
            requestAnimationFrame(tick);
        }};
        requestAnimationFrame(tick);

        return window.dash_clientside.no_update;
    }}
    """,
    Output("fs-tick", "data"),
    Input("fs-map", "id"),
)


@callback(Output("fs-state-readout", "children"), Input("fs-map", "viewport"))
def state_readout(vp):
    if not vp:
        return "—"
    return (
        "center: [{:.4f}, {:.4f}]\nzoom: {}\nbearing: {}°\n"
        "bounds: N {:.3f} S {:.3f} E {:.3f} W {:.3f}"
    ).format(
        vp["center"][0],
        vp["center"][1],
        vp["zoom"],
        round(vp.get("bearing") or 0),
        vp["bounds"]["north"],
        vp["bounds"]["south"],
        vp["bounds"]["east"],
        vp["bounds"]["west"],
    )


register_theme_swap("fs-tile", TILES)
```


---

*Source: /flight-sim*

---

<!-- /flyto — https://leaflet.2plot.dev/flyto/llms.txt -->

# FlyTo

> smooth viewport transitions, modelled on dash-leaflet's `viewport` API.

---



### Overview

`dl2.Map.flyTo` is a [MUTABLE] trigger prop. Setting it dispatches the matching
Leaflet 2 method (`flyTo` / `setView` / `panTo` / `fitBounds` / `flyToBounds` /
`panInsideBounds`). The `flyTo` and `flyToBounds` transitions give the smooth
glide-and-zoom motion the old dash-leaflet doc page demos with "Fly to Paris".

Companion events:
  - `n_movestart` increments when a transition BEGINS
  - `n_moveend`   increments when it COMPLETES
  - `viewport`    is the existing READONLY state read-back

A "FLYING…" HUD is the canonical use of the counter pair — show it while
`n_movestart > n_moveend`, otherwise show "IDLE".

Trigger payload shape:
    {
        'transition': 'flyTo',          # or setView, panTo, fitBounds, ...
        'center': [lat, lng],
        'zoom': 11,
        'options': {'duration': 2.5, 'easeLinearity': 0.25},
        'n_clicks': bump_me,            # required — bump per call to retrigger
    }

### Live demo


### The shape


**dl2.Map.flyTo — trigger + HUD pattern**

```python
# File: docs/flyto/example.py  (region: map)

dl2.Map(
    id="fly-map",
    center=START,
    zoom=START_ZOOM,
    style={"height": "62vh"},
    children=[
        dl2.TileLayer(
            id="fly-tile", url=TILE_URL, attribution=ATTR
        ),
        # One marker per city — useful both visually and as a click target.
        *[
            dl2.Marker(
                position=[c["lat"], c["lng"]],
                iconify=c["icon"],
                iconSize=30,
                children=[
                    dl2.Tooltip(children=c["name"]),
                ],
            )
            for c in CITIES
        ],
    ],
),
```


### Source


```python
# File: docs/flyto/example.py

"""
FlyTo — smooth viewport transitions, modelled on dash-leaflet's `viewport` API.

`dl2.Map.flyTo` is a [MUTABLE] trigger prop. Setting it dispatches the matching
Leaflet 2 method (`flyTo` / `setView` / `panTo` / `fitBounds` / `flyToBounds` /
`panInsideBounds`). The `flyTo` and `flyToBounds` transitions give the smooth
glide-and-zoom motion the old dash-leaflet doc page demos with "Fly to Paris".

Companion events:
  - `n_movestart` increments when a transition BEGINS
  - `n_moveend`   increments when it COMPLETES
  - `viewport`    is the existing READONLY state read-back

A "FLYING…" HUD is the canonical use of the counter pair — show it while
`n_movestart > n_moveend`, otherwise show "IDLE".

Trigger payload shape:
    {
        'transition': 'flyTo',          # or setView, panTo, fitBounds, ...
        'center': [lat, lng],
        'zoom': 11,
        'options': {'duration': 2.5, 'easeLinearity': 0.25},
        'n_clicks': bump_me,            # required — bump per call to retrigger
    }
"""

import dash
import dash_leaflet2 as dl2
import dash_mantine_components as dmc
from dash import (
    Input,
    Output,
    State,
    callback,
    clientside_callback,
    ctx,
    dcc,
    html,
    no_update,
)
from dash_iconify import DashIconify
from dl2_tiles import POSITRON, register_theme_swap
from dl2_shared import info_panel

# Basemap pair for this page. dl2_tiles owns the light/dark wiring so
# every example themes the same way — see register_theme_swap below.
TILES = POSITRON
TILE_URL = TILES.url("light")
ATTR = TILES.attribution()

# A small grand-tour. Each city has a sensible target zoom (CARTO lights up
# urban detail nicely at z=11–12). The `bounds` entry triggers flyToBounds
# instead of flyTo to demo the bounds-driven variant.
CITIES = [
    {
        "name": "Paris",
        "icon": "twemoji:eiffel-tower",
        "lat": 48.864716,
        "lng": 2.349014,
        "zoom": 12,
        "color": "blue",
    },
    {
        "name": "Tokyo",
        "icon": "twemoji:mount-fuji",
        "lat": 35.689487,
        "lng": 139.691711,
        "zoom": 11,
        "color": "red",
    },
    {
        "name": "Sydney",
        "icon": "twemoji:bridge-at-night",
        "lat": -33.86882,
        "lng": 151.2093,
        "zoom": 12,
        "color": "cyan",
    },
    {
        "name": "Rio",
        "icon": "twemoji:flag-brazil",
        "lat": -22.9068,
        "lng": -43.1729,
        "zoom": 11,
        "color": "green",
    },
    {
        "name": "Cape Town",
        "icon": "twemoji:mountain",
        "lat": -33.9249,
        "lng": 18.4241,
        "zoom": 11,
        "color": "orange",
    },
    {
        "name": "Reykjavík",
        "icon": "twemoji:snow-capped-mountain",
        "lat": 64.1466,
        "lng": -21.9426,
        "zoom": 11,
        "color": "indigo",
    },
    {
        "name": "New York",
        "icon": "twemoji:statue-of-liberty",
        "lat": 40.7128,
        "lng": -74.0060,
        "zoom": 12,
        "color": "grape",
    },
]

# Bounds-based examples — center+zoom can't frame a bbox without manual math,
# bounds transitions can. We pick three with different visual character:
#   * Hawaii      — flyToBounds: smooth pan+zoom into a tight island chain.
#   * Italy       — fitBounds:   instant snap (animate:False default) to a country.
#   * Mediterranean — panInsideBounds: only pans if the current view is OUTSIDE
#                     the bbox; if you're already inside it, the call is a no-op.
HAWAII_BOUNDS = [[18.91, -160.25], [22.24, -154.80]]
ITALY_BOUNDS = [[36.65, 6.62], [47.10, 18.52]]
MEDITERRANEAN_BOUNDS = [[30.0, -6.0], [46.0, 36.0]]

START = [25.0, -30.0]  # Atlantic — a "neutral" starting view
START_ZOOM = 3




# ---- layout ----------------------------------------------------------------


def city_button(c):
    return dmc.Button(
        c["name"],
        id={"type": "fly-city", "name": c["name"]},
        color=c["color"],
        variant="light",
        size="xs",
        fullWidth=True,
        leftSection=DashIconify(icon=c["icon"], width=16),
    )


component = dmc.Stack(
    [
        dmc.Grid(
            [
                dmc.GridCol(
                    dmc.Paper(
                        # region map
                        dl2.Map(
                            id="fly-map",
                            center=START,
                            zoom=START_ZOOM,
                            style={"height": "62vh"},
                            children=[
                                dl2.TileLayer(
                                    id="fly-tile", url=TILE_URL, attribution=ATTR
                                ),
                                # One marker per city — useful both visually and as a click target.
                                *[
                                    dl2.Marker(
                                        position=[c["lat"], c["lng"]],
                                        iconify=c["icon"],
                                        iconSize=30,
                                        children=[
                                            dl2.Tooltip(children=c["name"]),
                                        ],
                                    )
                                    for c in CITIES
                                ],
                            ],
                        ),
                        # endregion
                        shadow="sm",
                        radius="md",
                        withBorder=True,
                        style={"overflow": "hidden"},
                    ),
                    span={"base": 12, "md": 8},
                ),
                dmc.GridCol(
                    dmc.Stack(
                        [
                            info_panel(
                                "HUD",
                                dmc.Stack(
                                    [
                                        dmc.Group(
                                            [
                                                dmc.Text(
                                                    "STATE", size="xs", c="dimmed"
                                                ),
                                                dmc.Badge(
                                                    id="fly-state",
                                                    color="gray",
                                                    variant="light",
                                                    size="lg",
                                                    children="idle",
                                                ),
                                            ],
                                            justify="space-between",
                                        ),
                                        dmc.Group(
                                            [
                                                dmc.Text(
                                                    "MOVES", size="xs", c="dimmed"
                                                ),
                                                dmc.Code(
                                                    id="fly-counts",
                                                    children="start: 0 / end: 0",
                                                    style={"fontSize": "11px"},
                                                ),
                                            ],
                                            justify="space-between",
                                        ),
                                        dmc.Code(
                                            id="fly-viewport",
                                            block=True,
                                            style={
                                                "fontSize": "11px",
                                                "minHeight": "70px",
                                            },
                                        ),
                                    ],
                                    gap="xs",
                                ),
                            ),
                            info_panel(
                                "Transition",
                                dmc.Stack(
                                    [
                                        dmc.SegmentedControl(
                                            id="fly-transition",
                                            data=[
                                                {"value": "flyTo", "label": "flyTo"},
                                                {
                                                    "value": "setView",
                                                    "label": "setView",
                                                },
                                                {"value": "panTo", "label": "panTo"},
                                            ],
                                            value="flyTo",
                                            size="xs",
                                            fullWidth=True,
                                        ),
                                        dmc.Stack(
                                            [
                                                dmc.Group(
                                                    [
                                                        dmc.Text(
                                                            "duration",
                                                            size="xs",
                                                            c="dimmed",
                                                        ),
                                                        dmc.Code(
                                                            id="fly-duration-val",
                                                            children="2.5s",
                                                            style={"fontSize": "11px"},
                                                        ),
                                                    ],
                                                    justify="space-between",
                                                ),
                                                dmc.Slider(
                                                    id="fly-duration",
                                                    min=0.5,
                                                    max=6,
                                                    step=0.25,
                                                    value=2.5,
                                                    marks=[
                                                        {"value": v}
                                                        for v in [1, 2, 3, 4, 5, 6]
                                                    ],
                                                ),
                                            ],
                                            gap=2,
                                        ),
                                        dmc.Stack(
                                            [
                                                dmc.Group(
                                                    [
                                                        dmc.Text(
                                                            "easeLinearity",
                                                            size="xs",
                                                            c="dimmed",
                                                        ),
                                                        dmc.Code(
                                                            id="fly-ease-val",
                                                            children="0.25",
                                                            style={"fontSize": "11px"},
                                                        ),
                                                    ],
                                                    justify="space-between",
                                                ),
                                                dmc.Slider(
                                                    id="fly-ease",
                                                    min=0.05,
                                                    max=1.0,
                                                    step=0.05,
                                                    value=0.25,
                                                    marks=[
                                                        {"value": 0.25},
                                                        {"value": 0.5},
                                                        {"value": 0.75},
                                                        {"value": 1.0},
                                                    ],
                                                ),
                                            ],
                                            gap=2,
                                        ),
                                    ],
                                    gap="sm",
                                ),
                            ),
                            info_panel(
                                "Destinations",
                                dmc.SimpleGrid(
                                    cols=2,
                                    spacing="xs",
                                    verticalSpacing="xs",
                                    children=[city_button(c) for c in CITIES],
                                ),
                            ),
                            info_panel(
                                "Bounds transitions",
                                dmc.Stack(
                                    [
                                        dmc.Text(
                                            "These need a bbox, not a center+zoom — the SegmentedControl "
                                            "above doesn't apply.",
                                            size="xs",
                                            c="dimmed",
                                        ),
                                        dmc.Button(
                                            "flyToBounds → Hawaii",
                                            id="fly-hawaii",
                                            color="lime",
                                            variant="light",
                                            size="xs",
                                            fullWidth=True,
                                            leftSection=DashIconify(
                                                icon="twemoji:beach-with-umbrella",
                                                width=16,
                                            ),
                                        ),
                                        dmc.Button(
                                            "fitBounds → Italy (instant)",
                                            id="fly-italy",
                                            color="red",
                                            variant="light",
                                            size="xs",
                                            fullWidth=True,
                                            leftSection=DashIconify(
                                                icon="twemoji:flag-italy", width=16
                                            ),
                                        ),
                                        dmc.Button(
                                            "panInsideBounds → Mediterranean",
                                            id="fly-med",
                                            color="cyan",
                                            variant="light",
                                            size="xs",
                                            fullWidth=True,
                                            leftSection=DashIconify(
                                                icon="twemoji:water-wave", width=16
                                            ),
                                        ),
                                    ],
                                    gap="xs",
                                ),
                            ),
                            info_panel(
                                "Tour",
                                dmc.Stack(
                                    [
                                        # Button shows "Start tour" when idle, "Stop tour" while running —
                                        # state is mirrored from ws-tour-state via the callback below so
                                        # the user can SEE the tour is active without staring at the map.
                                        dmc.Button(
                                            "Start grand tour (7 cities)",
                                            id="fly-tour",
                                            color="violet",
                                            variant="light",
                                            size="xs",
                                            fullWidth=True,
                                            leftSection=DashIconify(
                                                id="fly-tour-icon",
                                                icon="twemoji:globe-with-meridians",
                                                width=16,
                                            ),
                                        ),
                                        # Progress text — "leg 3 / 7: Sydney" while running, hidden otherwise.
                                        dmc.Text(
                                            id="fly-tour-progress",
                                            size="xs",
                                            c="dimmed",
                                            children="",
                                        ),
                                        dmc.Button(
                                            "Home",
                                            id="fly-home",
                                            color="gray",
                                            variant="light",
                                            size="xs",
                                            fullWidth=True,
                                            leftSection=DashIconify(
                                                icon="mdi:home-map-marker", width=16
                                            ),
                                        ),
                                    ],
                                    gap="xs",
                                ),
                            ),
                        ],
                        gap="md",
                    ),
                    span={"base": 12, "md": 4},
                ),
            ]
        ),
        # Tour driver: stepper + tick keep the grand-tour moving from city to city.
        dcc.Store(id="fly-tour-state", data={"running": False, "i": -1}),
        dcc.Interval(id="fly-tour-tick", interval=400, disabled=True),
    ],
    gap="md",
)


# ---- live duration / ease readouts ----------------------------------------
clientside_callback(
    "(v) => `${v.toFixed(2)}s`",
    Output("fly-duration-val", "children"),
    Input("fly-duration", "value"),
)
clientside_callback(
    "(v) => v.toFixed(2)",
    Output("fly-ease-val", "children"),
    Input("fly-ease", "value"),
)


# ---- single-click city: trigger flyTo --------------------------------------
@callback(
    Output("fly-map", "flyTo"),
    Input({"type": "fly-city", "name": dash.ALL}, "n_clicks"),
    State("fly-transition", "value"),
    State("fly-duration", "value"),
    State("fly-ease", "value"),
    State("fly-map", "flyTo"),
    prevent_initial_call=True,
)
def fly_to_city(_, transition, duration, ease, prev):
    triggered = ctx.triggered_id
    if not triggered:
        return no_update
    # Pattern-matching ids fire on initial mount with n_clicks=None — guard so
    # mounting the buttons doesn't immediately fly somewhere.
    if not any(ctx.triggered) or all(
        t.get("value") in (None, 0) for t in ctx.triggered
    ):
        return no_update
    city = next((c for c in CITIES if c["name"] == triggered["name"]), None)
    if not city:
        return no_update
    bump = (prev or {}).get("n_clicks", 0) + 1
    return {
        "transition": transition or "flyTo",
        "center": [city["lat"], city["lng"]],
        "zoom": city["zoom"],
        "options": {"duration": float(duration), "easeLinearity": float(ease)},
        "n_clicks": bump,
    }


# ---- specials: Hawaii (flyToBounds), Home (flyTo), Tour --------------------
@callback(
    Output("fly-map", "flyTo", allow_duplicate=True),
    Input("fly-hawaii", "n_clicks"),
    State("fly-duration", "value"),
    State("fly-ease", "value"),
    State("fly-map", "flyTo"),
    prevent_initial_call=True,
)
def fly_to_hawaii(_, duration, ease, prev):
    bump = (prev or {}).get("n_clicks", 0) + 1
    return {
        "transition": "flyToBounds",
        "bounds": HAWAII_BOUNDS,
        "options": {
            "duration": float(duration),
            "easeLinearity": float(ease),
            "padding": [40, 40],
        },
        "n_clicks": bump,
    }


@callback(
    Output("fly-map", "flyTo", allow_duplicate=True),
    Input("fly-italy", "n_clicks"),
    State("fly-map", "flyTo"),
    prevent_initial_call=True,
)
def fit_italy(_, prev):
    # fitBounds: instant snap (Map.tsx defaults animate:False). Demonstrates the
    # difference from flyToBounds — same bbox-driven framing, no animation.
    bump = (prev or {}).get("n_clicks", 0) + 1
    return {
        "transition": "fitBounds",
        "bounds": ITALY_BOUNDS,
        "options": {"padding": [30, 30]},
        "n_clicks": bump,
    }


@callback(
    Output("fly-map", "flyTo", allow_duplicate=True),
    Input("fly-med", "n_clicks"),
    State("fly-duration", "value"),
    State("fly-ease", "value"),
    State("fly-map", "flyTo"),
    prevent_initial_call=True,
)
def pan_inside_mediterranean(_, duration, ease, prev):
    # panInsideBounds: a no-op if the current view is already inside the bbox,
    # otherwise pans the minimum amount to bring the view inside (zoom is kept).
    # Click this from far away (NYC) to see motion; click again from inside the
    # Med to see nothing happen — that's the intended Leaflet semantic.
    bump = (prev or {}).get("n_clicks", 0) + 1
    return {
        "transition": "panInsideBounds",
        "bounds": MEDITERRANEAN_BOUNDS,
        "options": {"duration": float(duration), "easeLinearity": float(ease)},
        "n_clicks": bump,
    }


@callback(
    Output("fly-map", "flyTo", allow_duplicate=True),
    Input("fly-home", "n_clicks"),
    State("fly-duration", "value"),
    State("fly-ease", "value"),
    State("fly-map", "flyTo"),
    prevent_initial_call=True,
)
def fly_home(_, duration, ease, prev):
    bump = (prev or {}).get("n_clicks", 0) + 1
    return {
        "transition": "flyTo",
        "center": START,
        "zoom": START_ZOOM,
        "options": {"duration": float(duration), "easeLinearity": float(ease)},
        "n_clicks": bump,
    }


# ---- Grand tour ------------------------------------------------------------
# Pressing "Grand tour" arms the loop. The Interval ticks every 400 ms; on
# each tick we check whether we should launch the NEXT leg. We launch when:
#   * no leg is in flight (n_movestart == n_moveend), AND
#   * we have at least one leg already finished since the last launch (so we
#     don't fire two legs back-to-back on the same idle moment).
@callback(
    Output("fly-tour-state", "data"),
    Output("fly-tour-tick", "disabled"),
    Input("fly-tour", "n_clicks"),
    State("fly-tour-state", "data"),
    prevent_initial_call=True,
)
def start_tour(_, st):
    if not _:
        return no_update, no_update
    # Toggle off if already running.
    if (st or {}).get("running"):
        return {"running": False, "i": -1}, True
    return {"running": True, "i": -1}, False


@callback(
    Output("fly-map", "flyTo", allow_duplicate=True),
    Output("fly-tour-state", "data", allow_duplicate=True),
    Output("fly-tour-tick", "disabled", allow_duplicate=True),
    Input("fly-tour-tick", "n_intervals"),
    State("fly-tour-state", "data"),
    State("fly-map", "n_movestart"),
    State("fly-map", "n_moveend"),
    State("fly-map", "flyTo"),
    prevent_initial_call=True,
)
def tour_step(_, st, n_start, n_end, prev):
    st = st or {"running": False, "i": -1}
    if not st.get("running"):
        return no_update, no_update, no_update
    # Wait for current leg to finish.
    if (n_start or 0) > (n_end or 0):
        return no_update, no_update, no_update
    i = st.get("i", -1) + 1
    if i >= len(CITIES):
        return no_update, {"running": False, "i": -1}, True
    city = CITIES[i]
    bump = (prev or {}).get("n_clicks", 0) + 1
    return (
        {
            "transition": "flyTo",
            "center": [city["lat"], city["lng"]],
            "zoom": city["zoom"],
            "options": {"duration": 2.5, "easeLinearity": 0.25},
            "n_clicks": bump,
        },
        {"running": True, "i": i},
        False,
    )


# ---- Tour button: label + icon + per-leg progress text --------------------
# Without this, the tour runs silently — the button never changes appearance,
# so users can't tell whether their click did anything. Mirror the tour state
# (running flag + leg index) into the button label / icon / progress line.
@callback(
    Output("fly-tour", "children"),
    Output("fly-tour", "color"),
    Output("fly-tour-icon", "icon"),
    Output("fly-tour-progress", "children"),
    Input("fly-tour-state", "data"),
)
def tour_button(st):
    st = st or {"running": False, "i": -1}
    if not st.get("running"):
        return (
            "Start grand tour (7 cities)",
            "violet",
            "twemoji:globe-with-meridians",
            "",
        )
    i = st.get("i", -1)
    # While running, i is the index of the leg CURRENTLY in flight (or just
    # completed). Clamp to [0, len) so the readout never shows leg 0 of 7 at -1.
    leg = max(0, min(i, len(CITIES) - 1))
    city = CITIES[leg]["name"]
    return (
        "Stop tour",
        "red",
        "mdi:stop-circle-outline",
        f"leg {leg + 1} of {len(CITIES)}: {city}",
    )


# ---- HUD: flying vs idle, counters, viewport ------------------------------
@callback(
    Output("fly-state", "children"),
    Output("fly-state", "color"),
    Output("fly-counts", "children"),
    Input("fly-map", "n_movestart"),
    Input("fly-map", "n_moveend"),
)
def hud_state(n_start, n_end):
    n_start = n_start or 0
    n_end = n_end or 0
    flying = n_start > n_end
    return (
        ("flying…" if flying else "idle"),
        ("orange" if flying else "gray"),
        f"start: {n_start} / end: {n_end}",
    )


@callback(Output("fly-viewport", "children"), Input("fly-map", "viewport"))
def viewport(vp):
    if not vp:
        return "—"
    c = vp["center"]
    return f"center: [{c[0]:.4f}, {c[1]:.4f}]\nzoom:   {vp['zoom']}"


# ---- light/dark theme sync -------------------------------------------------
register_theme_swap("fly-tile", TILES)
```


---

*Source: /flyto*

---

<!-- /geojson-cluster — https://leaflet.2plot.dev/geojson-cluster/llms.txt -->

# GeoJSON clustering

> SuperCluster-backed point clustering for dl2.GeoJSON — cluster, pointToLayer, clusterToLayer, hideout, superClusterOptions.

---



### Overview

dash-leaflet 1.x lets `GeoJSON` collapse dense point sets into clusters that
expand on zoom. dl2 now does the same — `dl2.GeoJSON(cluster=True, ...)` runs
the [SuperCluster](https://github.com/mapbox/supercluster) index that
dash-leaflet 1.x uses, and accepts the same customization hooks:

| Prop                  | What it does |
|-----------------------|--------------|
| `cluster`             | Turn clustering on/off. |
| `superClusterOptions` | `{radius, minPoints, maxZoom, minZoom, extent}` — tuning passed to SuperCluster. |
| `pointToLayer`        | JS function source `(feature, latlng, ctx) => layer` for individual points. |
| `clusterToLayer`      | JS function source `(feature, latlng, index, ctx) => layer` for cluster bubbles. |
| `hideout`             | `dict` passed to your JS as `ctx.hideout` — color maps, label dicts, anything. |
| `zoomToBoundsOnClick` | Click a cluster to fly the camera to fit its children. |

The JS function source is wrapped in `new Function(...)` at construction
time. `ctx` carries `{ hideout, leaflet, map }` so your function can build
any Leaflet 2 layer without depending on a global.

### Live demo

200 random "vessel positions" around San Diego, CA, colored by category. Pan
out and they collapse into glass bubbles; pan in and they expand. Click a
cluster and you fly to its children's bounding box.


### The shape


**dl2.GeoJSON with clustering**

```python
# File: docs/geojson-cluster/example.py  (region: map)

dl2.GeoJSON(
    id="cl-geo",
    data=POINTS,
    cluster=True,
    superClusterOptions={
        "radius": 80,
        "minPoints": 2,
        "maxZoom": 16,
    },
    zoomToBoundsOnClick=True,
    hideout={"colors": COLORS},
    pointToLayer=POINT_TO_LAYER,
    clusterToLayer=CLUSTER_TO_LAYER,
),
```


### The shape


**dl2.GeoJSON with clustering**

```python
# File: docs/geojson-cluster/example.py  (region: map)

dl2.GeoJSON(
    id="cl-geo",
    data=POINTS,
    cluster=True,
    superClusterOptions={
        "radius": 80,
        "minPoints": 2,
        "maxZoom": 16,
    },
    zoomToBoundsOnClick=True,
    hideout={"colors": COLORS},
    pointToLayer=POINT_TO_LAYER,
    clusterToLayer=CLUSTER_TO_LAYER,
),
```


### Source


```python
# File: docs/geojson-cluster/example.py

"""
GeoJSON clustering — limited working example.

200 random vessel-position points around San Diego, CA. The hideout dict ships a
{category: color} map into the JS pointToLayer so circles paint without a
Python round-trip. Slider on the right tunes superClusterOptions.radius live.
"""

import json
import random

import dash_leaflet2 as dl2
import dash_mantine_components as dmc
from dash import Input, Output, callback, html
from dl2_tiles import OCEAN, register_theme_swap
from dl2_locations import SAN_DIEGO
from dl2_shared import info_panel

# Basemap pair for this page — dl2_tiles owns the light/dark wiring.
TILES = OCEAN

CATEGORIES = ["fishing", "sailing", "ferry", "cargo"]
COLORS = {
    "fishing": "#4dabf7",
    "sailing": "#69db7c",
    "ferry": "#ffd43b",
    "cargo": "#ff8787",
}


def make_points(n=200, seed=42):
    rng = random.Random(seed)
    features = []
    for i in range(n):
        # Scatter in kilometres, not degrees: a fixed degree jitter would
        # produce an east-west-stretched blob at low latitudes and a
        # squashed one up north. +/- 11 km N-S by +/- 13 km E-W.
        lat, lng = SAN_DIEGO.at(
            north_km=(rng.random() - 0.5) * 22.2,
            east_km=(rng.random() - 0.5) * 25.0,
        )
        category = rng.choice(CATEGORIES)
        features.append(
            {
                "type": "Feature",
                "geometry": {"type": "Point", "coordinates": [lng, lat]},
                "properties": {
                    "id": i,
                    "category": category,
                    "name": f"{category.title()} #{i}",
                },
            }
        )
    return {"type": "FeatureCollection", "features": features}


POINTS = make_points()

# JS source — `new Function('return (' + source + ')')()` is called per prop.
POINT_TO_LAYER = """
function (feature, latlng, ctx) {
    var color = (ctx.hideout && ctx.hideout.colors)
        ? ctx.hideout.colors[feature.properties.category] || '#868e96'
        : '#228be6';
    return new ctx.leaflet.CircleMarker(latlng, {
        radius: 6,
        color: color,
        weight: 1.5,
        fillColor: color,
        fillOpacity: 0.85
    });
}
"""

CLUSTER_TO_LAYER = """
function (feature, latlng, index, ctx) {
    var count = feature.properties.point_count;
    var leaves = index.getLeaves(feature.properties.cluster_id, Infinity);
    var counts = {};
    for (var i = 0; i < leaves.length; i++) {
        var c = leaves[i].properties.category;
        counts[c] = (counts[c] || 0) + 1;
    }
    var top = Object.keys(counts).sort(function (a, b) { return counts[b] - counts[a]; })[0];
    var color = (ctx.hideout && ctx.hideout.colors && ctx.hideout.colors[top]) || '#228be6';
    var size = count >= 100 ? 56 : count >= 10 ? 44 : 36;
    var html =
        '<div class="dl2-cluster-bubble" style="background:' + color
        + 'cc;color:#fff;font-weight:700;">' + count + '</div>';
    return new ctx.leaflet.Marker(latlng, {
        icon: new ctx.leaflet.DivIcon({
            html: html,
            className: '',
            iconSize: [size, size],
            iconAnchor: [size / 2, size / 2]
        })
    });
}
"""

component = dmc.Stack(
    [
        dmc.Grid(
            [
                dmc.GridCol(
                    dmc.Paper(
                        dl2.Map(
                            id="cl-map",
                            center=SAN_DIEGO.center,
                            zoom=10,
                            style={"height": "60vh"},
                            children=[
                                dl2.TileLayer(id="cl-tile", **TILES.kwargs("light")),
                                # region map
                                dl2.GeoJSON(
                                    id="cl-geo",
                                    data=POINTS,
                                    cluster=True,
                                    superClusterOptions={
                                        "radius": 80,
                                        "minPoints": 2,
                                        "maxZoom": 16,
                                    },
                                    zoomToBoundsOnClick=True,
                                    hideout={"colors": COLORS},
                                    pointToLayer=POINT_TO_LAYER,
                                    clusterToLayer=CLUSTER_TO_LAYER,
                                ),
                                # endregion
                            ],
                        ),
                        shadow="sm",
                        radius="md",
                        withBorder=True,
                        style={"overflow": "hidden", "height": "60vh"},
                    ),
                    span=8,
                ),
                dmc.GridCol(
                    dmc.Stack(
                        [
                            info_panel(
                                "Cluster radius (px)",
                                dmc.Slider(
                                    id="cl-radius",
                                    min=20,
                                    max=160,
                                    step=10,
                                    value=80,
                                    marks=[
                                        {"value": 20, "label": "20"},
                                        {"value": 80, "label": "80"},
                                        {"value": 160, "label": "160"},
                                    ],
                                ),
                            ),
                            info_panel(
                                "Categories",
                                html.Div(
                                    [
                                        dmc.Group(
                                            [
                                                html.Div(
                                                    style={
                                                        "width": "14px",
                                                        "height": "14px",
                                                        "borderRadius": "50%",
                                                        "background": COLORS[c],
                                                    }
                                                ),
                                                dmc.Text(c.title(), size="sm"),
                                            ],
                                            gap="xs",
                                        )
                                        for c in CATEGORIES
                                    ]
                                ),
                            ),
                            info_panel(
                                "Last click",
                                dmc.Code(id="cl-click-readout", block=True, children="(click a marker or cluster)"),
                            ),
                        ],
                        gap="md",
                    ),
                    span=4,
                ),
            ],
            gutter="md",
        ),
    ],
    gap="md",
)


@callback(Output("cl-geo", "superClusterOptions"), Input("cl-radius", "value"))
def update_radius(r):
    return {"radius": int(r or 80), "minPoints": 2, "maxZoom": 16}


@callback(Output("cl-click-readout", "children"), Input("cl-geo", "clickFeature"))
def show_click(feat):
    if not feat:
        return "(click a marker or cluster)"
    return json.dumps(feat, indent=2)


# Light/dark basemap, driven off the color-scheme store.
register_theme_swap("cl-tile", TILES)
```


---

*Source: /geojson-cluster*

---

<!-- /layer-group — https://leaflet.2plot.dev/layer-group/llms.txt -->

# LayerGroup & FeatureGroup

> Bundle N layers so they can be added, removed, toggled, or measured as one — wraps Leaflet 2's LayerGroup / FeatureGroup.

---



### Overview

`dl2.LayerGroup` and `dl2.FeatureGroup` mirror Leaflet's container primitives:

| Component   | When to use it |
|-------------|----------------|
| `LayerGroup`   | Bundle any layers so a single `addTo` / `remove` shows or hides the whole set. Pair with `dl2.Overlay` inside a `LayersControl` to toggle the entire group as one entry. |
| `FeatureGroup` | Like LayerGroup, but extends `leaflet.FeatureGroup` — also emits a combined `geojson` (vector children), a single `n_clicks` no matter which child was clicked, and an `n_layers` counter that bumps on add/remove. Pair with `EditControl` when you want to ship the user's drawings out of the map as one piece. |

Both components also accept any layer as a child via the same React context that
`<Map>` uses — children attach to the group via a proxy map instead of the real
map directly.

### Live demo

A switch toggles a `LayerGroup` of three markers on/off. Below it, a
`FeatureGroup` aggregates four shapes and reports its combined geojson and
the bumping `n_clicks` counter.


### The shape


**LayerGroup**

```python
# File: docs/layer-group/example.py  (region: layergroup)

return dl2.LayerGroup(
    children=[
        dl2.Marker(position=PHILADELPHIA.center),
        dl2.Marker(position=PHILADELPHIA.at(2.2, 2.9)),
        dl2.Marker(position=PHILADELPHIA.at(-2.2, -2.9)),
    ]
)
```



**FeatureGroup**

```python
# File: docs/layer-group/example.py  (region: featuregroup)

dl2.FeatureGroup(
    id="fg",
    children=[
        dl2.Polygon(
            # (north_km, east_km) from the
            # city centre — the shape keeps its
            # real-world size at any latitude.
            positions=PHILADELPHIA.ring([
                (2.2, -4.9),
                (4.5, 1.0),
                (2.2, 5.9),
                (0.0, 0.0),
            ]),
            color="#228be6",
            fillOpacity=0.35,
        ),
        dl2.Polyline(
            positions=PHILADELPHIA.ring([
                (-3.3, -4.9),
                (-3.3, 1.0),
                (-3.3, 6.9),
            ]),
            color="#fa5252",
            weight=3,
        ),
        dl2.Circle(
            center=PHILADELPHIA.at(1.1, 2.9),
            radius=600,
            color="#40c057",
            fillOpacity=0.25,
        ),
        dl2.Marker(position=PHILADELPHIA.at(-1.1, -1.0)),
    ],
),
```


### Source


```python
# File: docs/layer-group/example.py

"""
LayerGroup & FeatureGroup — limited working example.

Top map: a LayerGroup containing three markers — toggle them all on/off with one
switch (the group itself is conditionally rendered, so all children come and go
together).

Bottom map: a FeatureGroup wrapping three vector layers + a marker. Click any
of them and FeatureGroup's `n_clicks` bumps; the readout shows the combined
GeoJSON it emits.
"""

import json

import dash_leaflet2 as dl2
import dash_mantine_components as dmc
from dash import Input, Output, callback, html
from dl2_tiles import ESRI_CANVAS, register_theme_swap
from dl2_locations import PHILADELPHIA
from dl2_shared import info_panel

# Basemap pair for this page — dl2_tiles owns the light/dark wiring.
TILES = ESRI_CANVAS




component = dmc.Stack(
    [

        dmc.Title("1. LayerGroup", order=3, mt="md"),
        dmc.Grid(
            [
                dmc.GridCol(
                    dmc.Paper(
                        dl2.Map(
                            id="lg-map",
                            center=PHILADELPHIA.center,
                            zoom=12,
                            style={"height": "45vh"},
                            children=[
                                dl2.TileLayer(id="lg-tile", **TILES.kwargs("light")),
                                html.Div(id="lg-container"),
                            ],
                        ),
                        shadow="sm",
                        radius="md",
                        withBorder=True,
                        style={"overflow": "hidden", "height": "45vh"},
                    ),
                    span=8,
                ),
                dmc.GridCol(
                    info_panel(
                        "Toggle the whole group",
                        dmc.Switch(
                            id="lg-toggle",
                            checked=True,
                            label="Show three markers (all in one LayerGroup)",
                        ),
                    ),
                    span=4,
                ),
            ],
            gutter="md",
        ),

        dmc.Title("2. FeatureGroup", order=3, mt="md"),
        dmc.Grid(
            [
                dmc.GridCol(
                    dmc.Paper(
                        dl2.Map(
                            id="fg-map",
                            center=PHILADELPHIA.center,
                            zoom=12,
                            style={"height": "45vh"},
                            children=[
                                dl2.TileLayer(),
                                # region featuregroup
                                dl2.FeatureGroup(
                                    id="fg",
                                    children=[
                                        dl2.Polygon(
                                            # (north_km, east_km) from the
                                            # city centre — the shape keeps its
                                            # real-world size at any latitude.
                                            positions=PHILADELPHIA.ring([
                                                (2.2, -4.9),
                                                (4.5, 1.0),
                                                (2.2, 5.9),
                                                (0.0, 0.0),
                                            ]),
                                            color="#228be6",
                                            fillOpacity=0.35,
                                        ),
                                        dl2.Polyline(
                                            positions=PHILADELPHIA.ring([
                                                (-3.3, -4.9),
                                                (-3.3, 1.0),
                                                (-3.3, 6.9),
                                            ]),
                                            color="#fa5252",
                                            weight=3,
                                        ),
                                        dl2.Circle(
                                            center=PHILADELPHIA.at(1.1, 2.9),
                                            radius=600,
                                            color="#40c057",
                                            fillOpacity=0.25,
                                        ),
                                        dl2.Marker(position=PHILADELPHIA.at(-1.1, -1.0)),
                                    ],
                                ),
                                # endregion
                            ],
                        ),
                        shadow="sm",
                        radius="md",
                        withBorder=True,
                        style={"overflow": "hidden", "height": "45vh"},
                    ),
                    span=8,
                ),
                dmc.GridCol(
                    dmc.Stack(
                        [
                            info_panel(
                                "Group n_clicks",
                                dmc.Badge(id="fg-clicks", color="blue", variant="light", children="0"),
                            ),
                            info_panel(
                                "Combined geojson (vector children)",
                                dmc.Code(
                                    id="fg-geojson-readout",
                                    block=True,
                                    children="(click a shape)",
                                    style={"maxHeight": "20vh", "overflow": "auto"},
                                ),
                            ),
                        ],
                        gap="md",
                    ),
                    span=4,
                ),
            ],
            gutter="md",
        ),
    ],
    gap="md",
)


@callback(Output("lg-container", "children"), Input("lg-toggle", "checked"))
def render_group(show):
    if not show:
        return []
    # region layergroup
    return dl2.LayerGroup(
        children=[
            dl2.Marker(position=PHILADELPHIA.center),
            dl2.Marker(position=PHILADELPHIA.at(2.2, 2.9)),
            dl2.Marker(position=PHILADELPHIA.at(-2.2, -2.9)),
        ]
    )
    # endregion


@callback(Output("fg-clicks", "children"), Input("fg", "n_clicks"))
def show_clicks(n):
    return str(n or 0)


@callback(Output("fg-geojson-readout", "children"), Input("fg", "geojson"))
def show_geojson(gj):
    if not gj:
        return "(no children yet)"
    return json.dumps(gj, indent=2)[:2000]


# Light/dark basemap, driven off the color-scheme store.
register_theme_swap("lg-tile", TILES)
```


---

*Source: /layer-group*

---

<!-- /layers-control — https://leaflet.2plot.dev/layers-control/llms.txt -->

# Layers Control

> the compiled dl2.LayersControl component.

---



### Overview

Demonstrates the real Python API (this is what users would write), not the JS DEMO style
used by the other showcase pages. Two-way: the UI radios/checkboxes write activeBase /
activeOverlays back to Python; Python callbacks also push those props to flip the control.

### Live demo


### The shape


**dl2.LayersControl pattern**

```python
# File: docs/layers-control/example.py  (region: map)

dl2.Map(
    id="lc-map",
    center=MINNEAPOLIS.center,
    zoom=12,
    style={"height": "60vh"},
    children=[
        dl2.LayersControl(
            id="lc",
            position="topright",
            children=[
                dl2.BaseLayer(
                    dl2.TileLayer(
                        url=CARTO_LIGHT, attribution=ATTR
                    ),
                    name="Light",
                    checked=True,
                ),
                dl2.BaseLayer(
                    dl2.TileLayer(
                        url=CARTO_DARK, attribution=ATTR
                    ),
                    name="Dark",
                ),
                dl2.BaseLayer(
                    dl2.TileLayer(url=OSM), name="OSM"
                ),
                dl2.Overlay(
                    dl2.Polygon(
                        # (north_km, east_km) offsets
                        positions=MINNEAPOLIS.ring([
                            (3.3, -4.9),
                            (4.5, 2.9),
                            (-1.1, 4.9),
                            (-2.2, -2.9),
                        ]),
                        color="#2f9e44",
                        fillOpacity=0.25,
                        children=dl2.Tooltip(
                            children="harbor zone"
                        ),
                    ),
                    name="Harbor zone",
                    checked=True,
                ),
                dl2.Overlay(
                    dl2.Circle(
                        center=MINNEAPOLIS.at(-2.2, 1.0),
                        radius=1500,
                        color="#e8590c",
                        fillOpacity=0.2,
                        children=dl2.Tooltip(
                            children="buoy radius"
                        ),
                    ),
                    name="Buoy radius",
                ),
                dl2.Overlay(
                    dl2.GeoJSON(
                        data=SENSORS,
                        style={"color": "#9c36b5", "weight": 2},
                    ),
                    name="Sensors",
                ),
            ],
        ),
    ],
),
```


### Source


```python
# File: docs/layers-control/example.py

"""
Layers Control — the compiled dl2.LayersControl component.

Demonstrates the real Python API (this is what users would write), not the JS DEMO style
used by the other showcase pages. Two-way: the UI radios/checkboxes write activeBase /
activeOverlays back to Python; Python callbacks also push those props to flip the control.
"""

import dash_leaflet2 as dl2
import dash_mantine_components as dmc
from dash import Input, Output, State, callback
from dl2_locations import MINNEAPOLIS
from dl2_shared import info_panel

OSM = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
CARTO_LIGHT = "https://basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png"
CARTO_DARK = "https://basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png"
ATTR = (
    '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap</a> '
    '&copy; <a href="https://carto.com/attributions">CARTO</a>'
)

SENSORS = {
    "type": "FeatureCollection",
    "features": [
        {
            "type": "Feature",
            "properties": {"name": "buoy 1"},
            # GeoJSON is [lon, lat] — the opposite order to Leaflet.
            "geometry": {"type": "Point", "coordinates": MINNEAPOLIS.at_lonlat(2.2, 1.0)},
        },
        {
            "type": "Feature",
            "properties": {"name": "buoy 2"},
            "geometry": {"type": "Point", "coordinates": MINNEAPOLIS.at_lonlat(-1.1, -2.9)},
        },
        {
            "type": "Feature",
            "properties": {"name": "buoy 3"},
            "geometry": {"type": "Point", "coordinates": MINNEAPOLIS.at_lonlat(4.5, -1.0)},
        },
    ],
}


component = dmc.Stack(
    [
        dmc.Grid(
            [
                dmc.GridCol(
                    dmc.Paper(
                        # region map
                        dl2.Map(
                            id="lc-map",
                            center=MINNEAPOLIS.center,
                            zoom=12,
                            style={"height": "60vh"},
                            children=[
                                dl2.LayersControl(
                                    id="lc",
                                    position="topright",
                                    children=[
                                        dl2.BaseLayer(
                                            dl2.TileLayer(
                                                url=CARTO_LIGHT, attribution=ATTR
                                            ),
                                            name="Light",
                                            checked=True,
                                        ),
                                        dl2.BaseLayer(
                                            dl2.TileLayer(
                                                url=CARTO_DARK, attribution=ATTR
                                            ),
                                            name="Dark",
                                        ),
                                        dl2.BaseLayer(
                                            dl2.TileLayer(url=OSM), name="OSM"
                                        ),
                                        dl2.Overlay(
                                            dl2.Polygon(
                                                # (north_km, east_km) offsets
                                                positions=MINNEAPOLIS.ring([
                                                    (3.3, -4.9),
                                                    (4.5, 2.9),
                                                    (-1.1, 4.9),
                                                    (-2.2, -2.9),
                                                ]),
                                                color="#2f9e44",
                                                fillOpacity=0.25,
                                                children=dl2.Tooltip(
                                                    children="harbor zone"
                                                ),
                                            ),
                                            name="Harbor zone",
                                            checked=True,
                                        ),
                                        dl2.Overlay(
                                            dl2.Circle(
                                                center=MINNEAPOLIS.at(-2.2, 1.0),
                                                radius=1500,
                                                color="#e8590c",
                                                fillOpacity=0.2,
                                                children=dl2.Tooltip(
                                                    children="buoy radius"
                                                ),
                                            ),
                                            name="Buoy radius",
                                        ),
                                        dl2.Overlay(
                                            dl2.GeoJSON(
                                                data=SENSORS,
                                                style={"color": "#9c36b5", "weight": 2},
                                            ),
                                            name="Sensors",
                                        ),
                                    ],
                                ),
                            ],
                        ),
                        # endregion
                        shadow="sm",
                        radius="md",
                        withBorder=True,
                        style={"overflow": "hidden"},
                    ),
                    span=8,
                ),
                dmc.GridCol(
                    dmc.Stack(
                        [
                            info_panel(
                                "Active state (map → Python)",
                                dmc.Code(
                                    id="lc-out",
                                    block=True,
                                    style={
                                        "minHeight": "84px",
                                        "whiteSpace": "pre-wrap",
                                    },
                                ),
                            ),
                            info_panel(
                                "Python → control",
                                dmc.Stack(
                                    [
                                        dmc.Text(
                                            "Pick a base from a Python callback:",
                                            size="sm",
                                            c="dimmed",
                                        ),
                                        dmc.Group(
                                            [
                                                dmc.Button(
                                                    "Light",
                                                    id="lc-btn-light",
                                                    size="xs",
                                                    variant="light",
                                                    color="gray",
                                                ),
                                                dmc.Button(
                                                    "Dark",
                                                    id="lc-btn-dark",
                                                    size="xs",
                                                    variant="light",
                                                    color="dark",
                                                ),
                                                dmc.Button(
                                                    "OSM",
                                                    id="lc-btn-osm",
                                                    size="xs",
                                                    variant="light",
                                                    color="blue",
                                                ),
                                            ]
                                        ),
                                        dmc.Text(
                                            "Toggle an overlay from Python:",
                                            size="sm",
                                            c="dimmed",
                                            mt="sm",
                                        ),
                                        dmc.Group(
                                            [
                                                dmc.Button(
                                                    "Toggle Buoy radius",
                                                    id="lc-btn-buoy",
                                                    size="xs",
                                                    variant="light",
                                                    color="orange",
                                                ),
                                                dmc.Button(
                                                    "Toggle Sensors",
                                                    id="lc-btn-sensors",
                                                    size="xs",
                                                    variant="light",
                                                    color="grape",
                                                ),
                                            ]
                                        ),
                                    ],
                                    gap="xs",
                                ),
                            ),
                        ],
                        gap="md",
                    ),
                    span=4,
                ),
            ]
        ),
    ],
    gap="md",
)


# Two-way: map -> Python (state readout) ----------------------------------------
@callback(
    Output("lc-out", "children"),
    Input("lc", "activeBase"),
    Input("lc", "activeOverlays"),
)
def show_active(base, overlays):
    return f"activeBase:\n  {base}\nactiveOverlays:\n  {overlays}"


# Two-way: Python -> map (button-driven base switch) ----------------------------
@callback(
    Output("lc", "activeBase", allow_duplicate=True),
    Input("lc-btn-light", "n_clicks"),
    prevent_initial_call=True,
)
def to_light(_):
    return "Light"


@callback(
    Output("lc", "activeBase", allow_duplicate=True),
    Input("lc-btn-dark", "n_clicks"),
    prevent_initial_call=True,
)
def to_dark(_):
    return "Dark"


@callback(
    Output("lc", "activeBase", allow_duplicate=True),
    Input("lc-btn-osm", "n_clicks"),
    prevent_initial_call=True,
)
def to_osm(_):
    return "OSM"


def _toggle(current, name):
    current = list(current or [])
    return [o for o in current if o != name] if name in current else current + [name]


@callback(
    Output("lc", "activeOverlays", allow_duplicate=True),
    Input("lc-btn-buoy", "n_clicks"),
    State("lc", "activeOverlays"),
    prevent_initial_call=True,
)
def toggle_buoy(_, current):
    return _toggle(current, "Buoy radius")


@callback(
    Output("lc", "activeOverlays", allow_duplicate=True),
    Input("lc-btn-sensors", "n_clicks"),
    State("lc", "activeOverlays"),
    prevent_initial_call=True,
)
def toggle_sensors(_, current):
    return _toggle(current, "Sensors")
```


---

*Source: /layers-control*

---

<!-- /map-pro-props — https://leaflet.2plot.dev/map-pro-props/llms.txt -->

# Map pro props

> minZoom, maxZoom, maxBounds, zoomControl, keyboard — the dash-leaflet 1.x Map options ported to dl2.

---



### Overview

`dl2.Map` previously exposed only `center`, `zoom`, `bearing`, `viewport`,
`preferCanvas`, and `attributionControl`. This release adds the remaining
constraint / interaction props that dash-leaflet 1.x exposed:

| Prop              | What it does |
|-------------------|--------------|
| `minZoom`         | Lower zoom bound applied by the map (largest of map.minZoom and any TileLayer.minZoom wins). |
| `maxZoom`         | Upper zoom bound applied by the map. |
| `maxBounds`       | `[[s,w],[n,e]]` — pan beyond the edges bounces back to the box. |
| `zoomControl`     | Show the built-in `+/-` zoom buttons. Constructor-only. |
| `keyboard`        | Arrow-key panning + `+`/`-` zooming. |
| `dragging`        | Pointer drag-pan. |
| `scrollWheelZoom` | Mouse-wheel zoom. |
| `doubleClickZoom` | Double-click zoom-in. |
| `boxZoom`         | Shift-drag box-zoom selection. |
| `pinchZoom`       | Touch pinch-zoom (v2's name for v1's `touchZoom`). |
| `tapHold`         | Mobile-safari long-press emulation (constructor-only). |

Every interaction-handler prop is two-way and `[MUTABLE]` — a Dash callback can
disable scroll-wheel zoom for a single panel mode, lock dragging while a
walkthrough plays, and so on.

### Live demo


### The shape


**Map with the new pro props**

```python
# File: docs/map-pro-props/example.py  (region: map)

dl2.Map(
    id="mpp-map",
    center=NEW_ORLEANS.center,
    zoom=12,
    minZoom=10,
    maxZoom=18,
    maxBounds=HARBOR_BOUNDS,
    zoomControl=True,
    keyboard=True,
    dragging=True,
    scrollWheelZoom=True,
    doubleClickZoom=True,
    boxZoom=True,
    pinchZoom=True,
    style={"height": "55vh"},
    children=[dl2.TileLayer(id="mpp-tile", **TILES.kwargs("light"))],
),
```


### Source


```python
# File: docs/map-pro-props/example.py

"""
Map pro props — limited working example.

A New Orleans riverfront map. Sliders clamp the user's allowed zoom range, the
SegmentedControl swaps maxBounds on/off (pan past the edges and you'll bounce
back), and six switches at the bottom toggle the interaction handlers
(dragging, scrollWheelZoom, doubleClickZoom, boxZoom, pinchZoom, keyboard)
at runtime — exactly the surface dash-leaflet 1.x exposed.
"""

import dash_leaflet2 as dl2
import dash_mantine_components as dmc
from dash import Input, Output, callback
from dl2_tiles import CYCLE, register_theme_swap
from dl2_locations import NEW_ORLEANS
from dl2_shared import info_panel

# Basemap pair for this page — dl2_tiles owns the light/dark wiring.
TILES = CYCLE

# A ~17 x 20 km box centred on the city — the same real-world size in every
# demo that clamps or drapes something, wherever that demo is set.
HARBOR_BOUNDS = NEW_ORLEANS.bounds(8.35, 9.83)


component = dmc.Stack(
    [
        dmc.Grid(
            [
                dmc.GridCol(
                    dmc.Paper(
                        # region map
                        dl2.Map(
                            id="mpp-map",
                            center=NEW_ORLEANS.center,
                            zoom=12,
                            minZoom=10,
                            maxZoom=18,
                            maxBounds=HARBOR_BOUNDS,
                            zoomControl=True,
                            keyboard=True,
                            dragging=True,
                            scrollWheelZoom=True,
                            doubleClickZoom=True,
                            boxZoom=True,
                            pinchZoom=True,
                            style={"height": "55vh"},
                            children=[dl2.TileLayer(id="mpp-tile", **TILES.kwargs("light"))],
                        ),
                        # endregion
                        shadow="sm",
                        radius="md",
                        withBorder=True,
                        style={"overflow": "hidden", "height": "55vh"},
                    ),
                    span=8,
                ),
                dmc.GridCol(
                    dmc.Stack(
                        [
                            info_panel(
                                "Zoom range",
                                dmc.RangeSlider(
                                    id="mpp-zoom-range",
                                    min=0,
                                    max=22,
                                    step=1,
                                    value=[10, 18],
                                    marks=[
                                        {"value": 0, "label": "0"},
                                        {"value": 22, "label": "22"},
                                    ],
                                ),
                            ),
                            info_panel(
                                "maxBounds (clamp panning)",
                                dmc.SegmentedControl(
                                    id="mpp-bounds",
                                    data=[
                                        {"label": "Harbor box", "value": "harbor"},
                                        {"label": "Unclamped", "value": "off"},
                                    ],
                                    value="harbor",
                                    fullWidth=True,
                                ),
                            ),
                            info_panel(
                                "Interaction handlers",
                                dmc.Stack(
                                    [
                                        dmc.Switch(id="mpp-dragging", checked=True, label="dragging"),
                                        dmc.Switch(id="mpp-scrollwheel", checked=True, label="scrollWheelZoom"),
                                        dmc.Switch(id="mpp-dblclick", checked=True, label="doubleClickZoom"),
                                        dmc.Switch(id="mpp-boxzoom", checked=True, label="boxZoom"),
                                        dmc.Switch(id="mpp-pinchzoom", checked=True, label="pinchZoom"),
                                        dmc.Switch(id="mpp-keyboard", checked=True, label="keyboard"),
                                    ],
                                    gap=4,
                                ),
                            ),
                            info_panel(
                                "Current viewport (read back from Map.viewport)",
                                dmc.Code(id="mpp-viewport-readout", block=True),
                            ),
                        ],
                        gap="md",
                    ),
                    span=4,
                ),
            ],
            gutter="md",
        ),
    ],
    gap="md",
)


@callback(Output("mpp-map", "minZoom"), Output("mpp-map", "maxZoom"), Input("mpp-zoom-range", "value"))
def update_zoom_range(rng):
    if not rng:
        return 10, 18
    return int(rng[0]), int(rng[1])


@callback(Output("mpp-map", "maxBounds"), Input("mpp-bounds", "value"))
def update_bounds(mode):
    return HARBOR_BOUNDS if mode == "harbor" else None


@callback(Output("mpp-map", "keyboard"), Input("mpp-keyboard", "checked"))
def update_keyboard(checked):
    return bool(checked)


@callback(Output("mpp-map", "dragging"), Input("mpp-dragging", "checked"))
def update_dragging(checked):
    return bool(checked)


@callback(Output("mpp-map", "scrollWheelZoom"), Input("mpp-scrollwheel", "checked"))
def update_scrollwheel(checked):
    return bool(checked)


@callback(Output("mpp-map", "doubleClickZoom"), Input("mpp-dblclick", "checked"))
def update_dblclick(checked):
    return bool(checked)


@callback(Output("mpp-map", "boxZoom"), Input("mpp-boxzoom", "checked"))
def update_boxzoom(checked):
    return bool(checked)


@callback(Output("mpp-map", "pinchZoom"), Input("mpp-pinchzoom", "checked"))
def update_pinchzoom(checked):
    return bool(checked)


@callback(Output("mpp-viewport-readout", "children"), Input("mpp-map", "viewport"))
def viewport_readout(vp):
    if not vp:
        return "(no viewport yet)"
    c = vp.get("center", [0, 0])
    return (
        f"center: [{c[0]:.4f}, {c[1]:.4f}]\n"
        f"zoom:   {vp.get('zoom')}\n"
        f"bounds: {vp.get('bounds')}"
    )


# Light/dark basemap, driven off the color-scheme store.
register_theme_swap("mpp-tile", TILES)
```


---

*Source: /map-pro-props*

---

<!-- /minimap — https://leaflet.2plot.dev/minimap/llms.txt -->

# MiniMap

> small overview map pinned to a corner of the main map.

---



### Overview

Demonstrates `dl2.MiniMap`, the native Leaflet 2 replacement for the leaflet-minimap
plugin (Leaflet-1-only). The corner toggle uses the standard class
`leaflet-control-minimap-toggle-display leaflet-control-minimap-toggle-display-<position>`
so existing CSS targeting those hooks keeps working. Two-way `minimized` prop —
the user's click round-trips to Python, and a Python callback can collapse/expand the
minimap from a button.

### Live demo


### The shape


**dl2.MiniMap — placement + two-way toggle**

```python
# File: docs/minimap/example.py  (region: map)

dl2.Map(
    id="mini-map",
    center=PITTSBURGH.center,
    zoom=12,
    style={"height": "62vh"},
    children=[
        dl2.TileLayer(
            id="mini-tile", url=CARTO_LIGHT, attribution=ATTR
        ),
        dl2.Marker(
            position=PITTSBURGH.center,
            iconify="mdi:lighthouse-on",
            iconColor="#e8590c",
            iconSize=32,
            children=dl2.Tooltip(children="Center marker"),
        ),
        dl2.MiniMap(
            id="mini",
            position="bottomright",
            url=MINI_LIGHT,
            width=160,
            height=160,
            zoomLevelOffset=-5,
            toggleDisplay=True,
        ),
    ],
),
```


### Source


```python
# File: docs/minimap/example.py

"""
MiniMap — small overview map pinned to a corner of the main map.

Demonstrates `dl2.MiniMap`, the native Leaflet 2 replacement for the leaflet-minimap
plugin (Leaflet-1-only). The corner toggle uses the standard class
`leaflet-control-minimap-toggle-display leaflet-control-minimap-toggle-display-<position>`
so existing CSS targeting those hooks keeps working. Two-way `minimized` prop —
the user's click round-trips to Python, and a Python callback can collapse/expand the
minimap from a button.
"""

import dash_leaflet2 as dl2
import dash_mantine_components as dmc
from dash import Input, Output, State, callback, clientside_callback
from dash_iconify import DashIconify
from dl2_tiles import ESRI_STREET, POSITRON, register_theme_swap
from dl2_locations import PITTSBURGH
from dl2_shared import info_panel

# TWO different basemaps on purpose. A minimap that renders the same tiles as
# the map above it is just a smaller copy; giving the overview its own, more
# generalised cartography is what makes it useful — you read context from the
# inset and detail from the main map. Both pairs theme independently.
MAIN_TILES = ESRI_STREET      # detailed street cartography
MINI_TILES = POSITRON         # generalised, low-contrast overview

CARTO_LIGHT = MAIN_TILES.url("light")
ATTR = MAIN_TILES.attribution()
MINI_LIGHT = MINI_TILES.url("light")



component = dmc.Stack(
    [
        dmc.Grid(
            [
                dmc.GridCol(
                    dmc.Paper(
                        # region map
                        dl2.Map(
                            id="mini-map",
                            center=PITTSBURGH.center,
                            zoom=12,
                            style={"height": "62vh"},
                            children=[
                                dl2.TileLayer(
                                    id="mini-tile", url=CARTO_LIGHT, attribution=ATTR
                                ),
                                dl2.Marker(
                                    position=PITTSBURGH.center,
                                    iconify="mdi:lighthouse-on",
                                    iconColor="#e8590c",
                                    iconSize=32,
                                    children=dl2.Tooltip(children="Center marker"),
                                ),
                                dl2.MiniMap(
                                    id="mini",
                                    position="bottomright",
                                    url=MINI_LIGHT,
                                    width=160,
                                    height=160,
                                    zoomLevelOffset=-5,
                                    toggleDisplay=True,
                                ),
                            ],
                        ),
                        # endregion
                        shadow="sm",
                        radius="md",
                        withBorder=True,
                        style={"overflow": "hidden"},
                    ),
                    span=8,
                ),
                dmc.GridCol(
                    dmc.Stack(
                        [
                            info_panel(
                                "State",
                                dmc.Stack(
                                    [
                                        dmc.Group(
                                            [
                                                dmc.Text(
                                                    "minimized:", size="sm", c="dimmed"
                                                ),
                                                dmc.Badge(
                                                    id="mini-state",
                                                    color="gray",
                                                    variant="light",
                                                    children="false",
                                                ),
                                            ],
                                            gap="xs",
                                        ),
                                        dmc.Text(
                                            "Drag the main map — the rectangle on the minimap tracks the "
                                            "main viewport bounds.",
                                            size="sm",
                                            c="dimmed",
                                        ),
                                    ],
                                    gap="xs",
                                ),
                            ),
                            info_panel(
                                "Python → control",
                                dmc.Stack(
                                    [
                                        dmc.Text(
                                            "Drive the corner toggle from Python:",
                                            size="sm",
                                            c="dimmed",
                                        ),
                                        dmc.Button(
                                            "Toggle minimap",
                                            id="mini-collapse",
                                            size="xs",
                                            variant="light",
                                            color="green",
                                            leftSection=DashIconify(
                                                icon="mdi:arrow-collapse", width=14
                                            ),
                                        ),
                                    ],
                                    gap="xs",
                                ),
                            ),
                            info_panel(
                                "Reposition",
                                dmc.Stack(
                                    [
                                        dmc.Text(
                                            "Move the minimap to any corner:",
                                            size="sm",
                                            c="dimmed",
                                        ),
                                        dmc.SegmentedControl(
                                            id="mini-position",
                                            data=[
                                                {"value": "topleft", "label": "TL"},
                                                {"value": "topright", "label": "TR"},
                                                {"value": "bottomleft", "label": "BL"},
                                                {"value": "bottomright", "label": "BR"},
                                            ],
                                            value="bottomright",
                                            size="xs",
                                            fullWidth=True,
                                        ),
                                    ],
                                    gap="xs",
                                ),
                            ),
                        ],
                        gap="md",
                    ),
                    span=4,
                ),
            ]
        ),
    ],
    gap="md",
)


# Two-way: minimap → Python (state readout).
@callback(
    Output("mini-state", "children"),
    Output("mini-state", "color"),
    Input("mini", "minimized"),
)
def show_state(m):
    return ("true" if m else "false"), ("orange" if m else "gray")


# Python → minimap: toggle the corner button.
@callback(
    Output("mini", "minimized"),
    Input("mini-collapse", "n_clicks"),
    State("mini", "minimized"),
    prevent_initial_call=True,
)
def toggle_from_python(_, current):
    return not bool(current)


# Python → minimap: move it to a different corner via the SegmentedControl.
@callback(
    Output("mini", "position"),
    Input("mini-position", "value"),
)
def reposition(p):
    return p or "bottomright"


# Light/dark sync — same pattern every showcase page uses. Mirror the app
# color-scheme toggle to BOTH the main tile layer URL and the minimap's URL so
# the inner map theme stays in sync with the outer one.
register_theme_swap("mini-tile", MAIN_TILES)

register_theme_swap("mini", MINI_TILES)
```


---

*Source: /minimap*

---

<!-- /pointer-events — https://leaflet.2plot.dev/pointer-events/llms.txt -->

# Pointer Events

> Leaflet 2's headline change.

---



### Overview

This page demonstrates Pointer Events.

### Live demo


### The v2 pointer API

```javascript
// v2 fires POINTER events; the old mousemove/mousedown are gone.
map.on("pointermove", (e) => {
    const oe = e.originalEvent;        // a native PointerEvent
    console.log(oe.pointerType,        // "mouse" | "pen" | "touch"
                oe.pressure,           // 0..1 (real for a stylus)
                oe.tiltX, oe.tiltY,    // pen tilt
                e.latlng);             // geo coordinate
});
```

### Source


```python
# File: docs/pointer-events/example.py

"""Pointer Events — Leaflet 2's headline change."""

import dash_mantine_components as dmc
from dash import Input, Output, callback, dcc, html
from dl2_shared import info_panel, map_div


component = dmc.Stack(
    [
        dmc.Grid(
            [
                dmc.GridCol(map_div("pointer-events"), span=8),
                dmc.GridCol(
                    dmc.Stack(
                        [
                            info_panel(
                                "Live pointer (clientside HUD)",
                                html.Div(
                                    id="pe-live",
                                    className="dl2-hud",
                                    children="move the pointer over the map…",
                                ),
                            ),
                            info_panel(
                                "Round-tripped to Python",
                                dmc.Stack(
                                    [
                                        dmc.Text(
                                            "Throttled pointermove + every pointerdown reach a @callback:",
                                            size="sm",
                                            c="dimmed",
                                        ),
                                        dmc.Code(
                                            id="pe-py",
                                            block=True,
                                            style={"minHeight": "120px"},
                                        ),
                                    ],
                                    gap="xs",
                                ),
                            ),
                        ],
                        gap="md",
                    ),
                    span=4,
                ),
            ]
        ),
        dcc.Store(id="pe-store"),
    ],
    gap="md",
)


@callback(
    Output("pe-py", "children"), Input("pe-store", "data"), prevent_initial_call=True
)
def show_pointer(d):
    if not d:
        return "—"
    return (
        f"event:       {d['event']}\n"
        f"pointerType: {d['pointerType']}\n"
        f"pressure:    {d['pressure']}\n"
        f"lat, lng:    {d['lat']}, {d['lng']}"
    )
```


---

*Source: /pointer-events*

---

<!-- /privacy — https://leaflet.2plot.dev/privacy/llms.txt -->

# Privacy

> What dash-leaflet2 stores about a visit, what it does not store, and where the numbers go — described from the code that does it.

This page describes what the code in this repository actually does. Each claim
below corresponds to something readable in `lib/analytics_tracker.py`, and the
test suite holds the two together: a key that appears in a stored row and not
on this page fails the build.

## What is stored about a visit

Every request that is not network machinery records one row:

- the **time** of the request;
- the **path** requested;
- a **device type** (desktop, mobile, tablet, bot);
- the **User-Agent** string your browser or client sent;
- a **visitor key** — a keyed one-way hash of your network address and
  User-Agent, truncated, used to tell one visitor from another within the
  retention window;
- a **location**, if and only if the network edge in front of this site sent
  one (see below).

Crawler rows additionally carry the vendor identity the classifier
determined — which bot it was, and whether it verified.

## What is NOT stored

- **Your IP address.** It is read from the request so the site can tell one
  visitor from another, and then reduced to the visitor key and discarded. It
  is not written to disk. (An operator running their own copy of this
  repository can set `ANALYTICS_KEEP_CLIENT_IP=1` to keep it. This site does
  not.)
- **Anything from a third-party lookup service.** Earlier versions of this
  site sent visitor addresses to a geolocation API over plain HTTP. That code
  was **removed** — not disabled — in release 1.6.44. This app makes no
  outbound request about you.
- **Cookies for analytics.** The visitor key is computed per request from what
  your client already sent. Nothing is stored in your browser to track you.
  Signing in sets a session cookie, which is what keeps you signed in.

## Where location comes from

From the network edge, or not at all. Cloudflare sits in front of this site and
adds headers describing where a request entered its network: `CF-IPCountry`
always, and `CF-IPCity`, `CF-Region`, `CF-IPLatitude` and `CF-IPLongitude` when
the zone is configured to send them. Whatever arrives is stored; whatever does
not is simply absent. There is no lookup and no fallback to one.

You can see which of those headers this host is actually receiving — they are
listed in the `geo.headers_seen` field of
[https://leaflet.2plot.dev/healthz](https://leaflet.2plot.dev/healthz).

## The maps on this site

The examples render real maps, which means your browser fetches map tiles
directly from the tile provider shown in each map's attribution. Those requests
go from your browser to that provider and are subject to their privacy policy,
not this one. This site does not proxy them and does not see them.

## Network machinery is counted nowhere

The 2plot network's own traffic — health checks, deploy batteries, link
audits — carries a marker in its User-Agent and is dropped before anything is
recorded. It is not in these numbers, by design.

## How long it is kept, and where it goes

Rows are pruned on a retention window and the file is capped in size. A daily
summary — counts by day, by page, by country, by crawler vendor — is sent to
the 2plot network hub. The summary carries no visitor keys, no addresses and no
User-Agent strings: it is counts.

## Signing in

Sign-in is handled by Clerk. What Clerk stores about an account is governed by
Clerk's own privacy policy. This site keeps the identifier it needs to decide
what you may see.

## Questions

The [Discord](https://discord.gg/e5s5uHWUHH), or an issue on
[the repository](https://github.com/pip-install-python/dash-leaflet2).

---

<!-- /resize-observer — https://leaflet.2plot.dev/resize-observer/llms.txt -->

# ResizeObserver Sizing

> no gray tiles in collapsible layouts.

---



### Overview

This page demonstrates ResizeObserver Sizing.

### Live demo


### It just works

```javascript
// Leaflet 2 observes its container with a ResizeObserver (trackResize,
// default ON). When the container changes size — opening a side panel, a tab,
// an accordion — the map re-renders itself. No more:
//     map.invalidateSize();   // the classic Leaflet 1 dance
const map = new leaflet.Map(el).setView([29.7589, -95.3677], 12);
```

### Source


```python
# File: docs/resize-observer/example.py

"""ResizeObserver Sizing — no gray tiles in collapsible layouts."""

import dash_mantine_components as dmc
from dash import Input, Output, clientside_callback, html
from dl2_shared import info_panel


component = dmc.Stack(
    [
        dmc.Button(
            "Toggle side panel", id="resize-toggle", color="green", variant="light"
        ),
        html.Div(
            id="resize-row",
            className="dl2-resize-row",
            children=[
                html.Div(className="leaflet2-map", **{"data-demo": "resize-observer"}),
                html.Div(
                    className="dl2-resize-panel",
                    children=[
                        dmc.Title("Side panel", order=5),
                        dmc.Text(
                            "Opening/closing me changes the map's width. Watch it reflow "
                            "instantly — no gray tiles, no manual resize call.",
                            size="sm",
                            c="dimmed",
                        ),
                    ],
                ),
            ],
        ),
        info_panel(
            "Why this matters in Dash",
            dmc.Text(
                "Dash layouts lean on tabs, accordions and AppShell panels that hide/resize "
                "content. With dash-leaflet (Leaflet 1) you wire invalidateSize on every "
                "visibility change. With v2 it's automatic.",
                size="sm",
            ),
        ),
    ],
    gap="md",
)

# Toggle the .open class on the flex row; Leaflet's ResizeObserver does the rest.
clientside_callback(
    """
    function(n) {
        var r = document.getElementById('resize-row');
        if (r) { r.classList.toggle('open'); }
        return window.dash_clientside.no_update;
    }
    """,
    Output("resize-toggle", "id"),
    Input("resize-toggle", "n_clicks"),
    prevent_initial_call=True,
)
```


---

*Source: /resize-observer*

---

<!-- /rotation-basic — https://leaflet.2plot.dev/rotation-basic/llms.txt -->

# Basic Rotation

> `bearing` on dl2.Map + dl2.KeyboardControl

---



### Overview

The minimum-viable mirror of dash-leaflet's `rotation_basic.py`, adapted to
the dash-leaflet2 primitives we just added:

  * `dl2.Map(bearing=...)`        — CSS-rotated map pane
  * `dl2.KeyboardControl()`       — arrow keys rotate, Cmd/Ctrl+Arrow pans
  * `dl2.Marker(rotateWithMap=False)` — icon stays in a fixed SCREEN
    orientation (upright) while the map rotates around it. The opposite mode,
    `rotateWithMap=True`, is what flight-sim / walking-sim use for sprites
    that should rotate together with the world.

CAVEAT (documented in dl2.Map's docstring too): this is CSS rotation, not
coordinate-correct rotation. Tiles, markers, polygons render in the right
place visually; click-to-latlng resolution at non-zero bearing is OFF by
the rotation amount because Leaflet's hit-testing doesn't know we rotated.
For a basic showcase + flight/walking sims (camera-follow), this is fine.

### Live demo


### The shape


**Pattern**

```python
# File: docs/rotation-basic/example.py  (region: map)

dl2.Map(
    id="rb-map",
    center=LONDON.center,
    zoom=13,
    bearing=0,
    style={"height": "62vh"},
    children=[
        dl2.TileLayer(
            id="rb-tile", url=TILE_URL, attribution=ATTR
        ),
        dl2.KeyboardControl(
            id="rb-kbd", bearingStep=5, panStep=80
        ),
        dl2.Marker(
            position=[51.505, -0.09],
            iconify="mdi:home",
            iconSize=36,
            iconColor="var(--mantine-color-green-6)",
            # iconAnchor at center (default for iconify is
            # bottom-center; we override here so the icon's
            # visual center IS the geographic point — the icon
            # then rotates in place around its own center).
            iconAnchor=[18, 18],
            rotateWithMap=False,
            children=dl2.Tooltip(
                "London — center pin (rotateWithMap=False, stays upright)",
            ),
        ),
    ],
),
```


### Source


```python
# File: docs/rotation-basic/example.py

"""
Basic Map Rotation — `bearing` on dl2.Map + dl2.KeyboardControl

The minimum-viable mirror of dash-leaflet's `rotation_basic.py`, adapted to
the dash-leaflet2 primitives we just added:

  * `dl2.Map(bearing=...)`        — CSS-rotated map pane
  * `dl2.KeyboardControl()`       — arrow keys rotate, Cmd/Ctrl+Arrow pans
  * `dl2.Marker(rotateWithMap=False)` — icon stays in a fixed SCREEN
    orientation (upright) while the map rotates around it. The opposite mode,
    `rotateWithMap=True`, is what flight-sim / walking-sim use for sprites
    that should rotate together with the world.

CAVEAT (documented in dl2.Map's docstring too): this is CSS rotation, not
coordinate-correct rotation. Tiles, markers, polygons render in the right
place visually; click-to-latlng resolution at non-zero bearing is OFF by
the rotation amount because Leaflet's hit-testing doesn't know we rotated.
For a basic showcase + flight/walking sims (camera-follow), this is fine.
"""

import dash
import dash_leaflet2 as dl2
import dash_mantine_components as dmc
from dash import Input, Output, State, callback, clientside_callback, ctx, dcc, html
from dash_iconify import DashIconify
from dl2_tiles import VOYAGER, register_theme_swap
from dl2_locations import LONDON
from dl2_shared import info_panel

# Basemap pair for this page. dl2_tiles owns the light/dark wiring so
# every example themes the same way — see register_theme_swap below.
TILES = VOYAGER
TILE_URL = TILES.url("light")
ATTR = TILES.attribution()



component = dmc.Stack(
    [
        dmc.Grid(
            [
                dmc.GridCol(
                    dmc.Paper(
                        # region map
                        dl2.Map(
                            id="rb-map",
                            center=LONDON.center,
                            zoom=13,
                            bearing=0,
                            style={"height": "62vh"},
                            children=[
                                dl2.TileLayer(
                                    id="rb-tile", url=TILE_URL, attribution=ATTR
                                ),
                                dl2.KeyboardControl(
                                    id="rb-kbd", bearingStep=5, panStep=80
                                ),
                                dl2.Marker(
                                    position=[51.505, -0.09],
                                    iconify="mdi:home",
                                    iconSize=36,
                                    iconColor="var(--mantine-color-green-6)",
                                    # iconAnchor at center (default for iconify is
                                    # bottom-center; we override here so the icon's
                                    # visual center IS the geographic point — the icon
                                    # then rotates in place around its own center).
                                    iconAnchor=[18, 18],
                                    rotateWithMap=False,
                                    children=dl2.Tooltip(
                                        "London — center pin (rotateWithMap=False, stays upright)",
                                    ),
                                ),
                            ],
                        ),
                        # endregion
                        shadow="sm",
                        radius="md",
                        withBorder=True,
                        style={"overflow": "hidden"},
                    ),
                    span=8,
                ),
                dmc.GridCol(
                    dmc.Stack(
                        [
                            info_panel(
                                "Bearing",
                                dmc.Stack(
                                    [
                                        dmc.Group(
                                            [
                                                dmc.Badge(
                                                    id="rb-bearing-badge",
                                                    color="green",
                                                    variant="light",
                                                    size="lg",
                                                    children="0°",
                                                ),
                                                DashIconify(
                                                    id="rb-compass-icon",
                                                    icon="mdi:compass-outline",
                                                    width=28,
                                                    color="var(--mantine-color-green-6)",
                                                ),
                                            ],
                                            gap="sm",
                                        ),
                                        dmc.Slider(
                                            id="rb-slider",
                                            min=0,
                                            max=359,
                                            step=1,
                                            value=0,
                                            marks=[
                                                {"value": 0, "label": "N"},
                                                {"value": 90, "label": "E"},
                                                {"value": 180, "label": "S"},
                                                {"value": 270, "label": "W"},
                                            ],
                                        ),
                                        dmc.Group(
                                            [
                                                dmc.Button(
                                                    "N",
                                                    id="rb-btn-N",
                                                    size="xs",
                                                    variant="light",
                                                ),
                                                dmc.Button(
                                                    "E",
                                                    id="rb-btn-E",
                                                    size="xs",
                                                    variant="light",
                                                ),
                                                dmc.Button(
                                                    "S",
                                                    id="rb-btn-S",
                                                    size="xs",
                                                    variant="light",
                                                ),
                                                dmc.Button(
                                                    "W",
                                                    id="rb-btn-W",
                                                    size="xs",
                                                    variant="light",
                                                ),
                                                dmc.Button(
                                                    "Reset",
                                                    id="rb-btn-reset",
                                                    size="xs",
                                                    variant="light",
                                                    color="gray",
                                                ),
                                            ],
                                            gap="xs",
                                            grow=True,
                                        ),
                                    ],
                                    gap="sm",
                                ),
                            ),
                            info_panel(
                                "KeyboardControl readout",
                                dmc.Stack(
                                    [
                                        dmc.Group(
                                            [
                                                dmc.Text(
                                                    "Rotations:", size="sm", c="dimmed"
                                                ),
                                                dmc.Badge(
                                                    id="rb-n-rot",
                                                    color="gray",
                                                    variant="light",
                                                    children="0",
                                                ),
                                                dmc.Text(
                                                    "Pans:", size="sm", c="dimmed"
                                                ),
                                                dmc.Badge(
                                                    id="rb-n-pan",
                                                    color="gray",
                                                    variant="light",
                                                    children="0",
                                                ),
                                            ],
                                            gap="xs",
                                        ),
                                        dmc.Code(
                                            id="rb-lastkey",
                                            block=True,
                                            style={
                                                "minHeight": "60px",
                                                "fontSize": "11px",
                                            },
                                        ),
                                    ],
                                    gap="xs",
                                ),
                            ),
                            info_panel(
                                "Viewport (map → Python)",
                                dmc.Code(
                                    id="rb-viewport",
                                    block=True,
                                    style={"fontSize": "11px", "minHeight": "80px"},
                                ),
                            ),
                        ],
                        gap="md",
                    ),
                    span=4,
                ),
            ]
        ),
    ],
    gap="md",
)


# Slider -> map bearing
@callback(Output("rb-map", "bearing"), Input("rb-slider", "value"))
def slider_to_bearing(deg):
    return deg


# Preset buttons -> slider (which then drives bearing).
@callback(
    Output("rb-slider", "value"),
    Input("rb-btn-N", "n_clicks"),
    Input("rb-btn-E", "n_clicks"),
    Input("rb-btn-S", "n_clicks"),
    Input("rb-btn-W", "n_clicks"),
    Input("rb-btn-reset", "n_clicks"),
    prevent_initial_call=True,
)
def preset_to_slider(*_):
    presets = {
        "rb-btn-N": 0,
        "rb-btn-E": 90,
        "rb-btn-S": 180,
        "rb-btn-W": 270,
        "rb-btn-reset": 0,
    }
    return presets.get(ctx.triggered_id, dash.no_update)


# Map bearing (driven by KeyboardControl) -> slider (so the slider tracks
# arrow-key rotation in real time).
@callback(
    Output("rb-slider", "value", allow_duplicate=True),
    Input("rb-map", "viewport"),
    prevent_initial_call=True,
)
def viewport_to_slider(vp):
    return round((vp or {}).get("bearing") or 0)


# Bearing badge + compass-icon visual rotation.
@callback(
    Output("rb-bearing-badge", "children"),
    Output("rb-compass-icon", "style"),
    Input("rb-slider", "value"),
)
def readouts(deg):
    deg = deg or 0
    return f"{deg}°", {
        "transform": f"rotate({deg}deg)",
        "transition": "transform 0.15s",
    }


# Viewport JSON readout.
@callback(Output("rb-viewport", "children"), Input("rb-map", "viewport"))
def viewport_text(vp):
    if not vp:
        return "—"
    return (
        "center: [{lat}, {lng}]\nzoom: {z}\nbearing: {b}°\nbounds: "
        "N {n}  S {s}  E {e}  W {w}"
    ).format(
        lat=vp["center"][0],
        lng=vp["center"][1],
        z=vp["zoom"],
        b=round(vp.get("bearing") or 0),
        n=vp["bounds"]["north"],
        s=vp["bounds"]["south"],
        e=vp["bounds"]["east"],
        w=vp["bounds"]["west"],
    )


# KeyboardControl readout.
@callback(
    Output("rb-n-rot", "children"),
    Output("rb-n-pan", "children"),
    Output("rb-lastkey", "children"),
    Input("rb-kbd", "n_rotations"),
    Input("rb-kbd", "n_pans"),
    Input("rb-kbd", "lastKey"),
)
def kbd_readout(n_rot, n_pan, last_key):
    import json as _json

    return (
        str(n_rot or 0),
        str(n_pan or 0),
        _json.dumps(last_key, indent=1) if last_key else "—",
    )


# Theme sync — mirrors the other showcase pages.
register_theme_swap("rb-tile", TILES)
```


---

*Source: /rotation-basic*

---

<!-- /scale-fullscreen-image — https://leaflet.2plot.dev/scale-fullscreen-image/llms.txt -->

# Scale, FullScreen, ImageOverlay

> Three small controls / overlays that close the remaining dl1 gaps: ScaleControl, FullScreenControl, ImageOverlay.

---



### Overview

Three thin wrappers around Leaflet 2 building blocks that dash-leaflet 1.x exposed
and dl2 didn't yet:

| Component         | Wraps |
|-------------------|-------|
| `ScaleControl`    | `Control.Scale` — metric / imperial scale bar in any corner. |
| `FullScreenControl` | A small `Control` over the browser's `requestFullscreen()` API. Reports `fullscreen` and `n_clicks` back to Dash. |
| `ImageOverlay`    | `ImageOverlay` — drape one image onto a geographic bounding box. Two-way `url`, `bounds`, `opacity`, `zIndex`. With `editable` it gains a TextMarker-style transform control system. |

### Editable ImageOverlay (resize · rotate · move)

Set `editable=True` and the overlay becomes a draggable, resizable, rotatable object — the
same control language as `dl2.TextMarker`:

- **Click** the image to select it (chrome + handles appear).
- **Drag** the body to move it (translates `bounds`).
- **Drag the corner dot** to resize — the bounds scale about the `anchor`, which stays pinned.
  That dot is also the **white anchor marker**: it sits at whichever `anchor` you choose
  (`center` defaults to the bottom-right corner).
- **Drag the top dot** to rotate. Rotation is a CSS-transform visual rotation pivoting at the
  `anchor` — Leaflet's `ImageOverlay` has no native geographic rotation, so `bounds` stay
  axis-aligned and only the rendered pixels turn.

`bounds`, `rotation`, and `selected` round-trip back to Dash (plus an `n_transforms` counter).

### Live demo

A single map with all three pieces: scale bar bottom-left, fullscreen button top-left, and an
editable image overlay — select it, then drag to move, resize from the corner/anchor dot, and
rotate from the top dot. Change the anchor pivot and watch the white dot follow.


### The shape


**Three new pieces in one map**

```python
# File: docs/scale-fullscreen-image/example.py  (region: map)

dl2.Map(
    id="sfi-map",
    center=HONOLULU.center,
    zoom=11,
    style={"height": "60vh"},
    children=[
        dl2.TileLayer(id="sfi-tile", **TILES.kwargs("light")),
        dl2.ScaleControl(
            id="sfi-scale",
            position="bottomleft",
            metric=True,
            imperial=True,
        ),
        dl2.FullScreenControl(
            id="sfi-fs",
            position="topleft",
            title="Enter full screen",
            titleCancel="Leave full screen",
        ),
        dl2.ImageOverlay(
            id="sfi-image",
            url=SAMPLE_IMAGE,
            bounds=OVERLAY_BOUNDS,
            opacity=0.85,
            editable=True,
            selected=True,
            anchor="center",
            rotation=0,
        ),
    ],
),
```


### Source


```python
# File: docs/scale-fullscreen-image/example.py

"""
ScaleControl, FullScreenControl, ImageOverlay — limited working example.

A single map with the scale bar bottom-left, the fullscreen button top-left, and
a sample raster ImageOverlay draped over a Honolulu, HI
bounding box. Right column tweaks each piece live.
"""

import dash_leaflet2 as dl2
import dash_mantine_components as dmc
from dash import Input, Output, callback
from dl2_tiles import NATGEO, register_theme_swap
from dl2_locations import HONOLULU
from dl2_shared import info_panel

# Basemap pair for this page — dl2_tiles owns the light/dark wiring.
TILES = NATGEO

# A ~17 x 20 km box centred on the city. Expressed in kilometres rather than
# degrees so the overlay covers the same ground area at Honolulu's latitude as
# it would anywhere else — a fixed degree box would stretch east-west near the
# equator and squash near the poles.
OVERLAY_BOUNDS = HONOLULU.bounds(8.35, 9.83)
# Public sample image used by Leaflet docs.
SAMPLE_IMAGE = "https://leafletjs.com/examples/crs-simple/uqm_map_full.png"
SAMPLE_IMAGE_2 = "https://maps.lib.utexas.edu/maps/historical/texas_southern_1895.jpg"

ANCHORS = [
    "top-left", "top", "top-right",
    "left", "center", "right",
    "bottom-left", "bottom", "bottom-right",
]


component = dmc.Stack(
    [
        dmc.Grid(
            [
                dmc.GridCol(
                    dmc.Paper(
                        # region map
                        dl2.Map(
                            id="sfi-map",
                            center=HONOLULU.center,
                            zoom=11,
                            style={"height": "60vh"},
                            children=[
                                dl2.TileLayer(id="sfi-tile", **TILES.kwargs("light")),
                                dl2.ScaleControl(
                                    id="sfi-scale",
                                    position="bottomleft",
                                    metric=True,
                                    imperial=True,
                                ),
                                dl2.FullScreenControl(
                                    id="sfi-fs",
                                    position="topleft",
                                    title="Enter full screen",
                                    titleCancel="Leave full screen",
                                ),
                                dl2.ImageOverlay(
                                    id="sfi-image",
                                    url=SAMPLE_IMAGE,
                                    bounds=OVERLAY_BOUNDS,
                                    opacity=0.85,
                                    editable=True,
                                    selected=True,
                                    anchor="center",
                                    rotation=0,
                                ),
                            ],
                        ),
                        # endregion
                        shadow="sm",
                        radius="md",
                        withBorder=True,
                        style={"overflow": "hidden", "height": "60vh"},
                    ),
                    span=8,
                ),
                dmc.GridCol(
                    dmc.Stack(
                        [
                            info_panel(
                                "Scale bar",
                                dmc.Stack(
                                    [
                                        dmc.SegmentedControl(
                                            id="sfi-scale-pos",
                                            data=[
                                                {"label": "BL", "value": "bottomleft"},
                                                {"label": "BR", "value": "bottomright"},
                                                {"label": "TL", "value": "topleft"},
                                                {"label": "TR", "value": "topright"},
                                            ],
                                            value="bottomleft",
                                            fullWidth=True,
                                        ),
                                        dmc.Switch(id="sfi-scale-metric", checked=True, label="metric"),
                                        dmc.Switch(id="sfi-scale-imperial", checked=True, label="imperial"),
                                    ],
                                    gap="xs",
                                ),
                            ),
                            info_panel(
                                "Fullscreen",
                                dmc.Stack(
                                    [
                                        dmc.Badge(id="sfi-fs-state", color="gray", variant="light", children="windowed"),
                                        dmc.Text(id="sfi-fs-clicks", size="xs", c="dimmed"),
                                    ],
                                    gap="xs",
                                ),
                            ),
                            info_panel(
                                "ImageOverlay opacity",
                                dmc.Slider(
                                    id="sfi-image-opacity",
                                    min=0,
                                    max=1,
                                    step=0.05,
                                    value=0.6,
                                ),
                            ),
                            info_panel(
                                "ImageOverlay source",
                                dmc.SegmentedControl(
                                    id="sfi-image-url",
                                    data=[
                                        {"label": "UQM sample", "value": SAMPLE_IMAGE},
                                        {"label": "1895 TX scan", "value": SAMPLE_IMAGE_2},
                                    ],
                                    value=SAMPLE_IMAGE,
                                    fullWidth=True,
                                ),
                            ),
                            info_panel(
                                "Transform (anchor pivot)",
                                dmc.Stack(
                                    [
                                        dmc.SegmentedControl(
                                            id="sfi-anchor",
                                            data=[{"label": a, "value": a} for a in ANCHORS],
                                            value="center",
                                            orientation="vertical",
                                            fullWidth=True,
                                            size="xs",
                                        ),
                                        dmc.Text("Rotation", size="xs", c="dimmed"),
                                        dmc.Slider(
                                            id="sfi-rot", min=-180, max=180, value=0,
                                            marks=[{"value": 0, "label": "0°"}],
                                        ),
                                        dmc.Code(id="sfi-image-out", block=True, children="…"),
                                    ],
                                    gap="xs",
                                ),
                            ),
                        ],
                        gap="md",
                    ),
                    span=4,
                ),
            ],
            gutter="md",
        ),
    ],
    gap="md",
)


@callback(Output("sfi-scale", "position"), Input("sfi-scale-pos", "value"))
def scale_pos(v):
    return v or "bottomleft"


@callback(Output("sfi-scale", "metric"), Input("sfi-scale-metric", "checked"))
def scale_metric(checked):
    return bool(checked)


@callback(Output("sfi-scale", "imperial"), Input("sfi-scale-imperial", "checked"))
def scale_imperial(checked):
    return bool(checked)


@callback(Output("sfi-fs-state", "children"), Output("sfi-fs-state", "color"), Input("sfi-fs", "fullscreen"))
def fs_state(is_full):
    if is_full:
        return "fullscreen", "blue"
    return "windowed", "gray"


@callback(Output("sfi-fs-clicks", "children"), Input("sfi-fs", "n_clicks"))
def fs_clicks(n):
    return f"button clicks: {n or 0}"


@callback(Output("sfi-image", "opacity"), Input("sfi-image-opacity", "value"))
def image_opacity(v):
    return float(v or 0)


@callback(Output("sfi-image", "url"), Input("sfi-image-url", "value"))
def image_url(v):
    return v or SAMPLE_IMAGE


@callback(Output("sfi-image", "anchor"), Input("sfi-anchor", "value"))
def image_anchor(v):
    return v or "center"


@callback(Output("sfi-image", "rotation"), Input("sfi-rot", "value"))
def image_rotation(v):
    return int(v or 0)


@callback(
    Output("sfi-image-out", "children"),
    Input("sfi-image", "bounds"),
    Input("sfi-image", "rotation"),
    Input("sfi-image", "n_transforms"),
)
def image_readback(bounds, rotation, n):
    b = bounds or OVERLAY_BOUNDS
    return (
        f"rotation: {rotation or 0}°\n"
        f"bounds  : [[{b[0][0]:.4f}, {b[0][1]:.4f}],\n"
        f"           [{b[1][0]:.4f}, {b[1][1]:.4f}]]\n"
        f"n_transforms: {n or 0}"
    )


# Light/dark basemap, driven off the color-scheme store.
register_theme_swap("sfi-tile", TILES)
```


---

*Source: /scale-fullscreen-image*

---

<!-- /subclassing — https://leaflet.2plot.dev/subclassing/llms.txt -->

# ES6 Subclassing

> custom Control + a BlanketOverlay canvas layer.

---



### Overview

This page demonstrates ES6 Subclassing.

### Live demo


### Extending Leaflet 2 with ES6 classes

```javascript
// v2 uses standard ES6 classes — no more L.Class.extend.

class CenterControl extends leaflet.Control {        // custom control
    onAdd(map) {
        const div = leaflet.DomUtil.create("div", "dl2-ctl");
        map.on("move zoom", () => div.textContent = map.getCenter());
        return div;
    }
}
new CenterControl({position: "topright"}).addTo(map);

class GlowLayer extends leaflet.BlanketOverlay {     // custom canvas renderer
    _initContainer() { this._container = leaflet.DomUtil.create("canvas"); }
    _onSettled() {                                   // re-paint after each settle
        for (const p of this._points) {
            const lp = this._map.latLngToLayerPoint([p.lat, p.lng]);
            // …draw a radial glow at (lp - this._bounds.min)…
        }
    }
}
new GlowLayer(sensors).addTo(map);
```

### Source


```python
# File: docs/subclassing/example.py

"""ES6 Subclassing — custom Control + a BlanketOverlay canvas layer."""

import dash_mantine_components as dmc
from dl2_shared import info_panel, map_div


component = dmc.Stack(
    [
        map_div("subclassing"),
        dmc.Grid(
            [
                dmc.GridCol(
                    info_panel(
                        "Custom Control",
                        dmc.Text(
                            "class CenterControl extends leaflet.Control — a plain ES6 subclass "
                            "overriding onAdd(). Watch the top-right box update as you pan/zoom.",
                            size="sm",
                        ),
                    ),
                    span=6,
                ),
                dmc.GridCol(
                    info_panel(
                        "BlanketOverlay layer",
                        dmc.Text(
                            "class GlowLayer extends leaflet.BlanketOverlay paints 40 geo-anchored "
                            "sensor glows onto one <canvas>. _onSettled() re-projects them on every "
                            "pan/zoom — the hook for WebGL/canvas overlays without fighting SVG.",
                            size="sm",
                        ),
                    ),
                    span=6,
                ),
            ]
        ),
    ],
    gap="md",
)
```


---

*Source: /subclassing*

---

<!-- /terms — https://leaflet.2plot.dev/terms/llms.txt -->

# Terms of Use

> Terms of use for dash-leaflet2: what this site is, what it is not, and the terms the documentation and its code examples are offered under.

## What this site is

dash-leaflet2 is documentation, published by Pip Install Python LLC. It documents a
component library that wraps **Leaflet 2.0.0-alpha.1** for Dash, and the
live examples on it are demonstrations of that library. It is reference
material: it is not advice, it is not a service, and nothing on it is a
commitment to keep any particular behaviour working in your project.

Leaflet 2 is itself an **alpha**. This library tracks it, so the components
documented here will change while it does, and pages describing them may
describe a version you are not running.

## The documentation and the code in it

The prose and the code examples are published so you can read them, run them
and copy them. The source repository states the licence the code is offered
under, and that licence — not this page — is what governs your use of it:

- [https://github.com/pip-install-python/dash-leaflet2](https://github.com/pip-install-python/dash-leaflet2)

Everything here is offered **as is**, without warranty of any kind. Running a
code example against your own data, in your own deployment, is your decision
and your responsibility. The maps on this site load tiles from third-party
providers under their own terms; the attribution shown on each map names them.

## Accounts

Some pages may be gated behind a sign-in. An account exists so the site can
tell whether you may see a page; it is not a subscription and carries no
entitlement. Accounts may be ended at any time, by you or by us, and the
[Privacy](/privacy) page describes what is kept while one exists.

## Links to other sites

This site links to other sites in the 2plot network, to Leaflet's own
documentation, and to third-party projects. Those sites have their own terms
and their own privacy practices, and this page does not speak for them.

## Changes

These terms change when the site does. The change history for the whole site,
including this page, is the [Changelog](/changelog) and the repository's commit
history — there is no separate archive of previous versions, because the
repository already is one.

## Contact

Questions about these terms: the [Discord](https://discord.gg/e5s5uHWUHH) or an issue on
[the repository](https://github.com/pip-install-python/dash-leaflet2).

---

<!-- /text-marker — https://leaflet.2plot.dev/text-marker/llms.txt -->

# TextMarker

> Editable, draggable, styleable text placed on the map like a Marker — drag to move, double-click to edit, on-canvas resize/rotate handles, and a style toolbar; round-trips position/text/rotation/fontSize/color back to Dash.

---



### Overview

`dl2.TextMarker` is editable, draggable, styleable text anchored to a `[lat, lng]` — used
exactly like a `dl2.Marker`. Under the hood it is a Leaflet 2 `Marker` whose icon is a
content-sized, optionally-`contentEditable` text box, so it pans and zooms with the basemap and
behaves like a first-class map feature. It closes the "captions can't live on the map" gap from
the `text-caption-marker-proposal` hand-off.

| Interaction | Result |
|---|---|
| **Drag** the label | moves it; writes `position` back (+ bumps `n_drags`) |
| **Double-click** | inline-edit the text; commit on blur / Enter writes `text` back (+ `n_edits`) |
| **Resize handle** (corner) | scales `fontSize` |
| **Rotate handle** (top) | sets `rotation` (hold Shift to snap to 15°) |
| **Style toolbar** (while selected) | font family / size, bold / italic, text & background color, rotation — each round-trips to Dash |
| Click the label / empty map | toggles `selected` (two-way, so a host can drive selection) |

When `position` is omitted the label spawns at the center of the current viewport and writes
that position back, so you can drop a caption with no coordinates and read where it landed.

The white **anchor dot** (which also doubles as the resize grip) is drawn at the chosen
`anchor` — pick `bottom` and it sits at the bottom-center, `top-left` and it sits at the
top-left, etc. — so you can always see where the label is pinned. `center` is special-cased
to the bottom-right corner so the dot never covers the text.

Two size models via `scaleWithZoom`:

- `False` (default) — a constant screen-size HUD caption: `fontSize` is literal px at every
  zoom (like a Tooltip).
- `True` — geographic sizing: the on-screen size grows/shrinks by `2^(zoom − referenceZoom)`,
  so the caption keeps a fixed *ground* footprint as the camera flies (like a polygon's edge).

### Live demo

Drag the "Fisherman's Wharf" caption, double-click to retype it, and use the on-canvas
handles + glass toolbar — or drive every prop from the right column. The red "PIER 39" label
has `scaleWithZoom=True`, so zoom in/out to watch it hold its ground size. The `T` tool in the
top-right toolbar is the `EditControl` `text` tool (Route B): click it, click the map, and type —
the caption is added to `EditControl.geojson` as a `kind:"text"` Point.


### Route B — the EditControl `text` tool

`dl2.EditControl` gains a `text` tool alongside `marker` / `polyline` / `polygon` / …. Picking it
and clicking the map drops an inline-editable caption that participates in the same `geojson`
round-trip as every other shape — a GeoJSON `Point` carrying the caption + style in
`properties`:

```json
{
  "type": "Feature",
  "geometry": { "type": "Point", "coordinates": [-122.41, 37.808] },
  "properties": {
    "kind": "text", "text": "Fisherman's Wharf",
    "color": "#111827", "fontSize": 18,
    "fontFamily": "system-ui, sans-serif", "fontWeight": 600
  }
}
```

In edit mode the caption is draggable and double-click re-opens the inline editor. Enable it
per-tool with `draw={"text": True}` (and disable the others to get a text-only toolbar).

### The shape


**A caption that places, styles, and round-trips like a Marker**

```python
# File: docs/text-marker/example.py  (region: minimal)

def _map():
    return dl2.Map(
        id="tm-map",
        center=CENTER,
        zoom=14,
        style={"height": "62vh"},
        children=[
            dl2.TileLayer(id="tm-tile", url=TILE_URL, attribution=ATTR),
            # A caption you place like a Marker: drag to move, double-click
            # to edit, and (when selected) resize / rotate with the
            # on-canvas handles.
            dl2.TextMarker(
                id="tm-cap",
                text="Fisherman's Wharf",
                position=CENTER,
                color="#0b3d66",
                fontSize=26,
                fontWeight=700,
                backgroundColor="rgba(255,255,255,0.6)",
                anchor="center",
                selected=True,
            ),
            # scaleWithZoom keeps a fixed GROUND size, so it grows on
            # screen as you zoom in.
            dl2.TextMarker(
                id="tm-geo",
                text="PIER 39",
                position=[37.8087, -122.4098],
                color="#c92a2a",
                fontSize=16,
                fontWeight=700,
                scaleWithZoom=True,
            ),
            # Route B — the EditControl 'text' tool: click the T, click the
            # map, type. Captions serialize as kind:"text" Point features in
            # EditControl.geojson.
            dl2.EditControl(
                id="tm-edit",
                position="topright",
                # Only expose the text tool here to keep the demo focused.
                draw={
                    "marker": False, "polyline": False, "polygon": False,
                    "rectangle": False, "circle": False, "circlemarker": False,
                    "text": True,
                },
            ),
        ],
    )
```


### Source


```python
# File: docs/text-marker/example.py

"""
TextMarker — editable, draggable, styleable map captions (limited working example).

The map hosts one selected `dl2.TextMarker` you can drag, double-click to edit, and
restyle with the on-canvas resize / rotate handles + the contextual toolbar — OR drive
every prop from the right column. A second caption has `scaleWithZoom=True` so it keeps a
fixed ground footprint as you zoom. The `EditControl`'s `text` tool (Route B) is wired in
too: pick the **T** tool, click the map, and type — the caption round-trips through the
same `geojson` channel as every drawn shape, as a `kind:"text"` Point.
"""

import dash_leaflet2 as dl2
import dash_mantine_components as dmc
from dash import Input, Output, callback, clientside_callback
from dl2_tiles import SATELLITE, register_theme_swap
from dl2_locations import SAN_FRANCISCO
from dl2_shared import info_panel

# Basemap pair for this page. dl2_tiles owns the light/dark wiring so
# every example themes the same way — see register_theme_swap below.
TILES = SATELLITE
TILE_URL = TILES.url("light")
ATTR = TILES.attribution()

CENTER = SAN_FRANCISCO.at(1.4, -1.3)  # Fisherman's Wharf

ANCHORS = [
    "top-left", "top", "top-right",
    "left", "center", "right",
    "bottom-left", "bottom", "bottom-right",
]



# region minimal
def _map():
    return dl2.Map(
        id="tm-map",
        center=CENTER,
        zoom=14,
        style={"height": "62vh"},
        children=[
            dl2.TileLayer(id="tm-tile", url=TILE_URL, attribution=ATTR),
            # A caption you place like a Marker: drag to move, double-click
            # to edit, and (when selected) resize / rotate with the
            # on-canvas handles.
            dl2.TextMarker(
                id="tm-cap",
                text="Fisherman's Wharf",
                position=CENTER,
                color="#0b3d66",
                fontSize=26,
                fontWeight=700,
                backgroundColor="rgba(255,255,255,0.6)",
                anchor="center",
                selected=True,
            ),
            # scaleWithZoom keeps a fixed GROUND size, so it grows on
            # screen as you zoom in.
            dl2.TextMarker(
                id="tm-geo",
                text="PIER 39",
                position=[37.8087, -122.4098],
                color="#c92a2a",
                fontSize=16,
                fontWeight=700,
                scaleWithZoom=True,
            ),
            # Route B — the EditControl 'text' tool: click the T, click the
            # map, type. Captions serialize as kind:"text" Point features in
            # EditControl.geojson.
            dl2.EditControl(
                id="tm-edit",
                position="topright",
                # Only expose the text tool here to keep the demo focused.
                draw={
                    "marker": False, "polyline": False, "polygon": False,
                    "rectangle": False, "circle": False, "circlemarker": False,
                    "text": True,
                },
            ),
        ],
    )
# endregion


component = dmc.Stack(
    [
        dmc.Grid(
            [
                dmc.GridCol(
                    dmc.Paper(
                        _map(),
                        shadow="sm",
                        radius="md",
                        withBorder=True,
                        style={"overflow": "hidden", "height": "62vh"},
                    ),
                    span=8,
                ),
                dmc.GridCol(
                    dmc.Stack(
                        [
                            info_panel(
                                "Caption text",
                                dmc.TextInput(id="tm-text", value="Fisherman's Wharf"),
                            ),
                            info_panel(
                                "Typography",
                                dmc.Stack(
                                    [
                                        dmc.ColorInput(id="tm-color", value="#0b3d66", format="hex"),
                                        dmc.Text("Font size", size="xs", c="dimmed"),
                                        dmc.Slider(id="tm-size", min=10, max=64, value=26),
                                        dmc.Text("Rotation", size="xs", c="dimmed"),
                                        dmc.Slider(
                                            id="tm-rot", min=-180, max=180, value=0,
                                            marks=[{"value": 0, "label": "0°"}],
                                        ),
                                    ],
                                    gap="xs",
                                ),
                            ),
                            info_panel(
                                "Anchor",
                                dmc.SegmentedControl(
                                    id="tm-anchor",
                                    data=[{"label": a, "value": a} for a in ANCHORS],
                                    value="center",
                                    orientation="vertical",
                                    fullWidth=True,
                                    size="xs",
                                ),
                            ),
                            info_panel(
                                "Geographic sizing",
                                dmc.Switch(
                                    id="tm-scale",
                                    label="scaleWithZoom (fixed ground size)",
                                    checked=False,
                                ),
                            ),
                            info_panel(
                                "Live readback",
                                dmc.Code(id="tm-out", block=True, children="…"),
                            ),
                        ],
                        gap="md",
                    ),
                    span=4,
                ),
            ],
            gutter="md",
        ),
    ],
    gap="md",
)


# ---- controls -> component (one-way drivers; the on-map UI is the other path) ----------
@callback(Output("tm-cap", "text"), Input("tm-text", "value"))
def _text(v):
    return v or ""


@callback(Output("tm-cap", "color"), Input("tm-color", "value"))
def _color(v):
    return v or "#0b3d66"


@callback(Output("tm-cap", "fontSize"), Input("tm-size", "value"))
def _size(v):
    return int(v or 26)


@callback(Output("tm-cap", "rotation"), Input("tm-rot", "value"))
def _rot(v):
    return int(v or 0)


@callback(Output("tm-cap", "anchor"), Input("tm-anchor", "value"))
def _anchor(v):
    return v or "center"


@callback(Output("tm-cap", "scaleWithZoom"), Input("tm-scale", "checked"))
def _scale(checked):
    return bool(checked)


# ---- component -> readback panel -------------------------------------------------------
@callback(
    Output("tm-out", "children"),
    Input("tm-cap", "position"),
    Input("tm-cap", "text"),
    Input("tm-cap", "rotation"),
    Input("tm-cap", "fontSize"),
    Input("tm-cap", "n_edits"),
    Input("tm-cap", "n_drags"),
    Input("tm-edit", "geojson"),
)
def _readback(pos, text, rot, size, n_edits, n_drags, geo):
    n_captions = sum(
        1
        for f in (geo or {}).get("features", [])
        if (f.get("properties") or {}).get("kind") == "text"
    )
    return (
        f"position : {pos}\n"
        f"text     : {text!r}\n"
        f"rotation : {rot}\n"
        f"fontSize : {size}\n"
        f"n_edits  : {n_edits or 0}\n"
        f"n_drags  : {n_drags or 0}\n"
        f"edit-tool captions: {n_captions}"
    )


# ---- light/dark tile swap (standard pattern) ------------------------------------------
register_theme_swap("tm-tile", TILES)
```


---

*Source: /text-marker*

---

<!-- /tile-layers-pro — https://leaflet.2plot.dev/tile-layers-pro/llms.txt -->

# Tile Layers (Pro)

> EasyButton + Popover + MultiSelect + TreeViewPro.

---



### Overview

The SailsBoard `create_layers_card` pattern, rebuilt against dl2 and the
no-key, no-payment tile catalog ported into `pages/_tile_catalog.py`.

  * Click the EasyButton (📚 top-left) → DMC Popover opens beside it.
  * Inside the popover:
      - `dmc.MultiSelect` lists every provider in the catalog, each row
        rendered with a tiny rotating cube preview (renderTileCubeFace
        in assets/tile_cube.js).
      - `dash_mui_charts.TreeViewPro` shows the active stack — one
        "Active tilesets" group, one leaf per slug, with an opacity
        slider and a kebab menu (Show info / Remove layer).
  * Single source of truth: `dcc.Store(id="tlp-state")` shaped
        `{"order": [slug, …], "sliders": {slug: 0..100}}`.

Add / remove / reorder / opacity changes all rewrite the Store; one
output callback derives the Map's `LayerGroup` children + the
`renderoption` cube payload from it.

The base layer (`esri_world_imagery`) is pinned at the bottom of the
stack and excluded from the MultiSelect — kebab "Remove layer" is a
no-op on it, so the map can never end up empty.

### Live demo


### The shape


**Single-store pattern**

```python
# File: docs/tile-layers-pro/example.py  (region: map)

dl2.Map(
    id="tlp-map",
    center=NASHVILLE.center,
    zoom=5,
    style={"height": "70vh"},
    attributionControl=True,  # let layer attributions surface in the corner
    children=[
        # TileLayers find the map via React context, so any
        # container that keeps them mounted as children works
        # — no need for a Leaflet-side LayerGroup.
        html.Div(
            id="tlp-stack",
            children=build_layer_group_children(
                INITIAL_ORDER,
                INITIAL_SLIDERS,
            ),
        ),
        dl2.EasyButton(
            id="tlp-open",
            position="topleft",
            icon="mdi:layers-triple-outline",
            iconSize=20,
            title="Layers",
        ),
    ],
),
```


### Source


```python
# File: docs/tile-layers-pro/example.py

"""
/tile-layers-pro — EasyButton + Popover + MultiSelect + TreeViewPro.

The SailsBoard `create_layers_card` pattern, rebuilt against dl2 and the
no-key, no-payment tile catalog ported into `pages/_tile_catalog.py`.

  * Click the EasyButton (📚 top-left) → DMC Popover opens beside it.
  * Inside the popover:
      - `dmc.MultiSelect` lists every provider in the catalog, each row
        rendered with a tiny rotating cube preview (renderTileCubeFace
        in assets/tile_cube.js).
      - `dash_mui_charts.TreeViewPro` shows the active stack — one
        "Active tilesets" group, one leaf per slug, with an opacity
        slider and a kebab menu (Show info / Remove layer).
  * Single source of truth: `dcc.Store(id="tlp-state")` shaped
        `{"order": [slug, …], "sliders": {slug: 0..100}}`.

Add / remove / reorder / opacity changes all rewrite the Store; one
output callback derives the Map's `LayerGroup` children + the
`renderoption` cube payload from it.

The base layer (`esri_world_imagery`) is pinned at the bottom of the
stack and excluded from the MultiSelect — kebab "Remove layer" is a
no-op on it, so the map can never end up empty.
"""

from __future__ import annotations

import json
import os
from typing import Any

import dash_leaflet2 as dl2
import dash_mantine_components as dmc
from dash import (
    Input,
    Output,
    State,
    callback,
    clientside_callback,
    ctx,
    dcc,
    html,
    no_update,
)
from dash_iconify import DashIconify
from dash_mui_charts import TreeViewPro
from dl2_locations import NASHVILLE
from dl2_shared import info_panel
from _tile_catalog import (
    DEFAULT_BASE_SLUG,
    PROVIDER_OPTIONS_GROUPED,
    PROVIDERS,
    renderoption_payload,
    tile_kwargs,
)


# MUI X Pro license key — read at import time (load_dotenv() in app.py
# pulls .env into the process before this module loads). Without a
# license key TreeViewPro renders without drag/slider/kebab; we surface
# the missing-key state in a Badge so the user knows.
LICENSE_KEY = os.environ.get("MUI_PRO_API_KEY", "")

# Initial active stack — Esri World Imagery on the bottom, a couple of
# user-friendly overlays seeded on top so the page lands rich.
INITIAL_ORDER: list[str] = [
    "overlay_esri_reference",
    "carto_voyager",
    DEFAULT_BASE_SLUG,
]
INITIAL_SLIDERS: dict[str, int] = {
    DEFAULT_BASE_SLUG: 100,
    "carto_voyager": 95,
    "overlay_esri_reference": 90,
}


# Default opacity (0–100) when a user adds a new layer through the
# MultiSelect — overlays land at 85 so the layer underneath stays
# visible; bases land at 100.
def _default_opacity(slug: str) -> int:
    provider = PROVIDERS.get(slug, {})
    return 100 if provider.get("kind") == "tile" else 85


# Kebab menu — shared across every leaf.
KEBAB_MENU = [
    {"label": "Show info", "value": "info", "icon": "ContentCopy"},
    {"label": "Remove layer", "value": "delete", "icon": "Delete"},
]


# ---- store derivations ------------------------------------------------------


def build_tree_items(order: list[str]) -> list[dict[str, Any]]:
    """One 'Active tilesets' group, leaves in tree-top-down order."""
    children = []
    for slug in order:
        provider = PROVIDERS.get(slug, {})
        label = provider.get("label", slug)
        if slug == DEFAULT_BASE_SLUG:
            label = f"🛰️  {label}"
        children.append({"id": slug, "label": label})
    return [{"id": "_active", "label": "Active tilesets", "children": children}]


def slugs_from_tree(ordered_items: list[dict] | None) -> list[str]:
    """Pluck leaf ids from TreeViewPro's `orderedItems` shape."""
    if not ordered_items:
        return []
    out: list[str] = []
    for node in ordered_items:
        if node.get("id") == "_active":
            for child in node.get("children") or []:
                sid = child.get("id")
                if sid:
                    out.append(sid)
            break
    return out


def build_layer_group_children(order: list[str], sliders: dict[str, int]) -> list[Any]:
    """One dl2.TileLayer per active slug, mounted bottom-up.

    `order` is tree top -> bottom = visually front -> back on the
    map. Leaflet renders later-mounted layers on top, so we reverse
    for the LayerGroup children list. The slider value (0–100) maps
    directly to TileLayer.opacity (0..1).
    """
    bottom_up = list(reversed(order))
    out: list[Any] = []
    for slug in bottom_up:
        pct = sliders.get(slug, _default_opacity(slug))
        kwargs = tile_kwargs(slug, opacity=pct / 100.0)
        if not kwargs:
            continue
        out.append(dl2.TileLayer(**kwargs))
    return out


# ---- layout -----------------------------------------------------------------



def _popover():
    return dmc.Popover(
        id="tlp-popover",
        opened=True,  # popover opens with the page so the user sees the card immediately
        position="right-start",
        offset=6,
        withArrow=True,
        arrowSize=10,
        shadow="lg",
        radius="md",
        closeOnClickOutside=False,
        closeOnEscape=False,
        clickOutsideEvents=[],
        keepMounted=True,
        children=[
            # Invisible anchor positioned over the EasyButton (10px / 46px
            # from top-left of the map container — same numbers as
            # /easy-button). PopoverTarget wraps in a dmc.Box; the style
            # lives on boxWrapperProps so the inner Div isn't overridden.
            dmc.PopoverTarget(
                html.Div(id="tlp-anchor"),
                boxWrapperProps={
                    "style": {
                        "position": "absolute",
                        "top": "10px",
                        "left": "46px",
                        "width": "1px",
                        "height": "30px",
                        "pointerEvents": "none",
                        "zIndex": 600,
                    }
                },
            ),
            dmc.PopoverDropdown(
                dmc.Stack(
                    [
                        dmc.Group(
                            [
                                DashIconify(
                                    icon="mdi:layers-triple-outline",
                                    width=16,
                                    color="var(--mantine-color-blue-6)",
                                ),
                                dmc.Text("Layers", fw=600, size="sm"),
                                dmc.Badge(
                                    f"{len(PROVIDERS)}",
                                    color="blue",
                                    variant="light",
                                    size="xs",
                                    radius="sm",
                                    ml="auto",
                                ),
                            ],
                            gap="xs",
                            wrap="nowrap",
                        ),
                        dmc.Divider(),
                        dmc.MultiSelect(
                            id="tlp-multiselect",
                            data=PROVIDER_OPTIONS_GROUPED,
                            value=[s for s in INITIAL_ORDER if s != DEFAULT_BASE_SLUG],
                            placeholder="Add a tileset to the stack",
                            searchable=True,
                            clearable=True,
                            hidePickedOptions=True,
                            nothingFoundMessage="No tilesets match…",
                            maxDropdownHeight=320,
                            size="xs",
                            comboboxProps={
                                "withinPortal": True,
                                "zIndex": 2147483641,
                                "shadow": "md",
                                "transitionProps": {
                                    "transition": "pop",
                                    "duration": 140,
                                },
                            },
                            renderOption={
                                "function": "renderTileCubeFace",
                                "options": {"tilesets": renderoption_payload()},
                            },
                            leftSection=DashIconify(icon="mdi:map-search", width=14),
                            classNames={
                                "dropdown": "tlp-dropdown",
                                "option": "tlp-option",
                            },
                            styles={
                                "input": {"minHeight": "36px"},
                                "dropdown": {"padding": "6px"},
                            },
                        ),
                        html.Div(
                            TreeViewPro(
                                id="tlp-tree",
                                items=build_tree_items(INITIAL_ORDER),
                                defaultExpandedItems=["_active"],
                                multiSelect=True,
                                checkboxSelection=False,
                                itemsReordering=True,
                                isItemEditable=True,
                                reorderableItems=list(INITIAL_ORDER),
                                showItemControls=True,
                                controlsItems=list(INITIAL_ORDER),
                                sliderValues=INITIAL_SLIDERS,
                                sliderMin=0,
                                sliderMax=100,
                                sliderStep=1,
                                sliderColor="blue",
                                kebabMenuItems=KEBAB_MENU,
                                licenseKey=LICENSE_KEY,
                                expandIcon="ChevronRight",
                                collapseIcon="ExpandMore",
                                itemChildrenIndentation="14px",
                                sx={
                                    "& .MuiTreeItem-content": {"paddingY": "3px"},
                                    "& .MuiTreeItem-label": {
                                        "fontSize": "13px",
                                        "width": "100%",
                                    },
                                    "& .MuiSlider-root": {
                                        "height": "2px",
                                        "padding": "8px 0",
                                    },
                                },
                            ),
                            className="tlp-tree",
                        ),
                    ],
                    gap=10,
                ),
                p="md",
                style={"width": "420px", "maxHeight": "70vh", "overflowY": "auto"},
            ),
        ],
    )


component = dmc.Stack(
    [
        dmc.Grid(
            [
                dmc.GridCol(
                    html.Div(
                        style={"position": "relative"},  # anchor frame for the popover
                        children=[
                            dmc.Paper(
                                # region map
                                dl2.Map(
                                    id="tlp-map",
                                    center=NASHVILLE.center,
                                    zoom=5,
                                    style={"height": "70vh"},
                                    attributionControl=True,  # let layer attributions surface in the corner
                                    children=[
                                        # TileLayers find the map via React context, so any
                                        # container that keeps them mounted as children works
                                        # — no need for a Leaflet-side LayerGroup.
                                        html.Div(
                                            id="tlp-stack",
                                            children=build_layer_group_children(
                                                INITIAL_ORDER,
                                                INITIAL_SLIDERS,
                                            ),
                                        ),
                                        dl2.EasyButton(
                                            id="tlp-open",
                                            position="topleft",
                                            icon="mdi:layers-triple-outline",
                                            iconSize=20,
                                            title="Layers",
                                        ),
                                    ],
                                ),
                                # endregion
                                shadow="sm",
                                radius="md",
                                withBorder=True,
                                style={"overflow": "hidden", "height": "100%"},
                            ),
                            _popover(),
                        ],
                    ),
                    span={"base": 12, "md": 8},
                ),
                dmc.GridCol(
                    dmc.Stack(
                        [
                            info_panel(
                                "MUI X Pro license",
                                dmc.Badge(
                                    (
                                        "Loaded from .env"
                                        if LICENSE_KEY
                                        else "MUI_PRO_API_KEY not set"
                                    ),
                                    color="green" if LICENSE_KEY else "red",
                                    variant="light",
                                    size="sm",
                                ),
                            ),
                            info_panel(
                                "Active stack (top → bottom)",
                                dmc.Code(
                                    id="tlp-readout",
                                    block=True,
                                    style={
                                        "fontSize": "11px",
                                        "whiteSpace": "pre-wrap",
                                        "minHeight": "120px",
                                    },
                                ),
                            ),
                            info_panel(
                                "Last action",
                                dmc.Code(
                                    id="tlp-action",
                                    children="…",
                                    style={"fontSize": "11px"},
                                ),
                            ),
                        ],
                        gap="md",
                    ),
                    span={"base": 12, "md": 4},
                ),
            ]
        ),
        # Single source of truth. Every input (MultiSelect, tree reorder,
        # slider drag, kebab) updates this; one derivation callback rewires
        # the LayerGroup + tree props.
        dcc.Store(
            id="tlp-state",
            data={"order": list(INITIAL_ORDER), "sliders": dict(INITIAL_SLIDERS)},
            storage_type="memory",
        ),
    ],
    gap="md",
)


# ---- EasyButton toggles the popover ----------------------------------------
@callback(
    Output("tlp-popover", "opened"),
    Input("tlp-open", "n_clicks"),
    State("tlp-popover", "opened"),
    prevent_initial_call=True,
)
def toggle_popover(_, opened):
    return not bool(opened)


# ---- Inputs (MultiSelect / tree drag / slider / kebab) -> tlp-state --------
# Note: we also write `tlp-tree.sliderValues` directly from this callback
# (allow_duplicate) — but ONLY when the trigger is "slug added", so the tree
# learns about the new leaf's seeded opacity. For drag-only and reorder-only
# triggers, we return `no_update` for sliderValues to avoid pushing back the
# value the tree just sent us (the previous version round-tripped, which Dash
# correctly flagged as a circular dependency between the reducer and a
# `derive` callback that also output sliderValues).
@callback(
    Output("tlp-state", "data"),
    Output("tlp-action", "children"),
    Output("tlp-tree", "sliderValues", allow_duplicate=True),
    Output("tlp-multiselect", "value", allow_duplicate=True),
    Input("tlp-multiselect", "value"),
    Input("tlp-tree", "orderedItems"),
    Input("tlp-tree", "sliderValues"),
    Input("tlp-tree", "kebabAction"),
    State("tlp-state", "data"),
    prevent_initial_call=True,
)
def reduce_state(ms_value, ordered_items, slider_values, kebab, state):
    """Reduce every input into a new {order, sliders} value.

    The base layer (`DEFAULT_BASE_SLUG`) is forced to the BOTTOM of the
    stack regardless of where the tree puts it; the MultiSelect also
    never lists it (the user can't remove or hide it). This guarantees
    the map always has something to render against, the same way the
    SailsBoard layers card pins Esri World Imagery.
    """
    state = dict(state or {})
    order = list(state.get("order") or list(INITIAL_ORDER))
    sliders = dict(state.get("sliders") or {})
    trigger = ctx.triggered_id
    # `ctx.triggered_id` only carries the component id, but THREE inputs land
    # on the same `tlp-tree` component (orderedItems, sliderValues, kebabAction).
    # Look at the prop_id to pick the right branch — otherwise the FIRST branch
    # that finds a truthy value wins and the others never run (the kebab branch
    # never fired because `sliderValues` is always a non-empty dict).
    prop_id = ctx.triggered[0]["prop_id"] if ctx.triggered else ""
    action = "—"
    # Only set these when needed — otherwise return no_update so we don't
    # echo back values that came IN through this callback.
    push_sliders: dict | type(no_update) = no_update
    push_ms_value: list | type(no_update) = no_update

    # --- MultiSelect: add/remove overlays --------------------------------
    if trigger == "tlp-multiselect":
        new_selection = set(ms_value or [])
        # Existing overlays in order (excluding base) — preserve their order.
        existing = [s for s in order if s != DEFAULT_BASE_SLUG]
        kept = [s for s in existing if s in new_selection]
        added = [s for s in new_selection if s not in existing]
        # Newly-added slugs land at the TOP of the stack.
        next_order = added + kept + [DEFAULT_BASE_SLUG]
        # Drop sliders for removed slugs; seed sliders for added ones.
        next_sliders = {k: v for k, v in sliders.items() if k in next_order}
        for slug in next_order:
            next_sliders.setdefault(slug, _default_opacity(slug))
        order, sliders = next_order, next_sliders
        if added:
            action = f"+ added {', '.join(added)}"
            # New leaves need their sliders SEEDED so the tree knows the
            # opacity to display. Send the full sliders dict; the tree
            # only mounts new entries (existing leaves keep their state).
            push_sliders = dict(sliders)
        else:
            removed = [s for s in existing if s not in new_selection]
            action = f"− removed {', '.join(removed)}" if removed else "no-op"
            # Removed-only branches don't need to push — the leaf is gone
            # from items anyway.

    # --- Tree reorder ----------------------------------------------------
    elif prop_id.endswith(".orderedItems") and ordered_items is not None:
        # `orderedItems` falls back to `items` until the first reorder —
        # treat both the same way (it's just a nested list).
        tree_slugs = slugs_from_tree(ordered_items)
        if tree_slugs:
            # Force the base to the bottom no matter where the tree placed it.
            kept = [s for s in tree_slugs if s != DEFAULT_BASE_SLUG]
            order = kept + [DEFAULT_BASE_SLUG]
            action = "drag-reorder"

    # --- Slider drag -----------------------------------------------------
    elif prop_id.endswith(".sliderValues") and slider_values:
        # Find the slug whose value actually changed (vs current state).
        for slug, value in (slider_values or {}).items():
            if slug not in sliders or sliders[slug] != value:
                sliders[slug] = int(value)
                action = f"opacity · {slug} → {int(value)}%"
                break

    # --- Kebab action ----------------------------------------------------
    elif prop_id.endswith(".kebabAction") and kebab:
        slug = kebab.get("itemId")
        what = kebab.get("action")
        if what == "delete" and slug and slug != DEFAULT_BASE_SLUG:
            order = [s for s in order if s != slug]
            sliders.pop(slug, None)
            action = f"removed {slug}"
            # Sync the MultiSelect — it has to lose the leaf the user just
            # removed via the kebab, otherwise the chip stays in the picker.
            push_ms_value = [s for s in order if s != DEFAULT_BASE_SLUG]
        elif what == "delete" and slug == DEFAULT_BASE_SLUG:
            action = f"can't remove base layer ({DEFAULT_BASE_SLUG})"
        elif what == "info" and slug:
            provider = PROVIDERS.get(slug, {})
            action = (
                f"info · {provider.get('label', slug)} "
                f"(group={provider.get('group')}, "
                f"kind={provider.get('kind')}, "
                f"max_zoom={provider.get('max_zoom')})"
            )
        else:
            action = f"{what} · {slug}"

    return {"order": order, "sliders": sliders}, action, push_sliders, push_ms_value


# ---- Store -> Map LayerGroup + tree props + MultiSelect.value + readout ----
# NOTE: tlp-tree.sliderValues AND tlp-multiselect.value are intentionally NOT
# outputs here — the reducer pushes them directly (with allow_duplicate) only
# when it's the right semantic moment (slug added / kebab removed). Driving
# either from both directions creates a circular dependency.
@callback(
    Output("tlp-stack", "children"),
    Output("tlp-tree", "items"),
    Output("tlp-tree", "controlsItems"),
    Output("tlp-tree", "reorderableItems"),
    Output("tlp-readout", "children"),
    Input("tlp-state", "data"),
)
def derive(state):
    state = state or {"order": list(INITIAL_ORDER), "sliders": dict(INITIAL_SLIDERS)}
    order = state.get("order") or []
    sliders = state.get("sliders") or {}
    readout_lines = []
    for slug in order:
        provider = PROVIDERS.get(slug, {})
        pct = sliders.get(slug, _default_opacity(slug))
        kind = provider.get("kind", "tile")
        readout_lines.append(
            f"{slug:<28}  {pct:>3}%  {kind:<8}  ({provider.get('group_label', '?')})"
        )
    return (
        build_layer_group_children(order, sliders),
        build_tree_items(order),
        list(order),
        list(order),
        "\n".join(readout_lines) or "(empty)",
    )
```


---

*Source: /tile-layers-pro*

---

<!-- /tile-selector — https://leaflet.2plot.dev/tile-selector/llms.txt -->

# Tile Selector

> pick map tiles by clicking or shift-dragging, and round-trip them to Python.

---



### Overview

`dl2.TileSelector` is a map **control** that turns the map into a tile picker.
While it is armed the cursor becomes a crosshair, a dashed outline tracks the
tile under the pointer at the current zoom, and:

- **click** a tile to add or remove it from the selection
- **shift-drag** a box to capture every tile inside it

Place it as a child of `dl2.Map`, anywhere among the other layers.

### The data boundary

Each selected tile round-trips to Python as a dict:

```python
{
    "z": 11, "x": 470, "y": 843,
    "url": "https://tile.openstreetmap.org/11/470/843.png",
    "bounds": [south, west, north, east],
}
```

`tileUrl` decides which template those `url` values are built from — it does
**not** have to match the `dl2.TileLayer` you are displaying. The demo below
renders CARTO light tiles but hands back OpenStreetMap PNG URLs, which is the
usual shape when the map is a picker for some other tileset.

Selections are keyed by `z/x/y`, so panning and zooming never lose them.

### Props

| Prop | Type | Default | What it does |
|------|------|---------|--------------|
| `selectedTiles` | list of dicts | `[]` | The selection. **`[MUTABLE]`** — an output (user clicks) *and* an input (write `[]` to clear). |
| `tileUrl` | string | OSM `{z}/{x}/{y}` | Template the returned `url` values are built from. |
| `position` | string | `'topleft'` | `topleft` / `topright` / `bottomleft` / `bottomright`. |
| `hoverColor` | string | `'#fa5252'` | Colour of the dashed outline under the cursor. |
| `selectedColor` | string | `'#228be6'` | Stroke and fill of selected-tile rectangles and the box-drag preview. |

Because `selectedTiles` is `[MUTABLE]`, a plain callback both reads the user's
picks and pushes new state back — the Clear button in the demo is one line.

### Live demo


### The shape


**Usage**

```python
# File: docs/tile-selector/example.py  (region: map)

dl2.Map(
    id="ts-map",
    center=AUSTIN.center,
    zoom=11,
    style={"height": "58vh", "width": "100%"},
    children=[
        dl2.TileLayer(id="ts-tile", **TILES.kwargs("light")),
        dl2.TileSelector(
            id="ts-picker",
            position="topright",
            # The URLs handed back in `selectedTiles` point at THIS
            # template — it does not have to be the tileset you are
            # displaying. Here we show USGS Topo but hand back OSM PNGs.
            tileUrl=OSM,
            hoverColor="#fa5252",
            selectedColor="#228be6",
        ),
    ],
),
```


### Source


```python
# File: docs/tile-selector/example.py

"""
TileSelector — pick tiles off the map by clicking or shift-dragging.

`dl2.TileSelector` is a map control. While it is armed the cursor becomes a
crosshair, a dashed outline tracks the tile under the pointer at the current
zoom, and:

  • **click** a tile to add / remove it from the selection
  • **shift-drag** a box to capture every tile inside it

Each selected tile round-trips to Python as `{z, x, y, url, bounds}`, where
`bounds` is `[south, west, north, east]`. Selections are keyed by `z/x/y`, so
they survive panning and zooming.

`selectedTiles` is `[MUTABLE]` — it is both an output (the user's clicks) and
an input (the Clear button below writes `[]` straight back into it).
"""

import dash_leaflet2 as dl2
import dash_mantine_components as dmc
from dash import Input, Output, callback, html, no_update
from dash_iconify import DashIconify

from dl2_tiles import USGS_TOPO, register_theme_swap
from dl2_locations import AUSTIN
from dl2_shared import info_panel

# Basemap pair for this page — dl2_tiles owns the light/dark wiring.
TILES = USGS_TOPO
# The template the PICKED tile URLs are built from. Deliberately not the
# basemap: this page's point is that the two are independent.
OSM = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"



def _tile_row(tile):
    """One selected tile as a table row: z/x/y, bounds, and its URL."""
    s, w, n, e = tile.get("bounds") or [0, 0, 0, 0]
    return dmc.TableTr(
        [
            dmc.TableTd(
                dmc.Code(f"{tile['z']}/{tile['x']}/{tile['y']}"),
            ),
            dmc.TableTd(
                dmc.Text(f"{s:.4f}, {w:.4f} → {n:.4f}, {e:.4f}",
                         size="xs", c="dimmed", ff="monospace"),
            ),
            dmc.TableTd(
                dmc.Anchor(
                    dmc.Group(
                        [DashIconify(icon="tabler:photo", width=14), dmc.Text("PNG", size="xs")],
                        gap=4,
                    ),
                    href=tile.get("url", "#"),
                    target="_blank",
                ),
                style={"textAlign": "center"},
            ),
        ]
    )


# `component` is the name the `.. exec::` directive looks for.
component = dmc.Stack(
    [
        dmc.Alert(
            dmc.Stack(
                [
                    dmc.Text(
                        "Click the crosshair button in the map's top-right corner to arm "
                        "the selector, then click tiles — or hold Shift and drag a box.",
                        size="sm",
                    ),
                    dmc.Text(
                        "Zoom changes the tile grid; already-picked tiles stay picked "
                        "because each one is keyed by z/x/y.",
                        size="sm",
                        c="dimmed",
                    ),
                ],
                gap=4,
            ),
            title="How to use it",
            color="blue",
            variant="light",
            icon=DashIconify(icon="tabler:hand-click"),
        ),
        dmc.Paper(
            # region map
            dl2.Map(
                id="ts-map",
                center=AUSTIN.center,
                zoom=11,
                style={"height": "58vh", "width": "100%"},
                children=[
                    dl2.TileLayer(id="ts-tile", **TILES.kwargs("light")),
                    dl2.TileSelector(
                        id="ts-picker",
                        position="topright",
                        # The URLs handed back in `selectedTiles` point at THIS
                        # template — it does not have to be the tileset you are
                        # displaying. Here we show USGS Topo but hand back OSM PNGs.
                        tileUrl=OSM,
                        hoverColor="#fa5252",
                        selectedColor="#228be6",
                    ),
                ],
            ),
            # endregion
            shadow="sm",
            radius="md",
            withBorder=True,
            style={"overflow": "hidden"},
        ),
        dmc.Group(
            [
                dmc.Button(
                    "Clear selection",
                    id="ts-clear",
                    variant="light",
                    color="red",
                    leftSection=DashIconify(icon="tabler:trash", width=16),
                ),
                html.Div(id="ts-count"),
            ],
            justify="space-between",
            align="center",
        ),
        info_panel("Selected tiles", html.Div(id="ts-table")),
    ],
    gap="md",
)


@callback(
    Output("ts-count", "children"),
    Output("ts-table", "children"),
    Input("ts-picker", "selectedTiles"),
)
def show_selection(tiles):
    tiles = tiles or []
    badge = dmc.Badge(
        f"{len(tiles)} tile{'' if len(tiles) == 1 else 's'} selected",
        color="blue" if tiles else "gray",
        variant="light",
        size="lg",
    )
    if not tiles:
        return badge, dmc.Text(
            "Nothing selected yet — arm the control and click a tile.",
            c="dimmed", size="sm",
        )

    table = dmc.Table(
        [
            dmc.TableThead(
                dmc.TableTr(
                    [
                        dmc.TableTh("z/x/y"),
                        dmc.TableTh("bounds (S, W → N, E)"),
                        dmc.TableTh("tile", style={"textAlign": "center"}),
                    ]
                )
            ),
            # Newest first, and capped — a shift-drag at low zoom can select a
            # lot of tiles and this is a doc page, not a data grid.
            dmc.TableTbody([_tile_row(t) for t in list(reversed(tiles))[:25]]),
        ],
        striped=True,
        highlightOnHover=True,
    )
    footer = (
        dmc.Text(f"Showing the 25 most recent of {len(tiles)}.", size="xs", c="dimmed", mt="xs")
        if len(tiles) > 25
        else None
    )
    return badge, dmc.Stack([table, footer], gap=0)


@callback(
    Output("ts-picker", "selectedTiles"),
    Input("ts-clear", "n_clicks"),
    prevent_initial_call=True,
)
def clear_selection(n_clicks):
    """Python → map: writing the [MUTABLE] prop clears the component's state."""
    return [] if n_clicks else no_update


# Light/dark basemap, driven off the color-scheme store.
register_theme_swap("ts-tile", TILES)
```


---

*Source: /tile-selector*

---

<!-- /tilelayer-pro-props — https://leaflet.2plot.dev/tilelayer-pro-props/llms.txt -->

# TileLayer pro props

> minZoom, bounds, errorTileUrl, zIndex, subdomains, detectRetina, tms — the dash-leaflet 1.x TileLayer surface ported to dl2.

---



### Overview

Until now `dl2.TileLayer` accepted only `url`, `attribution`, `maxZoom`,
`maxNativeZoom`, and `opacity` — the bare minimum to paint a basemap. This
release fills in the rest of the dash-leaflet 1.x surface that downstream
projects (e.g. SailsBoard's harbor map) depend on:

| Prop            | What it does |
|-----------------|--------------|
| `minZoom`       | Lower zoom bound; below this Leaflet stops requesting tiles. |
| `bounds`        | `[[s,w],[n,e]]` — Leaflet skips tile requests outside this box (cheaper than server-side 404s). |
| `errorTileUrl`  | URL of the image painted in place of any 404 tile. A 1×1 transparent PNG hides them. |
| `zIndex`        | Stacking order across multiple tile layers (highest wins). |
| `subdomains`    | Substituted into the `{s}` placeholder in the URL template. |
| `detectRetina`  | Request 2× tiles on hi-DPI displays. |
| `tms`           | Y-flip for TMS-shaped pyramids. |

### Live demo


### The shape


**Stacked TileLayers with the new props**

```python
# File: docs/tilelayer-pro-props/example.py  (region: map)

dl2.Map(
    id="tlpro-map",
    center=CHARLESTON.center,
    zoom=10,
    minZoom=2,
    maxZoom=18,
    style={"height": "55vh"},
    children=[
        dl2.TileLayer(
            id="tlpro-base",
            # CARTO rather than OSM Mapnik: this page is
            # ABOUT `subdomains`, so the URL has to keep
            # its {s} token, and CARTO serves the a-d
            # hosts in both a light and a dark form.
            url=BASE_LIGHT,
            subdomains=["a", "b", "c"],
            detectRetina=True,
            minZoom=2,
            maxZoom=19,
            zIndex=1,
        ),
        dl2.TileLayer(
            id="tlpro-overlay",
            url=LABELS_LIGHT,
            subdomains=["a", "b", "c", "d"],
            bounds=CLIP_BOUNDS,
            errorTileUrl=BLANK_TILE,
            opacity=0.85,
            zIndex=10,
        ),
    ],
),
```


### Source


```python
# File: docs/tilelayer-pro-props/example.py

"""
TileLayer pro props — limited working example.

Demonstrates the new dl2.TileLayer surface (minZoom, bounds, errorTileUrl, zIndex,
subdomains, detectRetina, tms). Two stacked tile layers are mounted into one map:
a base OSM layer with subdomains + detectRetina, and an overlay layer constrained
to a bounding box around Charleston SC with a transparent errorTileUrl. Toggling the
zIndex slider reorders them; toggling 'detectRetina' swaps the hi-DPI tile request.
"""

import dash_leaflet2 as dl2
import dash_mantine_components as dmc
from dash import Input, Output, callback, clientside_callback, html
from dl2_tiles import POSITRON, register_theme_swap
from dl2_locations import CHARLESTON
from dl2_shared import info_panel

# 1x1 transparent PNG — replaces 404 tiles outside the bounds.
BLANK_TILE = (
    "data:image/png;base64,"
    "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAUAAdM6"
    "wQAAAABJRU5ErkJggg=="
)

# A ~21 x 25 km box centred on the peninsula. In kilometres, not degrees, so
# the clipped area is the same size here as in any other demo.
CLIP_BOUNDS = CHARLESTON.bounds(10.6, 12.3)

# Both layers theme. The base uses the POSITRON pair (CARTO serves it from the
# a-d subdomain hosts, which this page needs). The labels overlay has its own
# light/dark form — dark labels over a light base, light labels over a dark one
# — so it is swapped alongside rather than left to wash out.
TILES = POSITRON
BASE_LIGHT = TILES.url("light")
LABELS_LIGHT = "https://{s}.basemaps.cartocdn.com/rastertiles/voyager_only_labels/{z}/{x}/{y}.png"
LABELS_DARK = "https://{s}.basemaps.cartocdn.com/rastertiles/dark_only_labels/{z}/{x}/{y}.png"



component = dmc.Stack(
    [
        dmc.Grid(
            [
                dmc.GridCol(
                    dmc.Paper(
                        # region map
                        dl2.Map(
                            id="tlpro-map",
                            center=CHARLESTON.center,
                            zoom=10,
                            minZoom=2,
                            maxZoom=18,
                            style={"height": "55vh"},
                            children=[
                                dl2.TileLayer(
                                    id="tlpro-base",
                                    # CARTO rather than OSM Mapnik: this page is
                                    # ABOUT `subdomains`, so the URL has to keep
                                    # its {s} token, and CARTO serves the a-d
                                    # hosts in both a light and a dark form.
                                    url=BASE_LIGHT,
                                    subdomains=["a", "b", "c"],
                                    detectRetina=True,
                                    minZoom=2,
                                    maxZoom=19,
                                    zIndex=1,
                                ),
                                dl2.TileLayer(
                                    id="tlpro-overlay",
                                    url=LABELS_LIGHT,
                                    subdomains=["a", "b", "c", "d"],
                                    bounds=CLIP_BOUNDS,
                                    errorTileUrl=BLANK_TILE,
                                    opacity=0.85,
                                    zIndex=10,
                                ),
                            ],
                        ),
                        # endregion
                        shadow="sm",
                        radius="md",
                        withBorder=True,
                        style={"overflow": "hidden", "height": "55vh"},
                    ),
                    span=8,
                ),
                dmc.GridCol(
                    dmc.Stack(
                        [
                            info_panel(
                                "Overlay opacity",
                                dmc.Slider(
                                    id="tlpro-opacity",
                                    min=0,
                                    max=1,
                                    step=0.05,
                                    value=0.85,
                                    label=None,
                                ),
                            ),
                            info_panel(
                                "Overlay zIndex",
                                dmc.SegmentedControl(
                                    id="tlpro-zindex",
                                    data=[
                                        {"label": "Behind OSM", "value": "-1"},
                                        {"label": "Above OSM", "value": "10"},
                                    ],
                                    value="10",
                                    fullWidth=True,
                                ),
                            ),
                            info_panel(
                                "Base layer",
                                html.Div(
                                    [
                                        dmc.Badge(
                                            "detectRetina ON",
                                            color="blue",
                                            variant="light",
                                            mb=4,
                                        ),
                                        dmc.Text(
                                            "Subdomains a/b/c spread requests across "
                                            "three OSM hosts.",
                                            size="xs",
                                            c="dimmed",
                                        ),
                                    ]
                                ),
                            ),
                            info_panel(
                                "Overlay bounds",
                                dmc.Code(
                                    f"{CLIP_BOUNDS}",
                                    block=False,
                                ),
                            ),
                        ],
                        gap="md",
                    ),
                    span=4,
                ),
            ],
            gutter="md",
        ),
    ],
    gap="md",
)


@callback(Output("tlpro-overlay", "opacity"), Input("tlpro-opacity", "value"))
def update_opacity(v):
    return float(v or 0)


@callback(Output("tlpro-overlay", "zIndex"), Input("tlpro-zindex", "value"))
def update_zindex(v):
    return int(v) if v is not None else 10


# Light/dark for both layers, driven off the color-scheme store.
register_theme_swap("tlpro-base", TILES)

clientside_callback(
    f"(scheme) => (scheme === 'dark' ? '{LABELS_DARK}' : '{LABELS_LIGHT}')",
    Output("tlpro-overlay", "url"),
    Input("color-scheme-storage", "data"),
)
```


---

*Source: /tilelayer-pro-props*

---

<!-- /vector-layers — https://leaflet.2plot.dev/vector-layers/llms.txt -->

# Vector Layers

> Polygon / Polyline / Circle / CircleMarker.

---



### Overview

This page demonstrates Vector Layers.

### Live demo


### Vector primitives

```javascript
new leaflet.Polygon([[40.7780,-74.0438],[40.7888,-73.9513],[40.7385,-73.9276]],
    {color: "#2f9e44", fillOpacity: 0.25}).addTo(map);
new leaflet.Polyline([[40.7583,-74.0319],[40.7682,-73.9513]], {weight: 4}).addTo(map);
new leaflet.Circle([40.7286,-73.9738], {radius: 1500}).addTo(map);       // metres
new leaflet.CircleMarker([40.7430,-74.0094], {radius: 10}).addTo(map); // pixels

layer.on("click", () => set_props("vec-store", {data: {shape: "polygon"}}));
```

### Source


```python
# File: docs/vector-layers/example.py

"""Vector Layers — Polygon / Polyline / Circle / CircleMarker."""

import dash_mantine_components as dmc
from dash import Input, Output, callback, dcc
from dl2_shared import info_panel, map_div


component = dmc.Stack(
    [
        dmc.Grid(
            [
                dmc.GridCol(map_div("vector-layers"), span=8),
                dmc.GridCol(
                    info_panel(
                        "Last shape clicked",
                        dmc.Stack(
                            [
                                dmc.Badge(
                                    id="vec-badge",
                                    color="grape",
                                    children="none yet",
                                    size="lg",
                                ),
                                dmc.Text(
                                    "Click any shape on the map.", size="sm", c="dimmed"
                                ),
                            ],
                            gap="sm",
                        ),
                    ),
                    span=4,
                ),
            ]
        ),
        dcc.Store(id="vec-store"),
    ],
    gap="md",
)


@callback(
    Output("vec-badge", "children"),
    Input("vec-store", "data"),
    prevent_initial_call=True,
)
def show_shape(d):
    return d["shape"] if d else "none yet"
```


---

*Source: /vector-layers*

---

<!-- /walking-sim — https://leaflet.2plot.dev/walking-sim/llms.txt -->

# Walking Sim

> top-down RPG-style walking with the full pirate-captain sprite

---


### Overview

library. Uses all 4 animation types (idle / walking / running / jumping) across
all 8 compass directions.

## Model

  * **Map stays north-up.** Bearing isn't driven. Pan-only camera-follow keeps
    the character at viewport center as they move; the world doesn't spin
    around them.
  * **Sprite stays at viewport center, with NO `rotationAngle` transform.**
    The character "turns" by selecting a different directional sprite frame.
  * **8-way D-pad input.** Arrow keys are direct compass directions: ArrowUp
    = north, ArrowRight = east, ArrowUp+ArrowRight = north-east, etc. The
    touch joystick is read the same way — its angle becomes a heading, its
    magnitude becomes a speed throttle. (This replaces the prior turn-and-
    throttle scheme; the joystick angle naturally maps to direction and the
    8 sprite poses become reachable.)
  * **Animation state machine:**
       speed = 0                      → `idle`,    facing SOUTH (toward camera)
       0 < speed < 0.6 * MAX_SPEED    → `walking`, facing heading
       speed ≥ 0.6 * MAX_SPEED        → `running`, facing heading
       Space pressed                  → `jumping`, plays through once
                                         then returns to active state
  * **Heading → direction** is snapped to the nearest 45° (one of 8 buckets).

## Controls

  * Arrow keys (with combinations): walk in that compass direction
  * Space: jump
  * Cmd / Ctrl + Arrow: pan the camera away from the walker
  * Touch joystick (auto-shown on mobile): drag = walk in that direction at
    a magnitude-scaled speed
  * Click the top-right MiniMap to toggle WALK ↔ EXPLORE mode. Each toggle
    is animated with a smooth `map.flyTo()` — the walker NEVER teleports.
      - WALK: the rAF loop drives the character (this whole file's behavior).
        Minimap shows broad surrounding context (zoom-5), free-pan disabled.
      - EXPLORE: the rAF loop sits out — no input is read, no center is pushed.
        The map is a normal dl2 map (pan/zoom freely). The joystick is hidden.
        The minimap "inverses" — it now pins on the WALKER's last position at
        zoom+3 (a small "return-home" preview).
      - Entry (WALK → EXPLORE): we snapshot the walker's position + the
        walking zoom, then flyTo the walker at a pulled-back zoom so the
        user can scout.
      - Exit (EXPLORE → WALK): flyTo BACK to that snapshot — the walker
        stays put exactly where they were (you can't use this as a
        teleport). The rAF's per-frame `center: pos` push is suppressed
        for the flyTo's duration so it can't snap-jump mid-glide.

## Tile stack

  * Main map: **Esri World Imagery** (satellite, 100%) with an **Esri
    NatGeo World Map** overlay on top at 50%. The NatGeo overlay's tiles
    cap at z16 (`maxZoom=16`) — it's invisible at walking zoom (18) where
    the satellite carries the scene, and progressively blends in as the
    EXPLORE flyTo pulls back to wider context.
  * MiniMap: **Esri World Street Map** — a flat reference map that reads
    well in a 160×160 thumbnail at any zoom level.

### Live demo


### Source


```python
# File: docs/walking-sim/example.py

"""
Walking Sim — top-down RPG-style walking with the full pirate-captain sprite
library. Uses all 4 animation types (idle / walking / running / jumping) across
all 8 compass directions.

## Model

  * **Map stays north-up.** Bearing isn't driven. Pan-only camera-follow keeps
    the character at viewport center as they move; the world doesn't spin
    around them.
  * **Sprite stays at viewport center, with NO `rotationAngle` transform.**
    The character "turns" by selecting a different directional sprite frame.
  * **8-way D-pad input.** Arrow keys are direct compass directions: ArrowUp
    = north, ArrowRight = east, ArrowUp+ArrowRight = north-east, etc. The
    touch joystick is read the same way — its angle becomes a heading, its
    magnitude becomes a speed throttle. (This replaces the prior turn-and-
    throttle scheme; the joystick angle naturally maps to direction and the
    8 sprite poses become reachable.)
  * **Animation state machine:**
       speed = 0                      → `idle`,    facing SOUTH (toward camera)
       0 < speed < 0.6 * MAX_SPEED    → `walking`, facing heading
       speed ≥ 0.6 * MAX_SPEED        → `running`, facing heading
       Space pressed                  → `jumping`, plays through once
                                         then returns to active state
  * **Heading → direction** is snapped to the nearest 45° (one of 8 buckets).

## Controls

  * Arrow keys (with combinations): walk in that compass direction
  * Space: jump
  * Cmd / Ctrl + Arrow: pan the camera away from the walker
  * Touch joystick (auto-shown on mobile): drag = walk in that direction at
    a magnitude-scaled speed
  * Click the top-right MiniMap to toggle WALK ↔ EXPLORE mode. Each toggle
    is animated with a smooth `map.flyTo()` — the walker NEVER teleports.
      - WALK: the rAF loop drives the character (this whole file's behavior).
        Minimap shows broad surrounding context (zoom-5), free-pan disabled.
      - EXPLORE: the rAF loop sits out — no input is read, no center is pushed.
        The map is a normal dl2 map (pan/zoom freely). The joystick is hidden.
        The minimap "inverses" — it now pins on the WALKER's last position at
        zoom+3 (a small "return-home" preview).
      - Entry (WALK → EXPLORE): we snapshot the walker's position + the
        walking zoom, then flyTo the walker at a pulled-back zoom so the
        user can scout.
      - Exit (EXPLORE → WALK): flyTo BACK to that snapshot — the walker
        stays put exactly where they were (you can't use this as a
        teleport). The rAF's per-frame `center: pos` push is suppressed
        for the flyTo's duration so it can't snap-jump mid-glide.

## Tile stack

  * Main map: **Esri World Imagery** (satellite, 100%) with an **Esri
    NatGeo World Map** overlay on top at 50%. The NatGeo overlay's tiles
    cap at z16 (`maxZoom=16`) — it's invisible at walking zoom (18) where
    the satellite carries the scene, and progressively blends in as the
    EXPLORE flyTo pulls back to wider context.
  * MiniMap: **Esri World Street Map** — a flat reference map that reads
    well in a 160×160 thumbnail at any zoom level.
"""

import flexlayout_dash as dfl
import dash_leaflet2 as dl2
import dash_mantine_components as dmc
from dash import Input, Output, State, callback, clientside_callback, dcc, html
from dash_iconify import DashIconify
from dl2_locations import SAVANNAH
from dl2_shared import info_panel

# Esri ArcGIS tile services. Note the {z}/{y}/{x} order (ArcGIS convention) —
# Leaflet's URL templating doesn't care about order, the placeholders are pure
# text substitution.
ESRI_WORLD_IMAGERY = (
    "https://server.arcgisonline.com/ArcGIS/rest/services/"
    "World_Imagery/MapServer/tile/{z}/{y}/{x}"
)
ESRI_NATGEO = (
    "https://server.arcgisonline.com/ArcGIS/rest/services/"
    "NatGeo_World_Map/MapServer/tile/{z}/{y}/{x}"
)
ESRI_STREET = (
    "https://server.arcgisonline.com/ArcGIS/rest/services/"
    "World_Street_Map/MapServer/tile/{z}/{y}/{x}"
)
# USGS National Map Hydro Cached — transparent raster overlay where every water
# feature (lake, river, ocean) is drawn as solid blue and land is fully
# transparent. The walking-sim uses it for BOTH visual feedback (a tinted
# overlay so the user sees the water) AND as the collision source — the rAF
# loop fetches the same tiles via a CORS-friendly hidden <canvas> and rejects
# any step that lands on a non-transparent pixel.
#
# Cap: z16. Beyond that the server 404s, so set `maxNativeZoom=16` to make
# Leaflet upscale the z16 tile at the walking zoom (z18) instead of breaking.
USGS_HYDRO = (
    "https://basemap.nationalmap.gov/arcgis/rest/services/"
    "USGSHydroCached/MapServer/tile/{z}/{y}/{x}"
)
USGS_HYDRO_MAX_NATIVE_Z = 16
ESRI_ATTR_IMAGERY = (
    "Tiles &copy; Esri &mdash; Source: Esri, Maxar, Earthstar Geographics, "
    "and the GIS User Community"
)
ESRI_ATTR_NATGEO = (
    "Tiles &copy; Esri &mdash; National Geographic, Esri, DeLorme, NAVTEQ, "
    "UNEP-WCMC, USGS, NASA, ESA, METI, NRCAN, GEBCO, NOAA, iPC"
)

START = SAVANNAH.center
START_ZOOM = 18

# Physics — kept in deg/sec; the integrator multiplies by dt. MAX_SPEED tuned
# so the character makes a brisk walking pace at zoom 18 and a clear running
# pace once over 60% speed (the running-animation threshold).
MIN_SPEED = 0.0
MAX_SPEED = 0.000016
ACCEL_PER_S = 0.000045  # ≈0.35 s from 0 → MAX_SPEED
DECEL_PER_S = 0.000060  # quicker auto-decel when input released
RUN_THRESHOLD = 0.60  # fraction of MAX_SPEED above which we use running animation
JUMP_DURATION_MS = 700  # one play-through of the jumping animation

SPRITE_SIZE = 72

# Sprite library layout — keep these constants in sync with
# assets/sprites/pirate_captain/. Frame counts must match the actual file
# counts in each direction folder.
DIRECTIONS = [
    "north",
    "north-east",
    "east",
    "south-east",
    "south",
    "south-west",
    "west",
    "north-west",
]
FRAMES_PER_ANIM = {"idle": 4, "walking": 6, "running": 8, "jumping": 9}


def _frames(anim: str, direction: str) -> list:
    n = FRAMES_PER_ANIM[anim]
    return [
        f"/assets/sprites/pirate_captain/{anim}/{direction}/frame_{i:03d}.png"
        for i in range(n)
    ]


# Build the full sprite map: SPRITES[anim][direction] -> list of frame URLs.
# Sent into the rAF loop as a single JS object so frame swaps are pure lookups.
SPRITES = {anim: {d: _frames(anim, d) for d in DIRECTIONS} for anim in FRAMES_PER_ANIM}

# Per-animation frame interval (ms). Running is faster, idle is slower —
# tuned to feel like a real walk / jog / breathing rhythm.
FRAME_MS = {
    "idle": 240,
    "walking": 110,
    "running": 70,
    "jumping": JUMP_DURATION_MS // 9,
}

# The initial DivIcon HTML — a single <img> slot. The rAF loop hot-swaps
# `.src` on every frame tick to play whichever animation is current.
SPRITE_DIVICON_HTML = (
    f'<img class="ws-sprite-frame" '
    f'src="{SPRITES["idle"]["south"][0]}" '
    f'style="width:{SPRITE_SIZE}px; height:{SPRITE_SIZE}px; '
    f'display:block; image-rendering:pixelated; pointer-events:none;">'
)


def _joystick_div(prefix: str):
    return html.Div(
        # `id` so a clientside callback can flip its inline display when mode changes
        # — we hide the joystick whenever the user clicks the minimap into EXPLORE.
        id=f"{prefix}-joystick",
        className="dl2-joystick",
        children=[
            html.Div(
                className="dl2-joystick-base",
                id=f"{prefix}-joystick-base",
                children=html.Div(
                    className="dl2-joystick-controller",
                    id=f"{prefix}-joystick-controller",
                ),
            ),
            html.Div(
                className="dl2-joystick-hint",
                children=[
                    DashIconify(icon="mdi:gesture-tap", width=14),
                    html.Span(" Drag to walk · tap center to stop"),
                ],
            ),
        ],
    )


# ---------------------------------------------------------------------
# Pane builders for the DashFlexLayout shell.
# Map gets the wide left tab; HUD + Controls share two tabs on the right.
# Splitting these into functions keeps the dfl `children=` list readable
# and mirrors the pattern in docs/tile-selector/example.py.
# ---------------------------------------------------------------------


def _map_pane():
    """Walker map + touch joystick. Returns a position:relative wrapper so
    the joystick (absolute) anchors to the map even inside the dfl tab."""
    return html.Div(
        className="ws-map-pane",
        children=[
            dl2.Map(
                id="ws-map",
                center=START,
                zoom=START_ZOOM,
                bearing=0,
                style={"height": "100%", "width": "100%"},
                children=[
                    # Two-layer basemap: Esri World Imagery (satellite, 100%) +
                    # Esri NatGeo World Map overlay (50%, caps at z16). At
                    # walking zoom (18) only the satellite is visible; NatGeo
                    # fades in as the EXPLORE flyTo pulls back.
                    dl2.TileLayer(
                        id="ws-tile-satellite",
                        url=ESRI_WORLD_IMAGERY,
                        attribution=ESRI_ATTR_IMAGERY,
                        opacity=1.0,
                    ),
                    dl2.TileLayer(
                        id="ws-tile-natgeo",
                        url=ESRI_NATGEO,
                        attribution="",
                        opacity=0.5,
                        maxZoom=16,
                    ),
                    # USGS Hydro: visible tint + collision source for the rAF
                    # loop. maxNativeZoom=16 because the server 404s past z16.
                    dl2.TileLayer(
                        id="ws-tile-hydro",
                        url=USGS_HYDRO,
                        attribution="",
                        opacity=0.35,
                        maxNativeZoom=USGS_HYDRO_MAX_NATIVE_Z,
                        maxZoom=22,
                    ),
                    dl2.Marker(
                        id="ws-walker",
                        position=START,
                        iconOptions={
                            "html": SPRITE_DIVICON_HTML,
                            "className": "ws-sprite-icon",
                            "iconSize": [SPRITE_SIZE, SPRITE_SIZE],
                            "iconAnchor": [SPRITE_SIZE // 2, SPRITE_SIZE // 2],
                        },
                        # No rotation — direction is conveyed by swapping the
                        # IMG src to a different 8-direction frame.
                        rotateWithMap=False,
                        rotationAngle=0,
                    ),
                    dl2.Polyline(
                        id="ws-trail",
                        positions=[START],
                        color="#1971c2",
                        weight=3,
                        opacity=0.5,
                        dashArray="4,4",
                    ),
                    # Minimap doubles as the WALK ↔ EXPLORE switch. A clientside
                    # callback flips ws-mode on every n_clicks bump.
                    dl2.MiniMap(
                        id="ws-minimap",
                        position="topright",
                        url=ESRI_STREET,
                        width=160,
                        height=160,
                        zoomLevelOffset=-5,
                        toggleDisplay=True,
                    ),
                ],
            ),
            _joystick_div("ws"),
        ],
    )


def _hud_pane():
    """Live state readouts: MODE / FACING / STATE / TERRAIN / BLOCKS /
    POSITION / throttle / viewport. All ids identical to the previous
    layout so existing callbacks bind unchanged."""
    return html.Div(
        className="ws-side-pane",
        children=dmc.Stack(
            [
                info_panel(
                    "HUD",
                    dmc.Stack(
                        [
                            dmc.Group(
                                [
                                    dmc.Stack(
                                        [
                                            dmc.Text("MODE", size="xs", c="dimmed"),
                                            dmc.Badge(
                                                id="ws-mode-badge",
                                                color="green",
                                                variant="light",
                                                size="lg",
                                                children="walk",
                                            ),
                                        ],
                                        gap=2,
                                    ),
                                    dmc.Stack(
                                        [
                                            dmc.Text("FACING", size="xs", c="dimmed"),
                                            dmc.Badge(
                                                id="ws-direction",
                                                color="blue",
                                                variant="light",
                                                size="lg",
                                                children="south",
                                            ),
                                        ],
                                        gap=2,
                                    ),
                                    dmc.Stack(
                                        [
                                            dmc.Text("STATE", size="xs", c="dimmed"),
                                            dmc.Badge(
                                                id="ws-anim",
                                                color="cyan",
                                                variant="light",
                                                size="lg",
                                                children="idle",
                                            ),
                                        ],
                                        gap=2,
                                    ),
                                ],
                                justify="space-between",
                            ),
                            dmc.Progress(
                                id="ws-throttle-bar",
                                value=0,
                                color="cyan",
                                size="sm",
                            ),
                            # TERRAIN: pixel sampled under the walker on the
                            # USGS Hydro overlay. BLOCKS: count of frames the
                            # rAF loop rejected because the step landed on water.
                            dmc.Group(
                                [
                                    dmc.Stack(
                                        [
                                            dmc.Text("TERRAIN", size="xs", c="dimmed"),
                                            dmc.Badge(
                                                id="ws-terrain",
                                                color="lime",
                                                variant="light",
                                                size="lg",
                                                children="land",
                                            ),
                                        ],
                                        gap=2,
                                    ),
                                    dmc.Stack(
                                        [
                                            dmc.Text("BLOCKS", size="xs", c="dimmed"),
                                            dmc.Badge(
                                                id="ws-blocks",
                                                color="gray",
                                                variant="light",
                                                size="lg",
                                                children="0",
                                            ),
                                        ],
                                        gap=2,
                                    ),
                                ],
                                justify="flex-start",
                                gap="md",
                            ),
                            dmc.Group(
                                [
                                    dmc.Text("POSITION", size="xs", c="dimmed"),
                                    dmc.Code(
                                        id="ws-position",
                                        children="...",
                                        style={"fontSize": "11px"},
                                    ),
                                ],
                                justify="space-between",
                            ),
                        ],
                        gap="sm",
                    ),
                ),
                info_panel(
                    "Viewport",
                    dmc.Code(
                        id="ws-viewport",
                        block=True,
                        style={"fontSize": "11px", "minHeight": "100px"},
                    ),
                ),
            ],
            gap="md",
            p="md",
        ),
    )


def _controls_pane():
    """Keyboard cheat sheet — no dynamic state, just reference."""
    return html.Div(
        className="ws-side-pane",
        children=dmc.Stack(
            [
                info_panel(
                    "Keyboard",
                    dmc.Stack(
                        [
                            dmc.Group(
                                [
                                    dmc.Kbd("←"),
                                    dmc.Kbd("→"),
                                    dmc.Kbd("↑"),
                                    dmc.Kbd("↓"),
                                    dmc.Text("8-way D-pad", size="sm"),
                                ],
                                gap="xs",
                            ),
                            dmc.Group(
                                [
                                    dmc.Kbd("↑"),
                                    dmc.Text("+", size="sm"),
                                    dmc.Kbd("→"),
                                    dmc.Text("= NE  (any 2 = diagonal)", size="sm"),
                                ],
                                gap="xs",
                            ),
                            dmc.Group(
                                [dmc.Kbd("Space"), dmc.Text("jump", size="sm")],
                                gap="xs",
                            ),
                            dmc.Divider(),
                            dmc.Group(
                                [
                                    dmc.Kbd("⌘"),
                                    dmc.Text("+", size="sm"),
                                    dmc.Kbd("←/→/↑/↓"),
                                    dmc.Text("pan camera", size="sm"),
                                ],
                                gap="xs",
                            ),
                        ],
                        gap=6,
                    ),
                ),
                info_panel(
                    "Touch",
                    dmc.Stack(
                        [
                            dmc.Group(
                                [
                                    DashIconify(icon="mdi:gesture-tap", width=16),
                                    dmc.Text(
                                        "Drag the joystick to walk — its angle "
                                        "is the heading, its magnitude is the "
                                        "speed throttle.",
                                        size="sm",
                                        c="dimmed",
                                    ),
                                ],
                                gap="xs",
                                wrap="nowrap",
                                align="flex-start",
                            ),
                        ],
                        gap=6,
                    ),
                ),
                info_panel(
                    "Minimap",
                    dmc.Text(
                        "Click the top-right minimap to toggle WALK ↔ EXPLORE. "
                        "Each toggle is animated with a smooth flyTo — the "
                        "walker never teleports.",
                        size="sm",
                    ),
                ),
            ],
            gap="md",
            p="md",
        ),
    )


# ---------------------------------------------------------------------
# DashFlexLayout model — walker map left (67%), HUD + Controls tabs
# share the right column (33%).
#
# tabEnableRenderOnDemand=False keeps every tab's DOM mounted at page
# load so the rAF loop's set_props calls (HUD updates) keep landing
# even when the user has switched away from the HUD tab. Same gotcha
# the tile-selector page documents.
# ---------------------------------------------------------------------
WS_MODEL = {
    "global": {
        "tabEnableClose": False,
        "tabEnableFloat": False,
        "tabEnableRename": False,
        "tabSetEnableMaximize": True,
        "tabEnableRenderOnDemand": False,
    },
    "layout": {
        "type": "row",
        "weight": 100,
        "children": [
            {
                "type": "tabset",
                "weight": 67,
                "children": [
                    {"type": "tab", "name": "Walking Sim", "id": "ws-pane-map"},
                ],
            },
            {
                "type": "tabset",
                "weight": 33,
                "selected": 0,
                "children": [
                    {"type": "tab", "name": "HUD", "id": "ws-pane-hud"},
                    {"type": "tab", "name": "Controls", "id": "ws-pane-controls"},
                ],
            },
        ],
    },
}


component = html.Div(
    id="ws-shell",
    # Initial class matches the default ws-mode='walk' so the zoom control
    # is hidden on first paint — no flash-of-visible-zoom before the
    # mode→class clientside callback fires.
    className="ws-flush-shell ws-mode-walk",
    children=[
        dfl.DashFlexLayout(
            id="ws-flex",
            model=WS_MODEL,
            useStateForModel=True,
            supportsPopout=False,
            children=[
                dfl.Tab(id="ws-pane-map", children=_map_pane()),
                dfl.Tab(id="ws-pane-hud", children=_hud_pane()),
                dfl.Tab(id="ws-pane-controls", children=_controls_pane()),
            ],
        ),
        dcc.Store(id="ws-tick", data=0),
        # 'walk' | 'explore'. Driven by minimap clicks; consumed by the rAF
        # loop (window._ws_mode mirrors it so the loop can read it without
        # re-binding). The flyTo anchor lives in window globals — see
        # toggle_mode_from_minimap below.
        dcc.Store(id="ws-mode", data="walk"),
    ],
)


clientside_callback(
    f"""
    (mapId) => {{
        // dfl mounts the tab's DOM asynchronously via React portals, so the
        // clientside callback can fire BEFORE the ws-map div is in the
        // document. The original early-return then bailed permanently —
        // leaving Leaflet's keyboard module enabled (arrows panned the map)
        // and the rAF loop / joystick wiring unrun. Poll until the div
        // arrives, then run the rest of setup exactly once.
        const runSetup = () => {{
        const root = document.getElementById('ws-map');
        if (!root) {{ setTimeout(runSetup, 80); return; }}
        if (root.dataset.wsLoopRunning) return;
        root.dataset.wsLoopRunning = '1';

        const SPRITES = {SPRITES!r};
        const FRAME_MS = {FRAME_MS!r};
        const MIN_SPEED = {MIN_SPEED};
        const MAX_SPEED = {MAX_SPEED};
        const ACCEL_PER_S = {ACCEL_PER_S};
        const DECEL_PER_S = {DECEL_PER_S};
        const RUN_THRESHOLD = {RUN_THRESHOLD};
        const JUMP_DURATION_MS = {JUMP_DURATION_MS};

        // 0=N, 1=NE, 2=E, 3=SE, 4=S, 5=SW, 6=W, 7=NW — matches DIRECTIONS order
        const DIR_NAMES = ['north','north-east','east','south-east',
                           'south','south-west','west','north-west'];
        const headingToDir = (h) => {{
            const idx = Math.round(((h % 360) + 360) % 360 / 45) % 8;
            return DIR_NAMES[idx];
        }};
        // Convert a (dx, dy) input vector (screen coords: +y down) to a compass
        // bearing in degrees (0=N, 90=E, 180=S, 270=W).
        const vecToHeading = (dx, dy) => {{
            const rad = Math.atan2(dx, -dy);   // y-down → compass
            return (rad * 180 / Math.PI + 360) % 360;
        }};

        const state = {{
            lat: {START[0]}, lng: {START[1]},
            heading: 0,           // last MOVEMENT heading (0=N)
            lastDir: 'south',     // last direction we picked for the sprite
            speed: 0,
            trail: [[{START[0]}, {START[1]}]],
            keys: new Set(),
            lastFrame: performance.now(),
            lastSpriteSwap: 0,
            spriteIdx: 0,
            currentAnim: 'idle',
            jumpEndsAt: 0,
            terrain: 'land',      // last-known terrain under the walker
            blocks: 0,            // count of full-block (slide failed) frames
            lastTerrainPushed: 'land',
            lastBlocksPushed: 0,
        }};

        // --- USGS Hydro collision sampler --------------------------------
        // The Hydro tileset is a TRANSPARENT raster overlay — water = solid
        // blue pixel, land = alpha 0. We fetch the same tile URLs that the
        // visible Hydro TileLayer fetches (with `crossOrigin='anonymous'` so
        // we can read pixels — verified CORS-OK on basemap.nationalmap.gov),
        // draw them into a 256-px scratch canvas once, and keep the raw
        // ImageData. Per-step terrain check = one Uint8 array lookup.
        //
        // Cap is z16 (the server 404s above). We always sample at z16 even
        // though the map is at z18 — Leaflet's `map.project([lat,lng], 16)`
        // gives the global pixel coord at z16 regardless of the displayed
        // zoom, and 2 m/px is plenty for a coastline-style barrier.
        const HYDRO_URL = {USGS_HYDRO!r};
        const HYDRO_Z = {USGS_HYDRO_MAX_NATIVE_Z};
        const sampleCanvas = document.createElement('canvas');
        sampleCanvas.width = 256; sampleCanvas.height = 256;
        const sampleCtx = sampleCanvas.getContext('2d', {{ willReadFrequently: true }});
        const tileCache = new Map();  // "z/x/y" -> {{ ready, data, errored }}

        const getHydroTile = (z, x, y) => {{
            const key = z + '/' + x + '/' + y;
            const cached = tileCache.get(key);
            if (cached) return cached;
            const entry = {{ ready: false, data: null, errored: false }};
            tileCache.set(key, entry);
            const img = new Image();
            img.crossOrigin = 'anonymous';
            img.onload = () => {{
                sampleCtx.clearRect(0, 0, 256, 256);
                sampleCtx.drawImage(img, 0, 0, 256, 256);
                try {{
                    entry.data = sampleCtx.getImageData(0, 0, 256, 256).data;
                    entry.ready = true;
                }} catch (e) {{ entry.errored = true; }}
            }};
            img.onerror = () => {{ entry.errored = true; }};
            img.src = HYDRO_URL.replace('{{z}}', z)
                               .replace('{{x}}', x)
                               .replace('{{y}}', y);
            return entry;
        }};

        // Returns 'water' | 'land' | 'unknown' (tile not yet loaded).
        // 'unknown' = pass — we don't want to freeze the walker on first
        // boot waiting for a fetch; tiles cache within ~200 ms.
        const sampleTerrainAt = (lat, lng) => {{
            const m = root.__dl2_map;
            if (!m) return 'unknown';
            const pt = m.project([lat, lng], HYDRO_Z);
            const tx = Math.floor(pt.x / 256);
            const ty = Math.floor(pt.y / 256);
            const inX = Math.floor(pt.x - tx * 256);
            const inY = Math.floor(pt.y - ty * 256);
            const tile = getHydroTile(HYDRO_Z, tx, ty);
            if (tile.errored) return 'land';   // assume passable if tile broken
            if (!tile.ready || !tile.data) return 'unknown';
            const alpha = tile.data[(inY * 256 + inX) * 4 + 3];
            return alpha > 16 ? 'water' : 'land';
        }};

        // Warm up a 3x3 tile block around (lat, lng). Cheap on cache-hit; on
        // cache-miss it queues the fetch so by the time the walker arrives,
        // the pixels are ready. Called on init + when the walker crosses a
        // tile boundary.
        let lastWarmTx = null, lastWarmTy = null;
        const prewarm = (lat, lng) => {{
            const m = root.__dl2_map;
            if (!m) return;
            const pt = m.project([lat, lng], HYDRO_Z);
            const tx = Math.floor(pt.x / 256);
            const ty = Math.floor(pt.y / 256);
            if (tx === lastWarmTx && ty === lastWarmTy) return;
            lastWarmTx = tx; lastWarmTy = ty;
            for (let dy = -1; dy <= 1; dy++) {{
                for (let dx = -1; dx <= 1; dx++) {{
                    getHydroTile(HYDRO_Z, tx + dx, ty + dy);
                }}
            }}
        }};
        // Kick off the first prewarm as soon as the map handle appears.
        const prewarmWhenReady = () => {{
            if (root.__dl2_map) prewarm(state.lat, state.lng);
            else setTimeout(prewarmWhenReady, 50);
        }};
        prewarmWhenReady();

        window._dl2_joystick = window._dl2_joystick || {{ x: 0, y: 0, active: false }};
        const joy = window._dl2_joystick;

        // Mode flag mirrored from the ws-mode dcc.Store via a tiny clientside
        // callback (see set_ws_mode_flag below). Default 'walk' until the toggle
        // hook fires. The rAF tick reads this on every frame — when it's
        // 'explore' the loop skips input + camera-follow so the map operates
        // like a normal dl2 map.
        window._ws_mode = window._ws_mode || 'walk';

        // --- keyboard ---
        // Disable Leaflet's keyboard module — see flight_sim.py header for why.
        // tl;dr: Leaflet intercepts arrows once the map has focus and
        // stopPropagation()s the event, so our window listener never fires.
        const disableLeafletKeyboard = () => {{
            const m = root.__dl2_map;
            if (m && m.keyboard && typeof m.keyboard.disable === 'function') {{
                try {{ m.keyboard.disable(); }} catch (e) {{}}
                return;
            }}
            setTimeout(disableLeafletKeyboard, 80);
        }};
        disableLeafletKeyboard();

        const PAN_STEP_PX = 60;
        const panBy = (dx, dy) => {{
            const m = root.__dl2_map;
            if (m && typeof m.panBy === 'function') m.panBy([dx, dy]);
        }};
        const onDown = (e) => {{
            const t = e.target;
            if (t && /input|textarea|select/i.test(t.tagName)) return;
            if (e.metaKey || e.ctrlKey) {{
                if (e.key === 'ArrowLeft')  {{ panBy(-PAN_STEP_PX, 0); e.preventDefault(); }}
                if (e.key === 'ArrowRight') {{ panBy(PAN_STEP_PX, 0);  e.preventDefault(); }}
                if (e.key === 'ArrowUp')    {{ panBy(0, -PAN_STEP_PX); e.preventDefault(); }}
                if (e.key === 'ArrowDown')  {{ panBy(0, PAN_STEP_PX);  e.preventDefault(); }}
                return;
            }}
            if (['ArrowLeft','ArrowRight','ArrowUp','ArrowDown',' ','Space'].includes(e.key)) {{
                state.keys.add(e.key);
                if ((e.key === ' ' || e.key === 'Space') && state.jumpEndsAt <= performance.now()) {{
                    // Start a jump (cooldown enforced)
                    state.jumpEndsAt = performance.now() + JUMP_DURATION_MS;
                    state.currentAnim = 'jumping';
                    state.spriteIdx = 0;
                    state.lastSpriteSwap = performance.now();
                }}
                e.preventDefault();
            }}
        }};
        const onUp = (e) => state.keys.delete(e.key);
        window.addEventListener('keydown', onDown);
        window.addEventListener('keyup', onUp);

        // --- touch joystick ---
        const base = document.getElementById('ws-joystick-base');
        const ctrl = document.getElementById('ws-joystick-controller');
        if (base && ctrl && !base.dataset.wired) {{
            base.dataset.wired = '1';
            const reset = () => {{
                ctrl.style.transform = 'translate(-50%, -50%)';
                joy.x = 0; joy.y = 0; joy.active = false;
            }};
            reset();
            let activePtr = null;
            base.addEventListener('pointerdown', (e) => {{
                if (activePtr !== null) return;
                activePtr = e.pointerId;
                base.setPointerCapture(e.pointerId);
                joy.active = true;
                e.preventDefault();
            }});
            base.addEventListener('pointermove', (e) => {{
                if (e.pointerId !== activePtr) return;
                const r = base.getBoundingClientRect();
                const radius = r.width / 2;
                const dx = e.clientX - (r.left + radius);
                const dy = e.clientY - (r.top + radius);
                const mag = Math.sqrt(dx*dx + dy*dy);
                const k = mag > radius ? radius / mag : 1;
                const cx = dx * k, cy = dy * k;
                ctrl.style.transform = `translate(calc(-50% + ${{cx}}px), calc(-50% + ${{cy}}px))`;
                joy.x = cx / radius;
                joy.y = cy / radius;
            }});
            const onPtrUp = (e) => {{
                if (e.pointerId !== activePtr) return;
                try {{ base.releasePointerCapture(e.pointerId); }} catch (err) {{}}
                activePtr = null;
                reset();
            }};
            base.addEventListener('pointerup', onPtrUp);
            base.addEventListener('pointercancel', onPtrUp);
        }}

        // Preload all sprite URLs so the first walking step / first running-step
        // / first jump doesn't show a transparent flash while the PNG decodes.
        for (const anim of Object.keys(SPRITES)) {{
            for (const dir of Object.keys(SPRITES[anim])) {{
                for (const src of SPRITES[anim][dir]) {{
                    const i = new Image(); i.src = src;
                }}
            }}
        }}

        const tick = (now) => {{
            const dt = Math.min(0.1, (now - state.lastFrame) / 1000);
            state.lastFrame = now;

            // EXPLORE mode: bail out before reading input or pushing the camera.
            // The map operates as a normal dl2 map (free pan/zoom); the walker
            // stays put visually. We DO publish the walker's last-known position
            // to a window var so the page can snapshot it on a back-to-walk
            // toggle (and pin the minimap on it via centerFixed).
            if (window._ws_mode === 'explore') {{
                window._ws_walker_pos = [state.lat, state.lng];
                if (!document.getElementById('ws-map')) return;
                requestAnimationFrame(tick);
                return;
            }}

            // --- read input → direction vector (dx, dy) in screen coords ---
            const k = state.keys;
            let dx = 0, dy = 0;
            if (k.has('ArrowUp'))    dy -= 1;
            if (k.has('ArrowDown'))  dy += 1;
            if (k.has('ArrowLeft'))  dx -= 1;
            if (k.has('ArrowRight')) dx += 1;
            if (joy.active && (Math.abs(joy.x) > 0.05 || Math.abs(joy.y) > 0.05)) {{
                // Joystick takes precedence when actively engaged. (If both
                // keys and joystick are active we trust the joystick because
                // it's the "more deliberate" input on touch devices.)
                dx = joy.x; dy = joy.y;
            }}
            const inputMag = Math.min(1, Math.sqrt(dx*dx + dy*dy));

            // --- update heading + speed ---
            if (inputMag > 0.05) {{
                state.heading = vecToHeading(dx, dy);
                // Ramp speed toward target (target = inputMag * MAX_SPEED)
                const target = inputMag * MAX_SPEED;
                if (state.speed < target) {{
                    state.speed = Math.min(target, state.speed + ACCEL_PER_S * dt);
                }} else {{
                    state.speed = Math.max(target, state.speed - DECEL_PER_S * dt);
                }}
            }} else {{
                // No input → decel to zero.
                state.speed = Math.max(MIN_SPEED, state.speed - DECEL_PER_S * dt);
            }}

            // --- integrate position (with Hydro-collision slide) ---
            //
            // Take the desired step, but before committing it ask the Hydro
            // sampler whether the destination is water. If yes, try sliding
            // along each axis separately — so walking diagonally INTO a
            // coastline glides along it instead of jamming. If even the slide
            // is blocked, the walker is up against a wall: we bleed speed
            // off faster than normal decel and tick the BLOCKS counter.
            if (state.speed > 0) {{
                const hd = state.heading * Math.PI / 180;
                const stepLat = state.speed * Math.cos(hd) * dt * 60;
                const stepLng = state.speed * Math.sin(hd) * dt * 60;

                const tryMove = (dLat, dLng) => {{
                    const t = sampleTerrainAt(state.lat + dLat, state.lng + dLng);
                    // 'unknown' (tile mid-load) = allow so the player isn't
                    // frozen during initial paint; 'water' = reject.
                    if (t === 'water') return false;
                    state.lat += dLat;
                    state.lng += dLng;
                    return true;
                }};

                let moved = tryMove(stepLat, stepLng);
                if (!moved) {{
                    // Try axis-only slides (lng-only, then lat-only).
                    if (tryMove(0, stepLng))      moved = true;
                    else if (tryMove(stepLat, 0)) moved = true;
                }}
                if (!moved) {{
                    // Truly walled in this frame. Bleed speed off ~4× faster
                    // than normal decel so we don't grind against the wall.
                    state.speed = Math.max(MIN_SPEED, state.speed - DECEL_PER_S * dt * 4);
                    state.blocks += 1;
                }}

                if (state.trail.length === 0 ||
                    Math.hypot(state.lat - state.trail[state.trail.length-1][0],
                               state.lng - state.trail[state.trail.length-1][1]) > 0.00003) {{
                    state.trail.push([state.lat, state.lng]);
                    if (state.trail.length > 200) state.trail.shift();
                }}
            }}

            // Refresh the cached terrain readout (used by the HUD) AND keep
            // the tile prewarm in sync with the walker's current tile.
            const here = sampleTerrainAt(state.lat, state.lng);
            if (here !== 'unknown') state.terrain = here;
            prewarm(state.lat, state.lng);

            // --- animation state machine ---
            // Priority: jumping > running > walking > idle. The jumping animation
            // runs once through (length is JUMP_DURATION_MS); when it ends, we
            // fall back to whichever movement state is active.
            const speedFrac = state.speed / MAX_SPEED;
            let nextAnim, nextDir;
            const jumping = now < state.jumpEndsAt;
            if (jumping) {{
                nextAnim = 'jumping';
                // Lock direction at jump-start (use heading at start, or south if idle).
                nextDir = state.lastDir;
            }} else if (speedFrac >= RUN_THRESHOLD) {{
                nextAnim = 'running';
                nextDir = headingToDir(state.heading);
            }} else if (state.speed > MAX_SPEED * 0.02) {{
                nextAnim = 'walking';
                nextDir = headingToDir(state.heading);
            }} else {{
                nextAnim = 'idle';
                nextDir = 'south';   // face the user when idle
            }}

            // On a state TRANSITION, reset the frame index so the new animation
            // starts at frame 0 (otherwise a transition mid-cycle can look jumpy).
            if (nextAnim !== state.currentAnim || nextDir !== state.lastDir) {{
                state.currentAnim = nextAnim;
                state.lastDir = nextDir;
                state.spriteIdx = 0;
                state.lastSpriteSwap = now - FRAME_MS[nextAnim];  // force immediate swap
            }}

            // --- swap sprite frame on the interval ---
            const interval = FRAME_MS[state.currentAnim];
            const frames = SPRITES[state.currentAnim][state.lastDir];
            if (now - state.lastSpriteSwap >= interval) {{
                state.lastSpriteSwap = now;
                state.spriteIdx = (state.spriteIdx + 1) % frames.length;
                const imgs = document.querySelectorAll('.ws-sprite-frame');
                imgs.forEach((img) => {{ img.src = frames[state.spriteIdx]; }});
            }}

            // --- push state to components (camera-follow translation only) ---
            const pos = [state.lat, state.lng];
            window._ws_walker_pos = pos;
            dash_clientside.set_props('ws-walker', {{ position: pos }});
            // The mode-toggle flyTo (see toggle_mode_from_minimap below) sets
            // `window._ws_skip_camera_until` to the timestamp the flyTo ends.
            // While that's in the future, skip the per-frame center push so the
            // glide animation isn't fought by an instant `setView(pos)` on every
            // tick (Map.tsx's external-center effect uses {{animate: false}}).
            if (!(window._ws_skip_camera_until && now < window._ws_skip_camera_until)) {{
                dash_clientside.set_props('ws-map',    {{ center: pos }});  // bearing stays 0
            }}
            dash_clientside.set_props('ws-trail',  {{ positions: state.trail }});

            // HUD
            const pct = Math.round(speedFrac * 100);
            dash_clientside.set_props('ws-direction',    {{ children: state.lastDir }});
            dash_clientside.set_props('ws-anim',         {{ children: state.currentAnim }});
            dash_clientside.set_props('ws-throttle-bar', {{ value: pct }});
            dash_clientside.set_props('ws-position',     {{ children: pos[0].toFixed(5) + ', ' + pos[1].toFixed(5) }});
            // Only push terrain/blocks when they CHANGE — these update much
            // less often than position, and Dash's set_props is not free.
            if (state.terrain !== state.lastTerrainPushed) {{
                state.lastTerrainPushed = state.terrain;
                dash_clientside.set_props('ws-terrain', {{
                    children: state.terrain,
                    color: state.terrain === 'water' ? 'blue' : 'lime',
                }});
            }}
            if (state.blocks !== state.lastBlocksPushed) {{
                state.lastBlocksPushed = state.blocks;
                dash_clientside.set_props('ws-blocks', {{
                    children: String(state.blocks),
                    color: state.blocks > 0 ? 'red' : 'gray',
                }});
            }}

            if (!document.getElementById('ws-map')) return;
            requestAnimationFrame(tick);
        }};
        requestAnimationFrame(tick);
        }};  // end runSetup
        runSetup();
        return window.dash_clientside.no_update;
    }}
    """,
    Output("ws-tick", "data"),
    Input("ws-map", "id"),
)


@callback(Output("ws-viewport", "children"), Input("ws-map", "viewport"))
def viewport(vp):
    if not vp:
        return "—"
    return ("center: [{:.5f}, {:.5f}]\nzoom: {}\nbearing: {}° (north-up)").format(
        vp["center"][0],
        vp["center"][1],
        vp["zoom"],
        round(vp.get("bearing") or 0),
    )


# --- WALK ↔ EXPLORE: minimap click is the mode switch ------------------------
# Each click on the minimap (anywhere on its tiles — the corner toggle button is
# excluded by MiniMap itself) bumps ws-minimap.n_clicks. We flip ws-mode on every
# bump and run a `flyTo` animation; the walker is NEVER moved. Done clientside so
# the toggle feels instant and so we can talk to the Leaflet map directly via the
# handle Map.tsx publishes on the root div as `__dl2_map`.
clientside_callback(
    """
    (n, currentMode) => {
        if (!n) return window.dash_clientside.no_update;
        const next = currentMode === 'walk' ? 'explore' : 'walk';
        // Mirror to the window flag the rAF loop reads. Doing this here instead
        // of in a separate "mode -> flag" callback removes a round-trip and
        // avoids a one-tick gap where the loop still drives the camera mid-
        // transition.
        window._ws_mode = next;

        const m = document.getElementById('ws-map')?.__dl2_map;
        if (!m) return next;

        // Animation budget for both flyTos (seconds). Long enough to feel
        // deliberate, short enough not to annoy. Picked by feel.
        const FLY_DURATION = 1.5;
        // EXPLORE pulls the camera back by this many zoom levels for scouting.
        const EXPLORE_ZOOM_OUT = 4;

        if (next === 'explore') {
            // WALK -> EXPLORE: snapshot the walker's pos + the walking zoom
            // so the return flyTo can land exactly where we left them. Then
            // fly OUT to a wider vantage centered on the walker.
            const walkerPos = window._ws_walker_pos
                              || [m.getCenter().lat, m.getCenter().lng];
            window._ws_walk_anchor = walkerPos;
            window._ws_walk_zoom = m.getZoom();
            const minZ = (typeof m.getMinZoom === 'function')
                            ? m.getMinZoom() : 1;
            const targetZoom = Math.max(minZ, m.getZoom() - EXPLORE_ZOOM_OUT);
            m.flyTo(walkerPos, targetZoom, { duration: FLY_DURATION });
        } else {
            // EXPLORE -> WALK: the walker did NOT move while we were exploring,
            // so fly BACK to the same anchor + walking zoom we snapshotted on
            // entry. Suppress the rAF's per-frame center push until the flyTo
            // ends, otherwise its instant `setView(pos)` snap-jumps the map
            // before the glide can play. The +100ms guard absorbs any easing
            // tail Leaflet adds beyond the nominal duration.
            const target = window._ws_walk_anchor
                            || window._ws_walker_pos
                            || [m.getCenter().lat, m.getCenter().lng];
            const targetZoom = window._ws_walk_zoom || 18;
            window._ws_skip_camera_until = performance.now()
                                           + (FLY_DURATION * 1000) + 100;
            m.flyTo(target, targetZoom, { duration: FLY_DURATION });
        }
        return next;
    }
    """,
    Output("ws-mode", "data"),
    Input("ws-minimap", "n_clicks"),
    State("ws-mode", "data"),
    prevent_initial_call=True,
)


# Mirror ws-mode -> the MiniMap's `centerFixed` + `zoomLevelOffset` + the HUD
# badge. WALK = broad context (track main map, zoom -5); EXPLORE = pin on the
# walker, zoom +3 (a small "return-home" preview). Reading the walker position
# from the window var avoids a Store round-trip — the rAF loop has been
# publishing it every tick.
clientside_callback(
    """
    (mode) => {
        const wp = window._ws_walker_pos || null;
        if (mode === 'explore') {
            return [wp, 3, 'explore', 'orange'];
        }
        // walk: clear the fixed center so the inner map tracks the main map again.
        return [null, -5, 'walk', 'green'];
    }
    """,
    Output("ws-minimap", "centerFixed"),
    Output("ws-minimap", "zoomLevelOffset"),
    Output("ws-mode-badge", "children"),
    Output("ws-mode-badge", "color"),
    Input("ws-mode", "data"),
)


# Hide the joystick wrapper while in EXPLORE mode (the user is now using the
# map normally — joystick has nothing to drive). Toggling display via inline
# style respects the existing `.dl2-joystick` CSS so the hover-opacity etc.
# stays correct when shown again.
clientside_callback(
    """
    (mode) => ({ display: mode === 'explore' ? 'none' : '' })
    """,
    Output("ws-joystick", "style"),
    Input("ws-mode", "data"),
)


# Mirror ws-mode onto the flush-shell className so CSS can gate any UI that
# should differ between modes (currently: the Leaflet zoom control, hidden in
# WALK / shown in EXPLORE — see assets/walking_sim.css). Setting className from
# the same shape that markup writes ('ws-flush-shell ws-mode-<mode>') keeps the
# host class deterministic and easy to grep for.
clientside_callback(
    """
    (mode) => 'ws-flush-shell ws-mode-' + (mode || 'walk')
    """,
    Output("ws-shell", "className"),
    Input("ws-mode", "data"),
)


# No color-scheme tile swap here: the Esri tile stack (World Imagery + NatGeo
# overlay) is theme-independent. The app's light/dark toggle still affects DMC
# chrome and the Leaflet UI glass styling, just not the tiles themselves.
```


---

*Source: /walking-sim*
