Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-geomap/src/osm2d.rs

//! **osm2d** — the *state-of-the-art* 2D OSM viewer. Where [`crate::GeoMap`] is a
//! fit-to-data scatter + hotspot overlay, [`OsmView`] is a real **slippy map**: a
//! live [`MercatorCamera`] you drive with the mouse wheel (zoom **to the cursor**,
//! the map standard), click-drag pan, the keyboard (arrows / WASD pan, `+`/`−` /
//! `PageUp`/`PageDown` zoom), and the trackpad (macOS pinch + two-finger pan,
//! Windows wheel) — all routed through facett-core's per-OS [`look::KeyMap`] so
//! the feel matches the active theme preset.
//!
//! **Fast as hell.** Both layers are indexed once at load and only the *visible*
//! features touch the painter each frame:
//!
//! - **points** → a uniform [`PointGrid`] in Mercator-unit space; a frame queries
//!   only the cells the viewport overlaps (viewport culling) and **decimates** by
//!   a zoom-driven LOD stride so a whole-country view draws a readable sample
//!   instead of a million coincident dots.
//! - **ways** → facett-map's [`facett_map::index::MapIndex`] (LOD tiers + per-way
//!   Mercator bbox); a frame `query_visible`s only the ways whose bbox crosses the
//!   viewport at the current zoom's LOD.
//!
//! The hot path reuses a scratch `Vec<Pos2>` (no per-frame polyline heap alloc).
//!
//! It is a canonical [`Facet`] (discovery-contract law): `caps()` advertises
//! scalable + resizable + themeable, `state_json()` reports the camera
//! (centre lon/lat + zoom), the **visible-feature counts**, and the rolling
//! **fps**, and it ships `local()` / `remote()` constructors so the test matrix
//! mechanically enumerates it.

use std::cell::{Cell, RefCell};

use facett_core::label_grid as lg;
use facett_core::look::{Action, keymap};
use facett_core::scroll_engine::SmoothScroll;
use facett_core::{Elm, FacetCaps, Semantics, theme};
use facett_map::index::MapIndex;
use facett_map::{Way, mercator, paint_order, tessellate};
use serde::{Deserialize, Serialize};

use crate::inv_mercator;

mod clip;
pub mod cluster;

pub use cluster::{ClusterNode, CLUSTER_BIN_PX, cluster_projected, member_lonlat_bbox};

/// **The explicit z-band paint order of the 2D OSM map**, bottom (drawn first,
/// furthest back) to top (drawn last, in front). `OsmView::view` paints strictly in
/// this order, and the LINE band draws the SAME whether it lands on the GPU (paint
/// callback) or falls through to the CPU painter — so roads always sit above the
/// land/water fills and below the labels/pins, on either lane. (The historical
/// latent bug: the GPU line callback was emitted before the fills, so on the GPU
/// lane roads were hidden under water while the CPU lane drew them correctly.)
///
/// The discriminant is the band's rank; a larger rank paints later (on top). Guarded
/// by `z_band_order_is_documented_and_monotone` + the pixel test
/// `fills_paint_under_roads_under_pins`.
///
/// **The ranks are `facett_map::paint_order`'s band constants, not a private ladder.**
/// This enum used to carry its own `10/20/30/40` shape — exactly the per-kind z the
/// paint-order cure's preamble names as defect two — while facett-map's real bands are
/// `LANDMASS_BAND −1000 / AREA_BAND 0 / LINE_BAND 1000 / POINT_BAND 2000 /
/// LABEL_BAND 3000`. One ladder now (LAW 5): the bands both painters share come from
/// the shared constants; the two bands only this viewer has (`Overlay`, `Pin`) slot
/// halfway into the gaps above `Point` / `Label`. And WITHIN the `Fill` and `Line`
/// bands the per-way order is `paint_order::way_paint_key` — the band enum only
/// sequences the layer emissions.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum PaintBand {
    /// The pane background rect (land "paper" / theme bg) — under the landmass floor.
    Background = (facett_map::paint_order::LANDMASS_BAND - 1_000) as isize,
    /// Filled areas: land, water, landuse, buildings (holes punched).
    Fill = facett_map::paint_order::AREA_BAND as isize,
    /// Line ways: roads / paths / coastline — GPU lane *or* CPU fall-through.
    Line = facett_map::paint_order::LINE_BAND as isize,
    /// The point (OSM-node) layer.
    Point = facett_map::paint_order::POINT_BAND as isize,
    /// Overlays glued to the map: the graph overlay + the route ribbon. This
    /// viewer's own band — halfway between the shared `POINT_BAND` and `LABEL_BAND`.
    Overlay = (facett_map::paint_order::POINT_BAND + 500) as isize,
    /// Road-name + pin text labels (collision-placed).
    Label = facett_map::paint_order::LABEL_BAND as isize,
    /// Address-pin markers / cluster badges — topmost; this viewer's own band,
    /// above the shared `LABEL_BAND`.
    Pin = (facett_map::paint_order::LABEL_BAND + 500) as isize,
}

impl PaintBand {
    /// The canonical bottom→top paint order. `view` emits its layers in exactly
    /// this sequence; the guard test asserts the ranks are strictly increasing.
    pub const ORDER: [PaintBand; 7] = [
        PaintBand::Background,
        PaintBand::Fill,
        PaintBand::Line,
        PaintBand::Point,
        PaintBand::Overlay,
        PaintBand::Label,
        PaintBand::Pin,
    ];
}

/// Pan step (fraction of the viewport span) for one keyboard arrow/WASD press.
const KEY_PAN_FRAC: f64 = 0.12;
/// Zoom multiplier for one keyboard `+`/`−` (or PageUp/Down) press.
const KEY_ZOOM_STEP: f64 = 1.25;
/// Mouse-wheel zoom sensitivity (per scroll unit). Tuned to feel like a slippy map.
const WHEEL_ZOOM_RATE: f64 = 0.0015;
/// Smoothing factor for the damped zoom approach (per frame, ~60fps). Higher =
/// snappier; lower = floatier. The camera eases `zoom` toward `target_zoom`.
const ZOOM_DAMP: f64 = 0.35;
/// Zoom clamp: pixels-per-Mercator-unit. 1 ≈ whole world in 1px (never), up to a
/// street-level close-up.
const ZOOM_MIN: f64 = 64.0;
const ZOOM_MAX: f64 = 80_000_000.0;

/// **Tilt clamp (degrees).** `0` = top-down (the default, byte-identical to the
/// untilted map). Larger pitches the flat ground plane back toward the horizon —
/// the optional 2.5D lean. Capped short of the horizon-grazing angles where the
/// far half of the pane would recede past the vanishing line.
const TILT_MAX_DEG: f64 = 60.0;
/// Smallest pane edge (logical px) whose fit is worth PERSISTING. Below this the
/// pane is still being laid out — typical of an embedded wasm canvas in an iframe,
/// whose size arrives a frame or two after the first paint — and committing the
/// one-shot data fit to it would strand the camera at the wrong zoom.
const MIN_FIT_RECT_PX: f32 = 64.0;

/// Fraction of the binding axis the fitted data spans — the breathing room a
/// `fitBounds` leaves. `0.92` fills the pane while keeping the coastline off the edge.
const FIT_FILL: f64 = 0.92;

/// **Re-fit threshold.** An embedded wasm canvas (Streamlit/iframe) does not merely
/// start at zero — it lays out *progressively*, so a pane can legitimately report
/// 300×200 for a frame or two before the host sizes it to its real 1140×620. The
/// one-shot fit would COMMIT at that intermediate size (it clears [`MIN_FIT_RECT_PX`])
/// and, never re-fitting, leave the map drawn for a pane a third of the real canvas —
/// a correct-but-small country with big empty margins. So while the user has not taken
/// over navigation, a pane that changes size by more than this fraction on either axis
/// re-fits. Native hosts are unaffected (their pane size is stable after the first
/// layout); a window resize simply re-frames the data, which is the desired behaviour.
const REFIT_SIZE_TOLERANCE: f32 = 0.12;

/// **Per-frame drawn-point budget.** The CPU painter draws each kept point as a
/// tessellated `circle_filled` (several triangles each), so an un-capped frame on
/// a dense country (Liechtenstein = 353 k nodes, all inside a whole-country fit)
/// emits *hundreds of thousands* of circles per frame — the 2D-feels-sluggish bug.
/// We cap the count actually drawn to this budget: the adaptive stride
/// ([`OsmView::point_stride`]) divides the *visible* count down to ≤ this many, so
/// a packed view stays a readable, cheap sample regardless of absolute zoom. At
/// real street-level zoom the visible count is already under the budget, so every
/// point draws (stride 1) — the cap only bites when the whole dense set is in view.
const MAX_DRAWN_POINTS: usize = 40_000;

/// **The building-vertex ink budget** — the fraction of the pane the point layer's
/// dots may cover before the layer is culled OUTRIGHT.
///
/// The OSM point layer is the node cloud, and in a real extract most nodes are
/// **building corners**. Zoomed out those corners are far denser than the pane has
/// pixels: at the demo's Sigtuna fit the painter emitted ~21500 dots of radius 3 px
/// over an 800x600 pane — 600000 px2 of ink on a 480000 px2 pane, i.e. *more than a
/// full screen of dots*. That is the reported "the corners of the houses are blue,
/// it makes it look like a sea when zooming out". No colour fixes that; the corners
/// simply must not render at that scale.
///
/// Expressing the rule as **ink** rather than as a raw zoom cutoff is what makes it
/// honest at BOTH ends, because ink is data-aware:
///
/// * a dense node cloud is culled the moment its dots merge into a texture — for
///   the Sigtuna extract the knee lands at ~1-2 metres per pixel, i.e. exactly
///   "building zoom": below it a 10 m house wall spans >= 5 px, so its corners are
///   distinct ink on a footprint you can actually see;
/// * a SPARSE annotation layer (tens of points) is never culled at any zoom,
///   because tens of dots are nowhere near a screenful.
///
/// A plain `metres_per_px` threshold would have got the first case right and the
/// second one wrong — the same class of bug as a zoom-only stride (see
/// [`MIN_DRAWN_POINTS`]).
const POINT_INK_FRAC_MAX: f32 = 0.06;

/// **Per-frame drawn-point FLOOR.** The zoom LOD thins crowded views; it must never
/// thin a view that is not crowded. Below this many visible points the stride is
/// pinned to 1 and every point draws.
///
/// Without this, [`OsmView::point_stride`]'s zoom tier was applied unconditionally —
/// it reads the camera and nothing else — so a SMALL dataset at a zoomed-out camera
/// was decimated by the same factor as a dense one. The demo's "layers" pane holds
/// 43 points; at a whole-dataset fit the tier is 64, so it drew indices 0, 64, 128…
/// i.e. **one point of 43**, and the pane read as blank. `MAX_DRAWN_POINTS`' own doc
/// already promised "at real street-level zoom the visible count is already under
/// the budget, so every point draws (stride 1)" — the budget half honoured that, the
/// zoom half did not.
const MIN_DRAWN_POINTS: usize = 2_000;

/// Margin (px) the viewport rect is expanded by before way geometry is clipped
/// to it. A few × the max stroke width (way strokes top out around 9px), so an
/// edge stroke clipped right at the rect boundary is never visibly cut, yet no
/// drawn vertex lands at an extreme (flicker-inducing) coordinate.
const CLIP_MARGIN_PX: f32 = 64.0;

/// A live Web-Mercator camera: a centre in Mercator unit space (`[0,1]²`) and a
/// `zoom` in **pixels per Mercator unit** (so a screen pixel maps to a fixed
/// fraction of the world independent of the pane size — true slippy-map feel).
///
/// `target_zoom` is the wheel/keyboard *goal*; `zoom` eases toward it each frame
/// for damped motion. Pan acts on `center` directly (drag is already smooth).
///
/// This is the [`OsmView`] [`Elm::Model`] — the complete observable navigation
/// state (FC-1 / FC-3), serializable + round-trippable so a headless driver
/// snapshots it after feeding a `Vec<Msg>`.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct MercatorCamera {
    /// Viewport centre in Mercator unit space (`0..1`, x east, y south).
    pub center: (f64, f64),
    /// Current zoom (pixels per Mercator unit), eased toward `target_zoom`.
    pub zoom: f64,
    /// The wheel/keyboard zoom goal the camera damps toward.
    pub target_zoom: f64,
    /// **Pitch in degrees** — the optional 2.5D tilt. `0` = top-down (the default;
    /// [`to_px`](Self::to_px) is then the plain flat projection, byte-identical to
    /// the untilted map). Larger leans the flat ground plane back toward the
    /// horizon (perspective foreshortening about the pane's horizontal centre
    /// axis), clamped to `[0, TILT_MAX_DEG]`. `#[serde(default)]` so older Models
    /// (no `pitch_deg`) round-trip to `0`.
    #[serde(default)]
    pub pitch_deg: f64,
}

impl Default for MercatorCamera {
    fn default() -> Self {
        Self { center: (0.5, 0.5), zoom: 512.0, target_zoom: 512.0, pitch_deg: 0.0 }
    }
}

impl MercatorCamera {
    /// The perspective focal length (px) for the ground-plane tilt, keyed to the
    /// pane height so the lean reads the same regardless of pane size. Only used
    /// when [`pitch_deg`](Self::pitch_deg) > 0. Larger = weaker perspective.
    #[inline]
    fn focal(rect: egui::Rect) -> f64 {
        (rect.height() as f64).max(1.0)
    }

    /// Screen pixel for a Mercator-unit point, given the pane `rect`.
    ///
    /// At [`pitch_deg`](Self::pitch_deg) `== 0` this is the plain flat projection
    /// `rect.center + (merc − center)·zoom` (bit-for-bit the untilted map). With a
    /// non-zero pitch the flat screen offset is run through a ground-plane
    /// perspective about the pane's horizontal centre axis: the northern (upper)
    /// half recedes toward a vanishing line and the southern (lower) half comes
    /// forward — the optional 2.5D lean. Every layer (ways, points, pins, the graph
    /// overlay) projects through this one function, so they all tilt together and
    /// stay registered (the shared coordinate space is preserved under tilt).
    #[inline]
    pub fn to_px(&self, mx: f64, my: f64, rect: egui::Rect) -> egui::Pos2 {
        let cx = rect.center().x as f64;
        let cy = rect.center().y as f64;
        let sx = (mx - self.center.0) * self.zoom;
        let sy = (my - self.center.1) * self.zoom;
        if self.pitch_deg == 0.0 {
            return egui::pos2((cx + sx) as f32, (cy + sy) as f32);
        }
        let f = Self::focal(rect);
        let (sinp, cosp) = self.pitch_deg.to_radians().sin_cos();
        // Depth grows to the north (upper, sy<0) and shrinks to the south; guarded
        // against the horizon (D→0) so a vertex above the vanishing line still maps
        // to a far-but-finite point instead of flipping behind the camera.
        let d = (f - sy * sinp).max(1e-3);
        let scale = f / d;
        egui::pos2((cx + sx * scale) as f32, (cy + sy * cosp * scale) as f32)
    }

    /// Inverse of [`to_px`](Self::to_px): a screen pixel back to Mercator unit.
    /// Exact inverse on both lanes — the flat one at `pitch_deg == 0`, and the
    /// ground-plane perspective otherwise (so zoom-to-cursor + hit-testing stay
    /// truthful under tilt). Screen points above the vanishing line are clamped to
    /// the horizon rather than folding to a bogus southern coordinate.
    #[inline]
    pub fn to_merc(&self, p: egui::Pos2, rect: egui::Rect) -> (f64, f64) {
        let cx = rect.center().x as f64;
        let cy = rect.center().y as f64;
        let sx_screen = p.x as f64 - cx;
        let sy_screen = p.y as f64 - cy;
        if self.pitch_deg == 0.0 {
            return (self.center.0 + sx_screen / self.zoom, self.center.1 + sy_screen / self.zoom);
        }
        let f = Self::focal(rect);
        let (sinp, cosp) = self.pitch_deg.to_radians().sin_cos();
        // sy_screen = f·cosp·sy / (f − sy·sinp)  ⇒  sy = sy_screen·f / (f·cosp + sy_screen·sinp).
        let denom = (f * cosp + sy_screen * sinp).max(1e-3); // horizon guard
        let sy = sy_screen * f / denom;
        let d = f - sy * sinp;
        let sx = sx_screen * d / f;
        (self.center.0 + sx / self.zoom, self.center.1 + sy / self.zoom)
    }

    /// Set the pitch (2.5D tilt) in degrees, clamped to `[0, TILT_MAX_DEG]`.
    #[inline]
    pub fn set_pitch(&mut self, deg: f64) {
        self.pitch_deg = deg.clamp(0.0, TILT_MAX_DEG);
    }

    /// The Mercator-unit bounding box `[x0, y0, x1, y1]` the pane shows right now.
    /// Unprojects all four pane corners (not just the diagonal) so a **tilted**
    /// view's receding, widening trapezoid is fully covered by the axis-aligned
    /// cull box — the far (northern) corners fan out, so a two-corner box would
    /// under-cull and drop ways/points near the horizon. At `pitch_deg == 0` the
    /// four corners collapse to the same min/max as before (byte-identical cull).
    pub fn viewport_merc_bbox(&self, rect: egui::Rect) -> [f32; 4] {
        let corners = [
            self.to_merc(rect.left_top(), rect),
            self.to_merc(rect.right_top(), rect),
            self.to_merc(rect.left_bottom(), rect),
            self.to_merc(rect.right_bottom(), rect),
        ];
        let mut x0 = f64::INFINITY;
        let mut y0 = f64::INFINITY;
        let mut x1 = f64::NEG_INFINITY;
        let mut y1 = f64::NEG_INFINITY;
        for (x, y) in corners {
            x0 = x0.min(x);
            y0 = y0.min(y);
            x1 = x1.max(x);
            y1 = y1.max(y);
        }
        [x0 as f32, y0 as f32, x1 as f32, y1 as f32]
    }

    /// **Zoom toward an anchor pixel** — the map standard (the geo point under the
    /// cursor stays put while the world scales around it). `k>1` zooms in.
    /// Operates on `target_zoom`; the eased `zoom` follows. Returns the clamped k.
    pub fn zoom_to(&mut self, k: f64, anchor: egui::Pos2, rect: egui::Rect) {
        let before = self.to_merc(anchor, rect);
        let new = (self.target_zoom * k).clamp(ZOOM_MIN, ZOOM_MAX);
        let applied = new / self.target_zoom;
        self.target_zoom = new;
        // Snap `zoom` too so the anchor math is exact this frame (the easing then
        // smooths subsequent frames toward any *remaining* target).
        self.zoom = (self.zoom * applied).clamp(ZOOM_MIN, ZOOM_MAX);
        let after = self.to_merc(anchor, rect);
        // Shift centre so the anchor's geo point is unchanged on screen.
        self.center.0 += before.0 - after.0;
        self.center.1 += before.1 - after.1;
        self.clamp_center();
    }

    /// Centre-anchored zoom (keyboard `+`/`−`), eased.
    pub fn zoom_by(&mut self, k: f64) {
        self.target_zoom = (self.target_zoom * k).clamp(ZOOM_MIN, ZOOM_MAX);
    }

    /// Pan by a pixel delta (drag).
    pub fn pan_px(&mut self, dx: f32, dy: f32) {
        self.center.0 -= dx as f64 / self.zoom;
        self.center.1 -= dy as f64 / self.zoom;
        self.clamp_center();
    }

    /// Pan by a fraction of the viewport span (keyboard arrows/WASD). `rect` sets
    /// the span in Mercator units. +x east, +y south.
    pub fn pan_frac(&mut self, fx: f64, fy: f64, rect: egui::Rect) {
        let span_x = rect.width() as f64 / self.zoom;
        let span_y = rect.height() as f64 / self.zoom;
        self.center.0 += fx * span_x;
        self.center.1 += fy * span_y;
        self.clamp_center();
    }

    fn clamp_center(&mut self) {
        self.center.0 = self.center.0.clamp(0.0, 1.0);
        self.center.1 = self.center.1.clamp(0.0, 1.0);
    }

    /// Pan by an explicit velocity over `dt` seconds (used by the momentum glide).
    pub fn pan_velocity(&mut self, vx: f32, vy: f32, dt: f32) {
        self.pan_px(vx * dt, vy * dt);
    }

    /// Ease `zoom` toward `target_zoom`. Returns `true` while still animating (so
    /// the host can request another repaint). A no-op once converged.
    pub fn tick(&mut self) -> bool {
        let d = self.target_zoom - self.zoom;
        if d.abs() < self.target_zoom * 1e-4 {
            self.zoom = self.target_zoom;
            return false;
        }
        self.zoom += d * ZOOM_DAMP;
        true
    }

    /// Fit the camera to a Mercator-unit bbox so it fills `rect` with a margin.
    ///
    /// **Per-axis** (aspect-correct): the binding axis is whichever of width/height
    /// needs the *smaller* zoom to contain its span, exactly as a slippy map's
    /// `fitBounds` does. This used to fit the data's LARGER span into the pane's
    /// SMALLER side (`max(span_x, span_y)` into `min(w, h)`) — a "square" fit that is
    /// only right when the data's long axis happens to line up with the pane's short
    /// side, and otherwise leaves the map far too small. Wide data in a landscape pane
    /// was the worst case: a 0.08 × 0.02 bbox in a 1140 × 620 pane fitted at
    /// `620·0.9/0.08 ≈ 6975` where the correct fit is
    /// `min(1140/0.08, 620/0.02)·0.9 ≈ 12825` — a **1.8× under-zoom**. Portrait data
    /// (Sweden: ~13° lon × 14° lat) in a landscape canvas is height-bound, which this
    /// now resolves correctly on the height term instead of by coincidence.
    pub fn fit_merc_bbox(&mut self, bbox: [f64; 4], rect: egui::Rect) {
        let [x0, y0, x1, y1] = bbox;
        self.center = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
        let (w, h) = (rect.width() as f64, rect.height() as f64);
        if w <= 0.0 || h <= 0.0 {
            return; // a 0-sized pane carries no fit (an embedded canvas mid-layout)
        }
        let span_x = (x1 - x0).abs().max(1e-9);
        let span_y = (y1 - y0).abs().max(1e-9);
        let z = ((w / span_x).min(h / span_y) * FIT_FILL).clamp(ZOOM_MIN, ZOOM_MAX);
        self.zoom = z;
        self.target_zoom = z;
        self.clamp_center();
    }
}

/// A dependency-free **uniform grid** over `(lon, lat)` points, bucketed in
/// Mercator-unit space. Built once; a frame queries only the cells the viewport
/// overlaps, so culling is `O(visible)` not `O(all points)`.
pub struct PointGrid {
    /// Source points, kept so visibility queries return real `(lon, lat)`.
    pub points: Vec<(f64, f64)>,
    /// Per-point Mercator-unit position (parallel to `points`), precomputed.
    merc: Vec<(f32, f32)>,
    /// `cells[gy * cols + gx]` → indices into `points` falling in that cell.
    cells: Vec<Vec<u32>>,
    cols: usize,
    rows: usize,
    /// The lattice's **origin + inverse span** in Mercator units — the grid is laid
    /// over the DATA's bounding box, not the whole world (see [`PointGrid::build`]).
    /// `(x - ox) * inv_w * cols` is the column.
    ox: f32,
    oy: f32,
    inv_w: f32,
    inv_h: f32,
}

impl PointGrid {
    /// Build a grid sized so each cell holds ~a handful of points on average
    /// (clamped to a sane `1..=256` per side). Empty input → an empty 1×1 grid.
    pub fn build(points: Vec<(f64, f64)>) -> Self {
        let merc: Vec<(f32, f32)> = points
            .iter()
            .map(|&(lon, lat)| {
                let (x, y) = mercator(lon, lat);
                (x as f32, y as f32)
            })
            .collect();
        // Target ~8 points/cell → side ≈ sqrt(n/8).
        let side = ((points.len() as f64 / 8.0).sqrt().ceil() as usize).clamp(1, 256);
        let (cols, rows) = (side, side);

        // ── The lattice spans the DATA's bbox, not the whole world ────────────
        // BUG FIXED (found by `benches/heavy_navigator.rs`): the lattice used to be
        // laid over the entire `[0,1]` Mercator unit square. A REGIONAL dataset
        // then collapses into a corner of it — Sweden is ~3.6% of the world in x
        // and ~4% in y, so a 256×256 world lattice gave it only ~9×10 = 90 live
        // cells. At 1M points that is ~11000 points per cell, and a zoomed-in
        // viewport query still had to scan whole 11000-point cells: the cull cost
        // tracked the DATASET, not the screen — the exact opposite of this type's
        // documented contract, and it showed up as `cull_city_block` timing
        // identically to `cull_whole_country` in the bench.
        //
        // Anchoring the lattice on the data's own bbox restores ~8 points/cell for
        // any extent, so a city-block viewport touches a handful of small cells.
        // Degenerate spans (all points identical, or a single point) fall back to a
        // unit span so the arithmetic stays finite.
        let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
        for &(x, y) in &merc {
            x0 = x0.min(x);
            y0 = y0.min(y);
            x1 = x1.max(x);
            y1 = y1.max(y);
        }
        if !x0.is_finite() || x1 <= x0 {
            (x0, x1) = (0.0, 1.0);
        }
        if !y0.is_finite() || y1 <= y0 {
            (y0, y1) = (0.0, 1.0);
        }
        // A hair of padding so the max-corner point lands inside the last cell.
        let (w, h) = ((x1 - x0) * 1.000_001, (y1 - y0) * 1.000_001);
        let (inv_w, inv_h) = (1.0 / w.max(f32::MIN_POSITIVE), 1.0 / h.max(f32::MIN_POSITIVE));

        let mut cells = vec![Vec::<u32>::new(); cols * rows];
        for (i, &(x, y)) in merc.iter().enumerate() {
            let (gx, gy) = Self::cell_at(x, y, cols, rows, x0, y0, inv_w, inv_h);
            cells[gy * cols + gx].push(i as u32);
        }
        Self { points, merc, cells, cols, rows, ox: x0, oy: y0, inv_w, inv_h }
    }

    /// Lattice cell of a Mercator position, relative to the grid's data bbox.
    /// Positions outside the bbox clamp to the edge cells, so a viewport query
    /// wider than the data still resolves to a legal cell range.
    #[inline]
    #[allow(clippy::too_many_arguments)] // hot-path grid lookup: point + grid geometry, inlined
    fn cell_at(
        x: f32,
        y: f32,
        cols: usize,
        rows: usize,
        ox: f32,
        oy: f32,
        inv_w: f32,
        inv_h: f32,
    ) -> (usize, usize) {
        let fx = ((x - ox) * inv_w).clamp(0.0, 1.0);
        let fy = ((y - oy) * inv_h).clamp(0.0, 1.0);
        let gx = (fx * cols as f32) as usize;
        let gy = (fy * rows as f32) as usize;
        (gx.min(cols - 1), gy.min(rows - 1))
    }

    /// The data bbox's max corner in Mercator units (the lattice origin plus its
    /// span). Used by the whole-data fast path in [`Self::visible_into`].
    #[inline]
    fn data_max_x(&self) -> f32 {
        self.ox + 1.0 / self.inv_w
    }
    #[inline]
    fn data_max_y(&self) -> f32 {
        self.oy + 1.0 / self.inv_h
    }

    #[inline]
    fn cell_of(&self, x: f32, y: f32) -> (usize, usize) {
        Self::cell_at(x, y, self.cols, self.rows, self.ox, self.oy, self.inv_w, self.inv_h)
    }

    pub fn len(&self) -> usize {
        self.points.len()
    }
    pub fn is_empty(&self) -> bool {
        self.points.is_empty()
    }

    /// **Viewport cull + LOD decimation.** Push the Mercator-unit positions of
    /// every point inside `bbox` (`[x0,y0,x1,y1]`) into `out`, keeping only every
    /// `stride`-th point (stride ≥ 1; 1 = all). Touches only the overlapping
    /// cells, so the cost scales with what's on screen, not the dataset size.
    pub fn visible_into(&self, bbox: [f32; 4], stride: usize, out: &mut Vec<(f32, f32)>) {
        out.clear();
        if self.cells.is_empty() {
            return;
        }
        let stride = stride.max(1);
        let [x0, y0, x1, y1] = bbox;
        // ── WHOLE-DATA fast path ─────────────────────────────────────────────
        // When the viewport contains the entire dataset (zoomed all the way out —
        // the "show me the country" frame), the lattice buys nothing: every cell
        // is visited anyway, and hopping between 65536 separate `Vec`s costs more
        // than one sequential sweep. Scan `merc` linearly instead — contiguous,
        // prefetch-friendly, no per-cell indirection. Same output, same order
        // guarantees are not claimed by the API, and the exactness test sweeps
        // both paths.
        if x0 <= self.ox && y0 <= self.oy && x1 >= self.data_max_x() && y1 >= self.data_max_y() {
            out.reserve(self.merc.len() / stride + 1);
            if stride == 1 {
                out.extend_from_slice(&self.merc);
            } else {
                out.extend(self.merc.iter().step_by(stride).copied());
            }
            return;
        }
        let (cx0, cy0) = self.cell_of(x0, y0);
        let (cx1, cy1) = self.cell_of(x1, y1);
        let mut kept = 0usize;
        for gy in cy0..=cy1 {
            for gx in cx0..=cx1 {
                for &i in &self.cells[gy * self.cols + gx] {
                    let (px, py) = self.merc[i as usize];
                    if px < x0 || px > x1 || py < y0 || py > y1 {
                        continue; // inside the cell band but outside the exact rect
                    }
                    if kept.is_multiple_of(stride) {
                        out.push((px, py));
                    }
                    kept += 1;
                }
            }
        }
    }

    /// Count of points whose Mercator position lies inside `bbox` (no decimation)
    /// — the honest "visible feature" number for `state_json` / tests.
    pub fn visible_count(&self, bbox: [f32; 4]) -> usize {
        if self.cells.is_empty() {
            return 0;
        }
        let [x0, y0, x1, y1] = bbox;
        let (cx0, cy0) = self.cell_of(x0, y0);
        let (cx1, cy1) = self.cell_of(x1, y1);
        let mut n = 0;
        for gy in cy0..=cy1 {
            for gx in cx0..=cx1 {
                for &i in &self.cells[gy * self.cols + gx] {
                    let (px, py) = self.merc[i as usize];
                    if px >= x0 && px <= x1 && py >= y0 && py <= y1 {
                        n += 1;
                    }
                }
            }
        }
        n
    }
}

/// A small rolling FPS meter (EMA over frame times). Exposed in `state_json`.
#[derive(Clone, Copy, Debug)]
struct Fps {
    ema_dt: f64,
    last_t: f64,
}
impl Default for Fps {
    fn default() -> Self {
        Self { ema_dt: 1.0 / 60.0, last_t: 0.0 }
    }
}
impl Fps {
    fn sample(&mut self, t: f64) {
        if self.last_t > 0.0 {
            let dt = (t - self.last_t).clamp(1e-4, 1.0);
            self.ema_dt = self.ema_dt * 0.9 + dt * 0.1;
        }
        self.last_t = t;
    }
    fn fps(&self) -> f64 {
        1.0 / self.ema_dt.max(1e-4)
    }
}

/// An **address pin** — a labelled marker registered to a `(lon, lat)` on the
/// map, drawn on the CPU f64 [`MercatorCamera::to_px`] lane (dodging the
/// high-zoom f32 line cancellation) so it stays glued to its geo location at any
/// zoom. Reuses the GeoMap hotspot *pattern* (marker + badge label + click
/// hit-test + a **stable id** invariant across pan/zoom) rather than twinning it.
#[derive(Clone, Debug, PartialEq)]
pub struct Pin {
    /// Source `(lon, lat)` in WGS-84 degrees.
    pub lonlat: (f64, f64),
    /// A short label drawn beside the marker (empty = marker only).
    pub label: String,
    /// **Stable id** (FC-5) — invariant across pan/zoom; a click resolves to it and
    /// `state_json["selected_pin"]` reports it. Defaults to the label, else the
    /// `lon,lat` (see [`Pin::new`]).
    pub id: String,
}

impl Pin {
    /// A pin at `(lon, lat)` with `label`. The stable id is the label when
    /// non-empty, else the coordinate — matching the GeoMap `hotspot_key` rule.
    pub fn new(lon: f64, lat: f64, label: impl Into<String>) -> Self {
        let label = label.into();
        let id = if label.is_empty() { format!("{lon:.5},{lat:.5}") } else { label.clone() };
        Self { lonlat: (lon, lat), label, id }
    }

    /// A pin with an explicit stable id (when the label is not unique).
    pub fn with_id(lon: f64, lat: f64, label: impl Into<String>, id: impl Into<String>) -> Self {
        Self { lonlat: (lon, lat), label: label.into(), id: id.into() }
    }
}

/// A **graph overlay** registered to the map. Reuses facett-graphview's
/// [`GraphModel`](facett_graphview::GraphModel) (nodes + edges + kind colours) as
/// the data model — the overlay does NOT re-layout: it places each node at a geo
/// `(lon, lat)` (looked up in `geo` by the node's stable id) and draws it through
/// the MAP camera's [`MercatorCamera::to_px`], so a dependency / relationship
/// graph shares the ways/points coordinate space (a node co-located with a way
/// vertex lands on it). Nodes/edges whose id has no geo entry are skipped.
#[derive(Clone, Debug, Default)]
pub struct GeoGraph {
    /// The reused graph model (nodes carry id/label/fill/stroke; edges carry
    /// from/to ids + colour). Node `pos` is ignored — geo placement wins.
    pub model: facett_graphview::GraphModel,
    /// Geo position `(lon, lat)` per node **stable id**.
    pub geo: std::collections::HashMap<String, (f64, f64)>,
}

impl GeoGraph {
    /// A geo-placed graph from a [`GraphModel`](facett_graphview::GraphModel) + an
    /// id→`(lon,lat)` map.
    pub fn new(model: facett_graphview::GraphModel, geo: std::collections::HashMap<String, (f64, f64)>) -> Self {
        Self { model, geo }
    }
}

/// A prepared, screen-space line way for the cartographic (Garmin-nav) road
/// painter: its class `rank` (0 minor … 4 motorway), whether it is a road, the
/// on-screen fill width, colour, one-way direction, and its already-clipped
/// on-screen sub-polyline runs. Collected once per frame so the casing / fill /
/// arrow passes can each walk the ways in class order without re-projecting.
struct LineEntry {
    /// **The declared paint order of this line** — sorted ascending, later draws
    /// on top. `(way_rank, width-bucket, osm_id)`:
    /// [`facett_map::paint_order::way_rank`] first (layer·LAYER_STEP + class +
    /// tier — so `bridge`/`tunnel`/`layer` dominate, the shared cure), the Garmin
    /// width-bucket `rank` as the within-class refinement (minor → major), and
    /// the stable `osm_id` so a tie never falls back to decode order. The old key
    /// was `(is_road, rank)` with a STABLE sort: two roads of one width bucket
    /// tied and the tie went to whoever decoded last.
    order: (i32, u8, i64),
    rank: u8,
    is_road: bool,
    fill_w: f32,
    color: egui::Color32,
    oneway: i8,
    runs: Vec<Vec<egui::Pos2>>,
}

/// Screen-space arc-length spacing between one-way direction chevrons (px).
const ARROW_SPACING_PX: f32 = 64.0;

/// Draw **direction chevrons** (`>`-shaped, pointing in travel direction) along a
/// clipped screen-space road run at a fixed pixel cadence. `dir` is the OSM
/// one-way sense (`1` = along the vertex order, `-1` = against it); the chevron
/// half-angle + size scale with the on-screen road width `road_w`. Pure painter
/// work — no state — so it is exercised by the same headless render the road
/// hierarchy is.
fn draw_direction_arrows(painter: &egui::Painter, run: &[egui::Pos2], dir: i8, color: egui::Color32, road_w: f32) {
    if run.len() < 2 || dir == 0 {
        return;
    }
    let size = (road_w * 0.9).clamp(2.5, 6.0);
    let stroke = egui::Stroke::new((road_w * 0.35).clamp(1.0, 2.4), color);
    // Walk the polyline by accumulated length, dropping a chevron every
    // ARROW_SPACING_PX; start half a step in so short runs still get one.
    let mut acc = ARROW_SPACING_PX * 0.5;
    for seg in run.windows(2) {
        let (a, b) = (seg[0], seg[1]);
        let d = b - a;
        let len = d.length();
        if len < 1e-3 {
            continue;
        }
        // Travel direction along this segment (flip for a reverse one-way).
        let t = if dir >= 0 { d / len } else { -d / len };
        let n = egui::vec2(-t.y, t.x); // left normal
        let mut s = acc;
        while s < len {
            let tip = a + d * (s / len);
            // A `>` chevron: two short arms swept back from the tip.
            let back = tip - t * size;
            painter.line_segment([tip, back + n * size], stroke);
            painter.line_segment([tip, back - n * size], stroke);
            s += ARROW_SPACING_PX;
        }
        acc = s - len;
    }
}

/// Relative luminance (0..1, Rec. 601-ish) of a colour — picks label ink/halo
/// that contrasts with the cartographic paper.
fn luminance(c: egui::Color32) -> f32 {
    (0.299 * c.r() as f32 + 0.587 * c.g() as f32 + 0.114 * c.b() as f32) / 255.0
}

/// The state-of-the-art 2D OSM viewer (a [`Facet`]).
pub struct OsmView {
    pub title: String,
    /// Spatial grid over the `(lon, lat)` point layer (nodes).
    pub points: PointGrid,
    /// LOD + bbox spatial index over the vector-basemap ways.
    pub ways: MapIndex,
    /// The live camera (centre + zoom + damped target).
    pub camera: MercatorCamera,
    /// Whether the camera has been fitted to the data yet (deferred to the first
    /// paint so it can use the real pane rect). Set by [`Msg::Fit`] via
    /// [`update`](Self::update); the pure [`view`](Self::view) reads it and paints
    /// the first frame through a locally-fitted camera copy so there is no flash.
    fitted: bool,
    /// Pane size (logical px) the committed fit was computed for — so a pane that is
    /// still growing (an embedded canvas mid-layout) can be re-fitted instead of being
    /// stranded at an intermediate size. See [`REFIT_SIZE_TOLERANCE`].
    fitted_size: (f32, f32),
    /// **The camera zoom the last committed fit produced** — this map's *native* scale,
    /// i.e. `100%`, and the base the deck's uniform scale knob multiplies
    /// ([`Facet::scale`] / [`Facet::set_scale`]).
    ///
    /// It exists because a `FacetDeck`'s `+`/`−`/`Reset` toolbar speaks one vocabulary
    /// for every facet: a *relative* factor clamped to `[0.25, 4.0]`, with `1.0`
    /// meaning "native". A map has no fixed native pixel size — `fit_merc_bbox` picks
    /// whatever zoom fills the pane with the data, which for a city dataset is ~10⁶ px
    /// per Mercator unit. Reporting that absolute zoom as the knob's value (it used to
    /// be `camera.zoom / 512.0`) put `scale()` in the *thousands*, so the deck's clamp
    /// saturated on the very first click and `set_scale` slammed the camera to a
    /// whole-world view: a zoom-IN click zoomed OUT by ~400×, and `Reset` by ~1600×.
    /// Anchoring the knob to the fit makes `1.0` mean "the framing the pane opened on",
    /// which is what "native" means for a map. Guarded by
    /// `facett-geomap/tests/osm2d_deck_scale.rs`.
    ///
    /// `512.0` (the [`MercatorCamera::default`] zoom) until the first fit commits, so an
    /// unfitted view keeps reporting `scale() == 1.0` exactly as before.
    fit_zoom: f64,
    /// Has the USER taken over navigation (pan / zoom)? Once they have, the view stops
    /// auto-re-fitting on resize — their framing is theirs to keep.
    user_navigated: bool,
    /// Reusable polyline scratch — no per-frame heap alloc in the way painter.
    /// **Ephemeral paint scratch, not observable state** — interior-mutable so the
    /// FC-9 pure [`view`](Self::view) reuses it across frames through a `&self`
    /// borrow (FC-7); deliberately kept OUT of the [`MercatorCamera`] Model.
    scratch: RefCell<Vec<egui::Pos2>>,
    /// Reusable clipped-sub-polyline scratch (a polyline that leaves+re-enters
    /// the viewport clips into several runs). Cleared + refilled per way. FC-7.
    clip_runs: RefCell<Vec<Vec<egui::Pos2>>>,
    /// Reusable visible-point scratch (FC-7 ephemeral, outside the Model).
    vis_pts: RefCell<Vec<(f32, f32)>>,
    /// **GPU fast-path opt-in** (`map-wgpu` feature). When set AND a host has
    /// installed a `GpuMapRenderer` in its `egui_wgpu::RenderState`, the line-ways
    /// layer draws through the upload-once compute-cull + draw_indirect kernel via
    /// a paint callback driven by the live camera; otherwise the CPU painter draws
    /// every way. Filled silhouettes always draw on the CPU on top.
    #[cfg(feature = "map-wgpu")]
    use_gpu: bool,
    /// Upload-once latch: the GPU geometry is built+uploaded on the first GPU
    /// paint, then only the camera-driven uniforms change.
    #[cfg(feature = "map-wgpu")]
    gpu_uploaded: std::sync::atomic::AtomicBool,
    /// Whether the GPU line-ways callback was emitted last frame (for state_json).
    /// **Ephemeral paint cache** (FC-7) — interior-mutable so the pure [`view`](Self::view)
    /// records it through `&self`; not part of the [`MercatorCamera`] Model.
    last_drew_gpu: Cell<bool>,
    /// Last frame's observable counts (visible points / ways) for `state_json` (FC-7).
    last_vis_points: Cell<usize>,
    last_vis_ways: Cell<usize>,
    /// The decimation stride the painter ACTUALLY used last frame (zoom-LOD floor
    /// raised by the [`MAX_DRAWN_POINTS`] budget). Drives `state_json["point_stride"]`
    /// so the throttle is observable as data, and the count of circles drawn
    /// (`last_drawn_points`) proves the per-frame CPU work is bounded. FC-7 cache.
    last_point_stride: Cell<usize>,
    /// How many point-circles the painter emitted last frame (≤ [`MAX_DRAWN_POINTS`]).
    last_drawn_points: Cell<usize>,
    /// Rolling FPS meter — an ephemeral paint cache (FC-7), interior-mutable.
    fps: Cell<Fps>,
    /// **Pan momentum** (CygnusEd `SmoothScroll`, one axis each for x/y in *pixel*
    /// space). A drag-release flicks the last drag velocity into these; each frame
    /// `advance(dt)` decays the momentum with friction and the leftover delta is
    /// fed to [`MercatorCamera::pan_px`] so the map glides to a stop — the
    /// "bleeding-edge smooth" inertial pan. The axes are unbounded (`max = ∞`)
    /// because the camera clamps the centre, not the scroll offset.
    pan_x: SmoothScroll,
    pan_y: SmoothScroll,
    /// Which palette the basemap paints with (theme-derived by default, or a
    /// fixed cartographic preset). Selected via [`with_palette`](Self::with_palette);
    /// drives the land background + the (pre-recoloured) way hues, and is reported
    /// in `state_json` so a robot asserts the selection.
    palette: facett_map::PaletteMode,
    /// **Address-pin overlay** — labelled markers registered to `(lon, lat)`,
    /// drawn topmost on the CPU f64 lane. Empty by default (no overlay).
    pins: Vec<Pin>,
    /// The selected pin's **stable id** (FC-5), invariant across pan/zoom; `None`
    /// when nothing is selected. Set by [`Msg::SelectPin`] / cleared by
    /// [`Msg::ClearPin`]; reported in `state_json["selected_pin"]`.
    selected_pin: Option<String>,
    /// Last frame's on-screen pin count (ephemeral paint cache, FC-7).
    last_vis_pins: Cell<usize>,
    /// **Pin-cluster bin width, screen px.** Pins closer than this collapse into
    /// one count badge, so zooming out over a dense city shows readable groups
    /// instead of a pile of overlapping teardrops. `0` disables clustering
    /// (every pin draws as itself, the pre-clustering behaviour). See
    /// [`cluster`].
    cluster_px: f32,
    /// Last frame's drawn marker count AFTER clustering (ephemeral, FC-7) — the
    /// number of teardrops + badges actually painted.
    last_vis_clusters: Cell<usize>,
    /// **Graph overlay** (facett-graphview `GraphModel` placed at geo coords) drawn
    /// over the basemap in the shared coordinate space. `None` = no overlay.
    graph: Option<GeoGraph>,
    /// Last frame's on-screen graph-node count (ephemeral paint cache, FC-7).
    last_vis_graph: Cell<usize>,
    /// **Route-line highlight** — the "you are going here" polyline in `(lon,lat)`,
    /// painted as a bright cased ribbon on top of the basemap (below the pins) with
    /// direction chevrons. Empty = no route. Set via [`with_route`](Self::with_route)
    /// / [`set_route`](Self::set_route); its vertex count is in `state_json["route"]`.
    route: Vec<(f64, f64)>,
    /// Last frame's on-screen road-name label count (ephemeral paint cache, FC-7) —
    /// the red-when-broken oracle for the priority/collision label layer.
    last_vis_labels: Cell<usize>,
    /// Last frame's PIN-label count after collision avoidance (ephemeral, FC-7) — the
    /// labels actually printed. Pins clustered from the start; their labels did not,
    /// which is why this number exists: `pin_labels + pin_labels_suppressed` is what a
    /// naive painter would have printed on top of each other.
    last_pin_labels: Cell<usize>,
    /// Last frame's pin labels dropped because they would have overplotted.
    last_pin_labels_suppressed: Cell<usize>,
    /// **Building-vertex DEBUG mode** (default `false`): draw the point layer at
    /// every zoom in a flashing high-contrast colour. An opt-in developer view,
    /// never the shipped look; driven by [`Msg::SetDebugPoints`].
    debug_points: bool,
    /// Whether the [building-vertex LOD](Self::points_pass_lod) culled the point
    /// layer last frame (ephemeral paint cache, FC-7). `true` => ZERO point circles
    /// were emitted — the "no sea of corners when zoomed out" oracle.
    last_points_culled: Cell<bool>,
    /// The [ink fraction](Self::point_ink_frac) the point layer would have painted
    /// last frame (ephemeral paint cache, FC-7) — the quantity the LOD compares
    /// against [`POINT_INK_FRAC_MAX`], published so the decision is auditable data.
    last_point_ink: Cell<f32>,
}

impl OsmView {
    /// Build a viewer from a point layer + a way layer. The camera fits the data
    /// on the first paint. `ways` may be empty (point-only) or `points` empty
    /// (ways-only) — both are graceful.
    pub fn new(title: impl Into<String>, points: Vec<(f64, f64)>, ways: Vec<Way>) -> Self {
        Self {
            title: title.into(),
            points: PointGrid::build(points),
            ways: MapIndex::build(ways),
            camera: MercatorCamera::default(),
            fitted: false,
            fitted_size: (0.0, 0.0),
            fit_zoom: MercatorCamera::default().zoom,
            user_navigated: false,
            scratch: RefCell::new(Vec::with_capacity(1024)),
            clip_runs: RefCell::new(Vec::with_capacity(8)),
            vis_pts: RefCell::new(Vec::with_capacity(4096)),
            #[cfg(feature = "map-wgpu")]
            use_gpu: false,
            #[cfg(feature = "map-wgpu")]
            gpu_uploaded: std::sync::atomic::AtomicBool::new(false),
            last_drew_gpu: Cell::new(false),
            last_vis_points: Cell::new(0),
            last_vis_ways: Cell::new(0),
            last_point_stride: Cell::new(1),
            last_drawn_points: Cell::new(0),
            fps: Cell::new(Fps::default()),
            // A huge extent: the camera clamps the world, the scroll axis only
            // carries the *momentum integrator* (we read its frame-to-frame delta).
            pan_x: SmoothScroll { max: f32::MAX, friction: 4.0, ..SmoothScroll::default() },
            pan_y: SmoothScroll { max: f32::MAX, friction: 4.0, ..SmoothScroll::default() },
            palette: facett_map::PaletteMode::default(),
            pins: Vec::new(),
            selected_pin: None,
            last_vis_pins: Cell::new(0),
            cluster_px: cluster::CLUSTER_BIN_PX,
            last_vis_clusters: Cell::new(0),
            graph: None,
            last_vis_graph: Cell::new(0),
            route: Vec::new(),
            last_vis_labels: Cell::new(0),
            last_pin_labels: Cell::new(0),
            last_pin_labels_suppressed: Cell::new(0),
            debug_points: false,
            last_points_culled: Cell::new(false),
            last_point_ink: Cell::new(0.0),
        }
    }

    /// **Attach a route-line highlight** — a `(lon,lat)` polyline painted as a
    /// bright cased ribbon (the "you are going here" line) on top of the basemap,
    /// below the pins, with direction chevrons. Registered to the same
    /// [`to_px`](MercatorCamera::to_px) transform as every other layer, so it leans
    /// with the 2.5D tilt and stays aligned with the roads. Chainable; replaces any
    /// previously-set route. Reported in `state_json["route"]`.
    pub fn with_route(mut self, route_lonlat: impl Into<Vec<(f64, f64)>>) -> Self {
        self.route = route_lonlat.into();
        self
    }

    /// Set (or clear, with an empty slice) the [route-line highlight](Self::with_route)
    /// at runtime.
    pub fn set_route(&mut self, route_lonlat: impl Into<Vec<(f64, f64)>>) {
        self.route = route_lonlat.into();
    }

    /// **Attach a graph overlay** — a facett-graphview [`GeoGraph`] drawn over the
    /// basemap, each node placed at its geo `(lon,lat)` and projected through the
    /// map camera (shared coordinate space). Chainable.
    pub fn with_graph(mut self, graph: GeoGraph) -> Self {
        self.graph = Some(graph);
        self
    }

    /// **Attach an address-pin overlay** — labelled markers registered to the map
    /// transform (drawn topmost, hit-tested on click). Chainable; replaces any
    /// previously-set pins. Reuses the GeoMap marker + stable-id pattern.
    /// Set the **pin-cluster bin width** in screen pixels. Pins closer than this
    /// collapse into one count badge. `0` (or negative) turns clustering OFF, so
    /// every pin paints as itself — the pre-clustering behaviour, kept as the
    /// escape hatch for hosts that place a handful of deliberate markers.
    pub fn with_cluster_px(mut self, px: f32) -> Self {
        self.cluster_px = px.max(0.0);
        self
    }

    /// The active pin-cluster bin width, screen px (`0` = clustering off).
    #[must_use]
    pub fn cluster_px(&self) -> f32 {
        self.cluster_px
    }

    /// The pins registered on this view (read-only).
    #[must_use]
    pub fn pins(&self) -> &[Pin] {
        &self.pins
    }

    /// **Cluster the on-screen pins for `rect`** — the exact marker set the
    /// painter draws and the click hit-test resolves against, exposed so a
    /// headless test / host can assert it without a frame. Off-screen pins are
    /// dropped first (with the same 24 px slack the painter uses), then the
    /// survivors are binned by [`cluster::cluster_projected`].
    #[must_use]
    pub fn clusters_for(&self, rect: egui::Rect) -> Vec<cluster::ClusterNode> {
        self.clusters_for_cam(&self.camera, rect)
    }

    /// [`Self::clusters_for`] projected through an EXPLICIT camera — the one the
    /// caller is painting this frame with.
    ///
    /// `ui()` paints every frame through a local copy of the camera that has this
    /// frame's transitions replayed onto it (the zoom ease tick, the drag delta, the
    /// inertial glide), while `self.camera` is the persisted Model that catches up
    /// one frame later via the returned `Msg`s. The pin markers, their labels and the
    /// click hit-test used to be the ONE thing on the pane projected through
    /// `self.camera` — so during a `TickZoom` ease the basemap moved this frame and
    /// the pins moved the next, and every pin visibly slid off its anchor while the
    /// zoom settled ("the pins slide during zoom"). Everything the pane draws must go
    /// through the same camera in the same frame; this is the door that makes that
    /// possible for the pins. The Model-camera `clusters_for` stays for headless
    /// callers that have no frame.
    #[must_use]
    pub fn clusters_for_cam(&self, cam: &MercatorCamera, rect: egui::Rect) -> Vec<cluster::ClusterNode> {
        if self.pins.is_empty() {
            return Vec::new();
        }
        let mut projected: Vec<(egui::Pos2, String, String)> = Vec::with_capacity(self.pins.len());
        let mut index: Vec<usize> = Vec::with_capacity(self.pins.len());
        for (i, pin) in self.pins.iter().enumerate() {
            let (mx, my) = mercator(pin.lonlat.0, pin.lonlat.1);
            let p = cam.to_px(mx, my, rect);
            if !rect.expand(24.0).contains(p) {
                continue;
            }
            projected.push((p, pin.id.clone(), pin.label.clone()));
            index.push(i);
        }
        let mut out = cluster::cluster_projected(&projected, self.cluster_px);
        // Re-map member indices from the on-screen subset back onto `self.pins`,
        // so `member_lonlat_bbox` and a host's inspect path address the real pins.
        for c in &mut out {
            for m in &mut c.members {
                *m = index[*m];
            }
        }
        out
    }

    /// **Place the single pins' LABELS with collision avoidance** — the other half of
    /// the clustering lesson.
    ///
    /// Clustering fixed the *markers*: a pile of teardrops became one badge carrying a
    /// count, so no information was lost. It did nothing for the *text*. A cluster of
    /// exactly one pin is a real pin with a real name, and a street of them printed
    /// every name at its own anchor — on top of the next one. The pixels said "there
    /// are pins here"; they no longer said which.
    ///
    /// Text cannot be merged the way markers can (two overlapping names are strictly
    /// less readable than one), so the only honest move is to rank and drop, which is
    /// exactly what [`facett_core::legibility::place_labels`] does — the SAME placer
    /// the graph panes use (L5), not a second implementation living in the map.
    ///
    /// Ranking: the selected pin is **pinned** (never dropped — it is the answer to the
    /// user's click), then the shorter name wins, because a short name fits where a
    /// long one does not and dropping the long one costs the fewest labels overall.
    ///
    /// Returns `(cluster index → the box its label won, in screen px, suppressed count)`.
    /// Group badges carry their count inside their own disc and never compete.
    #[must_use]
    pub fn place_pin_labels(
        &self,
        painter: &egui::Painter,
        clusters: &[cluster::ClusterNode],
        rect: egui::Rect,
    ) -> (std::collections::BTreeMap<usize, egui::Rect>, usize) {
        use facett_core::legibility::{place_labels, LabelCandidate, PlaceOpts};
        let mut out = std::collections::BTreeMap::new();
        if clusters.is_empty() {
            return (out, 0);
        }
        let font = egui::FontId::proportional(11.0);
        // A candidate per cluster, index-aligned. Anything that offers no label (a
        // group badge, an unnamed pin) is parked far off-viewport so it is neither
        // considered nor counted as suppressed.
        let far = egui::pos2(f32::MAX * 0.5, f32::MAX * 0.5);
        let cands: Vec<LabelCandidate> = clusters
            .iter()
            .map(|c| {
                if !c.is_single() || c.label.is_empty() {
                    return LabelCandidate::new(far, egui::vec2(1.0, 1.0), 0.0);
                }
                let galley = painter.layout_no_wrap(c.label.clone(), font.clone(), egui::Color32::WHITE);
                LabelCandidate::new(
                    // The label's natural home is just above the marker head.
                    c.pos + egui::vec2(0.0, -(6.0 + 3.0 + galley.size().y * 0.5)),
                    galley.size() + egui::vec2(4.0, 2.0),
                    -(c.label.chars().count() as f32),
                )
                .pinned(self.selected_pin.as_deref() == Some(c.id.as_str()))
            })
            .collect();
        let placed = place_labels(&cands, rect, PlaceOpts { pad: 1.0, variable: true, ..PlaceOpts::default() });
        for p in &placed.placed {
            out.insert(p.index, p.rect);
        }
        (out, placed.suppressed)
    }

    pub fn with_pins(mut self, pins: Vec<Pin>) -> Self {
        self.pins = pins;
        self
    }

    /// **Set the 2.5D tilt** — pitch the flat ground plane back toward the horizon
    /// by `deg` degrees (clamped to `[0, TILT_MAX_DEG]`; `0` = top-down, the
    /// default). Chainable. Every layer projects through the camera's
    /// [`to_px`](MercatorCamera::to_px), so the whole map — ways, points, pins and
    /// the graph overlay — leans as one, staying registered. Reported in
    /// `state_json["pitch_deg"]`.
    pub fn with_tilt(mut self, deg: f64) -> Self {
        self.camera.set_pitch(deg);
        self
    }

    /// The screen position of a pin under this view's live camera (the CPU f64
    /// `to_px` lane) for `rect`. The single projection used by both the marker
    /// paint and the click hit-test, so a pin lands exactly where it is drawn —
    /// and shares the way/point coordinate space (a shared-feature alignment guard
    /// projects a pin and a co-located way vertex through this + `cam.to_px`).
    pub fn pin_screen_pos(&self, pin: &Pin, rect: egui::Rect) -> egui::Pos2 {
        Self::pin_screen_pos_cam(&self.camera, pin, rect)
    }

    /// [`Self::pin_screen_pos`] through an explicit camera — see
    /// [`Self::clusters_for_cam`] for why the frame's camera and the Model camera
    /// are two different things for one frame.
    #[must_use]
    pub fn pin_screen_pos_cam(cam: &MercatorCamera, pin: &Pin, rect: egui::Rect) -> egui::Pos2 {
        let (mx, my) = mercator(pin.lonlat.0, pin.lonlat.1);
        cam.to_px(mx, my, rect)
    }

    /// **Select the basemap palette.** [`PaletteMode::Theme`](facett_map::PaletteMode)
    /// keeps the re-themeable look (the default); a `Cartographic*` mode selects a
    /// fixed concrete-hex preset (day = light paper, night = dark skade basemap)
    /// and **reskins the already-built way set** in place (kind + width keyed, no
    /// source re-read) so the land background, area fills and road-tier hues all
    /// switch together. Chainable; call before the first paint so the GPU
    /// upload-once latch bakes the preset colours.
    pub fn with_palette(mut self, mode: facett_map::PaletteMode) -> Self {
        self.palette = mode;
        if mode.is_cartographic() {
            self.ways.recolor(&mode.palette(&facett_core::Theme::default()));
        }
        self
    }

    /// Convenience: the cartographic preset (`day` → light paper, else dark
    /// skade basemap). Shorthand for
    /// [`with_palette`](Self::with_palette)`(CartographicDay|CartographicNight)`.
    pub fn cartographic(self, day: bool) -> Self {
        let mode = if day {
            facett_map::PaletteMode::CartographicDay
        } else {
            facett_map::PaletteMode::CartographicNight
        };
        self.with_palette(mode)
    }

    /// **Real-data constructor** — build the viewer from pre-loaded OSM geometry.
    /// The host (e.g. facett-demo / korp) reads a GeoParquet `nodes.parquet` →
    /// `(lon, lat)` points and a `ways.parquet` → [`Way`]s (via `facett-osm`) and
    /// hands them here. Identical to [`Self::new`]; named for intent at the call
    /// site (this is the lane the demo's OSM 2D tab uses for real liechtenstein /
    /// Europe data). `points`/`ways` may each be empty (graceful).
    pub fn from_geometry(title: impl Into<String>, points: Vec<(f64, f64)>, ways: Vec<Way>) -> Self {
        Self::new(title, points, ways)
    }

    /// **`map-wgpu` feature.** Opt the line-ways layer into the GPU fast-path
    /// (upload-once compute-cull + `draw_indirect`). The host must have installed a
    /// [`facett_map::gpu::GpuMapRenderer`] into its `egui_wgpu::RenderState` (via
    /// `facett_map::gpu::install_renderer`); if it hasn't, the callback gracefully
    /// no-ops and the CPU painter still draws the basemap. Chainable.
    #[cfg(feature = "map-wgpu")]
    pub fn with_gpu(mut self, on: bool) -> Self {
        self.use_gpu = on;
        self
    }

    /// Draw the **line-ways** layer through the GPU compute-cull + draw_indirect
    /// kernel, driven by the LIVE [`MercatorCamera`] (so pan/zoom is a uniform
    /// update inside the callback, not a per-frame rebuild). Returns `true` if the
    /// callback was emitted; `false` falls back to the CPU line painter.
    ///
    /// The shader mirrors the camera's `to_px = rect.center + (merc − center)·zoom`,
    /// but **origin-local** to dodge f32 catastrophic cancellation at high zoom: the
    /// GPU vertices are baked as `mercator_f64(lonlat) − origin` and the shader
    /// projects `(pos − ref)·zoom + screen_center` with `ref = camera_center − origin`
    /// (see [`facett_map::gpu::MapTransform`]). `origin` (dataset Mercator bbox-min,
    /// f64) is the single source of truth shared by the vertex bake, the shader `ref`
    /// and the cull viewport. **GPU-only — cannot be exercised by the headless CPU
    /// harness.**
    #[cfg(feature = "map-wgpu")]
    fn draw_ways_gpu(&self, ui: &mut egui::Ui, rect: egui::Rect, cam: MercatorCamera) -> bool {
        use std::sync::atomic::Ordering;
        // The GPU kernel projects flat (uniform zoom, no pitch). Under a 2.5D tilt
        // it would NOT lean, so it would drift out of the CPU-drawn points/pins/graph
        // — fall back to the CPU line painter, which shares the tilted `to_px`.
        if self.ways.is_empty() || !self.use_gpu || cam.pitch_deg != 0.0 {
            return false;
        }
        // FAIL-SAFE: handing the line layer to the GPU means NOT drawing it on the
        // CPU this frame. If the host never installed the `GpuMapRenderer` the paint
        // callback silently does nothing and the map would render with no line ways
        // at all — worse than slow. The headless `egui_kittest` harness is exactly
        // such a host (it HAS a RenderState, so "is there a RenderState" is the wrong
        // question). Unknown ⇒ CPU.
        if !facett_map::gpu::renderer_installed() {
            return false;
        }
        // Data-local origin (f64) over all ways' Mercator bbox-mins — the SAME value
        // fed to the vertex bake and the per-frame transform. It is a CONSTANT of the
        // way set, so it comes from the index's cached field (O(1)). It used to call
        // `facett_map::gpu::ways_origin(self.ways.gpu_ways())` right here, which
        // walked every way in the dataset on EVERY frame: 7493600 ns per frame at 1M
        // ways, for a value that never changes (see `.nornir/osm-2d-perf-design.md`).
        let Some(origin) = self.ways.origin() else {
            return false;
        };
        let zoom = cam.zoom as f32;
        // Uniform camera: one zoom on both axes, centred on `cam.center`.
        let transform =
            facett_map::gpu::MapTransform::new(origin, [cam.center.0, cam.center.1], zoom, zoom, rect);
        let viewport_bbox = transform.viewport_bbox(rect);

        // Build + upload the line-ways geometry exactly once (filled silhouettes
        // are skipped — they draw on the CPU as egui polygon fills).
        let geometry = if !self.gpu_uploaded.swap(true, Ordering::Relaxed) {
            Some(Box::new(facett_map::gpu::build_gpu_geometry(
                self.ways.gpu_ways().filter(|(w, _)| w.fill.is_none()),
                origin,
            )))
        } else {
            None
        };

        ui.painter().add(egui_wgpu::Callback::new_paint_callback(
            rect,
            facett_map::gpu::MapPaintCallback {
                // Which pane, in which frame — the key facett-map keys its per-pane
                // offscreen target on (GG #37: two map panes in one frame each need their
                // own, or the second callback overwrites the first's cleared target
                // before it has painted). `ui.id()` is what `PaneStamp` asks for: stable
                // across frames for a stable layout, distinct between two Uis in one
                // frame, and NOT the rect (which changes on every layout animation and
                // would reallocate the target per frame).
                stamp: facett_map::gpu::PaneStamp::of(ui, ui.id()),
                geometry,
                tiles: None,
                viewport_bbox,
                lod: self.way_lod(),
                transform,
                // ONE width formula with the CPU painter (deck.gl's widthScale +
                // widthMinPixels/widthMaxPixels), applied per frame in the shader.
                width_scale: facett_map::style::line_width_scale(cam.zoom),
                // `OsmView` hands the lane only unfilled ways; there is no fill
                // stream to tint, so the identity.
                fill_tint: facett_core::render::gpu::DrawUniforms::NO_TINT,
                // The casing ink comes off the SAME palette the CPU painter above
                // resolves (`self.palette.palette(&theme(ui))`), not the shader's
                // `LINE_CASING_COL` fallback — otherwise a cartographic-night basemap
                // draws its road casings in the day palette's ink the moment the GPU
                // lane takes over, and the two lanes disagree on screen.
                casing_col: Some(
                    self.palette.palette(&theme(ui)).casing.to_normalized_gamma_f32(),
                ),
            },
        ));
        true
    }

    /// **Discovery-contract `local()`** — the canonical no-arg constructor the test
    /// matrix calls to enumerate this facet. Builds a small, deterministic sample
    /// (a Liechtenstein-ish point cluster + a motorway + a water ring) so the view
    /// renders real geometry with no fixture on disk.
    pub fn local() -> Self {
        let (points, ways) = sample_dataset(2_000);
        Self::new("OSM 2D", points, ways)
    }

    /// **Discovery-contract `remote()`** — same surface, the variant a host would
    /// build from a server-loaded dataset. Identical wiring here (no transport in
    /// facett); kept so the matrix sees both ctors.
    pub fn remote() -> Self {
        let mut v = Self::local();
        v.title = "OSM 2D (remote)".into();
        v
    }

    /// Zoom-driven point LOD stride **floor**: a cheap, rect-free heuristic that
    /// decimates harder the further you zoom out. This alone is NOT enough on a
    /// small-but-dense country: Liechtenstein's whole-country fit still has a huge
    /// absolute `zoom` (px per Mercator-unit) because its Mercator span is tiny, so
    /// this returns 1 ("draw everything") while 353 k points are in view — the
    /// sluggish bug. [`budget_stride`](Self::budget_stride) raises this floor by the
    /// actual visible count so the painter never draws more than [`MAX_DRAWN_POINTS`].
    fn point_stride(&self) -> usize {
        let z = self.camera.zoom;
        if z >= 100_000.0 {
            1
        } else if z >= 20_000.0 {
            2
        } else if z >= 4_000.0 {
            4
        } else if z >= 1_000.0 {
            16
        } else {
            64
        }
    }

    /// The **drawn-point stride** the painter uses: the zoom-LOD [`point_stride`]
    /// floor, raised so the number of circles actually emitted stays within
    /// [`MAX_DRAWN_POINTS`]. `visible` is the honest count of points inside the
    /// viewport this frame. Bounds per-frame CPU painter work to a constant
    /// regardless of dataset size or absolute zoom — the fix for the 2D sluggish
    /// bug (the GPU only draws the line ways; points are CPU `circle_filled`s).
    fn budget_stride(&self, visible: usize) -> usize {
        let floor = self.point_stride();
        // Ceil-divide so `visible / stride <= MAX_DRAWN_POINTS`.
        let need = visible.div_ceil(MAX_DRAWN_POINTS.max(1));
        // …and cap the zoom floor so it can never thin below [`MIN_DRAWN_POINTS`].
        // The zoom tier is a thinning optimisation for CROWDED views and reads only
        // the camera, so applied unconditionally it decimated a sparse dataset by the
        // same factor as a dense one — 43 points at a whole-dataset fit drew ONE.
        // `need` still wins over the cap, so a genuinely dense view is unaffected.
        let cap = visible.div_ceil(MIN_DRAWN_POINTS.max(1)).max(1);
        floor.min(cap).max(need).max(1)
    }

    /// The **ink fraction** the point layer would paint: the combined area of
    /// `drawn` dots of radius `dot_px` as a fraction of the pane. The single
    /// quantity the [building-vertex LOD](POINT_INK_FRAC_MAX) is defined on, so a
    /// test asserts exactly the number the painter branches on. Pure; no state.
    #[must_use]
    pub fn point_ink_frac(drawn: usize, dot_px: f32, rect: egui::Rect) -> f32 {
        let pane = (rect.width() * rect.height()).max(1.0);
        (drawn as f32 * std::f32::consts::PI * dot_px * dot_px) / pane
    }

    /// The **dot radius** (px) the point layer paints at this camera zoom. ONE
    /// definition shared by the painter and the LOD test — they must agree, or the
    /// oracle would measure ink the painter never emits.
    #[must_use]
    pub fn point_dot_px(zoom: f64) -> f32 {
        (zoom / 200_000.0).clamp(1.0, 3.0) as f32
    }

    /// **The building-vertex LOD** — may the point layer draw at all this frame?
    ///
    /// `false` culls it outright (ZERO circle primitives emitted), which is the
    /// zoomed-out case: OSM nodes are mostly building corners, and at a town-wide
    /// camera they merge into a coloured wash instead of marking anything. See
    /// [`POINT_INK_FRAC_MAX`] for why the rule is screen ink and not a zoom cutoff.
    ///
    /// [Debug mode](Self::debug_points) forces this `true` — the entire purpose of
    /// the developer view is to SEE where the vertices are, including where the LOD
    /// would normally have hidden them.
    #[must_use]
    pub fn points_pass_lod(&self, drawn: usize, dot_px: f32, rect: egui::Rect) -> bool {
        self.debug_points || Self::point_ink_frac(drawn, dot_px, rect) <= POINT_INK_FRAC_MAX
    }

    /// **Debug mode** for the building-vertex layer (default OFF): draws the point
    /// layer at EVERY zoom in a deliberately conspicuous flashing high-contrast
    /// colour, so a developer can see which nodes the layer holds and where.
    /// Reported in `state_json["debug_points"]`; reachable from a host UI through
    /// the pane's own **"debug pts" checkbox**, the **`V` key** (`D` is already the
    /// WASD pan-right alias), or [`Msg::SetDebugPoints`].
    #[must_use]
    pub fn debug_points(&self) -> bool {
        self.debug_points
    }

    /// Chainable [debug-mode](Self::debug_points) opt-in.
    #[must_use]
    pub fn with_debug_points(mut self, on: bool) -> Self {
        self.debug_points = on;
        self
    }

    /// The colour a building-vertex dot paints in.
    ///
    /// Normally the **building outline stroke** of the active palette
    /// ([`facett_map::MapPalette::building_stroke`] — the SAME ink the footprint
    /// edge is drawn in, consumed not twinned) so a corner reads as part of the wall
    /// it sits on. The layer used to paint the *theme*\'s `point` hue, which on the
    /// cartographic-day paper is an unrelated blue: hence "the corners of the houses
    /// are blue".
    ///
    /// In [debug mode](Self::debug_points) it is a high-contrast magenta/lime
    /// **flash** driven by `time_s` — no cartographic palette contains either
    /// colour, so debug ink can never be mistaken for map ink. Pure, and
    /// deterministic in `time_s`, so a headless test pins both phases.
    #[must_use]
    pub fn point_color(&self, pal: &facett_map::MapPalette, time_s: f64) -> egui::Color32 {
        if !self.debug_points {
            return pal.building_stroke();
        }
        if (time_s * 3.0).rem_euclid(1.0) < 0.5 {
            egui::Color32::from_rgb(255, 0, 220)
        } else {
            egui::Color32::from_rgb(190, 255, 0)
        }
    }

    /// Zoom\'s LOD tier for the way cull — **the shared cartographic ladder**
    /// ([`facett_map::layer::view_lod_tier`] over `LOD_TIER_ZOOMS`), the ONE
    /// writer the CPU painter, the label gate and the GPU cull all read.
    ///
    /// This was a private three-tier twin stepping at fit-multiplier `3_000` /
    /// `30_000` (slippy ~3.6 / ~6.9 — levels where the real ladder still says
    /// Country / Region) that never emitted [`facett_map::style::Lod::Detail`] at all, so
    /// footway-tier ways were undrawable at every zoom in this viewer. The only
    /// local arithmetic left is the unit change: `camera.zoom` is px per
    /// Mercator world unit, and the slippy zoom whose world span is that many px
    /// is `log2(zoom / 256)` — the inverse of `MapView::world_px`\'s `256·2^zoom`.
    fn way_lod(&self) -> u32 {
        facett_map::layer::view_lod_tier((self.camera.zoom.max(1.0) / 256.0).log2() as f32)
    }

    /// The full dataset bbox (points ∪ ways) in Mercator-unit space — the pure
    /// (`&self`) source both [`fit`](Self::fit) and the FC-9 [`view`](Self::view)
    /// first-paint camera compute from.
    fn fit_bbox(&self) -> Option<[f64; 4]> {
        let mut b: Option<[f64; 4]> = None;
        let mut acc = |x: f64, y: f64| {
            b = Some(match b {
                None => [x, y, x, y],
                Some([a, c, d, e]) => [a.min(x), c.min(y), d.max(x), e.max(y)],
            });
        };
        for &(lon, lat) in &self.points.points {
            let (x, y) = mercator(lon, lat);
            acc(x, y);
        }
        for (w, _) in self.ways.gpu_ways() {
            acc(w.bbox[0], w.bbox[1]);
            acc(w.bbox[2], w.bbox[3]);
        }
        b
    }

    /// Fit the camera to the full dataset bbox (points ∪ ways). The ONE place a fit
    /// happens, so it is also the one place [`Self::fit_zoom`] — the map's native
    /// `100%`, the base the deck's scale knob multiplies — is recorded.
    fn fit(&mut self, rect: egui::Rect) {
        if let Some(bbox) = self.fit_bbox() {
            self.camera.fit_merc_bbox(bbox, rect);
            self.fit_zoom = self.camera.zoom;
        }
    }

    /// **Pure** navigation-input reader (FC-9): translate this frame's wheel / drag
    /// / keyboard / pinch into [`Msg`]s. It applies each transition to a local
    /// camera *copy* `cam` (so the same frame paints the moved map — no one-frame
    /// lag) and pushes the matching [`Msg`] so [`update`](Self::update) persists it
    /// into the [`MercatorCamera`] Model. It never mutates `self` (the momentum
    /// integrators are seeded by the emitted `Msg`s, not here).
    fn collect_input(
        &self,
        ui: &mut egui::Ui,
        rect: egui::Rect,
        resp: &egui::Response,
        cam: &mut MercatorCamera,
        msgs: &mut Vec<Msg>,
    ) {
        // ── click-drag pan (+ seed inertial momentum on release, as Msgs) ──────
        if resp.dragged() {
            let d = resp.drag_delta();
            let dt = ui.input(|i| i.stable_dt).max(1e-3);
            cam.pan_px(d.x, d.y);
            msgs.push(Msg::DragPan { dx: d.x, dy: d.y, dt });
        } else if resp.drag_stopped() {
            msgs.push(Msg::ReleaseDrag);
        }

        // ── pointer: wheel zoom-to-cursor + trackpad pinch + two-finger pan ─────
        let pointer = ui.input(|i| i.pointer.hover_pos()).unwrap_or(rect.center());
        let anchor = if rect.contains(pointer) { pointer } else { rect.center() };
        let (scroll, zoom_delta, modifiers) =
            ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers));
        if resp.hovered() {
            // macOS trackpad pinch (+ ctrl-wheel on Windows) → multiplicative zoom
            // toward the cursor. egui folds the pinch + ctrl-wheel into `zoom_delta`.
            if (zoom_delta - 1.0).abs() > 1e-4 {
                cam.zoom_to(zoom_delta as f64, anchor, rect);
                msgs.push(Msg::ZoomTo { k: zoom_delta as f64, anchor, rect });
            } else if scroll.y.abs() > 0.0 && !modifiers.ctrl {
                // Plain vertical wheel → zoom to the cursor (the slippy-map standard).
                let k = (1.0 + scroll.y as f64 * WHEEL_ZOOM_RATE).clamp(0.5, 2.0);
                cam.zoom_to(k, anchor, rect);
                msgs.push(Msg::ZoomTo { k, anchor, rect });
            }
            // Two-finger horizontal scroll (and vertical when a pinch is active)
            // pans — natural-scroll friendly: egui already honours the OS setting.
            if scroll.x.abs() > 0.0 {
                cam.pan_px(scroll.x, 0.0);
                msgs.push(Msg::PanPx(scroll.x, 0.0));
            }
        }

        // ── keyboard: pan (arrows + WASD) & zoom (+/−, PageUp/Down) ─────────────
        // Pan via the per-OS keymap's Left/Right/Up/Down + the WASD aliases.
        let km = keymap(ui);
        let mut dx = 0.0;
        let mut dy = 0.0;
        if km.consume(Action::Left, ui) {
            dx -= KEY_PAN_FRAC;
        }
        if km.consume(Action::Right, ui) {
            dx += KEY_PAN_FRAC;
        }
        if km.consume(Action::Up, ui) {
            dy -= KEY_PAN_FRAC;
        }
        if km.consume(Action::Down, ui) {
            dy += KEY_PAN_FRAC;
        }
        // WASD aliases (raw keys — they aren't in the semantic keymap).
        ui.input(|i| {
            if i.key_pressed(egui::Key::A) {
                dx -= KEY_PAN_FRAC;
            }
            if i.key_pressed(egui::Key::D) {
                dx += KEY_PAN_FRAC;
            }
            if i.key_pressed(egui::Key::W) {
                dy -= KEY_PAN_FRAC;
            }
            if i.key_pressed(egui::Key::S) {
                dy += KEY_PAN_FRAC;
            }
        });
        // `V` toggles the building-VERTEX debug view (`D` is taken by the WASD pan).
        if ui.input(|i| i.key_pressed(egui::Key::V)) {
            msgs.push(Msg::SetDebugPoints(!self.debug_points));
        }
        if dx != 0.0 || dy != 0.0 {
            cam.pan_frac(dx, dy, rect);
            msgs.push(Msg::PanFrac { fx: dx, fy: dy, rect });
        }
        // Zoom: the keymap's ZoomIn/ZoomOut (Cmd/Ctrl-+/−) + bare PageUp/PageDown.
        if km.consume(Action::ZoomIn, ui) {
            cam.zoom_by(KEY_ZOOM_STEP);
            msgs.push(Msg::ZoomBy(KEY_ZOOM_STEP));
        }
        if km.consume(Action::ZoomOut, ui) {
            cam.zoom_by(1.0 / KEY_ZOOM_STEP);
            msgs.push(Msg::ZoomBy(1.0 / KEY_ZOOM_STEP));
        }
        if km.consume(Action::PageUp, ui) {
            cam.zoom_by(KEY_ZOOM_STEP);
            msgs.push(Msg::ZoomBy(KEY_ZOOM_STEP));
        }
        if km.consume(Action::PageDown, ui) {
            cam.zoom_by(1.0 / KEY_ZOOM_STEP);
            msgs.push(Msg::ZoomBy(1.0 / KEY_ZOOM_STEP));
        }
        // Fit: the keymap's FitToView (Cmd/Ctrl-0) OR a bare `F` (the map
        // convention: "frame the data"). Bare `F` is read raw since the semantic
        // keymap binds `F` to Find.
        let fit = km.consume(Action::FitToView, ui) || ui.input(|i| i.key_pressed(egui::Key::F));
        if fit {
            if let Some(b) = self.fit_bbox() {
                cam.fit_merc_bbox(b, rect);
            }
            msgs.push(Msg::Fit(rect));
        }

        // ── address-pin click hit-test (FC-5 select) ──────────────────────────
        // A click resolves to the nearest pin whose marker it lands in (within a
        // fixed pixel radius); a click on empty canvas clears the selection. The
        // pin is projected through the SAME `to_px` the marker paint uses, so the
        // hit-test lands exactly where the pin is drawn. Drags never select.
        if resp.clicked()
            && !self.pins.is_empty()
            && let Some(click) = resp.interact_pointer_pos()
        {
            const HIT_R: f32 = 14.0;
            // Resolve against the CLUSTERED marker set — the same nodes the
            // painter drew — so a click always hits what is visible. Clicking a
            // group selects the group's stable id (and a host can zoom to
            // `member_lonlat_bbox`); clicking a single selects the pin id, exactly
            // as before clustering existed. A group's larger badge gets a
            // proportionally larger hit radius so the click target matches the ink.
            let mut best: Option<(String, f32)> = None;
            for node in self.clusters_for_cam(cam, rect) {
                let radius = if node.is_single() {
                    HIT_R
                } else {
                    (11.0 + 3.4 * (node.count as f32).ln()).min(26.0) + 2.0
                };
                let d = node.pos.distance(click);
                if d <= radius && best.as_ref().map(|(_, bd)| d < *bd).unwrap_or(true) {
                    best = Some((node.id.clone(), d));
                }
            }
            match best {
                Some((id, _)) => msgs.push(Msg::SelectPin(id)),
                None if self.selected_pin.is_some() => msgs.push(Msg::ClearPin),
                None => {}
            }
        }
    }
}

/// Public navigation API for headless tests / hosts that drive the camera without
/// an egui input stream (mirrors the discovery-contract "drive it as data" law).
impl OsmView {
    /// Wheel-zoom to a screen anchor (the map standard) — exposed for tests.
    pub fn zoom_to_cursor(&mut self, k: f64, anchor: egui::Pos2, rect: egui::Rect) {
        self.camera.zoom_to(k, anchor, rect);
    }
    /// Pan by a pixel delta — exposed for tests.
    pub fn pan_pixels(&mut self, dx: f32, dy: f32) {
        self.camera.pan_px(dx, dy);
    }
    /// Force-fit the camera to the data for a given rect — exposed for tests.
    pub fn fit_to(&mut self, rect: egui::Rect) {
        self.fit(rect);
        self.fitted = true;
        self.fitted_size = (rect.width(), rect.height());
    }

    /// Seed the inertial-pan glide with a flick velocity (px/s) — exposed so a
    /// headless test can prove the momentum carries the camera after release.
    pub fn flick_pan(&mut self, vx: f32, vy: f32) {
        self.pan_x.flick(vx);
        self.pan_y.flick(vy);
    }

    /// **Advance the inertial-pan momentum** by `dt` seconds: decay the flick
    /// velocity with friction (the CygnusEd `SmoothScroll` model) and pan the
    /// camera by the resulting per-frame delta. Returns `true` while the glide is
    /// still moving (the host requests another repaint). Deterministic under an
    /// injected `dt` — same state + dt → same camera move (FC-7).
    pub fn tick_pan_momentum(&mut self, dt: f32) -> bool {
        let dt = dt.clamp(0.0, 0.1);
        let mut vx = 0.0;
        let mut vy = 0.0;
        let mut moved = false;
        if self.pan_x.velocity.abs() > 0.5 {
            vx = self.pan_x.velocity;
            self.pan_x.velocity *= (1.0 - self.pan_x.friction * dt).clamp(0.0, 1.0);
            moved = true;
        } else {
            self.pan_x.velocity = 0.0;
        }
        if self.pan_y.velocity.abs() > 0.5 {
            vy = self.pan_y.velocity;
            self.pan_y.velocity *= (1.0 - self.pan_y.friction * dt).clamp(0.0, 1.0);
            moved = true;
        } else {
            self.pan_y.velocity = 0.0;
        }
        if moved {
            self.camera.pan_velocity(vx, vy, dt);
        }
        moved
    }
    /// Count of points currently inside the camera's viewport for `rect`
    /// (viewport-culled, no decimation) — the honest visible-feature number.
    pub fn visible_point_count(&self, rect: egui::Rect) -> usize {
        self.points.visible_count(self.camera.viewport_merc_bbox(rect))
    }
    /// Count of ways whose bbox crosses the viewport at the current zoom's LOD.
    pub fn visible_way_count(&self, rect: egui::Rect) -> usize {
        self.ways.query_visible(self.camera.viewport_merc_bbox(rect), self.way_lod()).len()
    }

    /// Whether the host opted the line-ways layer into the GPU fast-path. Always
    /// `false` without the `map-wgpu` feature (so `state_json` reads consistently).
    pub fn gpu_opt_in(&self) -> bool {
        #[cfg(feature = "map-wgpu")]
        {
            self.use_gpu
        }
        #[cfg(not(feature = "map-wgpu"))]
        {
            false
        }
    }
}

/// A robot-/CLI-addressable navigation message (FC-2) — the single named boundary
/// a headless driver (or a live gesture) drives the [`MercatorCamera`] Model
/// through. Applied by [`OsmView::update`], the **only** camera-mutation path.
/// Rect-dependent variants carry the pane rect (zoom-to-cursor / keyboard-frac /
/// fit are defined relative to the viewport) so a driver reaches the identical
/// transitions the canvas gestures drive.
#[derive(Clone, Debug, PartialEq)]
pub enum Msg {
    /// Drag-pan by a pixel delta over `dt` seconds (also tracks the fling velocity
    /// so a subsequent [`ReleaseDrag`](Msg::ReleaseDrag) flicks with a real speed).
    DragPan { dx: f32, dy: f32, dt: f32 },
    /// Drag released — convert the tracked velocity into an inertial-pan flick.
    ReleaseDrag,
    /// A fresh press cancels any in-flight inertial glide (you "catch" the map).
    CancelMomentum,
    /// Two-finger / wheel pan by a pixel delta (no fling seeded).
    PanPx(f32, f32),
    /// Keyboard pan by a fraction of the viewport span (needs the pane `rect`).
    PanFrac { fx: f64, fy: f64, rect: egui::Rect },
    /// Wheel / trackpad-pinch zoom toward a screen `anchor` (the slippy-map
    /// standard: the geo point under the cursor stays put).
    ZoomTo { k: f64, anchor: egui::Pos2, rect: egui::Rect },
    /// Centre-anchored zoom (keyboard `+`/`−` / PageUp/Down) — eases via `target_zoom`.
    ZoomBy(f64),
    /// Set the zoom directly (the deck's scale knob), clamped + snapped to target.
    SetZoom(f64),
    /// Set the 2.5D tilt (pitch, degrees), clamped to `[0, TILT_MAX_DEG]`. `0` =
    /// top-down. A robot driver / a tilt slider reaches the ground-plane lean here.
    SetTilt(f64),
    /// Fit the camera to the data (points ∪ ways) for the pane `rect`.
    Fit(egui::Rect),
    /// Advance the inertial-pan momentum by `dt` seconds (decay + camera glide).
    TickMomentum(f32),
    /// Ease the damped `zoom` one step toward `target_zoom`.
    TickZoom,
    /// Select an address pin by its **stable id** (FC-5) — a click that lands in a
    /// pin's marker, or a headless driver.
    SelectPin(String),
    /// Clear the pin selection (a click on empty canvas).
    ClearPin,
    /// Turn the **building-vertex debug view** on/off (default off): draw the point
    /// layer at every zoom in a flashing high-contrast colour, bypassing the
    /// [LOD cull](OsmView::points_pass_lod). Driven by the pane's own checkbox, the
    /// `V` key, or a headless driver.
    SetDebugPoints(bool),
}

/// Side work as data (FC-8). The viewer does no I/O — every [`Msg`] mutates only
/// the in-memory camera + momentum integrators — so this is uninhabited on purpose.
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}

impl OsmView {
    /// **FC-3** — read the complete observable navigation state (the live camera).
    /// The camera, mutable — for hosts and headless tests that frame the view
    /// directly (e.g. `fit_merc_bbox` onto a feature's own bounds) rather than
    /// replaying an input stream. Additive; the `Msg` path is unchanged.
    pub fn camera_mut(&mut self) -> &mut MercatorCamera {
        &mut self.camera
    }

    pub fn state(&self) -> &MercatorCamera {
        &self.camera
    }

    /// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
    /// (always empty) [`Effect`]s. Every canvas gesture routes its [`Msg`] here too,
    /// so live == headless.
    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
        match msg {
            Msg::DragPan { dx, dy, dt } => {
                self.user_navigated = true;
                self.camera.pan_px(dx, dy);
                // Track the live drag velocity so a release flicks with a real speed.
                self.pan_x.velocity = dx / dt;
                self.pan_y.velocity = dy / dt;
            }
            Msg::ReleaseDrag => {
                // Clamp so a flick can't teleport (the CygnusEd inertial model).
                const MAX_FLICK: f32 = 6_000.0;
                self.pan_x.flick(self.pan_x.velocity.clamp(-MAX_FLICK, MAX_FLICK));
                self.pan_y.flick(self.pan_y.velocity.clamp(-MAX_FLICK, MAX_FLICK));
            }
            Msg::CancelMomentum => {
                self.pan_x.velocity = 0.0;
                self.pan_y.velocity = 0.0;
            }
            Msg::PanPx(dx, dy) => {
                self.user_navigated = true;
                self.camera.pan_px(dx, dy);
            }
            Msg::PanFrac { fx, fy, rect } => {
                self.user_navigated = true;
                self.camera.pan_frac(fx, fy, rect);
            }
            Msg::ZoomTo { k, anchor, rect } => {
                self.user_navigated = true;
                self.camera.zoom_to(k, anchor, rect);
            }
            Msg::ZoomBy(k) => {
                self.user_navigated = true;
                self.camera.zoom_by(k);
            }
            Msg::SetZoom(z) => {
                // An explicit zoom request is navigation: it must never be overridden
                // by a later resize re-fit.
                self.user_navigated = true;
                let z = z.clamp(ZOOM_MIN, ZOOM_MAX);
                self.camera.zoom = z;
                self.camera.target_zoom = z;
            }
            Msg::SetTilt(deg) => self.camera.set_pitch(deg),
            Msg::Fit(rect) => {
                self.fit(rect);
                self.fitted = true;
                self.fitted_size = (rect.width(), rect.height());
            }
            Msg::TickMomentum(dt) => {
                let _ = self.tick_pan_momentum(dt);
            }
            Msg::TickZoom => {
                let _ = self.camera.tick();
            }
            Msg::SelectPin(id) => self.selected_pin = Some(id),
            Msg::ClearPin => self.selected_pin = None,
            Msg::SetDebugPoints(on) => self.debug_points = on,
        }
        Vec::new()
    }

    /// **FC-9 render** — a **pure** function of `&self`: it paints the slippy map
    /// (basemap ways, culled+decimated points) and *returns* the [`Msg`]s the
    /// frame's interactions produced (drag/wheel/keyboard pan+zoom, fit, the damped
    /// zoom + inertial-pan ticks). It MUST NOT mutate the [`MercatorCamera`] Model;
    /// the [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies
    /// the returned messages through [`update`](Self::update). Every camera
    /// transition is *also* applied to a local camera **copy** `cam` so this same
    /// frame paints the moved map (no one-frame lag); the ephemeral paint caches
    /// (fps, visible/drawn counts, scratch buffers) are interior-mutable (FC-7),
    /// deliberately kept OUT of the Model.
    pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
        let mut msgs: Vec<Msg> = Vec::new();
        let (clon, clat) = inv_mercator(self.camera.center.0, self.camera.center.1);
        // Header row: the live readout + the **building-vertex debug toggle**. The
        // toggle lives on the pane itself, so every host that mounts this Facet gets
        // it for free (facett-demo included) rather than each host growing its own
        // chrome. FC-9: the checkbox never writes `self` — it emits a `Msg`.
        ui.horizontal(|ui| {
            ui.label(format!(
                "{}  ·  {} pts / {} ways  ·  zoom {:.0}px/u  ·  {:.4},{:.4}  ·  {:.0} fps",
                self.title,
                self.points.len(),
                self.ways.len(),
                self.camera.zoom,
                clat,
                clon,
                self.fps.get().fps(),
            ));
            let mut dbg = self.debug_points;
            if ui
                .checkbox(&mut dbg, "debug pts")
                .on_hover_text(
                    "Developer view: draw every building-vertex node at ANY zoom in a \
                     flashing colour, bypassing the LOD cull that normally hides them \
                     when zoomed out. Shortcut: V",
                )
                .changed()
            {
                msgs.push(Msg::SetDebugPoints(dbg));
            }
        });

        const MIN_W: f32 = 160.0;
        const MIN_H: f32 = 120.0;
        let avail = ui.available_size();
        let want = egui::vec2(avail.x.max(MIN_W), avail.y.max(MIN_H));
        let (rect, resp) = ui.allocate_exact_size(want, egui::Sense::click_and_drag());

        // The effective camera for THIS frame's paint — a local copy the frame's
        // transitions are replayed onto so the painted map is up to date, while the
        // persisted Model catches up via the returned `Msg`s (the FC-9 rule: view
        // never writes `self.camera`).
        let mut cam = self.camera;

        // First paint: fit to the data using the real pane rect. Painted through
        // the fitted copy (no unfitted flash); persisted via `Msg::Fit`.
        // A committed fit is stale if the pane has since changed size materially and
        // the user has not taken over navigation — the embedded-canvas case, where the
        // host grows the canvas a frame or two AFTER the first believable layout.
        let pane_resized = self.fitted
            && !self.user_navigated
            && {
                let (fw, fh) = self.fitted_size;
                let d = |a: f32, b: f32| (a - b).abs() > b.max(1.0) * REFIT_SIZE_TOLERANCE;
                fw > 0.0 && fh > 0.0 && (d(rect.width(), fw) || d(rect.height(), fh))
            };
        if !self.fitted || pane_resized {
            if let Some(b) = self.fit_bbox() {
                cam.fit_merc_bbox(b, rect);
            }
            // PERSIST the fit only once the pane has a believable size. An EMBEDDED
            // host (the wasm canvas in a Streamlit/web iframe) lays out
            // asynchronously, so the first frames can arrive with a 0-sized or
            // still-growing rect; committing the fit to one of those locks the camera
            // at the wrong zoom for the rest of the session (the map then draws a
            // correct-but-tiny country in the middle of a large canvas). Until the
            // rect is sane we keep re-fitting the local copy each frame, which paints
            // correctly and costs nothing.
            if rect.width() >= MIN_FIT_RECT_PX && rect.height() >= MIN_FIT_RECT_PX {
                msgs.push(Msg::Fit(rect));
            }
        }

        // FPS meter (ephemeral cache) + ease the damped zoom on the copy; keep
        // repainting while it (or the inertial glide) animates.
        let mut fps = self.fps.get();
        fps.sample(ui.input(|i| i.time));
        self.fps.set(fps);
        let dt = ui.input(|i| i.stable_dt).clamp(1e-3, 0.1);
        let mut animating = false;
        if (self.camera.target_zoom - self.camera.zoom).abs() >= self.camera.target_zoom * 1e-4 {
            cam.tick();
            msgs.push(Msg::TickZoom);
            animating = true;
        }

        // A fresh press cancels any lingering inertial glide so the grab feels
        // immediate (you "catch" the moving map).
        if resp.drag_started() || resp.is_pointer_button_down_on() {
            msgs.push(Msg::CancelMomentum);
        }

        // Route navigation (drag/wheel/keys) onto the copy + emit the Msgs.
        self.collect_input(ui, rect, &resp, &mut cam, &mut msgs);

        // Glide the inertial pan momentum (after input, so a release this frame
        // starts gliding next frame, and an in-progress drag overrides it). Mirror
        // `tick_pan_momentum`'s per-frame delta onto the copy for this paint.
        if !resp.dragged() {
            let (vx, vy) = (self.pan_x.velocity, self.pan_y.velocity);
            if vx.abs() > 0.5 || vy.abs() > 0.5 {
                let cvx = if vx.abs() > 0.5 { vx } else { 0.0 };
                let cvy = if vy.abs() > 0.5 { vy } else { 0.0 };
                cam.pan_velocity(cvx, cvy, dt);
                msgs.push(Msg::TickMomentum(dt));
                animating = true;
            }
        }
        if animating {
            ui.ctx().request_repaint();
        }

        let th = theme(ui);
        // The selected palette: theme-derived (re-themeable) or a fixed
        // cartographic preset. Its `land` is the "paper" background.
        let pal = self.palette.palette(&th);
        let painter = ui.painter_at(rect);
        let bg = if self.ways.is_empty() { th.bg } else { pal.land };
        painter.rect_filled(rect, egui::CornerRadius::ZERO, bg);

        if self.points.is_empty() && self.ways.is_empty() {
            painter.text(rect.center(), egui::Align2::CENTER_CENTER, "no geo data", egui::FontId::proportional(13.0), th.text_dim);
            self.last_vis_points.set(0);
            self.last_vis_ways.set(0);
            // a11y: still emit a value-bearing pane node.
            resp.widget_info(|| Semantics::image(format!("{} — empty", self.title)).value(0.0).widget_info());
            // ── render-lane emit (empty-data exit) ────────────────────────────
            #[cfg(feature = "testmatrix")]
            facett_core::testmatrix::emit(
                "facett-geomap::osm2d::OsmView::view",
                "ui_render",
                true,
                "points=0 ways=0 drew=empty_hint",
            );
            return msgs;
        }

        let vp = cam.viewport_merc_bbox(rect);

        // ── EXPLICIT Z-BAND PAINT ORDER (see `PaintBand`) ─────────────────────────
        // The whole 2D map paints strictly bottom→top in these bands, and the order
        // is the SAME whether the line layer draws on the GPU or falls through to the
        // CPU (the historical latent bug: the GPU line callback was emitted BEFORE
        // the fills, so on the GPU lane roads hid UNDER water/land, while on the CPU
        // lane they sat correctly on top). The bands, in paint order:
        //   Background → Fill (land/water/landuse) → Line (roads, GPU *or* CPU)
        //   → Point → Overlay (graph/route) → Label → Pin.
        // Concretely: the background rect was already drawn; the FILL loop runs next;
        // only THEN is the line layer emitted (GPU callback or CPU painter), so both
        // lanes land in the Line band above the fills. Points/overlay/labels/pins
        // follow in order below. See the `z_band_order_is_documented_and_monotone`
        // and `fills_paint_under_roads_under_pins` guards.

        // ── ways layer (viewport-culled by LOD) ───────────────────────────────
        let visible_ways = self.ways.query_visible(vp, self.way_lod());
        // deck.gl's `widthScale` for this frame — the SHARED multiplier the GPU line
        // lane also uses (`facett_map::style`), so the CPU fills/outlines and the GPU
        // roads are drawn at ONE scale. It used to be an ad-hoc `clamp(0.5, 3.0)` here
        // while the GPU baked a constant, which drew the same road 2–3× apart.
        let zoom_w = facett_map::style::line_width_scale(cam.zoom);
        // Geometry is clipped to a margin-expanded pane rect BEFORE it reaches the
        // painter. Bbox culling keeps a way whenever its *bbox* crosses the pane,
        // so a kept way can still have far off-screen vertices; at high zoom those
        // project to ~1e6–1e9 px. `painter_at(rect)` clips the raster, but egui
        // still tessellates the whole shape, and a near-infinite vertex makes the
        // tessellation flicker / shoot lines off-screen. Clipping (not clamping)
        // the geometry to the expanded rect keeps slopes exact while guaranteeing
        // no drawn vertex sits at an extreme coordinate. The margin (a few × the
        // max stroke width) keeps edge strokes from being visibly cut.
        let clip_rect = rect.expand(CLIP_MARGIN_PX);
        {
            // Reuse the interior-mutable scratch buffers (FC-7) — no per-frame alloc.
            let mut scratch = self.scratch.borrow_mut();
            // 1) filled areas (mesh + outline), 2) line ways — a real flat map.
            //
            // ── FILLS SORT ON THE DECLARED KEY, NOT ON DECODE ORDER ──────────────
            // This loop used to walk `query_visible`'s output directly, which is
            // insertion (= decode) order — the reported bug verbatim: whichever
            // overlapping fill was read LAST won every overlap, so a bridge deck
            // decoded before the water it crosses sank under it, and the stack
            // changed with every re-fetch. The order is `facett_map::paint_order`'s
            // ONE key (`way_paint_key`: layer·LAYER_STEP + band/class/tier, then
            // largest-area-first, then osm_id) — the SAME cure both facett-map
            // lanes already sort on, consumed not twinned (LAW 5). Guarded on
            // pixels by `tests/osm_paint_order.rs`.
            let mut fill_ways: Vec<&Way> =
                visible_ways.iter().copied().filter(|w| w.fill.is_some()).collect();
            fill_ways.sort_unstable_by_key(|w| paint_order::way_paint_key(w));
            for w in fill_ways {
                scratch.clear();
                // Reproject from source lonlat in f64 (not the f32 `points`) so CPU
                // fills/outlines stay pixel-crisp + aligned with the f64 GPU lines at
                // high zoom (f32 `points` snap to a ~5px grid at ZOOM_MAX).
                scratch.extend(w.lonlat.iter().map(|&(lon, lat)| {
                    let (mx, my) = mercator(lon, lat);
                    cam.to_px(mx, my, rect)
                }));
                if scratch.len() < 3 {
                    continue;
                }
                if let Some(fill) = w.fill {
                    // Sutherland–Hodgman ring clip (the rect is convex, so this is
                    // correct for concave OSM polygons too). Reuses `scratch` storage
                    // when the ring is fully on-screen (clip returns it unchanged).
                    let ring = clip::clip_polygon(scratch.as_slice(), clip_rect);
                    if ring.len() < 3 {
                        continue;
                    }
                    let stroke = egui::Stroke::new(facett_map::style::line_width_px_scaled(w.width, zoom_w), w.stroke);
                    // Project + clip every hole ring through the SAME f64 lane (empty
                    // when the way has none); the shared fill+outline recipe then punches
                    // them out and strokes each (island shoreline / courtyard edge). The
                    // mesh-select + outline tail lives once in
                    // `tessellate::paint_filled_way` (dedup cluster I) — the f64
                    // projection + Sutherland–Hodgman clip stays here (the map lane).
                    let hole_rings: Vec<Vec<egui::Pos2>> = w
                        .holes
                        .iter()
                        .filter_map(|hole| {
                            let proj: Vec<egui::Pos2> = hole
                                .iter()
                                .map(|&(lon, lat)| {
                                    let (mx, my) = mercator(lon, lat);
                                    cam.to_px(mx, my, rect)
                                })
                                .collect();
                            let clipped = clip::clip_polygon(&proj, clip_rect);
                            (clipped.len() >= 3).then_some(clipped)
                        })
                        .collect();
                    tessellate::paint_filled_way(&painter, ring, &hole_rings, Some(fill), stroke, true, None);
                }
            }

            // ── LINE BAND: emit the line layer NOW — AFTER the fills — so it lands
            // above land/water whether it draws on the GPU (paint callback) or the
            // CPU. This is the z-order unification: the callback used to be emitted
            // before the fills (roads hidden under water on the GPU lane). ─────────
            #[cfg(feature = "map-wgpu")]
            let drew_lines_on_gpu = self.draw_ways_gpu(ui, rect, cam);
            #[cfg(not(feature = "map-wgpu"))]
            let drew_lines_on_gpu = false;
            self.last_drew_gpu.set(drew_lines_on_gpu);
            // Line ways: drawn on the CPU UNLESS the GPU kernel already drew them this
            // frame (the GPU fast-path owns the line layer when active).
            if !drew_lines_on_gpu {
                let cased = self.palette.is_cartographic();
                let mut clip_runs = self.clip_runs.borrow_mut();
                if !cased {
                    // ── THEME mode: the historical single-stroke, single-pass line
                    // painter (no casing) — but the ORDER is the declared key, not
                    // decode order: `bridge`/`tunnel`/`layer` and the class ladder
                    // decide which line crosses on top, deterministically, exactly
                    // as on facett-map's own lanes. ────────────────────────────────
                    let mut line_ways: Vec<&Way> =
                        visible_ways.iter().copied().filter(|w| w.fill.is_none()).collect();
                    line_ways.sort_unstable_by_key(|w| paint_order::way_paint_key(w));
                    for w in line_ways {
                        scratch.clear();
                        scratch.extend(w.lonlat.iter().map(|&(lon, lat)| {
                            let (mx, my) = mercator(lon, lat);
                            cam.to_px(mx, my, rect)
                        }));
                        if scratch.len() < 2 {
                            continue;
                        }
                        let fill_w = facett_map::style::line_width_px_scaled(w.style.width, zoom_w);
                        let stroke = egui::Stroke::new(fill_w, w.style.color);
                        clip::clip_polyline(scratch.as_slice(), clip_rect, &mut clip_runs);
                        for run in clip_runs.drain(..) {
                            painter.add(egui::Shape::line(run, stroke));
                        }
                    }
                } else {
                    // ── CARTOGRAPHIC mode: the **Garmin-nav road network**. The road
                    // class hierarchy is painted as a real nav map: all ways are
                    // sorted by class rank (minor → major) and drawn in TWO passes —
                    // every casing first (low→high), then every fill (low→high) — so
                    // a major road runs UNBROKEN through a junction over the minor
                    // roads it crosses (clean joins), instead of a lower road's fill
                    // cutting across a higher road's casing. One-way roads then get
                    // direction chevrons. Theme mode above is untouched. ───────────
                    let mut lines: Vec<LineEntry> = Vec::new();
                    for w in visible_ways.iter().filter(|w| w.fill.is_none()) {
                        scratch.clear();
                        scratch.extend(w.lonlat.iter().map(|&(lon, lat)| {
                            let (mx, my) = mercator(lon, lat);
                            cam.to_px(mx, my, rect)
                        }));
                        if scratch.len() < 2 {
                            continue;
                        }
                        clip::clip_polyline(scratch.as_slice(), clip_rect, &mut clip_runs);
                        if clip_runs.is_empty() {
                            continue;
                        }
                        let is_road = w.kind == "road";
                        let rank = if is_road { facett_map::style::road_rank(w.style.width) } else { 0 };
                        lines.push(LineEntry {
                            order: (paint_order::way_rank(w), rank, w.osm_id),
                            rank,
                            is_road,
                            fill_w: facett_map::style::line_width_px_scaled(w.style.width, zoom_w),
                            color: w.style.color,
                            oneway: if is_road { w.oneway } else { 0 },
                            runs: clip_runs.drain(..).collect(),
                        });
                    }
                    // Paint order: THE declared key (see `LineEntry::order`). The old
                    // `(is_road, width-bucket)` STABLE sort left every tie — two roads
                    // of one bucket, two paths — to decode order, and never read
                    // `layer`/`bridge`/`tunnel` at all, so a bridge sank under the
                    // road it crosses whenever it happened to decode first.
                    lines.sort_unstable_by_key(|l| l.order);
                    // The casing/fill passes run PER OSM LAYER GROUP (`way_rank` is
                    // `effective_layer × LAYER_STEP + …`, so one `div_euclid` recovers
                    // the layer): within a layer every casing lands first, then every
                    // fill — the Garmin clean-junction look — while a bridge layer's
                    // casing is never buried under a ground road's fill painted later.
                    // One layer (the whole map, usually) ⇒ exactly the old two passes.
                    let mut start = 0usize;
                    while start < lines.len() {
                        let layer = lines[start].order.0.div_euclid(paint_order::LAYER_STEP);
                        let mut end = start;
                        while end < lines.len()
                            && lines[end].order.0.div_euclid(paint_order::LAYER_STEP) == layer
                        {
                            end += 1;
                        }
                        let group = &lines[start..end];
                        // Pass 1 — casings (roads only, once the fill is thick enough
                        // that a casing reads; the casing widens with class rank).
                        for l in group {
                            if l.is_road && l.fill_w >= 1.0 {
                                let extra = (1.4 * zoom_w).max(1.1) + l.rank as f32 * 0.5;
                                let casing = egui::Stroke::new(l.fill_w + extra, pal.casing);
                                for run in &l.runs {
                                    painter.add(egui::Shape::line(run.clone(), casing));
                                }
                            }
                        }
                        // Pass 2 — the bright road fill on top of this layer's casings.
                        for l in group {
                            let stroke = egui::Stroke::new(l.fill_w, l.color);
                            for run in &l.runs {
                                painter.add(egui::Shape::line(run.clone(), stroke));
                            }
                        }
                        start = end;
                    }
                    // Pass 3 — one-way direction chevrons along each one-way road,
                    // once the road is wide enough on screen that they read.
                    for l in &lines {
                        if l.is_road && l.oneway != 0 && l.fill_w >= 1.4 {
                            for run in &l.runs {
                                draw_direction_arrows(&painter, run, l.oneway, pal.casing, l.fill_w);
                            }
                        }
                    }
                }
            }
        }

        // ── point layer (viewport-culled + budget-decimated) ──────────────────
        // Honest visible count first, so the stride can be raised to keep the
        // per-frame circle count within MAX_DRAWN_POINTS (the sluggish-2D fix —
        // a whole-country fit no longer draws 353 k circles every frame).
        let vis_count = self.points.visible_count(vp);
        let stride = self.budget_stride(vis_count);
        let dot = Self::point_dot_px(cam.zoom);
        // ── THE BUILDING-VERTEX LOD ───────────────────────────────────────────
        // These points are OSM nodes, i.e. mostly BUILDING CORNERS. Zoomed out they
        // are denser than the pane has pixels and paint a solid wash — the "blue sea
        // when zooming out". Decide on the ink the layer WOULD paint
        // (POINT_INK_FRAC_MAX): over budget → the layer does not draw AT ALL this
        // frame. Debug mode overrides the cull (that is what it is for).
        let (drawn_points, point_ink, points_culled) = {
            let mut vis = self.vis_pts.borrow_mut();
            self.points.visible_into(vp, stride, &mut vis);
            let ink = Self::point_ink_frac(vis.len(), dot, rect);
            if !self.points_pass_lod(vis.len(), dot, rect) {
                (0usize, ink, true)
            } else {
                // The SAME ink as the footprint outline (debug: a flashing hue).
                let col = self.point_color(&pal, ui.input(|i| i.time));
                for &(mx, my) in vis.iter() {
                    painter.circle_filled(cam.to_px(mx as f64, my as f64, rect), dot, col);
                }
                (vis.len(), ink, false)
            }
        };

        // Observable counts (honest — pre-decimation point count) + the throttle.
        self.last_vis_points.set(vis_count);
        self.last_vis_ways.set(visible_ways.len());
        self.last_point_stride.set(stride);
        self.last_drawn_points.set(drawn_points);
        self.last_points_culled.set(points_culled);
        self.last_point_ink.set(point_ink);

        // ── graph overlay (facett-graphview nodes at geo coords) ──────────────
        // Drawn over the basemap in the shared coordinate space: each node/edge is
        // placed by looking up the node's geo (lon,lat) and projecting it through
        // the SAME `cam.to_px` the ways/points use (not the graphview camera, whose
        // [0.25,4] zoom clamp can't track the map's street zoom). Edges first, then
        // node chips on top. CPU f64 lane (dodges the high-zoom f32 cancellation).
        if let Some(g) = &self.graph {
            let px = |id: &str| g.geo.get(id).map(|&(lon, lat)| {
                let (mx, my) = mercator(lon, lat);
                cam.to_px(mx, my, rect)
            });
            for e in &g.model.edges {
                if let (Some(a), Some(b)) = (px(&e.from), px(&e.to))
                    && (rect.expand(64.0).contains(a) || rect.expand(64.0).contains(b))
                {
                    let col: egui::Color32 = e.color.into();
                    painter.line_segment([a, b], egui::Stroke::new(1.6_f32, col));
                }
            }
            let mut vis_nodes = 0usize;
            for n in &g.model.nodes {
                let Some(p) = px(&n.id) else { continue };
                if !rect.expand(24.0).contains(p) {
                    continue;
                }
                vis_nodes += 1;
                let fill: egui::Color32 = n.fill.into();
                let stroke: egui::Color32 = n.stroke.into();
                painter.circle_filled(p, 5.0, fill);
                painter.circle_stroke(p, 5.0, egui::Stroke::new(1.6_f32, stroke));
                if !n.label.is_empty() {
                    painter.text(
                        p + egui::vec2(7.0, 0.0),
                        egui::Align2::LEFT_CENTER,
                        &n.label,
                        egui::FontId::proportional(10.0),
                        th.text,
                    );
                }
            }
            self.last_vis_graph.set(vis_nodes);
        } else {
            self.last_vis_graph.set(0);
        }

        // ── route-line highlight ("you are going here") ───────────────────────
        // A bright cased ribbon on top of the basemap (below the pins), through the
        // SAME `cam.to_px` transform so it leans with the tilt and stays glued to
        // the roads. Painted for any palette (it is an overlay, not the basemap).
        // Casing (dark) → bright fill → forward chevrons, mirroring a road but wider
        // and hotter so it reads as the routed path.
        if self.route.len() >= 2 {
            let mut route_px: Vec<egui::Pos2> = self
                .route
                .iter()
                .map(|&(lon, lat)| {
                    let (mx, my) = mercator(lon, lat);
                    cam.to_px(mx, my, rect)
                })
                .collect();
            let mut runs: Vec<Vec<egui::Pos2>> = Vec::new();
            clip::clip_polyline(&route_px, rect.expand(CLIP_MARGIN_PX), &mut runs);
            route_px.clear();
            let route_w = ((cam.zoom / 50_000.0) as f32).clamp(0.5, 3.0) * 3.5;
            let route_w = route_w.clamp(3.0, 11.0);
            let casing = egui::Stroke::new(route_w + 3.0, egui::Color32::from_rgb(0x08, 0x2A, 0x4A));
            let fill = egui::Stroke::new(route_w, egui::Color32::from_rgb(0x3A, 0xA0, 0xFF));
            for run in &runs {
                painter.add(egui::Shape::line(run.clone(), casing));
            }
            for run in &runs {
                painter.add(egui::Shape::line(run.clone(), fill));
            }
            for run in &runs {
                draw_direction_arrows(&painter, run, 1, egui::Color32::from_rgb(0xEA, 0xF4, 0xFF), route_w);
            }
        }

        // ── road-name label layer (priority + collision) ──────────────────────
        // Cartographic mode only. Named roads place ONE horizontal label at their
        // on-screen midpoint, in class-priority order (motorway → residential) with
        // a greedy collision test so labels never overlap; the address pins reserve
        // their boxes FIRST so a street name never lands on a pin. A dark/light halo
        // (chosen off the paper luminance) keeps the ink legible on the road.
        // ── PIN LABEL COLLISION (the other half of the MapLibre lesson) ──────────
        // Pins already collide-cluster into count badges; their LABELS did not. A
        // cluster of one is a real pin with a real name, and a street of them printed
        // every name on top of every other — the marker pile was cured while the text
        // pile was left. So the single pins' labels go through the SAME screen-space
        // collision discipline the markers do, from the shared
        // `facett_core::legibility` placer (L5 — the graph panes use the identical
        // one). A group badge carries its count INSIDE its disc and never competes.
        //
        // Computed here, before the road names, so the road-name pass can reserve the
        // boxes the pin labels actually took instead of a fixed guess.
        // THIS frame's camera (`cam`), not the Model's — see `clusters_for_cam`: the
        // pins are the one overlay that used to be projected from the previous
        // frame's camera and slid during every zoom ease.
        let pin_clusters = if self.pins.is_empty() { Vec::new() } else { self.clusters_for_cam(&cam, rect) };
        let (pin_label_boxes, pin_labels_suppressed) =
            self.place_pin_labels(&painter, &pin_clusters, rect);
        self.last_pin_labels.set(pin_label_boxes.len());
        self.last_pin_labels_suppressed.set(pin_labels_suppressed);

        let mut vis_labels = 0usize;
        if self.palette.is_cartographic() && !self.ways.is_empty() {
            let paper_light = luminance(pal.land) > 0.5;
            let ink = if paper_light {
                egui::Color32::from_rgb(0x25, 0x25, 0x2A)
            } else {
                egui::Color32::from_rgb(0xEC, 0xEC, 0xF2)
            };
            let halo = if paper_light { egui::Color32::WHITE } else { egui::Color32::from_rgb(0x05, 0x07, 0x0C) };
            let font = egui::FontId::proportional(11.0);

            // ── The declutter contract, from its ONE home ──────────────────────
            //
            // This used to be a greedy pass right here: an O(n^2) rect scan over a
            // `placed: Vec<Rect>`, an O(n^2) same-name `distance(p, q) < 380` scan over
            // a `placed_names: Vec<(&str, Pos2)>`, both re-run from scratch every frame,
            // capped at 48 because that is what the quadratic loops could afford.
            //
            // The rule now lives in `facett_core::label_grid` and this pane merely
            // OFFERS candidates to it (GFX_V2 item 4). `label_collide.wgsl` is the same
            // rule on the device, and `cpu_and_gpu_lanes_resolve_the_same_labels` pins
            // the two together — so what is left below is a fail-safe execution of the
            // shared rule, not a second implementation of it. That distinction is the
            // whole point: the width formula drifted for exactly as long as the CPU
            // painter and the GPU lane each owned a copy of it.
            //
            // Positions are PANE-LOCAL (`p - rect.min`), because the grid is stretched
            // over `[0, viewport]`. They come from `cam.to_px`, so they carry the 2.5D
            // tilt the pane's points and pins are drawn with; that is why the candidates
            // are `FLAG_SCREEN_SPACE` rather than origin-local Mercator anchors the
            // shader would project itself. A label must never lean differently from the
            // road it names.
            let mut cands: Vec<lg::LabelCandidate> = Vec::new();
            // Parallel to `cands`: the text and the ABSOLUTE screen anchor to paint at.
            // Blockers carry an empty name and are never painted.
            let mut texts: Vec<(String, egui::Pos2)> = Vec::new();
            let blocker = |r: egui::Rect, cands: &mut Vec<lg::LabelCandidate>, texts: &mut Vec<(String, egui::Pos2)>| {
                let c = r.center();
                cands.push(lg::LabelCandidate {
                    pos: [c.x - rect.left(), c.y - rect.top()],
                    half_px: [r.width() * 0.5, r.height() * 0.5],
                    priority: lg::priority(lg::BLOCKER_RANK, cands.len() as u32),
                    name_hash: lg::name_hash("#blocker"),
                    glyph_start: 0,
                    glyph_count: 0,
                    lod: 0,
                    flags: lg::FLAG_SCREEN_SPACE | lg::FLAG_BLOCKER,
                    _pad: [0; 2],
                });
                texts.push((String::new(), c));
            };
            // The marker discs AND the boxes the pin labels really took, so a road name
            // can never land on either. (It used to reserve a fixed 90x26 per pin — a
            // guess both too big for a short name and, once labels could be displaced by
            // the placer, in the wrong place.) On the device these are candidates at
            // BLOCKER_RANK that claim cells and print nothing, which is how e88969f's
            // "the address pins reserve their boxes FIRST" survives onto a lane that has
            // no ordering to reserve in.
            for node in &pin_clusters {
                let r = if node.is_single() { 11.0 } else { (11.0 + 3.4 * (node.count as f32).ln()).min(26.0) };
                blocker(egui::Rect::from_center_size(node.pos, egui::vec2(2.0 * r, 2.0 * r + 10.0)), &mut cands, &mut texts);
            }
            for b in pin_label_boxes.values() {
                blocker(*b, &mut cands, &mut texts);
            }

            // Candidate labels: named roads, wide enough on-screen to letter, ranked by
            // class (so the network's spine is labelled before its capillaries). The
            // sort is retained because it defines the `order` half of the priority —
            // higher class first, then west-to-east — which is what reproduces the old
            // greedy pass's preference without depending on visit order at run time.
            let mut raw: Vec<(u8, egui::Pos2, &str)> = Vec::new();
            for w in visible_ways.iter().filter(|w| w.kind == "road" && !w.name.is_empty()) {
                if facett_map::style::line_width_px_scaled(w.style.width, zoom_w) < 1.0 || w.lonlat.len() < 2 {
                    continue;
                }
                let mid = w.lonlat[w.lonlat.len() / 2];
                let (mx, my) = mercator(mid.0, mid.1);
                let p = cam.to_px(mx, my, rect);
                if !rect.contains(p) {
                    continue;
                }
                raw.push((facett_map::style::road_rank(w.style.width), p, w.name.as_str()));
            }
            raw.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.x.partial_cmp(&b.1.x).unwrap_or(std::cmp::Ordering::Equal)));
            for (rank, p, name) in raw {
                let order = cands.len() as u32;
                // The text extent comes from the SAME galley the paint below uses, so
                // the collision box is the box the ink actually occupies.
                let size = painter.layout_no_wrap(name.to_string(), font.clone(), ink).size();
                cands.push(lg::LabelCandidate {
                    pos: [p.x - rect.left(), p.y - rect.top()],
                    half_px: lg::label_half_extent([size.x, size.y]),
                    priority: lg::priority(rank, order),
                    name_hash: lg::name_hash(name),
                    glyph_start: 0,
                    glyph_count: 0,
                    // LOD is already applied: `visible_ways` came out of
                    // `query_visible(vp, self.way_lod())`. Tiering again here would
                    // double-gate.
                    lod: 0,
                    flags: lg::FLAG_SCREEN_SPACE,
                    _pad: [0; 2],
                });
                texts.push((name.to_string(), p));
            }

            let frame = lg::LabelFrame::screen([rect.width(), rect.height()], 2, ink, halo);
            for i in lg::resolve(&lg::screen_labels(&cands, &frame), &frame.grid_params()) {
                let (name, p) = &texts[i as usize];
                // Cheap halo: the name drawn in the halo colour at 4 offsets, then ink —
                // the same 5 passes `label_collide.wgsl` expands each glyph into.
                for off in lg::HALO_OFFSETS {
                    painter.text(*p + egui::vec2(off[0], off[1]), egui::Align2::CENTER_CENTER, name, font.clone(), halo);
                }
                painter.text(*p, egui::Align2::CENTER_CENTER, name, font.clone(), ink);
                vis_labels += 1;
            }
        }
        self.last_vis_labels.set(vis_labels);

        // ── address-pin overlay (topmost, CPU f64 lane) ───────────────────────
        // Drawn last so pins sit above every other layer. Each marker projects
        // through `cam.to_px` (the same lane the hit-test uses); a selected pin
        // gets an accent ring. Off-screen pins are skipped. Collect the on-screen
        // hit rects + a11y semantics for the AccessKit pass below the painter.
        let mut pin_nodes: Vec<(String, egui::Rect, Semantics)> = Vec::new();
        let mut vis_pins = 0usize;
        if !self.pins.is_empty() {
            // CLUSTERED: co-located pins collapse into one count badge so a dense
            // city reads as groups, not a pile of overlapping teardrops. A bin
            // holding one pin comes back as that pin unchanged (same id, same
            // label), so a lone marker and its click are exactly as before.
            let nodes = &pin_clusters;
            for (ni, node) in nodes.iter().enumerate() {
                let p = node.pos;
                vis_pins += node.count;
                let selected = self.selected_pin.as_deref() == Some(node.id.as_str());
                if node.is_single() {
                    let r = 6.0_f32;
                    // A teardrop-ish marker with its TIP on the anchor: the head floats
                    // r+5 px ABOVE `p` and the stem runs down to it. The head used to sit
                    // ON `p` with the stem dangling below — but the ink a map-reader takes
                    // as "the point" is the teardrop's tip, so every pin read as standing
                    // a constant 11 px above the place it denoted (reported live as "the
                    // marker is a decimetre above"). The geo anchor is unchanged; only
                    // where the ink stands relative to it moved.
                    let head = p - egui::vec2(0.0, r + 5.0);
                    if selected {
                        painter.circle_stroke(head, r + 3.5, egui::Stroke::new(2.5_f32, th.accent));
                    }
                    painter.line_segment([p, head], egui::Stroke::new(2.0_f32, th.text));
                    painter.circle_filled(head, r, egui::Color32::from_rgb(230, 90, 90));
                    painter.circle_stroke(head, r, egui::Stroke::new(1.6_f32, egui::Color32::from_rgb(250, 235, 235)));
                    // Only a label that WON its pixels is drawn. A suppressed one is
                    // still reachable — the marker, the click and the AccessKit node are
                    // untouched — it just does not print over its neighbour.
                    if let Some(&box_rect) = pin_label_boxes.get(&ni) {
                        painter.text(
                            box_rect.center(),
                            egui::Align2::CENTER_CENTER,
                            &node.label,
                            egui::FontId::proportional(11.0),
                            th.text,
                        );
                    }
                    // The a11y/hit rect covers the INK — head top down to the tip plus a
                    // little slack — not a square centred on the anchor that missed the
                    // stem entirely (and, before the tip-anchoring above, missed most of
                    // the head too).
                    let hit = egui::Rect::from_min_max(
                        egui::pos2(p.x - (r + 3.0), p.y - (2.0 * r + 5.0 + 3.0)),
                        egui::pos2(p.x + (r + 3.0), p.y + 3.0),
                    );
                    let a11y = match self.pins.get(node.members[0]) {
                        Some(pin) if pin.label.is_empty() => {
                            format!("pin {:.4},{:.4}", pin.lonlat.0, pin.lonlat.1)
                        }
                        _ => format!("pin {}", node.label),
                    };
                    pin_nodes.push((node.id.clone(), hit, Semantics::button(a11y)));
                } else {
                    // A GROUP badge: a disc scaled gently with the count (log, so
                    // 1000 does not become a screen-filling blob) carrying the number.
                    let r = (11.0 + 3.4 * (node.count as f32).ln()).min(26.0);
                    if selected {
                        painter.circle_stroke(p, r + 3.5, egui::Stroke::new(2.5_f32, th.accent));
                    }
                    painter.circle_filled(p, r, egui::Color32::from_rgb(200, 70, 70).gamma_multiply(0.92));
                    painter.circle_stroke(p, r, egui::Stroke::new(2.0_f32, egui::Color32::from_rgb(250, 235, 235)));
                    painter.text(
                        p,
                        egui::Align2::CENTER_CENTER,
                        &node.label,
                        egui::FontId::proportional((r * 0.85).clamp(10.0, 16.0)),
                        egui::Color32::WHITE,
                    );
                    let hit = egui::Rect::from_center_size(p, egui::vec2(2.0 * r, 2.0 * r));
                    pin_nodes.push((
                        node.id.clone(),
                        hit,
                        Semantics::button(format!("{} pins", node.count)),
                    ));
                }
            }
            self.last_vis_clusters.set(nodes.len());
        } else {
            self.last_vis_clusters.set(0);
        }
        self.last_vis_pins.set(vis_pins);

        // a11y: the whole pane is a value-bearing image node carrying the visible
        // feature count, so a robot driver / screen reader reads back what's shown.
        let tilt_note = if cam.pitch_deg != 0.0 { format!(" · tilt {:.0}°", cam.pitch_deg) } else { String::new() };
        let summary = format!(
            "{} — {} visible points · {} visible ways · zoom {:.0}{}",
            self.title, vis_count, visible_ways.len(), cam.zoom, tilt_note
        );
        let val = vis_count as f64;
        resp.widget_info(|| Semantics::image(summary.clone()).value(val).widget_info());

        // ── FC-4: one AccessKit node per on-screen pin (label, click sense), keyed
        // on the pin's stable id — so each pin is queryable + selectable by a robot
        // driver / screen reader over the same marker rect the painter laid down.
        if !pin_nodes.is_empty() {
            let base = resp.id;
            for (id, hit, sem) in pin_nodes {
                let _ = facett_core::a11y::node(ui, base, &id, egui::Sense::click(), hit, sem);
            }
        }

        // ── render-lane emit: records WHICH lane drew the line-ways this frame ──
        // The recurring smear bug hid in the uninstrumented GPU branch; emit a row
        // tagged with the lane (gpu/cpu) so the matrix proves BOTH the
        // `draw_ways_gpu` fast-path and the CPU painter fall-through are exercised,
        // plus the visible geometry it drew (OK when something visible rendered).
        #[cfg(feature = "testmatrix")]
        {
            let drew_lines_on_gpu = self.last_drew_gpu.get();
            let lane = if drew_lines_on_gpu { "gpu" } else { "cpu" };
            let geom = vis_count + visible_ways.len();
            facett_core::testmatrix::emit(
                "facett-geomap::osm2d::OsmView::view",
                if drew_lines_on_gpu { "gpu_render" } else { "cpu_render" },
                geom > 0,
                &format!(
                    "lane={lane} used_gpu={drew_lines_on_gpu} visible_points={vis_count} visible_ways={} drawn_points={drawn_points}",
                    visible_ways.len()
                ),
            );
        }
        msgs
    }
}

// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
impl Elm for OsmView {
    type Model = MercatorCamera;
    type Msg = Msg;
    type Effect = Effect;

    fn title(&self) -> &str {
        &self.title
    }
    fn state(&self) -> &MercatorCamera {
        &self.camera
    }
    fn update(&mut self, msg: Msg) -> Vec<Effect> {
        OsmView::update(self, msg)
    }
    fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
        OsmView::view(self, ui)
    }
}

// The bridge macro writes `impl Facet for OsmView` from the `Elm` impl: `title`,
// the FC-9 `ui` loop (`for m in view(ui) { update(m) }`), plus the overrides below.
// **Form 3** (`custom_state_json`) because the viewer publishes a RICHER
// `state_json` than a plain `serde(state())`: the point/way totals + the LIVE
// visible-feature counts, the LOD strides + drawn-point budget, the fps, the LOD
// tier sizes, the inertial-pan momentum, and the GPU-lane flags — all derived from
// the model + the recorded paint caches, not from the raw `MercatorCamera` — so the
// default `state_json` is suppressed and this one supplied.
facett_core::impl_facet_via_elm!(OsmView, custom_state_json, {
    fn state_json(&self) -> serde_json::Value {
        let (clon, clat) = inv_mercator(self.camera.center.0, self.camera.center.1);
        let lod_sizes = self.ways.lod_sizes();
        // **How many of the drawn ways came from a source with NO OSM TAGS AT ALL** —
        // a plain WKB table the operator pointed the host at (Trafikverket, NVDB, a
        // survey export), which `facett_osm::read_ways_with` routes through
        // `facett_map::untagged_source_style` in the overprint ink. Zero for every OSM
        // cut. Reported because it is otherwise INVISIBLE in `way_kinds`: an overprint
        // way is `WayKind::Other`, the same bucket a fence or a platform lands in, so a deck could
        // not tell "the operator's overlay is on screen" from "it silently read
        // nothing" — which is exactly the regression this census was added for (lane
        // TL, 2026-09-03).
        // Keyed on the KIND and not the ink: `recolor` reskins by kind, so an ink
        // comparison would answer a different question after every palette swap.
        let overprint_ways =
            self.ways.gpu_ways().filter(|(w, _)| w.style.kind == facett_map::WayKind::Overprint).count();
        serde_json::json!({
            "points": self.points.len(),
            "ways": self.ways.len(),
            "overprint_ways": overprint_ways,
            "visible_points": self.last_vis_points.get(),
            "visible_ways": self.last_vis_ways.get(),
            // The stride the painter ACTUALLY used last frame (zoom-LOD floor raised
            // by the drawn-point budget) + how many circles it emitted. `drawn_points`
            // is provably ≤ MAX_DRAWN_POINTS — the per-frame CPU cost is bounded.
            "point_stride": self.last_point_stride.get(),
            "zoom_lod_stride": self.point_stride(),
            "drawn_points": self.last_drawn_points.get(),
            "max_drawn_points": MAX_DRAWN_POINTS,
            // ── the BUILDING-VERTEX LOD, as auditable data ────────────────────
            // `points_culled` is the oracle for "no sea of corners when zoomed
            // out": true => the painter emitted ZERO point circles this frame.
            // `point_ink_frac` is the quantity that decided it (the dots' combined
            // area over the pane) and `max_point_ink_frac` the budget it was
            // compared against, so the decision is reproducible from state alone.
            // `debug_points` is the developer view (default false), which bypasses
            // the cull.
            "points_culled": self.last_points_culled.get(),
            "point_ink_frac": self.last_point_ink.get(),
            "max_point_ink_frac": POINT_INK_FRAC_MAX,
            "debug_points": self.debug_points,
            "zoom": self.camera.zoom,
            "target_zoom": self.camera.target_zoom,
            // 2.5D tilt: the ground-plane pitch in degrees (`0` = top-down). A
            // robot asserts the lean; `tilted` is the boolean the painter branches on.
            "pitch_deg": self.camera.pitch_deg,
            "tilted": self.camera.pitch_deg != 0.0,
            "center": [clon, clat],
            "center_merc": [self.camera.center.0, self.camera.center.1],
            "fps": self.fps.get().fps(),
            // One entry per `facett_map::style::Lod` tier, ascending, KEYED OFF THE ENUM
            // rather than off a hand-written name list. The list this replaces read
            // `country/region/city/detail` against `lod_sizes[0..3]`, so the day a
            // variant was inserted at index 2 (`Lod::District`, the z9 rung) every tier
            // from there up was published under the previous tier's name — a HUD and a
            // robot driver both reading `city` for what the painter calls `district`,
            // with nothing failing. Derived, it cannot drift.
            "lod_sizes": facett_map::style::Lod::ALL
                .iter()
                .zip(lod_sizes.iter())
                .map(|(lod, n)| (format!("{lod:?}").to_lowercase(), serde_json::json!(n)))
                .collect::<serde_json::Map<String, serde_json::Value>>(),
            // Inertial-pan momentum (px/s) still in flight — non-zero while the
            // map is gliding after a flick. `navigable` advertises the model.
            "pan_momentum": [self.pan_x.velocity, self.pan_y.velocity],
            "navigable": true,
            // GPU fast-path: whether the line-ways layer was drawn on the GPU
            // (compute-cull + draw_indirect) last frame. False on the CPU path
            // (the headless test) and when no renderer is installed.
            "gpu_lines": self.last_drew_gpu.get(),
            "gpu_enabled": cfg!(feature = "map-wgpu") && self.gpu_opt_in(),
            // Which palette the basemap paints with — "theme" (re-themeable) or a
            // fixed "cartographic-day" / "cartographic-night" preset.
            "palette": self.palette.label(),
            // Garmin-nav layers: the route-line highlight vertex count (0 = no
            // route) and how many road-name labels the priority/collision layer
            // placed last frame (the red-when-broken oracle for the label layer).
            "route": self.route.len(),
            "visible_labels": self.last_vis_labels.get(),
            // PIN LABELS after collision avoidance. `pin_labels_suppressed > 0` is the
            // proof that a pile really was dissolved; the sum is what the pre-fix
            // painter printed on top of itself.
            "pin_labels": self.last_pin_labels.get(),
            "pin_labels_suppressed": self.last_pin_labels_suppressed.get(),
            // Address-pin overlay: total pins, how many were on-screen last frame,
            // and the selected pin's stable id (FC-5; `null` when none).
            "pins": self.pins.len(),
            "visible_pins": self.last_vis_pins.get(),
            // Markers actually painted after clustering (badges + singles), and the
            // bin width that produced them. `visible_pins` stays the PIN count they
            // stand for, so `visible_markers <= visible_pins` is the compression.
            "visible_markers": self.last_vis_clusters.get(),
            "cluster_px": self.cluster_px,
            "selected_pin": self.selected_pin,
            // Graph overlay: node/edge totals + how many nodes were on-screen.
            "graph_nodes": self.graph.as_ref().map(|g| g.model.nodes.len()).unwrap_or(0),
            "graph_edges": self.graph.as_ref().map(|g| g.model.edges.len()).unwrap_or(0),
            "visible_graph_nodes": self.last_vis_graph.get(),
        })
    }

    /// Zoom rides the uniform `scale` knob so a `FacetDeck`'s +/−/Reset toolbar
    /// drives the camera; pan/wheel/keyboard happen inside `view`.
    ///
    /// The knob is **relative to the fit** ([`Self::fit_zoom`]), not to the absolute
    /// default zoom: `1.0` is the framing the pane opened on, `2.0` is twice as big on
    /// screen. That is the only reading compatible with the deck's `[0.25, 4.0]` clamp —
    /// against the old absolute `camera.zoom / 512.0`, a fitted city map reported
    /// ~1600×, every toolbar click saturated the clamp, and the camera was slammed to a
    /// whole-world view (a zoom-IN click zoomed OUT ~400×). An unfitted view has
    /// `fit_zoom == 512.0`, so it reports exactly what it always did.
    fn scale(&self) -> f32 {
        (self.camera.zoom / self.fit_zoom) as f32
    }
    fn set_scale(&mut self, scale: f32) {
        let _ = self.update(Msg::SetZoom(scale as f64 * self.fit_zoom));
    }

    /// **The map's own zoom band, expressed in the knob's fit-relative units.**
    ///
    /// The deck's default is `[0.25, 4.0]` — a document viewer's range. A map's is
    /// `[ZOOM_MIN, ZOOM_MAX]` (`64 .. 80_000_000` px per Mercator unit), 1.25 million-
    /// fold, and the wheel/pinch/keyboard drive [`MercatorCamera::zoom_to`] over all of
    /// it WITHOUT passing through [`Facet::set_scale`]. Against the default band that
    /// produced the visible bug in two ways: `+` stalled 2 clicks past the fit, and —
    /// worse — after any wheel gesture past 4× the fit, the next `+` computed
    /// `scale() × 1.1`, saturated the clamp at `4.0` and wrote the camera BACKWARDS: a
    /// zoom-IN click that collapsed the map (measured 64051 px → 33296 px per world
    /// unit). Reporting the camera's real band makes every toolbar click a true
    /// relative step wherever the user has navigated to.
    ///
    /// Contains `1.0` for any `fit_zoom` in `[ZOOM_MIN, ZOOM_MAX]`, which `fit()`
    /// guarantees (`fit_merc_bbox` clamps into that band), so `reset_scale`'s `1.0`
    /// always survives the clamp and Reset still returns to the fit.
    fn scale_range(&self) -> (f32, f32) {
        ((ZOOM_MIN / self.fit_zoom) as f32, (ZOOM_MAX / self.fit_zoom) as f32)
    }

    fn caps(&self) -> FacetCaps {
        // Navigable: wheel zoom-to-cursor, drag-pan + inertial glide, keyboard
        // pan/zoom, fit-to-view — the shared pan/zoom/fit model. Selectable: the
        // address-pin overlay resolves a click to a pin's stable id (FC-5).
        FacetCaps::NONE.themeable().resizable().scalable().navigable().selectable()
    }

    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
        Some(self)
    }
});

/// Build a deterministic synthetic OSM dataset: `n` points in a Liechtenstein-ish
/// lon/lat box plus a handful of typed ways (a motorway, a coastline, a water
/// ring). Used by `local()`/`remote()` + the standalone demo's no-fixture path so
/// the viewer always has real geometry to draw. Deterministic (a small LCG) so
/// tests are stable.
pub fn sample_dataset(n: usize) -> (Vec<(f64, f64)>, Vec<Way>) {
    // Liechtenstein bbox-ish: lon 9.47..9.64, lat 47.05..47.27.
    let (lon0, lon1, lat0, lat1) = (9.47, 9.64, 47.05, 47.27);
    let mut state: u64 = 0x9E3779B97F4A7C15;
    let mut next = || {
        state ^= state << 13;
        state ^= state >> 7;
        state ^= state << 17;
        (state >> 11) as f64 / (1u64 << 53) as f64
    };
    let points: Vec<(f64, f64)> = (0..n)
        .map(|_| (lon0 + next() * (lon1 - lon0), lat0 + next() * (lat1 - lat0)))
        .collect();

    let th = facett_core::Theme::default();
    let motorway = Way::from_lonlat(
        1,
        &[(9.50, 47.10), (9.53, 47.15), (9.57, 47.20), (9.60, 47.24)],
        &[("highway", "motorway")],
        &th,
    );
    let water = Way::from_lonlat(
        2,
        &[(9.55, 47.12), (9.59, 47.12), (9.59, 47.16), (9.55, 47.16), (9.55, 47.12)],
        &[("natural", "water")],
        &th,
    );
    let coast = Way::from_lonlat(
        3,
        &[(9.48, 47.06), (9.51, 47.09), (9.54, 47.12)],
        &[("natural", "coastline")],
        &th,
    );
    let ways: Vec<Way> = [motorway, water, coast].into_iter().flatten().collect();
    (points, ways)
}

#[cfg(test)]
mod tests {
    use super::*;
    // `Facet` for `state_json`/`caps`; `state()`/`update()` are inherent. `harness`
    // drives the Elm split (apply a `Vec<Msg>`, snapshot the camera) with no GPU.
    use facett_core::{Facet, harness};
    // The tier names for the shared-ladder guard (the lib itself no longer
    // touches `Lod` — the way cull delegates to `facett_map::layer::view_lod_tier`).
    use facett_map::style::Lod;

    fn rect() -> egui::Rect {
        egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(800.0, 600.0))
    }

    /// Build a view whose data spans an explicit lon/lat box (a synthetic "country"),
    /// so a fit test can control the data's aspect ratio exactly.
    fn view_spanning(lon0: f64, lat0: f64, lon1: f64, lat1: f64) -> OsmView {
        let ring = vec![(lon0, lat0), (lon1, lat0), (lon1, lat1), (lon0, lat1), (lon0, lat0)];
        let way = Way::from_lonlat(1, &ring, &[("natural".to_string(), "coastline".to_string())], &facett_core::Theme::default())
            .expect("ring builds");
        OsmView::new("fit", vec![(lon0, lat0), (lon1, lat1)], vec![way])
    }

    /// The fraction of `rect`'s width/height the fitted DATA actually covers, via the
    /// live camera projection — the thing a viewer perceives as "does it fill?".
    fn fill_fracs(v: &OsmView, rect: egui::Rect) -> (f32, f32) {
        let b = v.fit_bbox().expect("data has a bbox");
        let a = v.state().to_px(b[0], b[1], rect);
        let c = v.state().to_px(b[2], b[3], rect);
        ((a.x - c.x).abs() / rect.width(), (a.y - c.y).abs() / rect.height())
    }

    /// **REGRESSION (the embedded-canvas "map frames SMALL" report).** A fit MUST fill
    /// the pane on its **binding axis** whatever the data/pane aspect ratio — the
    /// property the old `max(span)`-into-`min(side)` "square" fit only satisfied by
    /// coincidence. The killer case is Sweden-shaped data (tall + narrow) in a
    /// LANDSCAPE canvas (1140×620), which must be height-bound and fill the height.
    #[test]
    fn fit_fills_the_binding_axis_at_every_aspect_ratio() {
        // (data lon/lat box, pane size, which axis should bind)
        let cases: [(&str, [f64; 4], egui::Vec2, char); 5] = [
            // Sweden-ish: ~13° lon × ~14° lat, but Mercator stretches high latitudes,
            // so it is decisively PORTRAIT — in a landscape canvas it binds on height.
            ("sweden-in-landscape", [11.0, 55.3, 24.2, 69.1], egui::vec2(1140.0, 620.0), 'y'),
            // The same country in a portrait canvas — still height-bound.
            ("sweden-in-portrait", [11.0, 55.3, 24.2, 69.1], egui::vec2(620.0, 1140.0), 'y'),
            // WIDE data in a landscape canvas — the case the square fit under-zoomed
            // by ~1.8×; must bind on WIDTH and fill it.
            ("wide-in-landscape", [-20.0, 40.0, 20.0, 45.0], egui::vec2(1140.0, 620.0), 'x'),
            // Wide data in a portrait canvas — width-bound.
            ("wide-in-portrait", [-20.0, 40.0, 20.0, 45.0], egui::vec2(620.0, 1140.0), 'x'),
            // Square-ish data in a square pane.
            ("square-in-square", [5.0, 45.0, 15.0, 52.0], egui::vec2(800.0, 800.0), 'y'),
        ];
        for (name, [lo0, la0, lo1, la1], size, axis) in cases {
            let mut v = view_spanning(lo0, la0, lo1, la1);
            let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), size);
            v.fit_to(rect);
            let (fx, fy) = fill_fracs(&v, rect);
            // Neither axis may overflow the pane (the data stays framed)…
            assert!(fx <= 1.0001 && fy <= 1.0001, "{name}: data overflows the pane (fx={fx:.3} fy={fy:.3})");
            // …and the BINDING axis must actually be filled, not left tiny. FIT_FILL is
            // 0.92, so require ≥0.9 on that axis (the old square fit dropped far below
            // this for the wide-data cases).
            let bind = if axis == 'x' { fx } else { fy };
            assert!(bind >= 0.90, "{name}: the binding {axis} axis is only {bind:.3} full — the map frames SMALL");
        }
    }

    /// **REGRESSION (the embedded canvas commits its fit mid-layout).** An iframe/wasm
    /// canvas lays out progressively: a believable-but-INTERMEDIATE pane (300×200,
    /// which clears `MIN_FIT_RECT_PX`) can paint before the host sizes it to the real
    /// 1140×620. The one-shot fit committed there and never re-fitted, leaving the map
    /// drawn for a third-size pane. After the grow, the view must RE-fit and fill again
    /// — unless the user has taken over navigation, whose framing is preserved.
    #[test]
    fn a_pane_that_grows_after_its_first_fit_refits_unless_the_user_navigated() {
        let small = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(300.0, 200.0));
        let big = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1140.0, 620.0));

        // Committed at the intermediate size, then the canvas grows.
        let mut v = view_spanning(11.0, 55.3, 24.2, 69.1);
        v.fit_to(small);
        let zoom_small = v.state().zoom;
        harness::render_sized(&mut v, (big.width(), big.height()));
        let (_, fy) = fill_fracs(&v, big);
        assert!(
            v.state().zoom > zoom_small * 1.5,
            "the grown canvas re-fits (zoom {zoom_small:.0} → {:.0})",
            v.state().zoom
        );
        // Measured against the whole canvas, while the map PANE is the canvas minus the
        // header row (~37 px of readout + spacing) — so the achievable fill here is
        // `FIT_FILL × (620−37)/620 ≈ 0.865`, not `FIT_FILL` itself. Require ≥0.85: the
        // stranded pre-fix camera sat near 0.29 (fitted for a 200 px pane), so this
        // still fails hard when the re-fit is missing.
        assert!(fy >= 0.85, "after re-fitting the grown canvas the data fills the height ({fy:.3})");

        // But a user who has navigated keeps their framing across a resize.
        let mut v2 = view_spanning(11.0, 55.3, 24.2, 69.1);
        v2.fit_to(small);
        v2.update(Msg::ZoomBy(4.0));
        // `zoom_by` sets the GOAL; the live `zoom` then eases toward it each frame, so
        // the invariant to check across the resize is the goal the user asked for.
        let user_target = v2.state().target_zoom;
        harness::render_sized(&mut v2, (big.width(), big.height()));
        assert_eq!(v2.state().target_zoom, user_target, "a user-navigated camera is NOT re-fitted on resize");
    }

    /// **REGRESSION (the reported "two scales of land and lines when zooming in").**
    /// The CPU basemap (land/water fills) projects each way vertex through the f64
    /// [`MercatorCamera::to_px`]; the GPU line lane ([`OsmView::draw_ways_gpu`]) bakes
    /// each vertex **origin-local** against the index's CACHED [`MapIndex::origin`] and
    /// projects it through [`facett_map::gpu::MapTransform`]. If those two lanes disagree
    /// on scale/placement — e.g. if the cached origin drifted from the true Mercator
    /// bbox-min, collapsing the origin-local rebase back to the absolute-f32 cancellation
    /// bug — the land and the lines render at two scales. This replays the EXACT live GPU
    /// path (real cached `origin`, `cam.zoom as f32`) at MAX zoom over the sample dataset
    /// and asserts every GPU-projected vertex lands within 1 px of its CPU twin, so the
    /// two lanes share one scale. RED if `origin()` is ever wrong or a lane's transform drifts.
    #[cfg(feature = "map-wgpu")]
    #[test]
    fn gpu_line_lane_and_cpu_basemap_share_one_scale_at_high_zoom() {
        use facett_map::gpu::MapTransform;

        // A real-ish European dataset (Liechtenstein-scale) → Mercator coords ≈ 0.5, so
        // the cached origin is far from 0 and the origin-local rebase actually matters.
        let (points, ways) = sample_dataset(4_000);
        assert!(!ways.is_empty(), "sample has ways to project");
        let view = OsmView::new("scale-parity", points, ways);
        let origin = view.ways.origin().expect("non-empty index has a cached origin");

        let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(900.0, 900.0));
        // Frame the data, then zoom to the hard ceiling — where an f32 divergence bites.
        let mut cam = MercatorCamera::default();
        if let Some(bb) = view.fit_bbox() {
            cam.fit_merc_bbox(bb, rect);
        }
        cam.zoom = ZOOM_MAX;
        cam.target_zoom = ZOOM_MAX;

        // The GPU transform EXACTLY as `draw_ways_gpu` builds it (same origin, same
        // `cam.zoom as f32`, same center, same rect).
        let z = cam.zoom as f32;
        let transform = MapTransform::new(origin, [cam.center.0, cam.center.1], z, z, rect);

        // Project every way vertex on BOTH lanes; the worst gap must be sub-pixel.
        let mut worst = 0.0f32;
        let mut checked = 0usize;
        for (w, _lod) in view.ways.gpu_ways() {
            for &(lon, lat) in &w.lonlat {
                let (mx, my) = mercator(lon, lat);
                let cpu = cam.to_px(mx, my, rect); // f64 basemap lane
                // GPU baked origin-local vertex (mirrors `build_gpu_geometry`).
                let gpu_pos = [(mx - origin[0]) as f32, (my - origin[1]) as f32];
                let gpu = transform.project(gpu_pos); // origin-local GPU lane
                let d = ((gpu[0] - cpu.x).powi(2) + (gpu[1] - cpu.y).powi(2)).sqrt();
                worst = worst.max(d);
                checked += 1;
            }
        }
        assert!(checked >= 4, "projected a real vertex set ({checked})");
        assert!(
            worst < 1.0,
            "GPU lines and CPU land share one scale at max zoom (worst gap {worst:.3} px). \
             A large gap is the 'two scales of land and lines' regression — the cached \
             origin drifted or a lane's transform is wrong."
        );
    }

    #[test]
    fn grid_culls_to_the_viewport() {
        // 4 points spread across the world; a tight viewport over one of them must
        // return exactly that one (inject points → assert the visible count).
        let pts = vec![(0.0, 0.0), (90.0, 45.0), (-90.0, -45.0), (170.0, 80.0)];
        let grid = PointGrid::build(pts);
        // A bbox around (90, 45) in Mercator unit space.
        let (mx, my) = mercator(90.0, 45.0);
        let bbox = [mx as f32 - 0.01, my as f32 - 0.01, mx as f32 + 0.01, my as f32 + 0.01];
        assert_eq!(grid.visible_count(bbox), 1, "exactly the one point in view");
        // The whole-world bbox sees all four.
        assert_eq!(grid.visible_count([0.0, 0.0, 1.0, 1.0]), 4);
    }

    #[test]
    fn zoom_to_cursor_keeps_the_anchor_point_fixed() {
        // The map standard: the geo point under the cursor must not move when you
        // wheel-zoom toward it. Inject a camera + anchor, zoom in, assert the
        // anchor's Mercator coordinate is unchanged (to f32 precision).
        let r = rect();
        let mut cam = MercatorCamera { center: (0.5, 0.5), zoom: 1000.0, target_zoom: 1000.0, pitch_deg: 0.0 };
        let anchor = egui::pos2(600.0, 200.0); // off-centre, so a centre-zoom would move it
        let before = cam.to_merc(anchor, r);
        cam.zoom_to(2.0, anchor, r);
        let after = cam.to_merc(anchor, r);
        assert!((before.0 - after.0).abs() < 1e-6, "anchor lon-merc fixed: {} vs {}", before.0, after.0);
        assert!((before.1 - after.1).abs() < 1e-6, "anchor lat-merc fixed: {} vs {}", before.1, after.1);
        assert!(cam.zoom > 1000.0, "zoomed in");
    }

    #[test]
    fn zoom_in_reduces_the_visible_point_count() {
        // INJECT real points across a span, FIT, count visible, ZOOM IN at the
        // centre, count again → fewer points are in view after zooming. This is the
        // required inject-assert: a real navigation op changes a real output.
        let (points, ways) = sample_dataset(2_000);
        let mut v = OsmView::new("t", points, ways);
        let r = rect();
        v.fit_to(r);
        let before = v.visible_point_count(r);
        assert!(before > 100, "fit shows most of the 2000 points, got {before}");
        // Zoom in hard at the pane centre.
        for _ in 0..6 {
            v.zoom_to_cursor(1.6, r.center(), r);
        }
        let after = v.visible_point_count(r);
        assert!(after < before, "zooming in culls points out of view: {after} < {before}");
        assert!(after >= 1, "but the centre still shows some, got {after}");
    }

    #[test]
    fn pan_moves_the_centre_and_changes_visibility() {
        let (points, ways) = sample_dataset(2_000);
        let mut v = OsmView::new("t", points, ways);
        let r = rect();
        v.fit_to(r);
        for _ in 0..5 {
            v.zoom_to_cursor(1.5, r.center(), r);
        }
        let c0 = v.camera.center;
        v.pan_pixels(300.0, 0.0); // drag the world right → centre moves west
        assert!(v.camera.center.0 < c0.0, "panning +x moved the centre west");
    }

    #[test]
    fn local_and_remote_ctors_build_a_drawable_facet() {
        // Discovery-contract: local()/remote() must build a renderable facet whose
        // state_json reports the camera + counts.
        for mut v in [OsmView::local(), OsmView::remote()] {
            let r = harness::headless_render(&mut v);
            assert!(r.drew(), "the sample dataset tessellates to vertices");
            assert_eq!(r.state["points"], 2_000, "local sample has 2000 points");
            assert!(r.state["ways"].as_u64().unwrap() >= 1, "has at least one way");
            assert!(r.state["visible_points"].as_u64().unwrap() > 0, "some points are visible after the fit");
            assert!(r.state["zoom"].as_f64().unwrap() > 0.0);
            assert!(r.state["center"].is_array(), "centre reported as lon/lat");
            assert!(r.state["fps"].as_f64().unwrap() > 0.0);
        }
    }

    #[test]
    fn point_stride_grows_as_we_zoom_out() {
        let mut v = OsmView::local();
        v.camera.zoom = 200_000.0;
        let close = v.point_stride();
        v.camera.zoom = 200.0;
        let far = v.point_stride();
        assert_eq!(close, 1, "zoomed in draws every point");
        assert!(far > close, "zoomed out decimates: stride {far} > {close}");
    }

    /// RED-when-broken: a SMALL dataset at the most zoomed-out camera must draw
    /// EVERY visible point. The zoom tier reads only the camera, so applied
    /// unconditionally it decimated a sparse view exactly as hard as a dense one —
    /// the demo's "layers" pane (43 points) drew **one**, and read as a blank pane.
    ///
    /// The two halves are asserted together on purpose: the fix must not be "delete
    /// the LOD", so the dense case pins that a crowded view still clamps.
    #[test]
    fn the_zoom_lod_thins_a_crowded_view_but_never_a_sparse_one() {
        let mut v = OsmView::local();
        v.camera.zoom = 200.0; // whole-dataset fit: the zoom tier is at its coarsest
        assert!(v.point_stride() > 1, "precondition: this camera is in a thinning tier");

        // Sparse — the exact shape of the layers pane.
        assert_eq!(
            v.budget_stride(43),
            1,
            "43 points at a whole-dataset fit must ALL draw; a stride here is what \
             made the pane look black"
        );
        // …and nothing under the floor is thinned at all.
        for visible in [1, 10, 43, MIN_DRAWN_POINTS] {
            assert_eq!(v.budget_stride(visible), 1, "visible={visible} is not crowded");
        }

        // Dense — the LOD must still bite, or the sluggish-2D bug comes back.
        let dense = v.budget_stride(1_000_000);
        assert!(
            dense >= v.point_stride(),
            "a crowded view must still clamp to at least the zoom tier: {dense}"
        );
        assert!(
            1_000_000_usize.div_ceil(dense) <= MAX_DRAWN_POINTS,
            "the per-frame budget must still hold: {} drawn",
            1_000_000_usize.div_ceil(dense)
        );
    }

    /// **The way cull reads THE shared cartographic ladder** —
    /// `facett_map::layer::LOD_TIER_ZOOMS` through `view_lod_tier` — not a
    /// private twin.
    ///
    /// RED against the twin this replaces (2026-08-25): `way_lod` stepped at
    /// fit-multiplier thresholds `3_000` / `30_000` (slippy ~3.6 / ~6.9, where
    /// the real ladder still says Country / Region) and NEVER emitted
    /// `Lod::Detail`, so footway-tier ways were undrawable at EVERY zoom in this
    /// viewer while facett-map (and the GPU cull mirroring it) shows them from
    /// slippy 15. The old guard `way_lod_tightens_as_we_zoom_in` pinned the twin's
    /// own thresholds, i.e. guarded the defect.
    #[test]
    fn way_lod_is_the_shared_ladder_not_a_twin() {
        let mut v = OsmView::local();
        // camera.zoom is px per Mercator WORLD unit; the ladder steps sit at
        // 256·2^{0,6,12,15} = 256 / 16_384 / 1_048_576 / 8_388_608.
        for (zoom, want) in [
            (200.0, Lod::Country as u32),
            (5_000.0, Lod::Country as u32),      // the twin said Region here (>= 3_000)
            (50_000.0, Lod::Region as u32),      // the twin said City here (>= 30_000)
            (1_100_000.0, Lod::City as u32),     // past 256·2^12
            (9_000_000.0, Lod::Detail as u32),   // past 256·2^15 — the twin had NO Detail
        ] {
            v.camera.zoom = zoom;
            assert_eq!(v.way_lod(), want, "zoom {zoom} px/world-unit");
            // One writer: byte-agreement with facett-map's ladder at this zoom.
            assert_eq!(
                v.way_lod(),
                facett_map::layer::view_lod_tier((zoom / 256.0).log2() as f32),
                "zoom {zoom}: the way cull must read facett-map's LOD_TIER_ZOOMS ladder"
            );
        }
    }

    /// RED-when-broken: the documented z-band paint order ([`PaintBand::ORDER`])
    /// must be strictly increasing bottom→top — land/water fills UNDER roads UNDER
    /// points/overlays UNDER labels UNDER pins. `view` emits its layers in exactly
    /// this sequence on BOTH the GPU line lane and the CPU fall-through (the GPU
    /// line callback is now emitted after the fills, not before). If someone renames
    /// or reorders a band this locks the contract.
    #[test]
    fn z_band_order_is_documented_and_monotone() {
        use PaintBand::*;
        assert_eq!(
            PaintBand::ORDER,
            [Background, Fill, Line, Point, Overlay, Label, Pin],
            "the canonical bottom→top band order"
        );
        for w in PaintBand::ORDER.windows(2) {
            assert!(w[0] < w[1], "{:?} must paint below {:?}", w[0], w[1]);
        }
        // The load-bearing invariants the fills/roads/pins fix rests on.
        assert!(PaintBand::Fill < PaintBand::Line, "fills paint UNDER roads");
        assert!(PaintBand::Line < PaintBand::Point, "roads paint UNDER points");
        assert!(PaintBand::Overlay < PaintBand::Label, "overlays paint UNDER labels");
        assert!(PaintBand::Label < PaintBand::Pin, "labels paint UNDER pins");
    }

    #[test]
    fn caps_advertise_scalable_themeable_resizable_navigable() {
        let caps = OsmView::local().caps();
        assert!(caps.scalable && caps.themeable && caps.resizable);
        assert!(caps.navigable, "the OSM viewer drives the shared pan/zoom/fit model");
    }

    #[test]
    fn inertial_pan_glides_and_decays_to_a_stop() {
        // INJECT a flick velocity → the camera keeps moving for several frames
        // (the glide), then friction brings it to rest. Real input (a flick) →
        // real output (the centre moved, momentum decayed to zero). Deterministic
        // under the injected dt.
        let (points, ways) = sample_dataset(2_000);
        let mut v = OsmView::new("t", points, ways);
        let r = rect();
        v.fit_to(r);
        for _ in 0..5 {
            v.zoom_to_cursor(1.5, r.center(), r); // zoom in so a pan moves the centre
        }
        let c0 = v.camera.center;
        v.flick_pan(2_000.0, 0.0); // flick east at 2000 px/s
        let mut frames_moving = 0;
        for _ in 0..600 {
            if v.tick_pan_momentum(1.0 / 60.0) {
                frames_moving += 1;
            } else {
                break;
            }
        }
        let c1 = v.camera.center;
        assert!(frames_moving > 3, "the glide carried for several frames, got {frames_moving}");
        assert!(c1.0 < c0.0, "flicking +x velocity panned the world (centre moved west)");
        assert!(v.state_json()["pan_momentum"][0].as_f64().unwrap().abs() < 1.0, "momentum decayed to rest");
    }

    #[cfg(feature = "map-wgpu")]
    #[test]
    // ENVIRONMENT-GATED (not a logic bug): the `with_gpu(true)` half asserts
    // `gpu_lines == true`, which requires the GPU line-paint callback to actually
    // FIRE. `draw_ways_gpu` fail-safes to the CPU (returns false, so `gpu_lines`
    // stays false) whenever `facett_map::gpu::renderer_installed()` is false — and
    // the headless `egui_kittest` harness never installs a `GpuMapRenderer`. So this
    // lane can only pass on a real GPU host, exactly as the body comment notes. Skip
    // it by default (headless CI is always red otherwise); run it with `--ignored` on
    // a machine with an installed wgpu renderer. The always-on CPU lane is covered by
    // the `with_gpu(false)` half here + the other headless render tests.
    #[ignore = "needs a real installed wgpu GpuMapRenderer; the GPU paint callback no-ops headless so gpu_lines stays false — run with --ignored on a GPU host"]
    fn gpu_opt_in_emits_the_callback_but_with_gpu_false_keeps_the_cpu_lines() {
        // with_gpu(true): the line layer is handed to the GPU paint callback
        // (gpu_lines=true) — but the callback itself no-ops without an installed
        // renderer, so this lane needs a real GPU (cannot be proven headless).
        let (points, ways) = sample_dataset(500);
        let mut g = OsmView::from_geometry("gpu", points.clone(), ways.clone()).with_gpu(true);
        assert!(g.gpu_opt_in(), "with_gpu(true) opted the line layer in");
        let rg = harness::headless_render(&mut g);
        assert_eq!(rg.state["gpu_enabled"], true, "state advertises the GPU opt-in");
        assert_eq!(rg.state["gpu_lines"], true, "the GPU callback was emitted for the line layer");
        assert!(rg.drew(), "points + filled silhouettes still tessellate on the CPU");

        // with_gpu(false): the CPU painter owns the whole basemap — the always-on,
        // headless-provable lane. gpu_lines stays false.
        let mut c = OsmView::from_geometry("cpu", points, ways).with_gpu(false);
        assert!(!c.gpu_opt_in(), "with_gpu(false) keeps the CPU painter");
        let rc = harness::headless_render(&mut c);
        assert_eq!(rc.state["gpu_lines"], false, "CPU lane never emits the GPU callback");
        assert!(rc.drew(), "the CPU painter drew the basemap + points");
    }

    #[test]
    fn from_geometry_builds_the_same_drawable_facet() {
        // The real-data lane: from_geometry must build a renderable facet (this is
        // what the demo's OSM 2D tab calls with liechtenstein points/ways).
        let (points, ways) = sample_dataset(1_000);
        let mut v = OsmView::from_geometry("OSM 2D · liechtenstein", points, ways);
        let r = harness::headless_render(&mut v);
        assert!(r.drew(), "real geometry tessellates to vertices");
        assert_eq!(r.state["points"], 1_000);
        assert!(r.state["ways"].as_u64().unwrap() >= 1);
    }

    #[test]
    fn zoom_tick_eases_toward_the_target() {
        let mut cam = MercatorCamera { center: (0.5, 0.5), zoom: 1000.0, target_zoom: 4000.0, pitch_deg: 0.0 };
        let z0 = cam.zoom;
        let animating = cam.tick();
        assert!(animating, "still easing");
        assert!(cam.zoom > z0 && cam.zoom < 4000.0, "moved partway: {}", cam.zoom);
        for _ in 0..100 {
            cam.tick();
        }
        assert!((cam.zoom - 4000.0).abs() < 1.0, "converged to the target");
    }

    #[test]
    fn high_zoom_way_clips_far_vertices_yet_preserves_the_on_screen_segment() {
        // Reproduce the high-zoom flicker bug as an inject→assert: a line way with
        // one vertex inside the pane and one at Mercator coords that, at street
        // zoom, project to ~1e8 px. Clip against the margin-expanded rect and
        // assert (a) NO emitted vertex is at an extreme coordinate, and (b) the
        // on-screen portion is preserved — the segment still exits through a rect
        // edge where it should, rather than being dropped or clamped.
        let r = rect();
        let cam = MercatorCamera { center: (0.5, 0.5), zoom: 5_000_000.0, target_zoom: 5_000_000.0, pitch_deg: 0.0 };

        // One vertex at the camera centre (lands at pane centre), one far away in
        // Mercator-unit space → an enormous projected pixel.
        let inside_merc = cam.center;
        let far_merc = (cam.center.0 + 0.05, cam.center.1 + 0.05);
        let projected = [
            cam.to_px(inside_merc.0, inside_merc.1, r),
            cam.to_px(far_merc.0, far_merc.1, r),
        ];
        // Sanity: the far vertex really is extreme before clipping (the bug input).
        assert!(
            projected[1].x.abs() > 1.0e5 || projected[1].y.abs() > 1.0e5,
            "far vertex projects to an extreme pixel: {:?}",
            projected[1]
        );

        let clip_rect = r.expand(CLIP_MARGIN_PX);
        let mut runs: Vec<Vec<egui::Pos2>> = Vec::new();
        clip::clip_polyline(&projected, clip_rect, &mut runs);

        // (a) Something was drawn, and every emitted vertex is bounded.
        assert!(!runs.is_empty(), "the on-screen part of the way is still drawn");
        let bound = clip_rect.max.x.max(clip_rect.max.y) + 1.0;
        for run in &runs {
            assert!(run.len() >= 2, "each run is a real polyline");
            for p in run {
                assert!(
                    p.x.abs() <= bound && p.y.abs() <= bound,
                    "no extreme vertex reaches the tessellator: {p:?} (bound {bound})"
                );
            }
        }
        // (b) Geometry preserved: the run starts at the in-pane vertex and exits
        // through the expanded rect's edge (not clamped to the far point).
        let run = &runs[0];
        assert_eq!(run[0], projected[0], "kept the on-screen endpoint exactly");
        let exit = *run.last().unwrap();
        let on_edge = (exit.x - clip_rect.min.x).abs() < 1e-2
            || (exit.x - clip_rect.max.x).abs() < 1e-2
            || (exit.y - clip_rect.min.y).abs() < 1e-2
            || (exit.y - clip_rect.max.y).abs() < 1e-2;
        assert!(on_edge, "the segment exits on a rect edge (slope preserved): {exit:?}");
    }

    /// **REGRESSION (2D feels sluggish — "is it really GPU?")** — the painter must
    /// NOT draw a circle per node when a small-but-dense country is fitted whole.
    ///
    /// The GPU fast-path only owns the *line ways*; the *point* layer is drawn on the
    /// CPU as one tessellated `circle_filled` per kept node. The old `point_stride`
    /// keyed off ABSOLUTE zoom (px per Mercator-unit). Liechtenstein's whole-country
    /// fit has a huge absolute zoom (~3e5) because its Mercator span is tiny, so the
    /// old stride returned 1 → the painter emitted a circle for ALL ~350 k nodes
    /// every frame (hundreds of thousands of tessellated circles → the lag).
    ///
    /// Inject a dense point cloud, fit the whole set into the pane, render a real
    /// frame, and assert the painter drew ≤ the budget — proving the per-frame CPU
    /// cost is now bounded. The OLD code drew ~all points (≫ budget) → FAILS here.
    #[test]
    fn dense_country_fit_caps_drawn_points_to_the_budget() {
        // 200 k points packed into a tiny lon/lat box (a small dense "country").
        let mut state: u64 = 0xD1B5_4A32_D192_ED03;
        let mut next = || {
            state ^= state << 13;
            state ^= state >> 7;
            state ^= state << 17;
            (state >> 11) as f64 / (1u64 << 53) as f64
        };
        let (lon0, lon1, lat0, lat1) = (9.47, 9.64, 47.05, 47.27);
        let n = 200_000usize;
        let points: Vec<(f64, f64)> =
            (0..n).map(|_| (lon0 + next() * (lon1 - lon0), lat0 + next() * (lat1 - lat0))).collect();
        let mut v = OsmView::new("dense", points, vec![]);
        let r = rect();
        v.fit_to(r);

        // Sanity: the whole dense set IS in view (so the old code WOULD draw it all),
        // and the absolute zoom is high enough that the legacy zoom-LOD floor is 1.
        let vis = v.visible_point_count(r);
        assert!(vis > MAX_DRAWN_POINTS, "the whole dense set is in view ({vis} > {MAX_DRAWN_POINTS})");
        assert_eq!(v.point_stride(), 1, "legacy zoom-LOD floor is 1 at this fit (the bug input)");

        // The STRIDE throttle (this test's original subject) is unchanged: it still
        // bounds the sample the painter would be asked for, well under the budget
        // and far under the visible count.
        let stride = v.budget_stride(vis);
        let sample = vis.div_ceil(stride);
        assert!(stride > 1, "the budget raised the decimation stride above 1, got {stride}");
        assert!(
            sample <= MAX_DRAWN_POINTS,
            "the stride bounds the sample to the budget, got {sample} > {MAX_DRAWN_POINTS}"
        );
        assert!(sample * 3 < vis, "the sample is far under the visible {vis} (got {sample})");

        // …and on TOP of the throttle, the building-vertex LOD now culls the layer
        // OUTRIGHT at this camera (see `POINT_INK_FRAC_MAX`): even the throttled
        // sample would be `sample * π * 3²` px of ink over a 800×600 pane — several
        // SCREENFULS of dots, the "blue sea when zooming out" the owner reported.
        let res = harness::headless_render(&mut v);
        let drawn = res.state["drawn_points"].as_u64().unwrap() as usize;
        assert_eq!(drawn, 0, "the whole-dense-fit frame paints NO building vertices");
        assert_eq!(res.state["points_culled"], true, "…and says so in state");
        assert!(
            res.state["point_ink_frac"].as_f64().unwrap() > POINT_INK_FRAC_MAX as f64,
            "the cull fired because the ink was over budget"
        );

        // DEBUG MODE is the escape hatch: the same frame, opted in, draws again —
        // and is STILL bounded by the stride budget (debug bypasses the LOD, not
        // the throttle).
        let mut dv = std::mem::replace(&mut v, OsmView::new("x", vec![], vec![])).with_debug_points(true);
        let res = harness::headless_render(&mut dv);
        let drawn = res.state["drawn_points"].as_u64().unwrap() as usize;
        assert!(drawn > 0, "debug mode shows the vertices the LOD hides");
        assert!(drawn <= MAX_DRAWN_POINTS, "…still bounded by the budget, got {drawn}");
    }

    /// At real street-level zoom (few points in view) neither throttle bites —
    /// every visible point still draws (stride 1, ink well under budget), so the
    /// caps only touch the pathological whole-dense-country frame, never a normal
    /// close-up. This is the other half of the LOD oracle: culled when zoomed out,
    /// **stroke-coloured and present** at building zoom.
    #[test]
    fn close_up_view_draws_every_visible_point() {
        let (points, ways) = sample_dataset(2_000);
        let mut v = OsmView::new("close", points, ways);
        let r = rect();
        v.fit_to(r);
        // A whole-dataset FIT is NOT a close-up — that is precisely the frame the
        // LOD exists to cull, so assert it culls there first…
        let fitted = harness::headless_render(&mut v);
        assert_eq!(fitted.state["drawn_points"], 0, "the whole-dataset fit is culled");
        assert_eq!(fitted.state["points_culled"], true);

        // …then zoom in to a real close-up (a 3x zoom leaves ~1/9 of the sample in
        // view) and assert the layer comes back.
        v.update(Msg::ZoomTo { k: 3.0, anchor: r.center(), rect: r });
        while v.camera.tick() {}
        let res = harness::headless_render(&mut v);
        let drawn = res.state["drawn_points"].as_u64().unwrap() as usize;
        let vis = res.state["visible_points"].as_u64().unwrap() as usize;
        assert!(vis <= MAX_DRAWN_POINTS, "the small sample fits the budget");
        // budget_stride == zoom-LOD floor here (no extra decimation from the cap).
        assert_eq!(
            v.budget_stride(vis),
            v.point_stride(),
            "the budget is a no-op when the visible count fits"
        );
        assert!(drawn > 0, "real points drew at building zoom");
        assert_eq!(res.state["points_culled"], false, "the LOD does not cull a close-up");
    }

    #[test]
    fn empty_view_renders_hint_without_panicking() {
        let mut v = OsmView::new("empty", vec![], vec![]);
        let r = harness::headless_render(&mut v);
        assert_eq!(r.state["points"], 0);
        assert_eq!(r.state["visible_points"], 0);
        assert!(r.drew(), "the 'no geo data' hint still draws text");
    }

    // ── FC-2 → FC-3 as a *headless* property: feed a `Vec<Msg>` through the core
    //    harness and assert the resulting `MercatorCamera` — no egui, no GPU. ──────

    #[test]
    fn harness_snapshot_drives_camera_msgs() {
        let (points, ways) = sample_dataset(2_000);
        let mut v = OsmView::new("t", points, ways);
        let r = rect();
        // Fit, then zoom-to-cursor + pan, all as Msgs through the single update path.
        let snap = harness::snapshot(
            &mut v,
            [
                Msg::Fit(r),
                Msg::ZoomTo { k: 2.0, anchor: r.center(), rect: r },
                Msg::PanPx(120.0, 0.0),
            ],
        );
        assert!(snap.zoom > MercatorCamera::default().zoom, "fit+zoom raised the zoom");
        assert_eq!(&snap, v.state(), "the snapshot is a clone of the live camera");

        // ZoomBy only moves the *target* (the eased zoom follows via TickZoom).
        let z_before = v.state().zoom;
        let snap = harness::snapshot(&mut v, [Msg::ZoomBy(KEY_ZOOM_STEP)]);
        assert!(snap.target_zoom > z_before, "ZoomBy raised the zoom target");
        assert!((snap.zoom - z_before).abs() < 1e-6, "ZoomBy leaves the eased zoom to TickZoom");
        let snap = harness::snapshot(&mut v, std::iter::repeat_n(Msg::TickZoom, 200));
        assert!((snap.zoom - snap.target_zoom).abs() < 1.0, "TickZoom converges the eased zoom");

        // SetZoom snaps both zoom + target and clamps to the ZOOM_MIN..=ZOOM_MAX band.
        let snap = harness::snapshot(&mut v, [Msg::SetZoom(1e12)]);
        assert_eq!(snap.zoom, ZOOM_MAX, "SetZoom clamps at the ceiling");
        assert_eq!(snap.target_zoom, ZOOM_MAX, "target snaps with it");
    }

    #[test]
    fn harness_drives_flick_and_momentum_glide() {
        // FC-2: a ReleaseDrag (after a DragPan seeds velocity) flicks the inertial
        // glide; TickMomentum steps it and moves the camera centre, all via update.
        let (points, ways) = sample_dataset(2_000);
        let mut v = OsmView::new("t", points, ways);
        let r = rect();
        harness::drive(&mut v, [Msg::Fit(r)]);
        for _ in 0..5 {
            let _ = v.update(Msg::ZoomTo { k: 1.5, anchor: r.center(), rect: r });
        }
        let c0 = v.state().center;
        // A drag east then release seeds a flick; the momentum then glides.
        harness::drive(&mut v, [Msg::DragPan { dx: 40.0, dy: 0.0, dt: 1.0 / 60.0 }, Msg::ReleaseDrag]);
        let moved = v.state().center.0 != c0.0;
        assert!(moved, "the drag itself already panned the camera");
        let c1 = v.state().center;
        let glided = harness::snapshot(&mut v, std::iter::repeat_n(Msg::TickMomentum(1.0 / 60.0), 30));
        assert!(glided.center.0 != c1.0, "the inertial glide carried the camera after release");
        // CancelMomentum stops it dead.
        let _ = v.update(Msg::CancelMomentum);
        let c2 = v.state().center;
        let after = harness::snapshot(&mut v, [Msg::TickMomentum(1.0 / 60.0)]);
        assert_eq!(after.center, c2, "CancelMomentum halts the glide");
    }

    #[test]
    fn drive_reports_no_effects_and_camera_round_trips() {
        let (points, ways) = sample_dataset(500);
        let mut v = OsmView::new("t", points, ways);
        let r = rect();
        // FC-8: the viewer does no I/O, so the effect stream is always empty.
        let effects = harness::drive(&mut v, [Msg::Fit(r), Msg::PanPx(10.0, -5.0)]);
        assert!(effects.is_empty(), "FC-8: OsmView emits no Effects");
        // FC-3: the observable Model (the MercatorCamera) round-trips through serde.
        let json = serde_json::to_value(v.state()).unwrap();
        assert!(json["zoom"].as_f64().unwrap() > 0.0);
        assert!(json["center"].is_array(), "centre serialises as [x, y] in Mercator unit");
        let back: MercatorCamera = serde_json::from_value(json).unwrap();
        assert_eq!(&back, v.state(), "serde(MercatorCamera) -> MercatorCamera round-trips");
    }

    /// The deck's uniform scale knob drives the camera via the FC-2 update path — and
    /// it is relative to the **fit**, over the **camera's own band**.
    ///
    /// This replaces `set_scale_routes_through_update_and_scale_reads_back`, which was
    /// vacuous and stayed green through the entire 400× zoom-out bug. It drove
    /// `OsmView::local()` — never fitted, so `fit_zoom` is still the 512.0 default and
    /// `scale()` reads exactly 1.0, the ONE value at which an absolute `zoom / 512`
    /// knob is accidentally correct — then asserted a pure state round-trip (write 4.0,
    /// read 4.0) and never rendered. Measured: with the old absolute knob restored, all
    /// 68 lib tests here including that one stay green while the pixel guards in
    /// `tests/osm2d_deck_scale.rs` go red.
    ///
    /// So: FIT FIRST. Every value a user ever sees lives on the other side of the first
    /// fit, and the assertions below are stated against the fitted zoom rather than
    /// against a constant that has nothing to do with the map on screen.
    #[test]
    fn set_scale_is_relative_to_the_fit_and_spans_the_cameras_own_band() {
        let mut v = OsmView::local();
        let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1200.0, 800.0));
        let _ = v.update(Msg::Fit(rect));
        let fit_zoom = v.state().zoom;
        assert!(
            fit_zoom > 512.0,
            "precondition: the fit really moved the camera off the default zoom ({fit_zoom})",
        );

        // 1.0 is "the framing the pane opened on", not 512 px per Mercator unit.
        assert!(
            (<OsmView as Facet>::scale(&v) - 1.0).abs() < 1e-6,
            "a freshly fitted map is at 100%, got {}",
            <OsmView as Facet>::scale(&v),
        );

        // …and a knob write is a multiple OF THE FIT, applied through `update`.
        <OsmView as Facet>::set_scale(&mut v, 2.0);
        assert!(
            (v.state().zoom - 2.0 * fit_zoom).abs() < 1e-3,
            "set_scale(2.0) doubles the FITTED zoom ({fit_zoom} → expected {}), got {}",
            2.0 * fit_zoom,
            v.state().zoom,
        );
        assert!(
            (<OsmView as Facet>::scale(&v) - 2.0).abs() < 1e-3,
            "and reads back as 2.0",
        );

        // The band the deck is allowed to clamp into is the CAMERA's, not the
        // document-viewer default `[0.25, 4.0]`. Against that default the toolbar could
        // express 16× of a 1.25-million-fold camera, so any wheel gesture past 4× the
        // fit made the next `+` click saturate the clamp and write the camera BACKWARDS.
        let (lo, hi) = <OsmView as Facet>::scale_range(&v);
        assert!(
            (lo..=hi).contains(&1.0),
            "the band contains 1.0, so `FacetDeck::reset_scale` survives the clamp and \
             Reset still returns to the fit (got [{lo}, {hi}])",
        );
        assert!(
            (hi as f64 * fit_zoom) >= ZOOM_MAX - 1.0,
            "the toolbar can drive the camera all the way to ZOOM_MAX: {hi} × {fit_zoom} \
             = {} < {ZOOM_MAX}",
            hi as f64 * fit_zoom,
        );
        assert!(
            (lo as f64 * fit_zoom) <= ZOOM_MIN + 1.0,
            "…and all the way down to ZOOM_MIN: {lo} × {fit_zoom} = {} > {ZOOM_MIN}",
            lo as f64 * fit_zoom,
        );
    }
}