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

nordisk/facett

.nornir/map-guide.md

The geo/map subsystem — guide

Class: guide · audience: a techie building or driving a facett map component. The what is in map-design.md; the why in map-reasoning.md. This file is the workflow.

f0 — zero to a rendered map (the whole workflow)

  1. Get geometry. Either build it in code (a Vec<(f64,f64)> of points, facett_map::Way::line/Way::filled for a synthetic basemap) or ingest real OSM GeoParquet via facett_osm::read_points/read_ways (f3).
  2. Pick a viewer by what you need: a static fit-to-rect scatter → facett_map::MapView (f1); a scatter with pan/zoom + density hotspots → facett_geomap::GeoMap (f2); a full slippy map (spatial index, LOD, per-OS nav) → facett_geomap::osm2d::OsmView (f2).
  3. Feed it geometry at construction (MapView::new/with_ways, GeoMap::new/with_ways, OsmView::new).
  4. Drive it. Live: it's a Facet — drop it into a FacetDeck and gestures produce Msgs automatically. Headless: facett_core::harness::{drive, snapshot, headless_render} feed a Vec<Msg> and assert on the resulting Model / state_json — no window, no GPU.
  5. Opt into the GPU fast-path (optional) only once real geometry proves out on the CPU path — see f4.

f1 — MapView

use facett_map::MapView;

// Point-only (today's original behaviour).
let map = MapView::new("map", vec![(11.0, 57.0), (18.0, 59.0)]);

// With a vector basemap (ways drawn under the points).
let ways = facett_osm::read_ways("ways.parquet", 0 /*=unlimited*/, &facett_core::Theme::sci_fi())?;
let map = MapView::new("map", points).with_ways(ways);

Drive it headlessly:

use facett_core::harness;
use facett_map::Msg;

let mut v = MapView::new("li", points).with_ways(ways);
let snap = harness::snapshot(&mut v, [Msg::ZoomBy(500.0), Msg::Select(22)]);
assert_eq!(snap.selected, Some(22));

let r = harness::headless_render(&mut v);
assert!(r.drew());
assert_eq!(r.state["ways"], 3);

MapView::fit() / MapView::select(Some(id)) / select(None) are thin wrappers over update for when you don't want to build a Msg by hand.

f2 — GeoMap and OsmView

GeoMap — pan/zoom scatter + hotspots:

use facett_geomap::{GeoMap, Hotspot};

let map = GeoMap::new(
    "Sweden",
    vec![(18.07, 59.33), (13.00, 55.60)],           // points (lon, lat)
    vec![Hotspot::new(13.00, 55.60, 320, "Malmö")], // hotspots
);
// map.zoom_by(2.0); map.pan_unit(0.01, 0.0);  // host/headless interaction
let map = map.with_ways(some_ways);             // optional basemap; re-fits

Or feed it a bundled boundary instead of a file:

use facett_geomap::load_sweden_admin;
let b = load_sweden_admin()?;
let map = GeoMap::new("Sverige", b.centers(), vec![]).with_ways(b.to_ways());

OsmView — the full slippy map, for real (large) OSM data:

use facett_geomap::OsmView;

let points = facett_osm::read_points("nodes.parquet", 0)?;
let ways = facett_osm::read_ways("ways.parquet", 0, &theme)?;
let view = OsmView::from_geometry("OSM 2D", points, ways);

Both are driven the same headless way as MapView (harness::drive/snapshot/headless_render), plus their own navigation helpers for a host without an egui input stream: OsmView::zoom_to_cursor, pan_pixels, fit_to, flick_pan + tick_pan_momentum (deterministic under an injected dt, so a headless test can assert an inertial glide step by step).

f3 — osm ingest

use facett_osm::{read_points, read_ways};
use facett_core::Theme;

let points = read_points("nodes.parquet", 1_000_000)?;         // (lon, lat), capped
let ways   = read_ways("ways.parquet", 0, &Theme::sci_fi())?;   // 0 = unlimited

In-memory variants (no filesystem — the wasm demo include_bytes!s a clipped region into the binary):

let points = facett_osm::read_points_bytes(include_bytes!("nodes.parquet").as_slice(), 0)?;
let ways   = facett_osm::read_ways_bytes(include_bytes!("ways.parquet").as_slice(), 0, &theme)?;

decode_wkb_coords(&[u8]) -> Vec<(f64,f64)> decodes one raw WKB geometry's vertices directly, if you have a blob outside a parquet reader.

f4 — enabling the wgpu fast-path

The CPU painter is always on and needs no setup. To add the GPU line-ways lane:

// eframe creation closure, once:
if let Some(rs) = cc.wgpu_render_state.as_ref() {
    facett_map::gpu::install_renderer(rs);
}
# facett-map consumer:
facett-map = { version = "...", features = ["wgpu"] }
# facett-geomap (OsmView) consumer:
facett-geomap = { version = "...", features = ["map-wgpu"] }
let view = MapView::new("map", points).with_ways(ways).with_gpu(true);
// or: OsmView::from_geometry("OSM 2D", points, ways).with_gpu(true);

eframe must run its wgpu backend feature. Without install_renderer having been called, with_gpu(true) still works — the paint callback no-ops and the CPU line painter draws the basemap. Filled silhouettes always draw on the CPU regardless of this setting.

f5 — the region picker (facett-demo)

facett-demo's 🌍 Region button demonstrates the whole pipeline end to end: clip a region out of an already-cached OSM planet file and load it straight into the OSM 2D/OSM 3D tabs.

🌍 Region button ─▶ modal (Sverige / Skandinavien / Europa)
        │ pick(preset)
        ▼
  background thread
        │
        ├─ cache_hit(out)? ──yes──▶ skip the clip ─┐
        │                                          │
        └─no─▶ planet_source() (cached, never re-downloaded)
                │  build_clip(preset) → osm-katana Clip::Poly (Sverige) or Clip::Bbox (others)
                │  osm_katana::convert(…) IN-PROCESS (no subprocess)
                ▼                                       │
        osm_tabs::load_from(out/nodes.parquet, out/ways.parquet) ◀┘
                │  Status::Loading → Msg::Loaded(geo)
                ▼
  UI thread: RegionPicker::poll() → Status::Ready → take_loaded()
                │
                ▼
  DemoState swaps the loaded geometry into the "OSM 2D"/"OSM 3D" deck slots

Preset → clip mapping (facett-demo/src/region_picker.rs, Preset::clip_arg / Preset::clip_kind):

modal label Preset clip kind cache dir
Sverige Preset::Sverige Clip::Poly from the bundled assets/sweden.poly (the real country outline) ~/.cache/facett-osm/sweden-geo
Skandinavien Preset::Skandinavien Clip::Bbox("nordics") ~/.cache/facett-osm/nordics-geo
Europa Preset::Europa Clip::Bbox("europe") ~/.cache/facett-osm/europe-geo

The clip runs in-process (region_picker::build_clip, gated on feature region-clip) by calling osm-katana's convert() library API directly on a background thread — no subprocess, no re-download: planet_source() only ever looks at an already-cached .osm.bz2 on /home (fast disk) or /run/media/.../T9 (fallback). cache_hit(dir) (both nodes.parquet and ways.parquet present) short-circuits straight to loading. Everything lands in RegionPicker::state_json()["region_picker"] — region, clip_arg, clip_kind, source, out, cache_hit, status (idle|clipping|loading|ready|error) — so the pipeline is testable as data, not pixels.

Manual run:

cargo run -p facett-demo        # click 🌍 Region → pick Sverige/Skandinavien/Europa
# needs: the cached planet on /home, built with default features (region-clip on)

f6 — sharing a way slab, and plugging in a baked-vertex store

f7 — the paint order, and the one rule you must not break

Everything that paints a Way sorts on facett_map::paint_order::way_paint_key. The rank is

rank = effective_layer * LAYER_STEP + band + class + tier      LAYER_STEP = 100_000

effective_layer comes from the OSM layer / bridge / tunnel tags, and it dominates everything else by 100 000 per step. That is the rule people get wrong:

A band constant is not a floor. If you want something under every way, it has to sit under paint_order::WAY_RANK_FLOOR (-LAYER_CLAMP * LAYER_STEP + AREA_BAND = -800 000), not merely under AREA_BAND.

LANDMASS_BAND is WAY_RANK_FLOOR - LAYER_STEP for exactly this reason, with a const assertion holding it there. If you add a new base band (an aerial raster, a hillshade), derive it from WAY_RANK_FLOOR the same way — never as AREA_BAND - something.

The second thing to know is that the GPU callback is one opaque slot. MapFacet::ui puts it at the lane's own rank floor and paints the CPU features below that rank first, so a CPU-only feature whose rank sits inside the lane's span lands above everything the lane drew. In practice that is only the landmass, points and labels — points and labels belong on top anyway.

Run the two guards:

cargo test -p facett-map --lib paint_order
env -u WAYLAND_DISPLAY -u DISPLAY FACETT_TEST_GPU=discrete \
  cargo test -p facett-map --features wgpu --test gpu_lane_paint_order -- --nocapture

The second one needs a real device and saves its framebuffers to /home/rickard/scratch/cargo/gpu_lane_*.png — look at them, that is the point of the file.


Loading a source that is not OSM (Trafikverket, NVDB, a survey export)

Point facett_osm::read_ways_with at it. If the parquet has a geometry column and no tags column, every row draws — in the magenta overprint ink (MapPalette::overlay) at Lod::Region, WayKind::Overprint. Nothing to configure and no flag to pass: the schema is the signal.

let pal  = facett_map::MapPalette::cartographic(true);
let ways = facett_osm::read_ways_with("/data/trafikverket/roads.parquet", 20_000, &pal)?;
// every way: ways[i].style.kind == WayKind::Overprint, ways[i].stroke == pal.overlay

Two things this is not:

If your overlay draws nothing, check the schema first:

python3 -c "import pyarrow.parquet as pq; print(pq.read_schema('roads.parquet'))"

A tags column of a type this reader cannot read as UTF-8 counts as tagless and the whole file paints as an overlay. That is deliberate — loud beats silent — but if the whole file went magenta and you expected a classified basemap, that is the reason.

To see the count on a deck, read OsmView::state_json()["overprint_ways"]. It is 0 for every OSM cut.

Run the guards:

cargo test -p facett-map  --lib the_overprint_ink_is_nobody_elses_ink
cargo test -p facett-map  --lib the_three_undecided_classes_are_decided
cargo test -p facett-osm  --lib a_tagless_source_draws_and_an_untagged_way_in_a_tagged_cut_does_not
cargo test -p facett-demo --lib a_tagless_ways_source_reaches_the_deck_as_overprint

…and the frames, in korp:

cargo test --test map_render_truth -- --nocapture the_three_undecided a_tagless_source
# frames land in /home/rickard/scratch/tl-verdicts/