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

nordisk/facett

facett-geomap/src/boundary.rs

//! **Swedish administrative-boundary loader** — parses the bundled
//! `assets/sweden_admin.json` (Natural Earth 10m admin-1, the 21 Swedish *län*)
//! into typed [`AdminUnit`]s and turns them into drawable [`Way`] silhouettes for
//! the [`crate::GeoMap`] / [`crate::OsmView`] basemap layer.
//!
//! The asset is a compact custom JSON (NOT GeoJSON) so it stays small enough to
//! `include_str!` straight into the binary:
//!
//! ```text
//! { "dataset": "...", "country": "SE", "country_name": "Sverige",
//!   "admin_level_leaf": 4,
//!   "units": [ { "name": "Stockholm", "name_en": "Stockholm",
//!                "admin_level": 4, "iso": "SE-AB", "population": 2415139,
//!                "center": [lon, lat],
//!                "rings": [ [[lon,lat], …], … ] }, … ] }
//! ```
//!
//! **What this asset is FOR** (2026-09-03): administrative UNITS, not a coastline. At
//! Douglas-Peucker 0.012° ≈ 1.34 km it is excellent at "which län is this point in" and
//! hopeless as a shore — korp's land silhouette moved to its own cut's coastline ways
//! (`korp::landmass`, step `h`). See [`AdminBoundaries::landmass_rings`] for the three
//! consumers that remain.
//!
//! Each entry in `rings` is one **outer** polygon in `(lon, lat)` degrees — a län
//! with islands (an archipelago) carries several rings (Stockholm has 18). There
//! are no holes, so every ring maps 1:1 to a filled silhouette [`Way`].
//!
//! Parsing is via `serde_json::Value` (no `serde_derive` dependency) and is
//! **strict**: a missing/mistyped field or malformed JSON is a hard
//! [`BoundaryError`] rather than a silent empty map.

use facett_map::Way;

/// The boundary asset, embedded at compile time so [`load_sweden_admin`] needs no
/// filesystem access (the file is ~47 KB — negligible in the binary).
const SWEDEN_ADMIN_JSON: &str = include_str!("../assets/sweden_admin.json");

/// Fill colour for a län silhouette (translucent so overlapping archipelago rings
/// and the point/hotspot layers above still read).
const ADMIN_FILL: egui::Color32 = egui::Color32::from_rgba_premultiplied(150, 168, 150, 70);
/// Crisp administrative-boundary outline colour.
const ADMIN_STROKE: egui::Color32 = egui::Color32::from_rgb(90, 110, 90);
/// Boundary outline width (logical px, before the viewport's zoom scaling).
const ADMIN_WIDTH: f32 = 1.2;

/// Something went wrong loading the boundary asset.
#[derive(Debug)]
pub enum BoundaryError {
    /// The bytes were not valid JSON.
    Json(serde_json::Error),
    /// The JSON parsed but did not match the expected boundary schema.
    Schema(&'static str),
}

impl std::fmt::Display for BoundaryError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            BoundaryError::Json(e) => write!(f, "boundary JSON is malformed: {e}"),
            BoundaryError::Schema(what) => write!(f, "boundary JSON schema error: {what}"),
        }
    }
}

impl std::error::Error for BoundaryError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            BoundaryError::Json(e) => Some(e),
            BoundaryError::Schema(_) => None,
        }
    }
}

impl From<serde_json::Error> for BoundaryError {
    fn from(e: serde_json::Error) -> Self {
        BoundaryError::Json(e)
    }
}

/// One administrative unit (a Swedish *län* / region at `admin_level` 4).
#[derive(Clone, Debug, PartialEq)]
pub struct AdminUnit {
    /// Native name (e.g. `"Skåne"`).
    pub name: String,
    /// English/latinised name (e.g. `"Scania"`; often identical to `name`).
    pub name_en: String,
    /// OSM/Natural-Earth admin level (4 for a Swedish län).
    pub admin_level: u32,
    /// ISO-3166-2 code (e.g. `"SE-M"`).
    pub iso: String,
    /// Population (people).
    pub population: u64,
    /// Label anchor `(lon, lat)` in WGS-84 degrees.
    pub center: (f64, f64),
    /// One or more **outer** polygon rings in `(lon, lat)` degrees. A län with
    /// islands carries several rings; there are no holes.
    pub rings: Vec<Vec<(f64, f64)>>,
}

impl AdminUnit {
    /// Total vertex count across all of this unit's rings.
    pub fn vertex_count(&self) -> usize {
        self.rings.iter().map(Vec::len).sum()
    }

    /// Lon/lat bounding box `(min_lon, min_lat, max_lon, max_lat)` over all rings,
    /// or `None` if the unit has no vertices.
    pub fn bbox(&self) -> Option<(f64, f64, f64, f64)> {
        fold_bbox(self.rings.iter().flat_map(|r| r.iter().copied()))
    }
}

/// The whole boundary dataset.
#[derive(Clone, Debug)]
pub struct AdminBoundaries {
    /// Free-form provenance string (source + simplification).
    pub dataset: String,
    /// ISO country code (`"SE"`).
    pub country: String,
    /// Native country name (`"Sverige"`).
    pub country_name: String,
    /// Deepest admin level present (4 = län).
    pub admin_level_leaf: u32,
    /// The administrative units.
    pub units: Vec<AdminUnit>,
}

impl AdminBoundaries {
    /// Parse the boundary JSON. **Strict**: malformed JSON → [`BoundaryError::Json`];
    /// a missing/mistyped field or an empty `units` array → [`BoundaryError::Schema`].
    pub fn parse(s: &str) -> Result<Self, BoundaryError> {
        let root: serde_json::Value = serde_json::from_str(s)?;
        let obj = root.as_object().ok_or(BoundaryError::Schema("root is not a JSON object"))?;

        let str_field = |key: &'static str| -> Result<String, BoundaryError> {
            obj.get(key)
                .and_then(|v| v.as_str())
                .map(str::to_owned)
                .ok_or(BoundaryError::Schema(match key {
                    "dataset" => "missing/invalid `dataset`",
                    "country" => "missing/invalid `country`",
                    "country_name" => "missing/invalid `country_name`",
                    _ => "missing/invalid string field",
                }))
        };

        let dataset = str_field("dataset")?;
        let country = str_field("country")?;
        let country_name = str_field("country_name")?;
        let admin_level_leaf = obj
            .get("admin_level_leaf")
            .and_then(serde_json::Value::as_u64)
            .ok_or(BoundaryError::Schema("missing/invalid `admin_level_leaf`"))? as u32;

        let units_json = obj
            .get("units")
            .and_then(|v| v.as_array())
            .ok_or(BoundaryError::Schema("missing/invalid `units` array"))?;
        if units_json.is_empty() {
            return Err(BoundaryError::Schema("`units` is empty"));
        }

        let mut units = Vec::with_capacity(units_json.len());
        for u in units_json {
            units.push(parse_unit(u)?);
        }

        Ok(Self { dataset, country, country_name, admin_level_leaf, units })
    }

    /// Total ring count across all units (a län with islands has several).
    pub fn ring_count(&self) -> usize {
        self.units.iter().map(|u| u.rings.len()).sum()
    }

    /// Total vertex count across all units' rings.
    pub fn vertex_count(&self) -> usize {
        self.units.iter().map(AdminUnit::vertex_count).sum()
    }

    /// Lon/lat bounding box `(min_lon, min_lat, max_lon, max_lat)` over every ring
    /// of every unit, or `None` if the dataset carries no vertices.
    pub fn bbox(&self) -> Option<(f64, f64, f64, f64)> {
        fold_bbox(self.units.iter().flat_map(|u| u.rings.iter().flat_map(|r| r.iter().copied())))
    }

    /// The `(lon, lat)` label centre of each unit — handy as a hotspot/label layer.
    pub fn centers(&self) -> Vec<(f64, f64)> {
        self.units.iter().map(|u| u.center).collect()
    }

    /// Turn every ring into a **filled silhouette** [`Way`] (translucent fill +
    /// crisp boundary outline), ready for `GeoMap::with_ways` / `OsmView::new`.
    /// Rings with fewer than 3 vertices (which can't form a polygon) are skipped,
    /// so degenerate input never reaches the tessellator. The `osm_id` encodes the
    /// unit index so a caller can trace a ring back to its län.
    pub fn to_ways(&self) -> Vec<Way> {
        let mut ways = Vec::with_capacity(self.ring_count());
        for (ui, unit) in self.units.iter().enumerate() {
            for ring in &unit.rings {
                if ring.len() < 3 {
                    continue;
                }
                ways.push(Way::filled(
                    ui as i64,
                    "admin",
                    ring.clone(),
                    ADMIN_FILL,
                    ADMIN_STROKE,
                    ADMIN_WIDTH,
                ));
            }
        }
        ways
    }

    /// Every land ring `(lon, lat)` across all län — the country's landmass outline
    /// (mainland + archipelago + islands). The geometry source for the base
    /// [`landmass fill`](facett_map::layer::LandmassLayer): land-vs-sea reads because
    /// this silhouette paints under water/landuse/roads.
    /// **This is no longer korp's map shape** (2026-09-03, korp step `h`). korp's land
    /// silhouette is now assembled from its cut's own `natural=coastline` ways
    /// (`korp::landmass` → `landmass.parquet`, 64 014 rings / 3 202 648 vertices for
    /// Sweden), because 0.012° ≈ 1.34 km is 70 px of shore error at a z12 camera.
    ///
    /// These rings stay, and this asset must NOT be "cleaned up as unused". Its jobs:
    ///
    /// * **region membership** — korp's `clip.rs` measures its `.poly` against these 59
    ///   län rings;
    /// * **the planned clickable län / landsting / region layer**, which is what the
    ///   `name` / `name_en` / `iso` / `admin_level` / `population` / `center` fields on
    ///   every unit were put here for — a coastline needs none of them;
    /// * korp's FALLBACK silhouette on a host whose cut has not run step `h`.
    pub fn landmass_rings(&self) -> Vec<Vec<(f64, f64)>> {
        self.units.iter().flat_map(|u| u.rings.iter().cloned()).collect()
    }

    /// Build the **base landmass fill** ([`facett_map::layer::LandmassLayer`]) from
    /// this country's län rings, filled `fill` and stacked under every other map
    /// layer. This is the one-line wiring a host (korp's `rebuild_map_facet`) uses to
    /// give the pane an instant land-vs-sea read.
    pub fn to_landmass_layer(&self, id: impl Into<String>, fill: egui::Color32) -> facett_map::layer::LandmassLayer {
        facett_map::layer::LandmassLayer::from_rings(id, self.landmass_rings(), fill)
    }
}

/// Load + parse the bundled Swedish län boundary asset.
pub fn load_sweden_admin() -> Result<AdminBoundaries, BoundaryError> {
    AdminBoundaries::parse(SWEDEN_ADMIN_JSON)
}

/// Parse one `units[i]` object.
fn parse_unit(u: &serde_json::Value) -> Result<AdminUnit, BoundaryError> {
    let o = u.as_object().ok_or(BoundaryError::Schema("a `units` entry is not an object"))?;

    let name = o.get("name").and_then(|v| v.as_str()).ok_or(BoundaryError::Schema("unit missing `name`"))?.to_owned();
    // `name_en` is optional; fall back to the native name.
    let name_en =
        o.get("name_en").and_then(|v| v.as_str()).map(str::to_owned).unwrap_or_else(|| name.clone());
    let admin_level =
        o.get("admin_level").and_then(serde_json::Value::as_u64).ok_or(BoundaryError::Schema("unit missing `admin_level`"))? as u32;
    let iso = o.get("iso").and_then(|v| v.as_str()).ok_or(BoundaryError::Schema("unit missing `iso`"))?.to_owned();
    // Population may be absent for some units; treat as 0 rather than fail.
    let population = o.get("population").and_then(serde_json::Value::as_u64).unwrap_or(0);

    let center = o
        .get("center")
        .and_then(|v| v.as_array())
        .and_then(|a| match a.as_slice() {
            [lon, lat] => Some((lon.as_f64()?, lat.as_f64()?)),
            _ => None,
        })
        .ok_or(BoundaryError::Schema("unit `center` is not a [lon, lat] pair"))?;

    let rings_json =
        o.get("rings").and_then(|v| v.as_array()).ok_or(BoundaryError::Schema("unit missing `rings` array"))?;
    let mut rings = Vec::with_capacity(rings_json.len());
    for ring in rings_json {
        let ring_arr = ring.as_array().ok_or(BoundaryError::Schema("a `rings` entry is not an array"))?;
        let mut pts = Vec::with_capacity(ring_arr.len());
        for p in ring_arr {
            let pt = p.as_array().and_then(|a| match a.as_slice() {
                [lon, lat] => Some((lon.as_f64()?, lat.as_f64()?)),
                _ => None,
            });
            pts.push(pt.ok_or(BoundaryError::Schema("a ring vertex is not a [lon, lat] pair"))?);
        }
        rings.push(pts);
    }

    Ok(AdminUnit { name, name_en, admin_level, iso, population, center, rings })
}

/// Fold `(lon, lat)` vertices into a `(min_lon, min_lat, max_lon, max_lat)` bbox.
fn fold_bbox(pts: impl Iterator<Item = (f64, f64)>) -> Option<(f64, f64, f64, f64)> {
    let mut b: Option<(f64, f64, f64, f64)> = None;
    for (lon, lat) in pts {
        b = Some(match b {
            None => (lon, lat, lon, lat),
            Some((mnx, mny, mxx, mxy)) => (mnx.min(lon), mny.min(lat), mxx.max(lon), mxy.max(lat)),
        });
    }
    b
}

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

    // ── the bundled real asset ────────────────────────────────────────────────

    #[test]
    fn loads_all_21_swedish_lan() {
        let b = load_sweden_admin().expect("bundled sweden_admin.json parses");
        assert_eq!(b.country, "SE");
        assert_eq!(b.country_name, "Sverige");
        assert_eq!(b.admin_level_leaf, 4);
        assert_eq!(b.units.len(), 21, "Sweden has 21 län");
        assert!(b.dataset.contains("Natural Earth"), "provenance carried through");
    }

    #[test]
    fn named_lan_have_the_expected_geometry() {
        let b = load_sweden_admin().unwrap();
        let by_name = |n: &str| b.units.iter().find(|u| u.name == n).unwrap_or_else(|| panic!("no {n}"));

        // Stockholm is an archipelago → many rings.
        let sthlm = by_name("Stockholm");
        assert_eq!(sthlm.iso, "SE-AB");
        assert_eq!(sthlm.admin_level, 4);
        assert_eq!(sthlm.rings.len(), 18, "Stockholm's archipelago = 18 rings");

        // Skåne is a single mainland ring.
        let skane = by_name("Skåne");
        assert_eq!(skane.iso, "SE-M");
        assert_eq!(skane.rings.len(), 1, "Skåne is one ring");

        // Gotland: an island län with a few rings.
        let gotland = by_name("Gotland");
        assert_eq!(gotland.iso, "SE-I");
        assert_eq!(gotland.rings.len(), 3);
        assert_eq!(gotland.population, 61_000);
    }

    #[test]
    fn ring_and_vertex_counts_are_exact() {
        // Hand-verified against the asset with jq/python.
        let b = load_sweden_admin().unwrap();
        assert_eq!(b.ring_count(), 59, "59 rings total across the 21 län");
        assert_eq!(b.vertex_count(), 2_422, "2422 vertices total");
    }

    #[test]
    fn every_center_and_vertex_sits_over_sweden() {
        let b = load_sweden_admin().unwrap();
        // Sweden spans ~lon 11..24, ~lat 55..69.
        for u in &b.units {
            let (clon, clat) = u.center;
            assert!((10.0..25.0).contains(&clon), "{} centre lon {clon}", u.name);
            assert!((54.0..70.0).contains(&clat), "{} centre lat {clat}", u.name);
            for ring in &u.rings {
                for &(lon, lat) in ring {
                    assert!((10.0..25.0).contains(&lon), "{} vertex lon {lon}", u.name);
                    assert!((54.0..70.0).contains(&lat), "{} vertex lat {lat}", u.name);
                }
            }
        }
    }

    #[test]
    fn dataset_bbox_covers_the_country() {
        let b = load_sweden_admin().unwrap();
        let (mnlon, mnlat, mxlon, mxlat) = b.bbox().expect("non-empty");
        // Matches the asset's true extent (python-verified): lon 11.1082..24.1634,
        // lat 55.3454..69.0364.
        assert!((11.0..11.2).contains(&mnlon), "west edge ~11.11, got {mnlon}");
        assert!((24.1..24.3).contains(&mxlon), "east edge ~24.16, got {mxlon}");
        assert!((55.3..55.4).contains(&mnlat), "south edge ~55.35, got {mnlat}");
        assert!((69.0..69.1).contains(&mxlat), "north edge ~69.04, got {mxlat}");
    }

    #[test]
    fn to_ways_makes_one_filled_silhouette_per_ring() {
        let b = load_sweden_admin().unwrap();
        let ways = b.to_ways();
        assert_eq!(ways.len(), b.ring_count(), "one Way per ring (no holes to skip)");
        assert!(ways.iter().all(|w| w.fill.is_some()), "every län ring is a filled silhouette");
        assert!(ways.iter().all(|w| w.kind == "admin"), "tagged as admin ways");
        // Vertices are preserved through Way::filled (source lonlat kept verbatim).
        let way_verts: usize = ways.iter().map(|w| w.lonlat.len()).sum();
        assert_eq!(way_verts, b.vertex_count(), "no vertices lost building the ways");
    }

    #[test]
    fn to_landmass_layer_covers_every_ring_as_the_base_fill() {
        use facett_map::layer::{LANDMASS_Z, LatLon, MapLayer, MapView};
        let b = load_sweden_admin().unwrap();
        // Every ring with ≥3 vertices becomes one land polygon (all 59 län rings do).
        let land = b.to_landmass_layer("sweden-land", egui::Color32::from_rgb(230, 226, 218));
        assert_eq!(land.polygon_count(), b.ring_count(), "one land polygon per län ring");
        assert_eq!(b.landmass_rings().len(), b.ring_count(), "landmass_rings flattens every län ring");
        // A Sweden-wide view yields land features, all at the bottom-most z (the base).
        let view = MapView::new(LatLon::new(63.0, 16.0), 4.0, (900.0, 900.0));
        let feats = land.features(&view);
        assert!(!feats.is_empty(), "the landmass draws over a country view");
        assert!(feats.iter().all(|f| f.order.rank == LANDMASS_Z), "every land polygon is the base fill (under all)");
        assert!(feats.iter().all(|f| f.fill.is_some()), "the land is filled, not outlined");
    }

    #[test]
    fn boundary_vertices_survive_the_mercator_round_trip() {
        // The län rings must project to Web Mercator and back to the same lon/lat
        // the geomap uses — otherwise the basemap wouldn't line up with the points.
        let b = load_sweden_admin().unwrap();
        let sample = &b.units[0].rings[0]; // Stockholm's first ring
        for &(lon, lat) in sample.iter().take(20) {
            let (mx, my) = crate::mercator(lon, lat);
            let (rlon, rlat) = crate::inv_mercator(mx, my);
            assert!((lon - rlon).abs() < 1e-6, "lon round-trip {lon} vs {rlon}");
            assert!((lat - rlat).abs() < 1e-6, "lat round-trip {lat} vs {rlat}");
        }
    }

    #[test]
    fn centers_returns_one_anchor_per_unit() {
        let b = load_sweden_admin().unwrap();
        assert_eq!(b.centers().len(), b.units.len());
    }

    // ── synthetic fixtures: schema strictness + geometry edge cases ────────────

    const TINY: &str = r#"{
        "dataset": "test", "country": "SE", "country_name": "Sverige",
        "admin_level_leaf": 4,
        "units": [
            { "name": "A", "name_en": "A", "admin_level": 4, "iso": "SE-A",
              "population": 100, "center": [15.0, 60.0],
              "rings": [ [[15.0,60.0],[16.0,60.0],[16.0,61.0],[15.0,60.0]] ] }
        ]
    }"#;

    #[test]
    fn parses_a_minimal_valid_fixture() {
        let b = AdminBoundaries::parse(TINY).unwrap();
        assert_eq!(b.units.len(), 1);
        let u = &b.units[0];
        assert_eq!(u.name, "A");
        assert_eq!(u.population, 100);
        assert_eq!(u.center, (15.0, 60.0));
        assert_eq!(u.rings.len(), 1);
        assert_eq!(u.vertex_count(), 4);
        assert_eq!(u.bbox(), Some((15.0, 60.0, 16.0, 61.0)));
    }

    #[test]
    fn malformed_json_is_an_error_not_a_panic() {
        let err = AdminBoundaries::parse("{ not json").unwrap_err();
        assert!(matches!(err, BoundaryError::Json(_)), "got {err:?}");
        // Display renders without panicking.
        assert!(!err.to_string().is_empty());
    }

    #[test]
    fn empty_units_is_rejected() {
        let s = r#"{"dataset":"t","country":"SE","country_name":"S","admin_level_leaf":4,"units":[]}"#;
        assert!(matches!(AdminBoundaries::parse(s), Err(BoundaryError::Schema(_))));
    }

    #[test]
    fn missing_required_field_is_a_schema_error() {
        // No `units` key at all.
        let s = r#"{"dataset":"t","country":"SE","country_name":"S","admin_level_leaf":4}"#;
        assert!(matches!(AdminBoundaries::parse(s), Err(BoundaryError::Schema(_))));
        // A unit missing its `iso`.
        let s2 = r#"{"dataset":"t","country":"SE","country_name":"S","admin_level_leaf":4,
            "units":[{"name":"A","admin_level":4,"center":[15.0,60.0],"rings":[]}]}"#;
        assert!(matches!(AdminBoundaries::parse(s2), Err(BoundaryError::Schema(_))));
    }

    #[test]
    fn bad_coordinate_shape_is_rejected() {
        // A ring vertex with 3 numbers instead of [lon, lat].
        let s = r#"{"dataset":"t","country":"SE","country_name":"S","admin_level_leaf":4,
            "units":[{"name":"A","name_en":"A","admin_level":4,"iso":"SE-A","population":1,
                      "center":[15.0,60.0],"rings":[[[15.0,60.0,99.0]]]}]}"#;
        assert!(matches!(AdminBoundaries::parse(s), Err(BoundaryError::Schema(_))));
    }

    #[test]
    fn degenerate_rings_are_skipped_when_building_ways() {
        // Two units: one real triangle, one degenerate 2-vertex ring.
        let s = r#"{"dataset":"t","country":"SE","country_name":"S","admin_level_leaf":4,
            "units":[
              {"name":"good","name_en":"good","admin_level":4,"iso":"SE-G","population":1,
               "center":[15.0,60.0],"rings":[[[15.0,60.0],[16.0,60.0],[16.0,61.0]]]},
              {"name":"degen","name_en":"degen","admin_level":4,"iso":"SE-D","population":1,
               "center":[15.0,60.0],"rings":[[[15.0,60.0],[16.0,60.0]]]}
            ]}"#;
        let b = AdminBoundaries::parse(s).unwrap();
        assert_eq!(b.ring_count(), 2, "both rings parsed");
        let ways = b.to_ways();
        assert_eq!(ways.len(), 1, "the 2-vertex ring can't be a polygon → skipped");
    }
}