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/lib.rs

//! **facett-geomap** — a *scrollable & zoomable* geo map. Unlike
//! [`facett_map::MapView`] (a static fit-to-rect scatter), this carries a live
//! **viewport** (centre + zoom) you can pan with the mouse and zoom with the
//! scroll wheel / `+`/`−`, and it draws **two layers**:
//!
//! 1. a **point layer** — `(lon, lat)` population points (small dots), and
//! 2. a **cluster-hotspot layer** — `(lon, lat, count, label)` density hotspots
//!    drawn as circles **sized by count** with a count badge + label.
//!
//! Web Mercator (same projection as `facett-map` / osm-viewer), so an optional
//! **vector basemap** layer (3. below) lines up exactly with the points + hotspots:
//!
//! 3. a **basemap layer** — reused [`facett_map::Way`]s (filled silhouettes /
//!    line ways, pre-projected to Mercator unit space) drawn *under* the points
//!    and hotspots, so the geomap shows a map and not just dots on a flat fill.
//!
//! The component is a proper [`Facet`] built on the canonical **Elm split**
//! ([`facett_core::Elm`], FC-2 / FC-9): the observable control state — the
//! viewport ([`GeoViewport`]: Mercator centre + zoom + the selected hotspot id) —
//! is the serializable [`Elm::Model`], every input (pan / zoom / fit / select) is
//! a [`Msg`] applied through the single [`GeoMap::update`] mutation path, and
//! [`GeoMap::view`] is a **pure** paint that reads `&self` and returns the `Msg`s
//! it produced. The raw geometry (points / hotspots / basemap ways) is held as
//! plain fields *outside* the Model — it is the render *input*, not the navigation
//! state a driver mutates. It exposes its full viewport + all three layers'
//! summaries as `state_json`, advertises `scalable` (zoom) + `selectable`
//! (hotspots), and drives through `facett_core::harness` with no display.
//!
//! Source-agnostic: the host loads the points (korp: from the simulated
//! population's lat/lon), the hotspots (korp: from the ekonomiskt_bistand cluster
//! pass), and an optional basemap (korp: real `ways.parquet` via the osm adapter,
//! or the synthetic znippy-rabbit fallback) and hands them here. **Graceful:** no
//! ways → the original point + hotspot behaviour, unchanged.

use facett_core::{Elm, FacetCaps, Semantics, theme};
pub use facett_map::{MapPalette, Way};
use facett_map::tessellate;
use serde::{Deserialize, Serialize};

pub mod osm2d;
pub use osm2d::{MercatorCamera, OsmView, PointGrid};

pub mod boundary;
pub use boundary::{AdminBoundaries, AdminUnit, BoundaryError, load_sweden_admin};

/// Web Mercator, normalised to `0..1` (x east, y south). Re-exported from
/// [`facett_map::mercator`] (REUSE-law): the geomap projects through the exact
/// same projection as `facett-map`, so the basemap ways line up with the points.
pub use facett_map::mercator;

/// A density hotspot to highlight on the map.
#[derive(Clone, Debug, PartialEq)]
pub struct Hotspot {
    pub lon: f64,
    pub lat: f64,
    /// How many things are in this hotspot (drives the circle size + badge).
    pub count: usize,
    /// Short label (e.g. the dominant kommun).
    pub label: String,
}

impl Hotspot {
    pub fn new(lon: f64, lat: f64, count: usize, label: impl Into<String>) -> Self {
        Self { lon, lat, count, label: label.into() }
    }
}

/// **The complete observable control state (FC-1 / FC-3)** of a [`GeoMap`]: the
/// live viewport (Mercator centre + zoom) plus the selected hotspot id. This is
/// the [`Elm::Model`] — serializable + round-trippable so a headless driver
/// snapshots it after feeding a `Vec<Msg>`. The raw layer geometry (points /
/// hotspots / ways) is *not* here: it is the render input, held on the [`GeoMap`].
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GeoViewport {
    /// Viewport centre in **Mercator** unit space (`0..1`, x east, y south).
    pub center: (f64, f64),
    /// Zoom: 1.0 = whole world fits the smaller rect side; higher = closer.
    pub zoom: f64,
    /// The selected hotspot's **stable id** (its label, or `lon,lat` when
    /// unlabelled) — invariant across pan/zoom (FC-5). `None` = nothing selected.
    pub selected: Option<String>,
}

impl Default for GeoViewport {
    fn default() -> Self {
        Self { center: (0.5, 0.5), zoom: 1.0, selected: None }
    }
}

/// A robot-/CLI-addressable control message (FC-2) — the single named boundary a
/// headless driver (or a live gesture) drives the [`GeoViewport`] through. Applied
/// by [`GeoMap::update`], the **only** mutation path for the Model.
#[derive(Clone, Debug, PartialEq)]
pub enum Msg {
    /// Pan the viewport centre by a Mercator-unit delta (a drag gesture).
    PanUnit(f64, f64),
    /// Set the viewport centre directly (programmatic; clamped to `0..1`).
    SetCenter(f64, f64),
    /// Multiply the zoom by `k` (a scroll-wheel tick), clamped `0.25..=5e6`.
    ZoomBy(f64),
    /// Set the zoom directly (the deck's scale knob), clamped `0.25..=5e6`.
    SetZoom(f64),
    /// Re-centre + re-zoom so the data (points ∪ hotspots ∪ ways) fills the view.
    Fit,
    /// Select a hotspot by its **stable id** (FC-5).
    Select(String),
    /// Clear the selection (an empty-canvas click, or a programmatic clear).
    ClearSelection,
}

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

/// A scrollable, zoomable geo map with a basemap layer, a point layer and a
/// hotspot layer.
pub struct GeoMap {
    pub title: String,
    /// Background population points `(lon, lat)`.
    pub points: Vec<(f64, f64)>,
    /// Highlighted density hotspots.
    pub hotspots: Vec<Hotspot>,
    /// Optional **vector basemap** (filled silhouettes / line ways), drawn under
    /// the points + hotspots. Reuses [`facett_map::Way`] (carries the source
    /// `lonlat` vertices + a fill/stroke), projected here through the same
    /// viewport as the points so the layers line up. Empty → point-only (the
    /// original behaviour).
    pub ways: Vec<Way>,
    /// All observable navigation state (FC-1 / FC-3) — the [`Elm`] Model.
    view: GeoViewport,
    /// The host selected the shared GPU cloud lane ([`GeoMap::with_gpu`]).
    use_gpu: bool,
    /// What the LAST frame did with the line ways, points and hotspot discs:
    /// `Some(true)` GPU, `Some(false)` egui painter, `None` not painted yet.
    lane_drew: std::cell::Cell<Option<bool>>,
}

impl GeoMap {
    /// Build a map and **fit the viewport** to the data (points ∪ hotspots).
    pub fn new(title: impl Into<String>, points: Vec<(f64, f64)>, hotspots: Vec<Hotspot>) -> Self {
        let mut m = Self {
            title: title.into(),
            points,
            hotspots,
            ways: Vec::new(),
            view: GeoViewport::default(),
            use_gpu: false,
            lane_drew: std::cell::Cell::new(None),
        };
        m.fit();
        m
    }

    /// Attach a vector basemap (chainable) and **re-fit** the viewport so the
    /// basemap + points + hotspots all sit in view. `no ways → just points`.
    pub fn with_ways(mut self, ways: Vec<Way>) -> Self {
        self.ways = ways;
        self.fit();
        self
    }

    /// Select the shared instanced GPU cloud lane (facett-core `graphcloud`, the renderer
    /// the graph panes and korp already install): line ways go to the GPU as segment
    /// instances, points and hotspot discs as disc instances. Filled silhouettes, the
    /// hotspot counts/labels, the selection ring and the a11y nodes stay on egui (fills
    /// paint over the GPU lines, the same rule `OsmView` follows). Needs the `map-wgpu`
    /// feature AND a renderer the host installed; otherwise the egui painter draws and
    /// `state_json()["lane"]` says so (`gpu: "no GPU"`).
    #[must_use]
    pub fn with_gpu(mut self, on: bool) -> Self {
        self.use_gpu = on;
        self
    }

    /// Is a GPU cloud renderer installed in this process?
    fn gpu_installed() -> bool {
        #[cfg(feature = "map-wgpu")]
        {
            facett_core::render::gpu::graphcloud::cloud::cloud_renderer_installed()
        }
        #[cfg(not(feature = "map-wgpu"))]
        {
            false
        }
    }

    /// Push line ways + points + hotspot discs through the shared GPU cloud lane; `true`
    /// only if it drew (the one gate the painter consults before skipping those loops).
    #[cfg(feature = "map-wgpu")]
    fn draw_gpu_cloud(&self, ui: &egui::Ui, rect: egui::Rect, bg: egui::Color32, point: egui::Color32, zoom_w: f32) -> bool {
        use facett_core::render::gpu::graphcloud::{CloudBuilder, cloud};
        let max_count = self.hotspots.iter().map(|h| h.count).max().unwrap_or(1).max(1) as f32;
        cloud::draw_cloud(ui, rect, self.use_gpu, |px_w, px_h| {
            let sx = px_w / rect.width().max(1.0);
            let sy = px_h / rect.height().max(1.0);
            let to_px = |p: egui::Pos2| egui::pos2((p.x - rect.min.x) * sx, (p.y - rect.min.y) * sy);
            let lin = |b: u8| {
                let s = f32::from(b) / 255.0;
                s * s
            };
            // Calm, not glowing: a map is read, not admired (emissive 1, edges at 1).
            let b = CloudBuilder { emissive: 1.0, edge_gain: 1.0, background: [lin(bg.r()), lin(bg.g()), lin(bg.b()), 1.0] };
            let hot = egui::Color32::from_rgb(230, 90, 90);
            let nodes = self
                .points
                .iter()
                .map(|&(lon, lat)| {
                    let (mx, my) = mercator(lon, lat);
                    (self.project(mx, my, rect), 1.3, point)
                })
                .filter(|(p, _, _)| rect.contains(*p))
                .chain(self.hotspots.iter().filter_map(|h| {
                    let (mx, my) = mercator(h.lon, h.lat);
                    let p = self.project(mx, my, rect);
                    rect.expand(40.0).contains(p).then(|| (p, 6.0 + 18.0 * (h.count as f32 / max_count).sqrt(), hot))
                }))
                .map(|(p, r, c)| (to_px(p), r * sy, c));
            let edges = self.ways.iter().filter(|w| w.fill.is_none()).flat_map(|way| {
                let w = (way.style.width * zoom_w).max(0.5) * 0.5 * sy;
                let c = way.style.color;
                way.points.windows(2).map(move |s| {
                    let a = self.project(f64::from(s[0].0), f64::from(s[0].1), rect);
                    let bb = self.project(f64::from(s[1].0), f64::from(s[1].1), rect);
                    (to_px(a), to_px(bb), w, c)
                })
            });
            b.scene(nodes, edges)
        })
    }

    #[cfg(not(feature = "map-wgpu"))]
    fn draw_gpu_cloud(&self, _ui: &egui::Ui, _rect: egui::Rect, _bg: egui::Color32, _point: egui::Color32, _zoom_w: f32) -> bool {
        false
    }

    /// Way-kind histogram (`kind → count`) for `state_json` / tests.
    fn way_kinds(&self) -> std::collections::BTreeMap<&'static str, usize> {
        let mut m = std::collections::BTreeMap::new();
        for w in &self.ways {
            *m.entry(w.kind).or_insert(0) += 1;
        }
        m
    }

    /// Lon/lat bounding box `(min_lon, min_lat, max_lon, max_lat)` over all layers
    /// (points ∪ hotspots ∪ basemap ways).
    pub fn bbox(&self) -> Option<(f64, f64, f64, f64)> {
        let mut b: Option<(f64, f64, f64, f64)> = None;
        let mut acc = |lon: f64, lat: f64| {
            b = Some(match b {
                None => (lon, lat, lon, lat),
                Some((a, c, d, e)) => (a.min(lon), c.min(lat), d.max(lon), e.max(lat)),
            });
        };
        for &(lon, lat) in &self.points {
            acc(lon, lat);
        }
        for h in &self.hotspots {
            acc(h.lon, h.lat);
        }
        for w in &self.ways {
            for &(lon, lat) in &w.lonlat {
                acc(lon, lat);
            }
        }
        b
    }

    /// Re-centre + re-zoom so the data fills the viewport (called on construction;
    /// the host can call it again after replacing the data, or on a "fit" button).
    /// Routes through the FC-2 [`update`](Self::update) mutation path.
    pub fn fit(&mut self) {
        let _ = self.update(Msg::Fit);
    }

    /// The actual fit computation (the sole writer that [`Msg::Fit`] dispatches to).
    fn fit_viewport(&mut self) {
        let Some((mnlon, mnlat, mxlon, mxlat)) = self.bbox() else {
            self.view.center = (0.5, 0.5);
            self.view.zoom = 1.0;
            return;
        };
        let (x0, y0) = mercator(mnlon, mxlat); // NW corner → (min x, min y)
        let (x1, y1) = mercator(mxlon, mnlat); // SE corner → (max x, max y)
        self.view.center = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
        let span = (x1 - x0).abs().max((y1 - y0).abs()).max(1e-6);
        // zoom so the data span (with a little margin) fills the unit viewport.
        self.view.zoom = (0.9 / span).clamp(1.0, 1_000_000.0);
    }

    /// **FC-3** — read the complete observable navigation state (viewport + selection).
    pub fn state(&self) -> &GeoViewport {
        &self.view
    }

    pub fn zoom(&self) -> f64 {
        self.view.zoom
    }
    /// Viewport centre back in `(lon, lat)`.
    pub fn center_lonlat(&self) -> (f64, f64) {
        inv_mercator(self.view.center.0, self.view.center.1)
    }

    /// Multiply the zoom (clamped). >1 zooms in. Routes through [`update`](Self::update).
    pub fn zoom_by(&mut self, k: f64) {
        let _ = self.update(Msg::ZoomBy(k));
    }
    /// Pan the viewport by `(dlon_frac, dlat_frac)` of the current view span —
    /// used by the host's drag handler (and headless tests). Routes through
    /// [`update`](Self::update).
    pub fn pan_unit(&mut self, dx: f64, dy: f64) {
        let _ = self.update(Msg::PanUnit(dx, dy));
    }

    /// Select a hotspot by its stable id, or clear (`None`). A thin wrapper over
    /// the FC-2 [`update`](Self::update) mutation path.
    pub fn select(&mut self, id: Option<String>) {
        let _ = match id {
            Some(id) => self.update(Msg::Select(id)),
            None => self.update(Msg::ClearSelection),
        };
    }

    /// The stable id for a hotspot (its label, or `lon,lat` when unlabelled) — the
    /// FC-5 selection key, invariant across pan/zoom.
    fn hotspot_key(h: &Hotspot) -> String {
        if h.label.is_empty() {
            format!("{:.5},{:.5}", h.lon, h.lat)
        } else {
            h.label.clone()
        }
    }

    /// Project a Mercator unit point into pixel space for the current viewport.
    fn project(&self, mx: f64, my: f64, rect: egui::Rect) -> egui::Pos2 {
        let side = rect.width().min(rect.height()) as f64;
        let scale = side * self.view.zoom;
        let cx = rect.center().x as f64;
        let cy = rect.center().y as f64;
        egui::pos2(
            (cx + (mx - self.view.center.0) * scale) as f32,
            (cy + (my - self.view.center.1) * scale) as f32,
        )
    }

    /// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
    /// (always empty) [`Effect`]s. Every live gesture routes its [`Msg`] here too.
    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
        match msg {
            Msg::PanUnit(dx, dy) => {
                self.view.center.0 = (self.view.center.0 + dx).clamp(0.0, 1.0);
                self.view.center.1 = (self.view.center.1 + dy).clamp(0.0, 1.0);
            }
            Msg::SetCenter(x, y) => {
                self.view.center = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0));
            }
            Msg::ZoomBy(k) => {
                self.view.zoom = (self.view.zoom * k).clamp(0.25, 5_000_000.0);
            }
            Msg::SetZoom(z) => {
                self.view.zoom = z.clamp(0.25, 5_000_000.0);
            }
            Msg::Fit => self.fit_viewport(),
            Msg::Select(id) => self.view.selected = Some(id),
            Msg::ClearSelection => self.view.selected = None,
        }
        Vec::new()
    }

    /// **FC-9 render** — a **pure** function of `&self`: it paints the label + the
    /// three map layers (basemap ways, points, sized hotspots) and *returns* the
    /// [`Msg`]s the interactions produced (drag-pan, scroll-zoom, click-select). It
    /// MUST NOT mutate the [`GeoViewport`] Model; the
    /// [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies the
    /// returned messages through [`update`](Self::update).
    pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
        facett_core::navgraph::declare_own_draw_order(ui); // atoms follow the data order (navgraph §9)
        let mut msgs: Vec<Msg> = Vec::new();
        ui.label(format!(
            "{} points · {} hotspots · {} basemap ways · zoom {:.1}×",
            self.points.len(),
            self.hotspots.len(),
            self.ways.len(),
            self.view.zoom
        ));
        let (rect, resp) =
            ui.allocate_exact_size(ui.available_size(), egui::Sense::click_and_drag());
        let base = resp.id;
        // ── FC-4: the whole pane is a value-bearing image node. Label carries the
        // title + both layer counts; numeric_value is the point count so a robot
        // driver / screen reader / `get_by_label` reads the pane back exactly.
        let pane_summary = format!(
            "{} — {} points · {} hotspots · {} basemap ways",
            self.title,
            self.points.len(),
            self.hotspots.len(),
            self.ways.len()
        );
        let point_count = self.points.len();
        // The empty-data state carries the stable UI error code facet-geomap-2 (see
        // facett_core::errcode) so tests + consumers react to the CODE, not a string;
        // when empty the AccessKit atom also carries the Error severity + the code.
        let empty = self.points.is_empty() && self.hotspots.is_empty() && self.ways.is_empty();
        resp.widget_info(|| {
            let s = Semantics::image(pane_summary.clone()).value(point_count as f64);
            let s = if empty {
                s.severity(facett_core::Severity::Error).error_code("facet-geomap-2")
            } else {
                s
            };
            s.widget_info()
        });
        let th = theme(ui);
        let pal = MapPalette::from_theme(&th);
        let painter = ui.painter_at(rect);
        // Land "paper" background when a basemap is present; plain themed bg when
        // it is point-only (the original look).
        let bg = if self.ways.is_empty() { th.bg } else { pal.land };
        painter.rect_filled(rect, egui::CornerRadius::ZERO, bg);

        // ── interaction: drag to pan, scroll to zoom → returned as Msgs (FC-2) ─
        if resp.dragged() {
            let d = resp.drag_delta();
            let side = rect.width().min(rect.height()) as f64;
            let scale = (side * self.view.zoom).max(1.0);
            if d != egui::Vec2::ZERO {
                msgs.push(Msg::PanUnit(-d.x as f64 / scale, -d.y as f64 / scale));
            }
        }
        let scroll = ui.input(|i| i.smooth_scroll_delta.y);
        if resp.hovered() && scroll.abs() > 0.0 {
            msgs.push(Msg::ZoomBy(1.0 + (scroll as f64) * 0.001));
        }
        // A click (not part of a drag) selects the nearest hotspot, or clears on
        // empty canvas — hit-test resolved below against the painted circles.
        let click = resp.clicked().then(|| resp.interact_pointer_pos()).flatten();

        if empty {
            painter.text(
                rect.center(),
                egui::Align2::CENTER_CENTER,
                "no geo data",
                egui::FontId::proportional(13.0),
                th.text_dim,
            );
            // The stable error CODE in clear pink (debug) beneath the human hint.
            facett_core::errcode::paint_code(&painter, rect.center() + egui::vec2(0.0, 16.0), "facet-geomap-2");
            // ── CPU render-lane emit (empty-data exit) ────────────────────────
            #[cfg(feature = "testmatrix")]
            facett_core::testmatrix::emit(
                "facett-geomap::GeoMap::view",
                "ui_render",
                true,
                "points=0 hotspots=0 ways=0 drew=empty_hint",
            );
            return msgs;
        }

        // ── basemap layer (vector ways, drawn UNDER points + hotspots) ────────
        // Reuses each Way's pre-projected Mercator-unit vertices, run through the
        // SAME viewport projection as the points so the layers line up. Filled
        // rings paint as silhouettes + a crisp outline; line ways as themed
        // segments. This is the CPU painter (line ways skip it when the GPU cloud drew); it is
        // always available + headless-testable.
        // Line widths scale with zoom (thin when the whole region is in view).
        let zoom_w = ((self.view.zoom / 800.0) as f32).clamp(0.5, 2.2);
        // The GPU lane (line ways + points + hotspot discs), pushed BEFORE the fills so
        // its opaque blit sits under them. `false` = the egui painter below draws all.
        let drew_gpu = self.draw_gpu_cloud(ui, rect, bg, th.point, zoom_w);
        self.lane_drew.set(Some(drew_gpu));
        let project_way = |way: &Way| -> Vec<egui::Pos2> {
            way.points.iter().map(|&(x, y)| self.project(x as f64, y as f64, rect)).collect()
        };
        // DRAW ORDER: area fills (concave-safe tessellation) + their outlines
        // first, then line ways (roads/coastline) on top — a real flat map.
        for way in self.ways.iter().filter(|w| w.fill.is_some()) {
            let pts = project_way(way);
            if pts.len() < 3 {
                continue;
            }
            if let Some(fill) = way.fill {
                // No holes in the geomap flat-map fast path; the shared fill+outline
                // recipe lives in `tessellate::paint_filled_way` (dedup cluster I).
                let stroke = egui::Stroke::new((way.width * zoom_w).max(0.6), way.stroke);
                tessellate::paint_filled_way(&painter, pts, &[], Some(fill), stroke, false, None);
            }
        }
        for way in self.ways.iter().filter(|w| w.fill.is_none() && !drew_gpu) {
            let pts = project_way(way);
            if pts.len() < 2 {
                continue;
            }
            let stroke = egui::Stroke::new((way.style.width * zoom_w).max(0.5), way.style.color);
            painter.add(egui::Shape::line(pts, stroke));
        }

        // ── point layer ──────────────────────────────────────────────────────
        for &(lon, lat) in self.points.iter().filter(|_| !drew_gpu) {
            let (mx, my) = mercator(lon, lat);
            let p = self.project(mx, my, rect);
            if rect.contains(p) {
                painter.circle_filled(p, 1.3, th.point);
            }
        }

        // ── hotspot layer (sized by count + badge + label) ───────────────────
        // First paint, collecting each on-screen hotspot's circle rect + a stable
        // domain key, so the a11y nodes (which need `&mut ui`) can be emitted after
        // the painter borrow ends — without indexing by loop position (FC-5).
        let max_count = self.hotspots.iter().map(|h| h.count).max().unwrap_or(1).max(1) as f32;
        let mut hotspot_nodes: Vec<(String, egui::Rect, Semantics)> = Vec::new();
        // Resolve a click to the hotspot whose circle it lands in (FC-5 select).
        let mut click_hit: Option<String> = None;
        for h in &self.hotspots {
            let (mx, my) = mercator(h.lon, h.lat);
            let p = self.project(mx, my, rect);
            if !rect.expand(40.0).contains(p) {
                continue;
            }
            let r = 6.0 + 18.0 * (h.count as f32 / max_count).sqrt();
            // Stable key: prefer the hotspot's own label; else its (lon,lat).
            let key = Self::hotspot_key(h);
            // A selection ring pops the chosen hotspot (drawn only when selected,
            // so the default render is byte-for-byte the original).
            if self.view.selected.as_deref() == Some(key.as_str()) {
                painter.circle_stroke(p, r + 3.0, egui::Stroke::new(2.5_f32, th.accent));
            }
            if !drew_gpu {
                painter.circle_filled(p, r, egui::Color32::from_rgba_unmultiplied(230, 90, 90, 90));
            }
            painter.circle_stroke(p, r, egui::Stroke::new(2.0_f32, egui::Color32::from_rgb(240, 110, 110)));
            painter.text(
                p,
                egui::Align2::CENTER_CENTER,
                h.count.to_string(),
                egui::FontId::proportional(11.0),
                th.text,
            );
            if !h.label.is_empty() {
                painter.text(
                    p + egui::vec2(0.0, r + 2.0),
                    egui::Align2::CENTER_TOP,
                    &h.label,
                    egui::FontId::proportional(11.0),
                    th.text,
                );
            }
            let a11y_label = if h.label.is_empty() {
                format!("hotspot {:.4},{:.4} · {} points", h.lon, h.lat, h.count)
            } else {
                format!("{} · {} points", h.label, h.count)
            };
            let hit = egui::Rect::from_center_size(p, egui::vec2(2.0 * r, 2.0 * r));
            if let Some(cpos) = click
                && click_hit.is_none()
                && hit.contains(cpos)
            {
                click_hit = Some(key.clone());
            }
            hotspot_nodes.push((key, hit, Semantics::button(a11y_label).value(h.count as f64)));
        }
        // A click that hit a hotspot selects it; a click on empty canvas clears.
        if let Some(cpos) = click {
            match &click_hit {
                Some(id) => msgs.push(Msg::Select(id.clone())),
                None if rect.contains(cpos) => msgs.push(Msg::ClearSelection),
                None => {}
            }
        }
        // ── FC-4: one AccessKit node per hotspot (label + count), keyed on a
        // stable domain id (the hotspot's label or coords), drawn over the same
        // circle the painter just laid down — so each hotspot is queryable.
        for (key, hit, sem) in hotspot_nodes {
            let _ = facett_core::a11y::node(ui, base, &key, egui::Sense::click(), hit, sem);
        }
        // ── CPU render-lane emit: the geomap painter path RAN (CPU-only) ───────
        #[cfg(feature = "testmatrix")]
        {
            let geom = self.points.len() + self.hotspots.len() + self.ways.len();
            facett_core::testmatrix::emit(
                "facett-geomap::GeoMap::view",
                "cpu_render",
                geom > 0,
                &format!("points={} hotspots={} ways={}", self.points.len(), self.hotspots.len(), self.ways.len()),
            );
        }
        msgs
    }
}

/// Inverse Web Mercator (unit `0..1` → lon/lat).
pub fn inv_mercator(x: f64, y: f64) -> (f64, f64) {
    let lon = x * 360.0 - 180.0;
    let n = std::f64::consts::PI * (1.0 - 2.0 * y);
    let lat = n.sinh().atan().to_degrees();
    (lon, lat)
}

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

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

// The bridge macro writes `impl Facet for GeoMap` 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 geomap publishes a RICHER
// `state_json` than a plain `serde(state())`: the per-layer summaries (hotspot
// list, way kind histogram, filled/vertex tallies, bbox) + the centre reported in
// lon/lat (the Model stores Mercator-unit) — the exact keys facett-demo +
// mega_matrix read — so the default `state_json` is suppressed and this one supplied.
facett_core::impl_facet_via_elm!(GeoMap, custom_state_json, {
    fn state_json(&self) -> serde_json::Value {
        let (clon, clat) = self.center_lonlat();
        let mut kinds = serde_json::Map::new();
        for (k, v) in self.way_kinds() {
            kinds.insert(k.to_string(), serde_json::json!(v));
        }
        serde_json::json!({
            "points": self.points.len(),
            "hotspots": self.hotspots.iter().map(|h| serde_json::json!({
                "lon": h.lon, "lat": h.lat, "count": h.count, "label": h.label,
            })).collect::<Vec<_>>(),
            "hotspot_count": self.hotspots.len(),
            "top_hotspot": self.hotspots.iter().max_by_key(|h| h.count).map(|h| serde_json::json!({
                "label": h.label, "count": h.count,
            })),
            // The vector basemap layer — proves the geomap shows a map, not just
            // dots: the way count, the kind histogram, the filled-silhouette
            // count, and total way vertices.
            "ways": self.ways.len(),
            "way_kinds": kinds,
            "way_vertices": self.ways.iter().map(|w| w.lonlat.len()).sum::<usize>(),
            "filled_ways": self.ways.iter().filter(|w| w.fill.is_some()).count(),
            "bbox": self.bbox().map(|(a, b, c, d)| serde_json::json!([a, b, c, d])),
            "zoom": self.view.zoom,
            "center": [clon, clat],
            // The selected hotspot id (FC-5) — invariant across pan/zoom. `null`
            // when nothing is selected (the original default).
            "selected": self.view.selected,
            // Which lane drew ways/points/hotspots (facett-core `lane::cloud_lane_json`).
            "lane": facett_core::render::lane::cloud_lane_json(self.use_gpu, Self::gpu_installed(), self.lane_drew.get()),
            // The stable UI ERROR CODE for the empty-data state (facett_core::errcode):
            // present so tests + consumers react to the CODE, not a matched string.
            "error_code": if self.points.is_empty() && self.hotspots.is_empty() && self.ways.is_empty() {
                serde_json::json!("facet-geomap-2")
            } else {
                serde_json::Value::Null
            },
        })
    }

    /// STRUCTURAL severity (the Robot-UI gate signal): RED when the geomap has no
    /// points/hotspots/ways to plot (the facet-geomap-2 error state), Info otherwise.
    fn severity(&self) -> facett_core::Severity {
        if self.points.is_empty() && self.hotspots.is_empty() && self.ways.is_empty() {
            facett_core::Severity::Error
        } else {
            facett_core::Severity::Info
        }
    }

    /// The selected hotspot as data (FC-5): its stable id + count/coords, or `null`.
    fn selection_json(&self) -> serde_json::Value {
        match &self.view.selected {
            Some(id) => {
                let hit = self.hotspots.iter().find(|h| GeoMap::hotspot_key(h) == *id);
                serde_json::json!({
                    "hotspot": id,
                    "count": hit.map(|h| h.count),
                    "lonlat": hit.map(|h| serde_json::json!([h.lon, h.lat])),
                })
            }
            None => serde_json::Value::Null,
        }
    }

    /// Zoom is exposed through the uniform `scale` knob so a `FacetDeck`'s
    /// +/−/Reset toolbar drives it; pan/scroll/select are handled inside `view`.
    fn scale(&self) -> f32 {
        self.view.zoom as f32
    }
    fn set_scale(&mut self, scale: f32) {
        let _ = self.update(Msg::SetZoom(scale as f64));
    }

    /// **This map's own zoom band** — the same `0.25..=5e6` `Msg::ZoomBy`/`Msg::SetZoom`
    /// clamp into (see `update`), so the deck's toolbar can express the whole camera.
    ///
    /// The [`Facet::scale_range`] default is `[0.25, 4.0]`, a document viewer's range,
    /// and against it this facet had the same defect `OsmView` did: the wheel drives
    /// `Msg::ZoomBy` directly up to 5e6 without ever passing through
    /// [`Facet::set_scale`], so as soon as the user wheels past `zoom = 4.0` the next
    /// `+` click computes `scale() × 1.1`, saturates the clamp at `4.0` and writes the
    /// viewport BACKWARDS — a zoom-IN button that zooms out. It was invisible at start-up
    /// only because [`GeoViewport::default`] is `zoom = 1.0`, the one value at which the
    /// default band is accidentally correct. Guarded by
    /// `the_toolbar_can_drive_the_whole_zoom_band` below.
    fn scale_range(&self) -> (f32, f32) {
        (0.25, 5_000_000.0)
    }

    /// Themed background/points/text, resizable to the host rect, scalable (zoom),
    /// and selectable (a hotspot drill-in) — the caps that light the deck toolbar.
    fn caps(&self) -> FacetCaps {
        FacetCaps::NONE.themeable().resizable().scalable().selectable()
    }

    /// Opt into [`FacetDeck::facet_mut`] downcasting, as `OsmView` does — a host (or a
    /// guard) holding the deck can reach the live `GeoMap` to read the applied viewport.
    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
        Some(self)
    }
});

#[cfg(test)]
mod tests {
    use super::*;
    // `Facet` for `state_json`/`selection_json`/`caps`/`title`; `state()`/`update()`
    // are inherent. `harness` drives the Elm split with no egui/GPU.
    use facett_core::{Facet, harness};

    fn sweden_sample() -> GeoMap {
        // a handful of Swedish points + two hotspots (Malmö, Göteborg).
        let points = vec![
            (18.0686, 59.3293), // Stockholm
            (11.9746, 57.7089), // Göteborg
            (13.0038, 55.6050), // Malmö
            (17.6389, 59.8586), // Uppsala
        ];
        let hotspots = vec![
            Hotspot::new(13.0038, 55.6050, 320, "Malmö"),
            Hotspot::new(11.9746, 57.7089, 210, "Göteborg"),
        ];
        GeoMap::new("Sweden", points, hotspots)
    }

    /// `with_gpu(true)` in a process with no cloud renderer (no device in a unit test):
    /// the state says `no GPU`, the frame is the egui painter's, and it really drew.
    #[test]
    fn a_selected_gpu_lane_without_a_renderer_says_no_gpu() {
        let mut m = sweden_sample().with_gpu(true);
        assert_eq!(facett_core::Facet::state_json(&m)["lane"]["lane_flag"], "pending", "nothing painted yet");
        let r = facett_core::harness::headless_render(&mut m);
        assert!(r.drew());
        let lane = &r.state["lane"];
        assert_eq!(lane["selected"], true);
        assert_eq!(lane["gpu"], "no GPU");
        assert_eq!(lane["lane_flag"], "cpu", "{lane}");
    }

    /// A small synthetic basemap over the same Swedish span: one filled water
    /// ring + one coastline line way, so the basemap layer is exercised under the
    /// points + hotspots.
    fn sweden_basemap() -> Vec<Way> {
        let water = Way::filled(
            1,
            "water",
            vec![(13.0, 57.0), (15.0, 57.0), (15.0, 58.0), (13.0, 58.0), (13.0, 57.0)],
            egui::Color32::from_rgb(120, 150, 200),
            egui::Color32::from_rgb(40, 60, 100),
            1.4,
        );
        let coast = Way::line(
            2,
            "coastline",
            vec![(11.5, 56.0), (12.5, 57.0), (13.5, 58.0), (14.5, 59.0)],
            egui::Color32::from_rgb(80, 90, 110),
            1.2,
        );
        vec![water, coast]
    }

    #[test]
    fn mercator_roundtrips() {
        let (x, y) = mercator(18.0686, 59.3293);
        let (lon, lat) = inv_mercator(x, y);
        assert!((lon - 18.0686).abs() < 1e-6, "lon roundtrip");
        assert!((lat - 59.3293).abs() < 1e-6, "lat roundtrip");
    }

    #[test]
    fn fit_centers_on_the_data() {
        let m = sweden_sample();
        let (clon, clat) = m.center_lonlat();
        // centre sits inside Sweden's lon/lat span.
        assert!((11.0..19.0).contains(&clon), "centre lon {clon} over Sweden");
        assert!((55.0..60.5).contains(&clat), "centre lat {clat} over Sweden");
        assert!(m.zoom() > 1.0, "fit zoomed in past the whole world");
    }

    #[test]
    fn zoom_and_pan_move_the_viewport() {
        let mut m = sweden_sample();
        let z0 = m.zoom();
        m.zoom_by(2.0);
        assert!((m.zoom() - z0 * 2.0).abs() < 1e-6, "zoom doubled");
        let (lon0, _) = m.center_lonlat();
        m.pan_unit(0.01, 0.0); // east
        let (lon1, _) = m.center_lonlat();
        assert!(lon1 > lon0, "panning +x moved the centre east");
    }

    #[test]
    fn state_json_carries_both_layers_and_viewport() {
        let m = sweden_sample();
        let j = m.state_json();
        assert_eq!(j["points"], 4, "all points reported");
        assert_eq!(j["hotspot_count"], 2);
        assert_eq!(j["top_hotspot"]["label"], "Malmö", "densest hotspot surfaced");
        assert_eq!(j["top_hotspot"]["count"], 320);
        assert!(j["bbox"].is_array());
        assert!(j["zoom"].as_f64().unwrap() > 1.0);
    }

    #[test]
    fn headless_render_draws_points_and_hotspots() {
        // Inject real Swedish geo + hotspots, render offscreen, assert it both
        // drew pixels and reported the layers in state — inject-in/assert-out.
        let mut m = sweden_sample();
        let r = harness::headless_render(&mut m);
        assert_eq!(r.title, "Sweden");
        assert!(r.drew(), "points + hotspot circles tessellate to vertices");
        assert_eq!(r.state["points"], 4);
        assert_eq!(r.state["hotspot_count"], 2);
        assert_eq!(r.state["top_hotspot"]["label"], "Malmö");
    }

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

    #[test]
    fn with_ways_adds_basemap_to_state_and_refits() {
        // Inject a basemap onto the Swedish sample; assert the ways layer shows up
        // in state_json (count + kind histogram + filled count + vertices) and the
        // viewport still has the points reported (point-only behaviour preserved).
        let m = sweden_sample().with_ways(sweden_basemap());
        let j = m.state_json();
        assert_eq!(j["points"], 4, "points still reported with a basemap");
        assert_eq!(j["hotspot_count"], 2, "hotspots still reported with a basemap");
        assert_eq!(j["ways"], 2, "the basemap layer is present in state_json");
        assert_eq!(j["way_kinds"]["water"], 1, "the water ring is one kind");
        assert_eq!(j["way_kinds"]["coastline"], 1, "the coastline is another kind");
        assert_eq!(j["filled_ways"], 1, "the water ring is a filled silhouette");
        assert_eq!(j["way_vertices"], 9, "5 (water ring) + 4 (coastline) vertices");
        // bbox now spans the basemap too (coastline reaches lon 11.5 / lat 59.0).
        let bbox = j["bbox"].as_array().unwrap();
        assert!(bbox[0].as_f64().unwrap() <= 11.5, "bbox grew west to cover the coastline");
    }

    #[test]
    fn point_only_geomap_reports_zero_ways() {
        // Graceful: a geomap with no basemap still works and reports zero ways.
        let j = sweden_sample().state_json();
        assert_eq!(j["ways"], 0, "no basemap → zero ways (the original behaviour)");
        assert_eq!(j["filled_ways"], 0);
        assert_eq!(j["way_vertices"], 0);
    }

    #[test]
    fn headless_render_draws_basemap_under_points() {
        // Inject points + hotspots + a basemap, render offscreen, assert it drew
        // pixels AND reported all three layers — basemap geometry, not just dots.
        let mut m = sweden_sample().with_ways(sweden_basemap());
        let r = harness::headless_render(&mut m);
        assert_eq!(r.title, "Sweden");
        assert!(r.drew(), "basemap silhouette/line + points + hotspots tessellate to vertices");
        assert_eq!(r.state["points"], 4);
        assert_eq!(r.state["ways"], 2, "the basemap layer rode into state_json");
        assert_eq!(r.state["filled_ways"], 1);

        // "UNDER points" is a claim about PAINT ORDER, and every assertion above reads
        // state_json — the DATA. Nothing checked which layer occludes which, so the
        // basemap could paint OVER the data and the user would see an empty map, and
        // this test would still be green. Assert the order in the render stream, with
        // the colours taken from the fixture and the palette rather than hardcoded so
        // the guard follows a restyle.
        let basemap_fill = sweden_basemap().iter().find_map(|w| w.fill).expect("the fixture has a filled way");
        let point = facett_core::Theme::default().point;
        r.paints_strictly_under(basemap_fill, point)
            .expect("the basemap must paint UNDER the point layer");
    }

    #[test]
    fn caps_advertise_scalable_themeable_resizable() {
        let caps = sweden_sample().caps();
        assert!(caps.scalable, "zoom → scalable");
        assert!(caps.themeable);
        assert!(caps.resizable);
    }

    #[test]
    fn sweden_admin_boundary_drives_the_geomap_basemap() {
        // End-to-end: load the bundled Swedish län boundary asset, feed it into a
        // GeoMap as the vector basemap, and assert it rides into state_json (way
        // count + all-admin kind histogram + filled-silhouette count + vertices)
        // and renders offscreen without panicking — the newest feature, live.
        let b = crate::load_sweden_admin().expect("bundled boundary asset parses");
        let ways = b.to_ways();
        let centers = b.centers();
        let mut m = GeoMap::new("Sverige", centers, vec![]).with_ways(ways);

        let j = m.state_json();
        assert_eq!(j["points"], 21, "one label anchor per län");
        assert_eq!(j["ways"], b.ring_count() as u64, "every ring is a basemap way");
        assert_eq!(j["filled_ways"], b.ring_count() as u64, "all län rings are filled silhouettes");
        assert_eq!(j["way_kinds"]["admin"], b.ring_count() as u64, "all ways tagged admin");
        assert_eq!(j["way_vertices"], b.vertex_count() as u64, "no vertices lost end-to-end");
        // The fitted viewport centre lands over Sweden.
        let (clon, clat) = m.center_lonlat();
        assert!((10.0..25.0).contains(&clon), "centre lon over Sweden: {clon}");
        assert!((54.0..70.0).contains(&clat), "centre lat over Sweden: {clat}");

        let r = harness::headless_render(&mut m);
        assert!(r.drew(), "the län silhouettes tessellate to real vertices");
        assert_eq!(r.state["ways"], b.ring_count() as u64);
    }

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

    #[test]
    fn harness_snapshot_drives_pan_zoom_and_selection() {
        let mut m = sweden_sample();
        // Zoom multiplies, pan accumulates on the Mercator centre, selection is
        // observable — all in one Vec<Msg>, applied through the single update path.
        let z0 = m.state().zoom;
        let c0 = m.state().center;
        let snap = harness::snapshot(
            &mut m,
            [Msg::ZoomBy(2.0), Msg::PanUnit(0.01, 0.0), Msg::Select("Malmö".into())],
        );
        assert!((snap.zoom - z0 * 2.0).abs() < 1e-9, "zoom doubled: {} vs {}", snap.zoom, z0 * 2.0);
        assert!(snap.center.0 > c0.0, "PanUnit(+x) moved the centre east");
        assert_eq!(snap.selected.as_deref(), Some("Malmö"), "selection is observable headlessly");
        assert_eq!(&snap, m.state(), "the snapshot is a clone of the live state");

        // Fit re-frames the data but keeps the selection; ClearSelection wipes it.
        let snap = harness::snapshot(&mut m, [Msg::Fit]);
        assert_eq!(snap.selected.as_deref(), Some("Malmö"), "fit keeps the selection");
        assert!(snap.zoom > 1.0, "fit re-zooms onto the data");
        let snap = harness::snapshot(&mut m, [Msg::ClearSelection]);
        assert_eq!(snap.selected, None, "ClearSelection empties the selection");

        // Zoom clamps at the 5e6 ceiling; SetZoom below the floor clamps to 0.25.
        let snap = harness::snapshot(&mut m, [Msg::SetZoom(9e9)]);
        assert_eq!(snap.zoom, 5_000_000.0, "zoom clamps at the ceiling");
        let snap = harness::snapshot(&mut m, [Msg::SetZoom(0.0)]);
        assert_eq!(snap.zoom, 0.25, "zoom clamps at the floor");
    }

    #[test]
    fn drive_reports_no_effects_and_state_round_trips() {
        let mut m = sweden_sample();
        // FC-8: the geomap does no I/O, so the effect stream is always empty.
        let effects = harness::drive(&mut m, [Msg::PanUnit(0.01, 0.01), Msg::Select("Göteborg".into())]);
        assert!(effects.is_empty(), "FC-8: geomap emits no Effects");
        // FC-3: the observable Model round-trips byte-for-byte through serde JSON.
        let json = serde_json::to_value(m.state()).unwrap();
        assert_eq!(json["selected"], "Göteborg");
        assert!(json["zoom"].as_f64().unwrap() > 0.0);
        let back: GeoViewport = serde_json::from_value(json).unwrap();
        assert_eq!(&back, m.state(), "serde(GeoViewport) -> GeoViewport round-trips");
    }

    #[test]
    fn selection_keys_on_the_stable_hotspot_id_and_surfaces_in_state() {
        // FC-5: selecting by a stable id surfaces the hotspot in both state_json
        // and selection_json; re-fitting (which changes zoom/centre) keeps it.
        let mut m = sweden_sample();
        m.select(Some("Malmö".into()));
        let j = m.state_json();
        assert_eq!(j["selected"], "Malmö", "selection rides into state_json");
        let sel = m.selection_json();
        assert_eq!(sel["hotspot"], "Malmö");
        assert_eq!(sel["count"], 320, "selection_json carries the hotspot count");
        // A pan/zoom does NOT disturb the selection (it keys on the id, not screen).
        m.zoom_by(1.5);
        m.pan_unit(0.02, -0.01);
        assert_eq!(m.state().selected.as_deref(), Some("Malmö"), "selection invariant across pan/zoom");
        m.select(None);
        assert_eq!(m.selection_json(), serde_json::Value::Null, "cleared selection is null");
    }

    #[test]
    fn public_navigation_api_routes_through_update() {
        // The public affordances are thin wrappers over the FC-2 mutation path.
        let mut m = sweden_sample();
        m.update(Msg::SetCenter(0.6, 0.4));
        assert_eq!(m.state().center, (0.6, 0.4), "SetCenter set the centre");
        m.zoom_by(2.0);
        let z = m.state().zoom;
        m.pan_unit(0.05, 0.0);
        assert!(m.state().center.0 > 0.6, "pan_unit moved the centre via update");
        assert!((m.state().zoom - z).abs() < 1e-9, "pan does not change zoom");
    }
}