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

nordisk/facett

facett-demo/src/region_picker.rs

//! **Region picker** — a 🌍 Region button → modal that clips a country/region out
//! of the **already-cached planet** OSM (via `osm-katana`, **in-process**) and loads
//! the result into the OSM 2D/3D viewer tabs.
//!
//! ## What it does (v2 — in-process clip)
//! 1. The toolbar shows a **🌍 Region** button (`region_picker_button`). It opens
//!    a small modal listing three hardcoded presets — **Sverige / Skandinavien /
//!    Europa** ([`PRESETS`]). Picking one kicks off the pipeline on a **background
//!    thread** so the UI never freezes; the modal shows live status (idle →
//!    clipping → loading → ready / error), polled each frame.
//! 2. **Clip via osm-katana — IN PROCESS** (no subprocess). We call the converter's
//!    library `convert()` directly with a [`Clip`] built per preset:
//!    - **Sverige** → a **real Sweden polygon** ([`SWEDEN_POLY`], the bundled
//!      `assets/sweden.poly`) via `Clip::Poly` — the country's true shape, not a
//!      bbox.
//!    - **Skandinavien** → the `nordics` bbox preset.
//!    - **Europa** → the `europe` bbox preset.
//!
//!    The preset → clip-kind mapping is [`Preset::clip_kind`]. When built
//!    **without** the `region-clip` feature there's no osm-katana dep, so the
//!    pipeline surfaces a friendly **"region-clip feature off"** status instead of
//!    crashing — the dialog + cache + load logic still wire up.
//! 3. **Source = the ALREADY-CACHED planet** ([`planet_source`]) — we **never
//!    re-download**. (Torrent download is a FUTURE hook — see the TODO in
//!    [`planet_source`].)
//! 4. **Cache** the clipped GeoParquet at a stable path ([`region_cache_dir`] —
//!    `~/.cache/facett-osm/<arg>-geo`). On a **cache hit** (the region's
//!    `nodes.parquet`+`ways.parquet` already exist) we **skip the clip and load
//!    instantly**.
//! 5. **Load into the viewer** — on `ready` the host pulls [`RegionPicker::take_loaded`]
//!    and swaps the geometry into the OSM tabs (see `lib.rs`).
//!
//! Everything observable lands in [`RegionPicker::state_json`] under `region_picker`
//! = `{region, clip_arg, clip_kind, source, out, cache_hit, status, …}` (the test
//! surface).

use std::path::{Path, PathBuf};
use std::sync::mpsc::{Receiver, Sender};

// The animated progress bar (§2): a `facett-pop` ProgressView, driven from the
// pipeline status so the modal shows a MOVING bar while the background clip/load
// runs. Comes via the umbrella `facett` re-export (wasm-clean, pure egui).
use facett::pop::{ProgressItem, ProgressView};

// `osm_tabs` (the module) and `OsmSource` are only referenced by the native clip
// pipeline; on wasm that pipeline is gated out, so allow the unused imports there.
#[cfg_attr(target_arch = "wasm32", allow(unused_imports))]
use crate::osm_tabs::{self, OsmGeometry, OsmSource};

/// The bundled **real Sweden boundary** in Osmosis `.poly` format. A reasonably
/// accurate single-ring outline: contains Stockholm/Göteborg/Malmö/Kiruna and
/// excludes Oslo/Copenhagen/Helsinki/Berlin. Used for `Preset::Sverige`'s
/// polygon clip so the cut follows the country's true shape, not a bbox.
pub const SWEDEN_POLY: &str = include_str!("../assets/sweden.poly");

/// A hardcoded v1 region preset. The display label is Swedish (the app's), the
/// **clip arg** is the osm-katana region name (preset or, for Sweden, the polygon
/// asset name).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Preset {
    /// Sweden — clipped by the bundled **real polygon** ([`SWEDEN_POLY`]).
    Sverige,
    /// The Nordics — osm-katana `nordics` bbox preset.
    Skandinavien,
    /// Europe — osm-katana `europe` bbox preset.
    Europa,
}

/// The three v1 presets, in menu order.
pub const PRESETS: [Preset; 3] = [Preset::Sverige, Preset::Skandinavien, Preset::Europa];

/// How a preset clips: a named **bbox preset** osm-katana understands, or the
/// **real polygon** asset. Pure data so a test can assert the mapping without
/// running a clip (and so `state_json` can report it).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ClipKind {
    /// An osm-katana bbox preset name (`nordics` / `europe`).
    BboxPreset(&'static str),
    /// The bundled `.poly` polygon (Sweden's real shape).
    Polygon,
}

impl ClipKind {
    /// A short, stable tag for `state_json` / tests.
    pub fn tag(self) -> &'static str {
        match self {
            ClipKind::BboxPreset(_) => "bbox",
            ClipKind::Polygon => "polygon",
        }
    }
}

impl Preset {
    /// The button/menu label (Swedish — the app's UI language).
    pub fn label(self) -> &'static str {
        match self {
            Preset::Sverige => "Sverige",
            Preset::Skandinavien => "Skandinavien",
            Preset::Europa => "Europa",
        }
    }

    /// The **clip arg** this maps to — `sweden` (the polygon region name, also the
    /// cache-dir stem), or the bbox preset name (`nordics` / `europe`). The
    /// load-bearing identity the cache path + state_json use.
    pub fn clip_arg(self) -> &'static str {
        match self {
            Preset::Sverige => "sweden",
            Preset::Skandinavien => "nordics",
            Preset::Europa => "europe",
        }
    }

    /// How this preset clips: Sweden uses the real polygon; the others use their
    /// bbox preset. Pure (no I/O) so tests assert the mapping directly.
    pub fn clip_kind(self) -> ClipKind {
        match self {
            Preset::Sverige => ClipKind::Polygon,
            Preset::Skandinavien => ClipKind::BboxPreset("nordics"),
            Preset::Europa => ClipKind::BboxPreset("europe"),
        }
    }
}

/// The status of the clip→load pipeline for the active region (the modal renders
/// it; tests assert it).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Status {
    /// Nothing requested yet.
    Idle,
    /// osm-katana is clipping the planet IN-PROCESS (background thread).
    Clipping,
    /// The clip is done (or a cache hit); reading the GeoParquet into the viewer.
    Loading,
    /// Loaded — the geometry is ready for the host to swap into the tabs.
    Ready,
    /// Something failed; the string is the user-facing reason.
    Error(String),
}

impl Status {
    /// A short, stable tag for `state_json` / the modal label.
    pub fn tag(&self) -> &str {
        match self {
            Status::Idle => "idle",
            Status::Clipping => "clipping",
            Status::Loading => "loading",
            Status::Ready => "ready",
            Status::Error(_) => "error",
        }
    }
}

/// `$HOME/.cache` (or `$XDG_CACHE_HOME`). Self-contained so facett-demo needn't pull
/// nornir's `bench::assets::default_cache_dir` (same `~/.cache/...` convention).
fn dirs_cache() -> PathBuf {
    if let Ok(xdg) = std::env::var("XDG_CACHE_HOME")
        && !xdg.is_empty()
    {
        return PathBuf::from(xdg);
    }
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
    PathBuf::from(home).join(".cache")
}

/// The **already-cached planet** source `.osm.bz2`. Reuses the bench's `PLANET_BZ2`
/// on `/home`, falling back to the T9 copy. We **never re-download**.
///
/// TODO(F2, future): the planet/borders will be hosted & versioned in **znippy** and
/// pulled from there (torrent / object store). v1 strictly reuses the cached file —
/// no network — and this is the single hook to swap when that lands.
pub fn planet_source() -> Option<PathBuf> {
    let candidates = [
        PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join("work/planet-260427.osm.bz2"),
        PathBuf::from(format!("/run/media/{}/T9/planet-260427.osm.bz2", std::env::var("USER").unwrap_or_default())),
    ];
    candidates.into_iter().find(|p| p.exists())
}

/// The stable GeoParquet **cache dir** for a preset: `~/.cache/facett-osm/<arg>-geo`
/// (e.g. `…/sweden-geo`). osm-katana writes `nodes.parquet`/`ways.parquet` here; a
/// cache **hit** is "both already exist".
pub fn region_cache_dir(p: Preset) -> PathBuf {
    region_cache_dir_named(p.clip_arg())
}

/// The stable GeoParquet **cache dir** for an arbitrary region NAME:
/// `~/.cache/facett-osm/<name>-geo`. Used by the connector path, which fetches a
/// region by its Geofabrik-style name (`sweden`, `testland`, …) rather than a
/// preset. A cache **hit** is "both `nodes.parquet` + `ways.parquet` already
/// exist" — same short-circuit as the preset path.
pub fn region_cache_dir_named(name: &str) -> PathBuf {
    dirs_cache().join("facett-osm").join(format!("{name}-geo"))
}

/// True iff the region's GeoParquet is already on disk (both `nodes.parquet` and
/// `ways.parquet` present) — the cache-hit short-circuit.
pub fn cache_hit(dir: &Path) -> bool {
    dir.join("nodes.parquet").exists() && dir.join("ways.parquet").exists()
}

/// Build the in-process osm-katana [`osm_katana::Clip`] for `preset`: Sweden →
/// `Clip::Poly` from [`SWEDEN_POLY`]; the others → `Clip::Bbox` from their preset.
/// Only compiled with the `region-clip` feature (it names osm-katana types).
#[cfg(feature = "region-clip")]
fn build_clip(preset: Preset) -> Result<osm_katana::Clip, String> {
    use osm_katana::{Bounds, Clip, Polygon};
    match preset.clip_kind() {
        ClipKind::Polygon => {
            let poly = Polygon::from_poly_str(SWEDEN_POLY).map_err(|e| e.to_string())?;
            Ok(Clip::Poly(poly))
        }
        ClipKind::BboxPreset(name) => {
            Bounds::parse(name).map(Clip::Bbox).map_err(|e| e.to_string())
        }
    }
}

/// What the background thread sends back to the UI thread. On wasm the worker
/// never runs (region clip is desktop-only), so the variants are constructed only
/// under the native build — allow the dead-code on wasm.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
enum Msg {
    Status(Status),
    /// The loaded geometry, ready to swap into the deck.
    Loaded(Box<OsmGeometry>),
}

/// The region-picker UI + pipeline state. Held by the host (`DemoState`), drawn each
/// frame via [`RegionPicker::ui`] / [`RegionPicker::button`], polled via
/// [`RegionPicker::poll`].
pub struct RegionPicker {
    /// Modal open?
    open: bool,
    /// The preset the user picked (the active region), if any.
    region: Option<Preset>,
    /// Live pipeline status.
    status: Status,
    /// Was the active region a cache hit (skipped the clip)?
    cache_hit: bool,
    /// The output dir for the active region.
    out: Option<PathBuf>,
    /// The planet source path used (for `state_json`).
    source: Option<PathBuf>,
    /// Channel from the worker thread (status + result).
    rx: Option<Receiver<Msg>>,
    /// Loaded geometry waiting for the host to swap in (taken once on `ready`).
    loaded: Option<OsmGeometry>,
    /// The animated progress bar (§2) — a `facett-pop` ProgressView driven from the
    /// pipeline [`Status`] so the modal shows a MOVING bar while the background
    /// clip/load runs (a sweeping indeterminate fill), and a full bar on `ready`.
    progress: ProgressView,
    /// The pluggable **connector** (a `korp_collectors::region::RegionSource`) that FETCHES
    /// a region's `.osm.pbf` before the clip+load, when the picker was built via
    /// [`RegionPicker::with_source`]. `Arc<Mutex<…>>` so the (non-`Sync`) source can
    /// be shared across successive background picks. `None` → the cached-planet path.
    #[cfg(feature = "region-connector")]
    connector: Option<std::sync::Arc<std::sync::Mutex<Box<dyn korp_collectors::region::RegionSource>>>>,
    /// The connector's `name()` (`"http-osm"` / `"cached-local"`), cached for
    /// `state_json.source`. `None` → no connector.
    #[cfg(feature = "region-connector")]
    connector_name: Option<String>,
    /// The active region NAME when connector-driven by an arbitrary name (not a
    /// preset) — e.g. `"testland"`. Surfaces as `state_json.region`.
    #[cfg(feature = "region-connector")]
    region_name: Option<String>,
    /// Did the active pick invoke the connector's `ensure()` (a fetch) — vs a
    /// GeoParquet cache-hit that short-circuited before the connector? Surfaces as
    /// `state_json.fetched`.
    #[cfg(feature = "region-connector")]
    fetched: bool,
}

impl Default for RegionPicker {
    fn default() -> Self {
        Self {
            open: false,
            region: None,
            status: Status::Idle,
            cache_hit: false,
            out: None,
            source: None,
            rx: None,
            loaded: None,
            progress: ProgressView::new("region-progress"),
            #[cfg(feature = "region-connector")]
            connector: None,
            #[cfg(feature = "region-connector")]
            connector_name: None,
            #[cfg(feature = "region-connector")]
            region_name: None,
            #[cfg(feature = "region-connector")]
            fetched: false,
        }
    }
}

impl RegionPicker {
    pub fn new() -> Self {
        Self::default()
    }

    /// Build a picker **fed by a connector** — a `korp_collectors::region::RegionSource`
    /// that FETCHES a region's `.osm.pbf` before the picker converts+loads it. With
    /// a connector set, [`pick`](Self::pick) (and [`pick_region`](Self::pick_region))
    /// run: `source.ensure(region)` on a background thread → obtain (fetch) the
    /// region PBF → convert it to GeoParquet **in-process** (osm-katana, whole
    /// extract — the connector already delivered the region) → load the geometry.
    /// The no-connector path (cached planet + clip) is unchanged.
    ///
    /// The source is stored behind `Arc<Mutex<…>>` because `RegionSource` is `Send`
    /// but not `Sync`: successive picks each lock it on their worker thread.
    #[cfg(feature = "region-connector")]
    pub fn with_source<S: korp_collectors::region::RegionSource + 'static>(source: S) -> Self {
        let name = source.name().to_string();
        let mut rp = Self::new();
        rp.connector = Some(std::sync::Arc::new(std::sync::Mutex::new(
            Box::new(source) as Box<dyn korp_collectors::region::RegionSource>,
        )));
        rp.connector_name = Some(name);
        rp
    }

    /// The connector's name, if this picker is connector-fed (`None` otherwise).
    #[cfg(feature = "region-connector")]
    pub fn connector_name(&self) -> Option<&str> {
        self.connector_name.as_deref()
    }

    /// Did the last pick invoke the connector's `ensure()` (a fetch) vs a
    /// GeoParquet cache-hit short-circuit? `false` on the no-connector path.
    #[cfg(feature = "region-connector")]
    pub fn fetched(&self) -> bool {
        self.fetched
    }

    /// The active region's display label — a preset label, or (connector-driven) an
    /// arbitrary region name. `None` before the first pick.
    fn region_label(&self) -> Option<String> {
        if let Some(p) = self.region {
            return Some(p.label().to_string());
        }
        #[cfg(feature = "region-connector")]
        if let Some(n) = &self.region_name {
            return Some(n.clone());
        }
        None
    }

    /// The current status (for tests / the host).
    pub fn status(&self) -> &Status {
        &self.status
    }

    /// Is the modal open?
    pub fn is_open(&self) -> bool {
        self.open
    }

    /// Open the modal.
    pub fn open(&mut self) {
        self.open = true;
    }

    /// **Kick off the clip→load pipeline** for `preset` on a background thread. The
    /// thread: checks the cache (hit → skip clip), else runs osm-katana **in
    /// process** (`convert()` with the per-preset [`osm_katana::Clip`]), then reads
    /// the GeoParquet and ships the geometry back. Returns immediately — poll
    /// [`RegionPicker::poll`] each frame for progress.
    pub fn pick(&mut self, preset: Preset) {
        // Connector-fed picker: the region's PBF is FETCHED by the connector, then
        // converted+loaded. The preset maps to its Geofabrik-style region name
        // (`Sverige → "sweden"`), which is exactly `clip_arg()`.
        #[cfg(feature = "region-connector")]
        if self.connector.is_some() {
            self.pick_via_connector(Some(preset), preset.clip_arg().to_string());
            return;
        }
        let out = region_cache_dir(preset);
        let hit = cache_hit(&out);
        let source = planet_source();
        self.region = Some(preset);
        self.cache_hit = hit;
        self.out = Some(out.clone());
        self.source = source.clone();
        self.status = Status::Clipping;
        self.loaded = None;

        let (tx, rx): (Sender<Msg>, Receiver<Msg>) = std::sync::mpsc::channel();
        self.rx = Some(rx);
        // Native: run the clip→load pipeline on a background thread.
        #[cfg(not(target_arch = "wasm32"))]
        std::thread::spawn(move || run_pipeline(preset, out, hit, source, tx));
        // wasm (the web demo): no threads + no filesystem in the browser, and the
        // osm-katana clip is a native-only dep anyway (built out of the wasm demo).
        // Surface a graceful message rather than panicking on `thread::spawn`.
        #[cfg(target_arch = "wasm32")]
        {
            let _ = (out, hit, source, tx);
            self.status = Status::Error("region clip is desktop-only (not in the web demo)".into());
        }
    }

    /// **Connector-driven pick by an arbitrary region NAME** (`"testland"`,
    /// `"sweden"`, …). Requires a connector ([`with_source`](Self::with_source));
    /// with none set it surfaces a graceful error. Kicks off the fetch→convert→load
    /// pipeline on a background thread — poll [`poll`](Self::poll) for progress.
    #[cfg(feature = "region-connector")]
    pub fn pick_region(&mut self, region: &str) {
        self.pick_via_connector(None, region.to_string());
    }

    /// Shared connector pick: obtain the region PBF from the connector (fetch),
    /// convert the (whole) extract to GeoParquet, load it. `preset` is `Some` when
    /// driven from a preset button (kept for `clip_arg`/`clip_kind` reporting),
    /// `None` for a raw region name. On a GeoParquet **cache hit** the connector is
    /// never touched (no fetch), so a re-pick makes no HTTP request.
    #[cfg(feature = "region-connector")]
    fn pick_via_connector(&mut self, preset: Option<Preset>, region: String) {
        let out = region_cache_dir_named(&region);
        let hit = cache_hit(&out);
        self.region = preset;
        self.region_name = Some(region.clone());
        self.cache_hit = hit;
        self.out = Some(out.clone());
        self.source = None; // the planet path is unused on the connector path.
        // A fetch happens exactly when we DON'T hit the GeoParquet cache and a
        // connector is present (the worker then calls `ensure`).
        self.fetched = self.connector.is_some() && !hit;
        self.status = Status::Clipping;
        self.loaded = None;

        let (tx, rx): (Sender<Msg>, Receiver<Msg>) = std::sync::mpsc::channel();
        self.rx = Some(rx);
        #[cfg(not(target_arch = "wasm32"))]
        match self.connector.clone() {
            Some(src) => {
                std::thread::spawn(move || run_connector_pipeline(region, out, hit, src, tx));
            }
            None => {
                self.status = Status::Error(
                    "no connector set — build the picker with RegionPicker::with_source(...)".into(),
                );
            }
        }
        #[cfg(target_arch = "wasm32")]
        {
            let _ = (out, hit, tx, region);
            self.status = Status::Error("region connector is desktop-only (not in the web demo)".into());
        }
    }

    /// Drain the worker channel — apply any status update / stash a loaded result.
    /// Call once per frame (the UI does). Returns `true` if anything changed.
    pub fn poll(&mut self) -> bool {
        let mut changed = false;
        // Take the rx out to avoid borrow conflicts, put it back unless disconnected.
        let mut disconnected = false;
        if let Some(rx) = self.rx.as_ref() {
            loop {
                match rx.try_recv() {
                    Ok(Msg::Status(s)) => {
                        self.status = s;
                        changed = true;
                    }
                    Ok(Msg::Loaded(geo)) => {
                        self.loaded = Some(*geo);
                        self.status = Status::Ready;
                        changed = true;
                    }
                    Err(std::sync::mpsc::TryRecvError::Empty) => break,
                    Err(std::sync::mpsc::TryRecvError::Disconnected) => {
                        disconnected = true;
                        break;
                    }
                }
            }
        }
        if disconnected {
            self.rx = None;
        }
        changed
    }

    /// Take the loaded geometry (if `ready`) so the host can swap it into the OSM
    /// tabs. Returns `Some` exactly once per successful load.
    pub fn take_loaded(&mut self) -> Option<OsmGeometry> {
        self.loaded.take()
    }

    /// Draw the **🌍 Region** toolbar button. Click → open the modal. Call inside the
    /// host's toolbar `ui.horizontal_wrapped`.
    pub fn button(&mut self, ui: &mut egui::Ui) {
        let label = match &self.region {
            Some(p) => format!("🌍 {}", p.label()),
            None => "🌍 Region".to_string(),
        };
        if ui.button(label).on_hover_text("Clip a region out of the cached planet and load it").clicked() {
            self.open();
        }
    }

    /// Draw the region-picker **modal** (when open) + drain the worker channel. Shows
    /// the three presets and the live status. Call once per frame after the toolbar.
    pub fn ui(&mut self, ctx: &egui::Context) {
        // Always poll so status advances even with the modal closed (a long clip can
        // finish after the user dismisses the dialog).
        self.poll();
        if !self.open {
            return;
        }
        // §2 — refresh the animated progress model from the current status BEFORE we
        // draw, so the bar reflects (and MOVES with) this frame's pipeline state.
        self.update_progress(ctx);
        let mut open = self.open;
        let mut clicked: Option<Preset> = None;
        egui::Window::new("🌍 Region")
            .collapsible(false)
            .resizable(false)
            .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
            .open(&mut open)
            .show(ctx, |ui| {
                ui.label("Clip a region out of the cached planet OSM and load it:");
                ui.add_space(6.0);
                let busy = matches!(self.status, Status::Clipping | Status::Loading);
                for &p in &PRESETS {
                    let selected = self.region == Some(p);
                    ui.add_enabled_ui(!busy, |ui| {
                        if ui.selectable_label(selected, p.label()).clicked() {
                            clicked = Some(p);
                        }
                    });
                }
                ui.add_space(8.0);
                ui.separator();
                // Live status line.
                let (status_txt, color) = match &self.status {
                    Status::Idle => ("idle".to_string(), egui::Color32::GRAY),
                    Status::Clipping => ("clipping planet…".to_string(), egui::Color32::YELLOW),
                    Status::Loading => ("loading GeoParquet…".to_string(), egui::Color32::LIGHT_BLUE),
                    Status::Ready => ("ready ✓".to_string(), egui::Color32::GREEN),
                    Status::Error(e) => (format!("error: {e}"), egui::Color32::LIGHT_RED),
                };
                ui.colored_label(color, status_txt);
                // §2 — the animated progress bar (facett-pop ProgressView). It SWEEPS
                // (a moving fill) while clipping/loading and reads full on `ready`;
                // hidden when idle / on error (the coloured label covers those).
                if matches!(self.status, Status::Clipping | Status::Loading | Status::Ready) {
                    ui.add_space(4.0);
                    let _ = self.progress.view(ui);
                }
                if self.cache_hit {
                    ui.label("(cache hit — clip skipped)");
                }
            });
        self.open = open;
        if let Some(p) = clicked {
            self.pick(p);
        }
        // Keep the frame clock ticking while a clip/load runs so the bar animates
        // (each repaint advances the sweep in `update_progress`).
        if matches!(self.status, Status::Clipping | Status::Loading) {
            ctx.request_repaint();
        }
    }

    /// Refresh the animated progress model (§2) from the pipeline [`Status`]:
    /// - **Clipping / Loading** → a **sweeping** fraction (0→1 over ~1.5 s) so the
    ///   bar visibly MOVES while the background thread works (an indeterminate feel,
    ///   as `facett-pop` renders determinate bars). The phase names the active step.
    /// - **Ready** → a full (100 %) bar.
    /// - **Idle / Error** → no rows (the coloured status label covers those states).
    ///
    /// Pure model mutation — the [`ProgressView::view`] call in [`ui`](Self::ui)
    /// paints it. Split out so the sweep is testable without a window.
    fn update_progress(&mut self, ctx: &egui::Context) {
        let region = self.region_label().unwrap_or_else(|| "region".to_string());
        match &self.status {
            Status::Clipping | Status::Loading => {
                let t = ctx.input(|i| i.time);
                // A 1.5 s sawtooth sweep → a bar that fills then resets, i.e. MOVES.
                let sweep = ((t / 1.5).rem_euclid(1.0)) as f32;
                let step = if matches!(self.status, Status::Clipping) {
                    "clipping planet"
                } else {
                    "loading GeoParquet"
                };
                self.progress.phase = format!("{step} · {region}");
                self.progress.items =
                    vec![ProgressItem { label: region, done: (sweep * 100.0) as u64, total: 100 }];
            }
            Status::Ready => {
                self.progress.phase = format!("ready · {region}");
                self.progress.items = vec![ProgressItem { label: region, done: 100, total: 100 }];
            }
            Status::Idle | Status::Error(_) => {
                self.progress.items.clear();
            }
        }
    }

    /// Everything observable, for the test matrix / headless assertions. Surfaced by
    /// the host's `state_json` under `region_picker`.
    pub fn state_json(&self) -> serde_json::Value {
        // `mut` is only used by the `region-connector` insert block below.
        #[cfg_attr(not(feature = "region-connector"), allow(unused_mut))]
        let mut v = serde_json::json!({
            "region": self.region_label(),
            "clip_arg": self.region.map(|p| p.clip_arg()),
            "clip_kind": self.region.map(|p| p.clip_kind().tag()),
            "source": self.source.as_ref().map(|p| p.to_string_lossy()),
            "out": self.out.as_ref().map(|p| p.to_string_lossy()),
            "cache_hit": self.cache_hit,
            "status": self.status.tag(),
            "open": self.open,
            "in_process": cfg!(feature = "region-clip"),
            "presets": PRESETS.iter().map(|p| p.label()).collect::<Vec<_>>(),
        });
        // Connector surface: `source` names the CONNECTOR (not the planet path) when
        // one feeds the picker, plus `connector` (name|null) and `fetched` (did this
        // pick invoke the connector's fetch vs a cache-hit short-circuit).
        #[cfg(feature = "region-connector")]
        {
            if let Some(name) = &self.connector_name {
                v["source"] = serde_json::Value::String(name.clone());
            }
            v["connector"] = self
                .connector_name
                .clone()
                .map(serde_json::Value::String)
                .unwrap_or(serde_json::Value::Null);
            v["fetched"] = serde_json::Value::Bool(self.fetched);
        }
        v
    }
}

/// The background pipeline: cache-hit short-circuit → (else clip via osm-katana
/// **in process**) → read the GeoParquet → ship geometry back. All status flows
/// through `tx`. Native-only — the wasm web demo can't clip (no threads/fs/katana).
#[cfg(not(target_arch = "wasm32"))]
fn run_pipeline(preset: Preset, out: PathBuf, hit: bool, source: Option<PathBuf>, tx: Sender<Msg>) {
    // 1. Clip (unless cache hit).
    if !hit && let Err(reason) = run_clip(preset, &out, source) {
        let _ = tx.send(Msg::Status(Status::Error(reason)));
        return;
    }

    // 2. Load the GeoParquet into the viewer.
    let _ = tx.send(Msg::Status(Status::Loading));
    let nodes = out.join("nodes.parquet");
    let ways = out.join("ways.parquet");
    match osm_tabs::load_from(
        &nodes.to_string_lossy(),
        &ways.to_string_lossy(),
        OsmSource::Region(preset.label().to_string()),
    ) {
        Some(geo) => {
            let _ = tx.send(Msg::Loaded(Box::new(geo)));
        }
        None => {
            let _ = tx.send(Msg::Status(Status::Error(format!(
                "no features read from {}",
                out.display()
            ))));
        }
    }
}

/// Run the in-process clip for `preset` from the cached planet into `out`. Returns
/// `Err(reason)` with a user-facing message on any failure (missing planet,
/// feature off, convert error). With the `region-clip` feature OFF this is a stub
/// that always reports the feature is disabled — keeps the pipeline graceful.
#[cfg(all(feature = "region-clip", not(target_arch = "wasm32")))]
fn run_clip(preset: Preset, out: &Path, source: Option<PathBuf>) -> Result<(), String> {
    use std::sync::Arc;

    let planet = source.ok_or_else(|| {
        "cached planet not found (looked on /home + T9) — never re-downloads".to_string()
    })?;
    let clip = build_clip(preset).map_err(|e| format!("build clip for {}: {e}", preset.label()))?;
    std::fs::create_dir_all(out).map_err(|e| format!("mkdir {}: {e}", out.display()))?;

    let opts = osm_katana::ConvertOptions {
        output_dir: out.to_path_buf(),
        clip: Some(Arc::new(clip)),
        ..Default::default()
    };
    osm_katana::convert(&planet, &opts)
        .map_err(|e| format!("osm-katana convert {} → {}: {e}", planet.display(), out.display()))
}

/// Feature-off stub: there's no osm-katana dep, so we can't clip. Surface a
/// friendly message rather than crash — the dialog + cache + load logic still wire
/// up, and a `--features region-clip` build makes it live.
#[cfg(all(not(feature = "region-clip"), not(target_arch = "wasm32")))]
fn run_clip(_preset: Preset, _out: &Path, _source: Option<PathBuf>) -> Result<(), String> {
    Err("region-clip feature is off — rebuild facett-demo with `--features region-clip` \
         (it's on by default) to clip regions in-process"
        .to_string())
}

/// The **connector** background pipeline: (unless the GeoParquet is already cached)
/// FETCH the region `.osm.pbf` via the `korp_collectors::region::RegionSource` connector →
/// convert the WHOLE fetched extract to GeoParquet in-process → read it back into
/// the viewer. On a cache hit the connector is never touched (no fetch). All status
/// flows through `tx`. Native-only (fetch + convert + fs are desktop-only).
#[cfg(all(feature = "region-connector", not(target_arch = "wasm32")))]
fn run_connector_pipeline(
    region: String,
    out: PathBuf,
    hit: bool,
    source: std::sync::Arc<std::sync::Mutex<Box<dyn korp_collectors::region::RegionSource>>>,
    tx: Sender<Msg>,
) {
    // 1. Fetch (via the connector) + convert, unless the GeoParquet is cached.
    if !hit {
        let pbf = {
            // A poisoned lock is still usable here: the source is a stateless
            // fetch seam, so recover the guard rather than propagate the panic.
            let src = source.lock().unwrap_or_else(|p| p.into_inner());
            match src.ensure(&region) {
                Ok(p) => p,
                Err(e) => {
                    let _ = tx.send(Msg::Status(Status::Error(format!(
                        "connector fetch `{region}`: {e}"
                    ))));
                    return;
                }
            }
        };
        if let Err(e) = run_convert(&pbf, &out) {
            let _ = tx.send(Msg::Status(Status::Error(e)));
            return;
        }
    }

    // 2. Load the GeoParquet into the viewer (same reader the preset path uses).
    let _ = tx.send(Msg::Status(Status::Loading));
    let nodes = out.join("nodes.parquet");
    let ways = out.join("ways.parquet");
    match osm_tabs::load_from(
        &nodes.to_string_lossy(),
        &ways.to_string_lossy(),
        OsmSource::Region(region),
    ) {
        Some(geo) => {
            let _ = tx.send(Msg::Loaded(Box::new(geo)));
        }
        None => {
            let _ = tx.send(Msg::Status(Status::Error(format!(
                "no features read from {}",
                out.display()
            ))));
        }
    }
}

/// Convert a fetched region `.osm.pbf` to GeoParquet (`nodes.parquet` +
/// `ways.parquet`) in `out`, WHOLE (no clip — the connector already delivered the
/// region extract). Uses osm-katana in-process (pulled in by
/// `region-connector ⇒ region-clip`).
#[cfg(all(feature = "region-connector", not(target_arch = "wasm32")))]
fn run_convert(pbf: &Path, out: &Path) -> Result<(), String> {
    std::fs::create_dir_all(out).map_err(|e| format!("mkdir {}: {e}", out.display()))?;
    let opts = osm_katana::ConvertOptions {
        output_dir: out.to_path_buf(),
        clip: None,
        ..Default::default()
    };
    osm_katana::convert(pbf, &opts)
        .map_err(|e| format!("osm-katana convert {} → {}: {e}", pbf.display(), out.display()))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn presets_list_three_in_order() {
        assert_eq!(PRESETS.len(), 3);
        let labels: Vec<_> = PRESETS.iter().map(|p| p.label()).collect();
        assert_eq!(labels, vec!["Sverige", "Skandinavien", "Europa"]);
    }

    #[test]
    fn preset_to_clip_arg_mapping() {
        assert_eq!(Preset::Sverige.clip_arg(), "sweden");
        assert_eq!(Preset::Skandinavien.clip_arg(), "nordics");
        assert_eq!(Preset::Europa.clip_arg(), "europe");
    }

    /// The picker reports the right CLIP KIND per preset: Sweden is a real polygon;
    /// the others are named bbox presets. This is the in-process mapping the
    /// pipeline acts on, asserted as readable data (per facett's emit LAW).
    #[test]
    fn preset_to_clip_kind_mapping() {
        assert_eq!(Preset::Sverige.clip_kind(), ClipKind::Polygon);
        assert_eq!(Preset::Sverige.clip_kind().tag(), "polygon");
        assert_eq!(Preset::Skandinavien.clip_kind(), ClipKind::BboxPreset("nordics"));
        assert_eq!(Preset::Europa.clip_kind(), ClipKind::BboxPreset("europe"));
        assert_eq!(Preset::Skandinavien.clip_kind().tag(), "bbox");
        assert_eq!(Preset::Europa.clip_kind().tag(), "bbox");
    }

    #[test]
    fn cache_dir_is_stable_under_home_cache() {
        let dir = region_cache_dir(Preset::Skandinavien);
        assert!(dir.ends_with("facett-osm/nordics-geo"), "got {}", dir.display());
        // Never on T9 (LAW: outputs on /home or ~/.cache).
        assert!(!dir.to_string_lossy().contains("/T9"), "cache must not be on T9");
    }

    #[test]
    fn cache_hit_detects_both_parquets() {
        let tmp = std::env::temp_dir().join(format!("facett-region-test-{}", std::process::id()));
        std::fs::create_dir_all(&tmp).unwrap();
        assert!(!cache_hit(&tmp), "empty dir is a miss");
        std::fs::write(tmp.join("nodes.parquet"), b"x").unwrap();
        assert!(!cache_hit(&tmp), "only nodes → still a miss");
        std::fs::write(tmp.join("ways.parquet"), b"x").unwrap();
        assert!(cache_hit(&tmp), "both present → hit");
        let _ = std::fs::remove_dir_all(&tmp);
    }

    /// The bundled Sweden polygon CONTAINS the three biggest Swedish cities + Kiruna
    /// and EXCLUDES the neighbouring capitals — proving the Sverige button clips the
    /// real country shape, not a Nordic bbox. Only meaningful with osm-katana's
    /// `Polygon` available (the `region-clip` feature).
    #[cfg(feature = "region-clip")]
    #[test]
    fn sweden_polygon_contains_swedish_cities_excludes_neighbours() {
        let p = osm_katana::Polygon::from_poly_str(SWEDEN_POLY).expect("sweden.poly parses");
        // Inside Sweden (lon, lat):
        assert!(p.contains(18.06, 59.33), "Stockholm must be inside Sweden");
        assert!(p.contains(11.97, 57.70), "Göteborg must be inside Sweden");
        assert!(p.contains(13.00, 55.60), "Malmö must be inside Sweden");
        assert!(p.contains(20.22, 67.85), "Kiruna must be inside Sweden");
        // Outside Sweden (neighbouring capitals):
        assert!(!p.contains(10.75, 59.91), "Oslo must be OUTSIDE Sweden");
        assert!(!p.contains(12.57, 55.68), "Copenhagen must be OUTSIDE Sweden");
        assert!(!p.contains(24.94, 60.17), "Helsinki must be OUTSIDE Sweden");
        assert!(!p.contains(13.40, 52.52), "Berlin must be OUTSIDE Sweden");
    }

    /// `build_clip` picks the right osm-katana `Clip` variant per preset: Sweden →
    /// `Poly` (and that poly contains Stockholm but not Oslo); nordics/europe →
    /// `Bbox` equal to the matching preset bbox.
    #[cfg(feature = "region-clip")]
    #[test]
    fn build_clip_maps_presets_to_clip_variants() {
        use osm_katana::{Bounds, Clip};
        match build_clip(Preset::Sverige).unwrap() {
            Clip::Poly(poly) => {
                assert!(poly.contains(18.06, 59.33), "Sweden poly contains Stockholm");
                assert!(!poly.contains(10.75, 59.91), "Sweden poly excludes Oslo");
            }
            other => panic!("Sverige must clip with a polygon, got {other:?}"),
        }
        match build_clip(Preset::Skandinavien).unwrap() {
            Clip::Bbox(b) => assert_eq!(b, Bounds::nordics(), "Skandinavien → nordics bbox"),
            other => panic!("Skandinavien must clip with a bbox, got {other:?}"),
        }
        match build_clip(Preset::Europa).unwrap() {
            Clip::Bbox(b) => assert_eq!(b, Bounds::europe(), "Europa → europe bbox"),
            other => panic!("Europa must clip with a bbox, got {other:?}"),
        }
    }

    #[test]
    fn state_json_surface_has_the_documented_keys() {
        let mut rp = RegionPicker::new();
        let s = rp.state_json();
        // Idle, never picked: documented keys present, region null.
        for k in ["region", "clip_arg", "clip_kind", "source", "out", "cache_hit", "status", "open", "in_process", "presets"]
        {
            assert!(s.get(k).is_some(), "state_json missing key {k}");
        }
        assert_eq!(s["status"], "idle");
        assert!(s["region"].is_null());
        assert!(s["clip_kind"].is_null(), "no region picked → no clip kind yet");
        assert_eq!(s["presets"].as_array().unwrap().len(), 3);

        // Open + drive the modal state (no thread/clip): open flips, presets stable.
        rp.open();
        assert!(rp.state_json()["open"].as_bool().unwrap());
    }

    /// After picking Sverige, the readable surface reports the right region, clip arg
    /// (`sweden`), clip KIND (`polygon`) and out dir — the test-matrix proof that the
    /// Sverige button drives the polygon clip path. Drives pick()→poll() to a
    /// terminal status without panicking (no planet/CLI needed on CI).
    #[test]
    fn pick_sverige_reports_polygon_clip_surface() {
        // Force a fresh temp cache so there's no hit and no real planet output.
        let tmp = std::env::temp_dir().join(format!("facett-region-empty-{}", std::process::id()));
        std::fs::create_dir_all(&tmp).unwrap();
        let prev = std::env::var("XDG_CACHE_HOME").ok();
        // SAFETY: single-threaded test setup before spawning the worker.
        unsafe {
            std::env::set_var("XDG_CACHE_HOME", &tmp);
        }

        let mut rp = RegionPicker::new();
        rp.pick(Preset::Sverige);
        assert!(!rp.cache_hit, "fresh temp cache is a miss");

        // The readable surface is set synchronously by pick(), regardless of the
        // (slow / possibly absent) clip outcome.
        let s = rp.state_json();
        assert_eq!(s["region"], "Sverige");
        assert_eq!(s["clip_arg"], "sweden");
        assert_eq!(s["clip_kind"], "polygon", "Sverige drives the REAL polygon clip");
        assert!(s["out"].as_str().unwrap().contains("sweden-geo"));

        // Poll until terminal OR bounded timeout (a real clip would be slow; we only
        // assert it terminates without panicking and the surface stays consistent).
        let mut tries = 0;
        loop {
            rp.poll();
            match rp.status() {
                Status::Ready | Status::Error(_) => break,
                _ => {
                    tries += 1;
                    if tries > 2000 {
                        break;
                    }
                    std::thread::sleep(std::time::Duration::from_millis(2));
                }
            }
        }
        // Surface is unchanged by the worker outcome.
        let s = rp.state_json();
        assert_eq!(s["region"], "Sverige");
        assert_eq!(s["clip_kind"], "polygon");

        // restore env
        unsafe {
            match prev {
                Some(v) => std::env::set_var("XDG_CACHE_HOME", v),
                None => std::env::remove_var("XDG_CACHE_HOME"),
            }
        }
        let _ = std::fs::remove_dir_all(&tmp);
    }

    /// Each preset reports a distinct, correct clip arg + kind through the picker's
    /// readable surface after being picked — the per-preset matrix row.
    #[test]
    fn each_preset_reports_its_clip_arg_and_kind() {
        let tmp = std::env::temp_dir().join(format!("facett-region-matrix-{}", std::process::id()));
        std::fs::create_dir_all(&tmp).unwrap();
        let prev = std::env::var("XDG_CACHE_HOME").ok();
        unsafe {
            std::env::set_var("XDG_CACHE_HOME", &tmp);
        }

        let expected = [
            (Preset::Sverige, "sweden", "polygon"),
            (Preset::Skandinavien, "nordics", "bbox"),
            (Preset::Europa, "europe", "bbox"),
        ];
        for (preset, arg, kind) in expected {
            let mut rp = RegionPicker::new();
            rp.pick(preset);
            let s = rp.state_json();
            assert_eq!(s["region"], preset.label());
            assert_eq!(s["clip_arg"], arg, "{} clip arg", preset.label());
            assert_eq!(s["clip_kind"], kind, "{} clip kind", preset.label());
            assert!(
                s["out"].as_str().unwrap().contains(&format!("{arg}-geo")),
                "{} out dir names its arg",
                preset.label()
            );
        }

        unsafe {
            match prev {
                Some(v) => std::env::set_var("XDG_CACHE_HOME", v),
                None => std::env::remove_var("XDG_CACHE_HOME"),
            }
        }
        let _ = std::fs::remove_dir_all(&tmp);
    }
}