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(®ion);
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(®ion) {
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);
}
}