src/osm_admin.rs
//! **Administrative-boundaries facet** — a "borders-only" world map layer built
//! from OSM, and the data behind a **trie over the world's admin divisions**
//! (world → country → region → … → municipality). This is the analysis +
//! plumbing for the korp connector that feeds a facett borders facet (Sweden's
//! 290 kommuner + 21 regioner/län, Norway's fylke+kommune, Finland's
//! maakunta+kunta, … then all of Europe, then the world).
//!
//! ## What OSM gives (and what it doesn't)
//! Admin areas are OSM **`boundary=administrative` relations** carrying
//! `admin_level` (the depth), `name` / `name:en`, `ISO3166-2`, `ref`, `wikidata`,
//! sometimes `population`. The **per-country meaning of `admin_level` differs** —
//! see [`country_division_name`]. OSM does **not** store a clean parent→child
//! tree: the hierarchy must be derived (geometric containment or `wikidata`/
//! relation membership) — that's what [`WorldAdminTrie`] is for. Authoritative
//! Swedish kommun codes/boundaries still come from Lantmäteriet/SCB; OSM is the
//! uniform *worldwide* approximation, re-imported periodically (skade snapshots
//! give the boundary history / time-travel for free).
//!
//! This module is pure (operates on a feature's **tags JSON**, way or relation),
//! so the filter + trie are tested offline; the PBF read lives in
//! [`super::osm`] behind `--features collectors-osm`.
use std::collections::BTreeMap;
use anyhow::Result;
use arrow_array::RecordBatch;
use arrow_schema::{DataType, Schema};
use serde_json::Value;
use super::arrow_util::{batch, bin_col, i64_col, schema, str_col};
/// One administrative unit extracted from an OSM feature.
#[derive(Debug, Clone, PartialEq)]
pub struct AdminUnit {
pub osm_id: i64,
pub admin_level: i64,
pub name: Option<String>,
pub name_en: Option<String>,
/// `ISO3166-2` (e.g. `SE-AB`) when present.
pub iso: Option<String>,
/// Country code (ISO 3166-1 alpha-2), derived from `ISO3166-1` or the
/// `ISO3166-2` prefix; the trie's top key.
pub country: Option<String>,
/// The per-country name for this `admin_level` (e.g. `"kommun"`).
pub division: Option<String>,
pub geometry_wkb: Option<Vec<u8>>,
}
/// Per-country `admin_level` → division name. The crux the user flagged: OSM's
/// `admin_level` is **not** uniform across countries. (Nordic focus first; a
/// generic fallback covers the rest of the world.)
pub fn country_division_name(country: &str, admin_level: i64) -> Option<&'static str> {
match (country, admin_level) {
// Sweden: 21 län/regioner (=4), 290 kommuner (=7).
("SE", 2) => Some("land"),
("SE", 4) => Some("län/region"),
("SE", 7) => Some("kommun"),
("SE", 9) => Some("stadsdel/distrikt"),
// Norway: fylke (=4), kommune (=7).
("NO", 2) => Some("land"),
("NO", 4) => Some("fylke"),
("NO", 7) => Some("kommune"),
// Finland: maakunta/landskap region (=4), seutukunta (=6), kunta (=8).
("FI", 2) => Some("maa"),
("FI", 4) => Some("maakunta/region"),
("FI", 6) => Some("seutukunta"),
("FI", 8) => Some("kunta/kommun"),
// Denmark: region (=4), kommune (=7).
("DK", 2) => Some("land"),
("DK", 4) => Some("region"),
("DK", 7) => Some("kommune"),
// Iceland: region (=4), sveitarfélag municipality (=6).
("IS", 2) => Some("land"),
("IS", 4) => Some("landshluti"),
("IS", 6) => Some("sveitarfélag"),
// Generic OSM ladder for the rest of the world.
(_, 2) => Some("country"),
(_, 3) => Some("region-group"),
(_, 4) => Some("state/region"),
(_, 5) => Some("province/district"),
(_, 6) => Some("county"),
(_, 7) => Some("municipality"),
(_, 8) => Some("municipality"),
(_, 9) => Some("ward/suburb"),
(_, 10) => Some("neighbourhood"),
_ => None,
}
}
fn tag<'a>(tags: &'a Value, key: &str) -> Option<&'a str> {
tags.get(key).and_then(|v| v.as_str())
}
/// Extract an [`AdminUnit`] from an OSM feature's tags JSON — `None` unless it is
/// a `boundary=administrative` feature with a parseable `admin_level`.
pub fn admin_unit_from_tags(
osm_id: i64,
tags_json: &str,
geometry: Option<Vec<u8>>,
) -> Option<AdminUnit> {
let tags: Value = serde_json::from_str(tags_json).ok()?;
if tag(&tags, "boundary") != Some("administrative") {
return None;
}
let admin_level: i64 = tag(&tags, "admin_level")?.trim().parse().ok()?;
let iso = tag(&tags, "ISO3166-2").map(str::to_string);
let country = tag(&tags, "ISO3166-1").map(str::to_string).or_else(|| {
iso.as_deref()
.and_then(|s| s.split('-').next())
.map(str::to_string)
});
let division = country
.as_deref()
.and_then(|c| country_division_name(c, admin_level))
.or_else(|| country_division_name("", admin_level))
.map(str::to_string);
Some(AdminUnit {
osm_id,
admin_level,
name: tag(&tags, "name").map(str::to_string),
name_en: tag(&tags, "name:en").map(str::to_string),
iso,
country,
division,
geometry_wkb: geometry,
})
}
/// Filter a batch of OSM features (id, tags_json, geometry) to admin units.
pub fn extract_admin_units(
features: impl IntoIterator<Item = (i64, String, Option<Vec<u8>>)>,
) -> Vec<AdminUnit> {
features
.into_iter()
.filter_map(|(id, tags, geom)| admin_unit_from_tags(id, &tags, geom))
.collect()
}
/// The `admin_units` table schema.
pub fn admin_schema() -> Schema {
schema(&[
("osm_id", DataType::Int64, false),
("admin_level", DataType::Int64, false),
("name", DataType::Utf8, true),
("name_en", DataType::Utf8, true),
("iso", DataType::Utf8, true),
("country", DataType::Utf8, true),
("division", DataType::Utf8, true),
("geometry_wkb", DataType::Binary, true),
])
}
/// Admin units → one Arrow `RecordBatch` (WKB borders for the facet).
pub fn admin_batch(units: &[AdminUnit]) -> Result<RecordBatch> {
let cols = vec![
i64_col(&units.iter().map(|u| Some(u.osm_id)).collect::<Vec<_>>()),
i64_col(
&units
.iter()
.map(|u| Some(u.admin_level))
.collect::<Vec<_>>(),
),
str_col(&units.iter().map(|u| u.name.clone()).collect::<Vec<_>>()),
str_col(&units.iter().map(|u| u.name_en.clone()).collect::<Vec<_>>()),
str_col(&units.iter().map(|u| u.iso.clone()).collect::<Vec<_>>()),
str_col(&units.iter().map(|u| u.country.clone()).collect::<Vec<_>>()),
str_col(&units.iter().map(|u| u.division.clone()).collect::<Vec<_>>()),
bin_col(
&units
.iter()
.map(|u| u.geometry_wkb.clone())
.collect::<Vec<_>>(),
),
];
batch(&admin_schema(), cols)
}
/// The "trie over the world": country → admin_level → unit names. A coarse index
/// for the borders facet's drill-down (world ▸ Nordics ▸ Sweden ▸ län ▸ kommun).
/// (Geometric parent→child nesting is the heavier follow-up; this groups by the
/// derived `country` + `admin_level`, the cheap structural skeleton.)
#[derive(Debug, Default)]
pub struct WorldAdminTrie {
/// country code → (admin_level → unit display names).
pub by_country: BTreeMap<String, BTreeMap<i64, Vec<String>>>,
}
impl WorldAdminTrie {
pub fn build(units: &[AdminUnit]) -> Self {
let mut t = WorldAdminTrie::default();
for u in units {
let country = u.country.clone().unwrap_or_else(|| "??".into());
let name = u
.name
.clone()
.unwrap_or_else(|| format!("osm/{}", u.osm_id));
t.by_country
.entry(country)
.or_default()
.entry(u.admin_level)
.or_default()
.push(name);
}
t
}
/// Count of units at `admin_level` in `country`.
pub fn count(&self, country: &str, admin_level: i64) -> usize {
self.by_country
.get(country)
.and_then(|m| m.get(&admin_level))
.map_or(0, Vec::len)
}
/// Number of distinct countries seen.
pub fn countries(&self) -> usize {
self.by_country.len()
}
}