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