Tests hold that a pan keeps a focused cluster, that a cluster away from the view takes no Tab, that a zoom out folding a focused pin hands its focus to the map, and that a press shows members far apart whole. The heat test pins the quartic kernel's value halfway out; a ring of fewer than four positions fails with its own reason, and has a test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
230 lines
8.5 KiB
Rust
230 lines
8.5 KiB
Rust
use anyhow::{Context, Result, bail};
|
|
use serde_json::{Map, Value};
|
|
|
|
use super::LatLon;
|
|
|
|
/// A GeoJSON shape with its positions as places, as RFC 7946 has it.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub enum Geometry {
|
|
Point(LatLon),
|
|
MultiPoint(Vec<LatLon>),
|
|
LineString(Vec<LatLon>),
|
|
MultiLineString(Vec<Vec<LatLon>>),
|
|
/// Rings: the outer one first, then its holes.
|
|
Polygon(Vec<Vec<LatLon>>),
|
|
MultiPolygon(Vec<Vec<Vec<LatLon>>>),
|
|
Collection(Vec<Geometry>),
|
|
}
|
|
|
|
/// A GeoJSON feature: its id when it has one, its shape when it is placed, and its properties.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct Feature {
|
|
pub id: Option<String>,
|
|
pub geometry: Option<Geometry>,
|
|
pub properties: Map<String, Value>,
|
|
}
|
|
|
|
/// Reads GeoJSON: a FeatureCollection, a Feature or a bare geometry. Fails on text that is not JSON, an unknown type, a position past the globe, a line of one position or a ring that does not close.
|
|
pub fn read_geojson(text: &str) -> Result<Vec<Feature>> {
|
|
let json: Value = serde_json::from_str(text).context("the GeoJSON is not JSON")?;
|
|
match kind(&json)? {
|
|
"FeatureCollection" => json
|
|
.get("features")
|
|
.and_then(Value::as_array)
|
|
.context("a FeatureCollection without features")?
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(ix, each)| feature(each).with_context(|| format!("feature {ix}")))
|
|
.collect(),
|
|
"Feature" => Ok(vec![feature(&json)?]),
|
|
_ => Ok(vec![Feature {
|
|
id: None,
|
|
geometry: Some(geometry(&json)?),
|
|
properties: Map::new(),
|
|
}]),
|
|
}
|
|
}
|
|
|
|
fn kind(json: &Value) -> Result<&str> {
|
|
json.get("type")
|
|
.and_then(Value::as_str)
|
|
.context("a GeoJSON object without a type")
|
|
}
|
|
|
|
fn feature(json: &Value) -> Result<Feature> {
|
|
let kind = kind(json)?;
|
|
if kind != "Feature" {
|
|
bail!("a {kind} where a Feature stands");
|
|
}
|
|
let id = match json.get("id") {
|
|
None | Some(Value::Null) => None,
|
|
Some(Value::String(text)) => Some(text.clone()),
|
|
Some(Value::Number(number)) => Some(number.to_string()),
|
|
Some(other) => bail!("an id {other} that is neither text nor a number"),
|
|
};
|
|
let geometry = match json.get("geometry") {
|
|
None => bail!("a Feature without a geometry member"),
|
|
Some(Value::Null) => None,
|
|
Some(shape) => Some(geometry(shape)?),
|
|
};
|
|
let properties = match json.get("properties") {
|
|
None | Some(Value::Null) => Map::new(),
|
|
Some(Value::Object(map)) => map.clone(),
|
|
Some(other) => bail!("properties {other} that are not an object"),
|
|
};
|
|
Ok(Feature {
|
|
id,
|
|
geometry,
|
|
properties,
|
|
})
|
|
}
|
|
|
|
fn geometry(json: &Value) -> Result<Geometry> {
|
|
let coordinates = || {
|
|
json.get("coordinates")
|
|
.context("a geometry without coordinates")
|
|
};
|
|
Ok(match kind(json)? {
|
|
"Point" => Geometry::Point(position(coordinates()?)?),
|
|
"MultiPoint" => Geometry::MultiPoint(list(coordinates()?, position)?),
|
|
"LineString" => Geometry::LineString(line(coordinates()?)?),
|
|
"MultiLineString" => Geometry::MultiLineString(list(coordinates()?, line)?),
|
|
"Polygon" => Geometry::Polygon(list(coordinates()?, ring)?),
|
|
"MultiPolygon" => {
|
|
Geometry::MultiPolygon(list(coordinates()?, |polygon| list(polygon, ring))?)
|
|
}
|
|
"GeometryCollection" => Geometry::Collection(
|
|
json.get("geometries")
|
|
.and_then(Value::as_array)
|
|
.context("a GeometryCollection without geometries")?
|
|
.iter()
|
|
.map(geometry)
|
|
.collect::<Result<_>>()?,
|
|
),
|
|
other => bail!("an unknown GeoJSON type {other:?}"),
|
|
})
|
|
}
|
|
|
|
fn list<T>(json: &Value, each: impl Fn(&Value) -> Result<T>) -> Result<Vec<T>> {
|
|
json.as_array()
|
|
.with_context(|| format!("coordinates {json} that are not a list"))?
|
|
.iter()
|
|
.map(each)
|
|
.collect()
|
|
}
|
|
|
|
/// A position: longitude, then latitude; an altitude past them is dropped.
|
|
fn position(json: &Value) -> Result<LatLon> {
|
|
let numbers = json
|
|
.as_array()
|
|
.with_context(|| format!("a position {json} that is not a list"))?;
|
|
let axis = |ix: usize| {
|
|
numbers
|
|
.get(ix)
|
|
.and_then(Value::as_f64)
|
|
.with_context(|| format!("a position {json} without a number at {ix}"))
|
|
};
|
|
let (lon, lat) = (axis(0)?, axis(1)?);
|
|
if !(-180.0..=180.0).contains(&lon) || !(-90.0..=90.0).contains(&lat) {
|
|
bail!("a position {json} past the globe");
|
|
}
|
|
Ok(LatLon::new(lat, lon))
|
|
}
|
|
|
|
fn line(json: &Value) -> Result<Vec<LatLon>> {
|
|
let line = list(json, position)?;
|
|
if line.len() < 2 {
|
|
bail!("a line of {} position", line.len());
|
|
}
|
|
Ok(line)
|
|
}
|
|
|
|
fn ring(json: &Value) -> Result<Vec<LatLon>> {
|
|
let ring = list(json, position)?;
|
|
if ring.len() < 4 {
|
|
bail!(
|
|
"a ring of {} positions; a ring holds four or more",
|
|
ring.len()
|
|
);
|
|
}
|
|
if ring.first() != ring.last() {
|
|
bail!("a ring that does not close");
|
|
}
|
|
Ok(ring)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{Geometry, read_geojson};
|
|
use crate::maps::LatLon;
|
|
|
|
const SAMPLE: &str = r#"{"type": "FeatureCollection", "features": [
|
|
{"type": "Feature", "id": 7, "properties": {"name": "Spot"},
|
|
"geometry": {"type": "Point", "coordinates": [2.35, 48.86, 35.0]}},
|
|
{"type": "Feature", "id": "path", "properties": null,
|
|
"geometry": {"type": "LineString", "coordinates": [[0, 0], [10, 5]]}},
|
|
{"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [
|
|
[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]],
|
|
[[1, 1], [2, 1], [2, 2], [1, 1]]]}},
|
|
{"type": "Feature", "geometry": null}
|
|
]}"#;
|
|
|
|
#[test]
|
|
fn a_collection_reads_its_features_in_order() {
|
|
let features = read_geojson(SAMPLE).expect("valid GeoJSON");
|
|
assert_eq!(features.len(), 4);
|
|
assert_eq!(features[0].id.as_deref(), Some("7"));
|
|
assert_eq!(features[0].properties["name"], "Spot");
|
|
assert_eq!(
|
|
features[0].geometry,
|
|
Some(Geometry::Point(LatLon::new(48.86, 2.35))),
|
|
"longitude first, the altitude dropped"
|
|
);
|
|
assert_eq!(features[1].id.as_deref(), Some("path"));
|
|
assert!(features[1].properties.is_empty());
|
|
match &features[2].geometry {
|
|
Some(Geometry::Polygon(rings)) => assert_eq!((rings.len(), rings[1].len()), (2, 4)),
|
|
other => panic!("a polygon with a hole, not {other:?}"),
|
|
}
|
|
assert_eq!(features[3].geometry, None, "a feature placed nowhere");
|
|
}
|
|
|
|
#[test]
|
|
fn bare_geometries_and_collections_read() {
|
|
let multi = r#"{"type": "MultiPolygon", "coordinates": [[[[0,0],[1,0],[1,1],[0,0]]]]}"#;
|
|
assert!(matches!(
|
|
read_geojson(multi).expect("valid")[0].geometry,
|
|
Some(Geometry::MultiPolygon(_))
|
|
));
|
|
let set = r#"{"type": "GeometryCollection", "geometries": [
|
|
{"type": "MultiPoint", "coordinates": [[1, 2]]},
|
|
{"type": "MultiLineString", "coordinates": [[[0, 0], [1, 1]]]}]}"#;
|
|
match &read_geojson(set).expect("valid")[0].geometry {
|
|
Some(Geometry::Collection(parts)) => assert_eq!(parts.len(), 2),
|
|
other => panic!("a collection, not {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn broken_geojson_fails_with_its_reason() {
|
|
let reason = |text: &str| format!("{:#}", read_geojson(text).expect_err("broken"));
|
|
assert!(reason("{").contains("not JSON"));
|
|
assert!(
|
|
reason(r#"{"type": "Circle", "coordinates": [0, 0]}"#).contains("unknown GeoJSON type")
|
|
);
|
|
assert!(reason(r#"{"type": "Point", "coordinates": [200, 0]}"#).contains("past the globe"));
|
|
assert!(reason(r#"{"type": "Point", "coordinates": [0, 95]}"#).contains("past the globe"));
|
|
assert!(reason(r#"{"type": "LineString", "coordinates": [[0, 0]]}"#).contains("line of 1"));
|
|
let open = r#"{"type": "Polygon", "coordinates": [[[0,0],[1,0],[1,1],[0,1]]]}"#;
|
|
assert!(reason(open).contains("does not close"));
|
|
let short = r#"{"type": "Polygon", "coordinates": [[[0,0],[1,1],[0,0]]]}"#;
|
|
assert!(reason(short).contains("four or more"));
|
|
let named = r#"{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point"}}]}"#;
|
|
let named = reason(named);
|
|
assert!(
|
|
named.contains("feature 0") && named.contains("without coordinates"),
|
|
"{named}"
|
|
);
|
|
}
|
|
}
|