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