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");
}
}