facet-wrapped/src/map.rs
//! **The map family** — the OSM 2D navigator, the OSM 3D orbit map, the geo
//! point/hotspot map, the point/line viewer.
//!
//! # One contract, and why it carries tags instead of colours
//!
//! ```json
//! { "title": "Sweden",
//! "points": [[lon, lat], …],
//! "ways": [{ "osm_id": 12, "coords": [lon,lat,lon,lat,…],
//! "tags": {"highway":"primary"} }],
//! "hotspots": [{ "lon": 18.0, "lat": 59.3, "count": 5, "label": "Sthlm" }] }
//! ```
//!
//! `coords` is a FLAT `[lon,lat,…]` run (the compact form geometry arrives in on a
//! wire); a nested `[[lon,lat],…]` form is accepted too. `tags` accepts a real object
//! **or** the raw JSON string a server sends.
//!
//! Those tags are handed straight to [`facett_map::Way::from_lonlat`], which is the
//! single most important line in this module: the COMPONENT classifies and styles the
//! way from its own OSM rules. The payload has no `color`, no `width`, no feature
//! class — a caller that can paint a facett map is a second styling implementation,
//! and it starts drifting the day it is written.
use facett_core::Facet;
use serde_json::Value;
use crate::registry::{Entry, MAP, Registry};
/// Register the map family.
pub(crate) fn register(r: &mut Registry) {
r.register(
Entry::new(
"osm2d",
"The OSM 2D navigator — pan/zoom Mercator map over real OSM ways + points.",
|| Box::new(facett_geomap::OsmView::local()),
)
.in_group(MAP)
.with_data(osm2d),
);
r.register(
Entry::new(
"osm3d",
"The OSM 3D orbit map — the same ways, extruded into a navigable city.",
|| Box::new(facett_map3d::Map3D::local()),
)
.in_group(MAP)
.with_data(osm3d),
);
r.register(
Entry::new("geomap", "A geo point/hotspot map (counts bubbled over a coastline).", || {
Box::new(facett_geomap::GeoMap::new("Geo", Vec::new(), Vec::new()).with_gpu(true))
})
.in_group(MAP)
.with_data(geomap),
);
r.register(
Entry::new("map", "The point/line map viewer.", || {
Box::new(facett_map::MapView::new("Map", Vec::new()))
})
.in_group(MAP)
.with_data(map),
);
}
fn title_of(v: &Value, fallback: &str) -> String {
v.get("title")
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.unwrap_or(fallback)
.to_string()
}
/// Parse `points: [[lon,lat], …]` (also accepts `{"lon":…,"lat":…}` objects).
fn points_of(v: &Value) -> Vec<(f64, f64)> {
let Some(arr) = v.get("points").and_then(Value::as_array) else { return Vec::new() };
arr.iter()
.filter_map(|p| {
if let Some(pair) = p.as_array() {
Some((pair.first()?.as_f64()?, pair.get(1)?.as_f64()?))
} else {
Some((p.get("lon")?.as_f64()?, p.get("lat")?.as_f64()?))
}
})
.filter(|(lon, lat)| lon.is_finite() && lat.is_finite())
.collect()
}
/// Normalise a way's `tags`: a real object, or the raw JSON string a server sends.
fn tags_of(v: Option<&Value>) -> Vec<(String, String)> {
let obj = match v {
Some(Value::Object(m)) => m.clone(),
Some(Value::String(s)) if !s.trim().is_empty() => match serde_json::from_str(s) {
Ok(Value::Object(m)) => m,
_ => return Vec::new(),
},
_ => return Vec::new(),
};
obj.into_iter()
.map(|(k, val)| {
let s = match val {
Value::String(s) => s,
other => other.to_string(),
};
(k, s)
})
.collect()
}
/// One way's vertices: the FLAT `[lon,lat,…]` run, or a nested `[[lon,lat],…]` form.
fn way_lonlat(w: &Value) -> Vec<(f64, f64)> {
let Some(flat) = w.get("coords").and_then(Value::as_array) else { return Vec::new() };
if flat.first().is_some_and(Value::is_array) {
return flat
.iter()
.filter_map(|p| {
let a = p.as_array()?;
Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?))
})
.collect();
}
let nums: Vec<f64> = flat.iter().filter_map(Value::as_f64).collect();
nums.as_chunks::<2>().0.iter().map(|c| (c[0], c[1])).collect()
}
/// Build REAL [`facett_map::Way`]s — classified + styled by the component's own OSM
/// rules from the caller's tags.
fn ways_of(v: &Value) -> Vec<facett_map::Way> {
let Some(arr) = v.get("ways").and_then(Value::as_array) else { return Vec::new() };
let theme = facett_core::Theme::default();
arr.iter()
.filter_map(|w| {
let lonlat = way_lonlat(w);
if lonlat.len() < 2 {
return None;
}
let osm_id = w.get("osm_id").and_then(Value::as_i64).unwrap_or(0);
facett_map::Way::from_lonlat(osm_id, &lonlat, &tags_of(w.get("tags")), &theme)
})
.collect()
}
/// **`osm2d`** — [`facett_geomap::OsmView`].
#[must_use]
pub fn osm2d(v: &Value) -> Option<Box<dyn Facet>> {
let points = points_of(v);
let ways = ways_of(v);
if points.is_empty() && ways.is_empty() {
return None;
}
// SELECT the GPU line lane (the `osm3d` defect below, one crate over: the renderer
// in the binary is not a lane in use). The CPU painter stays as the fallback when
// no renderer is installed; `gpu_lines` in the state says which one drew.
Some(Box::new(facett_geomap::OsmView::new(title_of(v, "OSM 2D"), points, ways).with_gpu(true)))
}
/// The extrusion lift applied to closed area/building rings. `0.18` is the crate's
/// own "chunky city" calibration — the wrapper picks the component's number rather
/// than inventing a second one, and a caller may override it with `height`.
const OSM3D_HEIGHT: f32 = 0.18;
/// **`osm3d`** — [`facett_map3d::Map3D`], the SAME ways as `osm2d`, extruded.
///
/// Deliberately the identical payload contract: a host that can already feed the 2D
/// navigator switches to the 3D one by changing the component name and nothing else.
/// Points alone cannot make a city, so this refuses a payload with no ways — where
/// `osm2d` would still have a point cloud to draw, `osm3d` would have an empty scene,
/// and an empty scene that renders is exactly the blank-but-green failure this crate
/// refuses everywhere else.
#[must_use]
pub fn osm3d(v: &Value) -> Option<Box<dyn Facet>> {
let ways = ways_of(v);
if ways.is_empty() {
return None;
}
let height = v
.get("height")
.and_then(Value::as_f64)
.map_or(OSM3D_HEIGHT, |h| h as f32)
.clamp(0.0, 10.0);
// SELECT the GPU lane. `facet-wrapped/Cargo.toml` enables `facett-map3d/wgpu`, and
// that only puts the renderer IN THE BINARY — `Map3D` still starts with
// `use_gpu: false` and a `CpuPainterBackend`. Measured on the bundle with the
// feature already on and this line missing: `gpu_lane_selected: false`,
// `backend: "cpu-painter"`. Every flag said yes and every triangle was still
// projected, culled and lit in software behind a WebGPU surface.
//
// `with_gpu` is `#[cfg(feature = "wgpu")]`, so this line is also the compile-time
// proof that the feature is on: remove it from the manifest and this stops
// building, by name. That is deliberate — a silent fall back to the CPU painter
// is exactly what row 3 exists to end.
//
// The CPU painter is NOT gone: if the host has not installed a `Map3dRenderer`
// into its `RenderState`, the callback no-ops and the CPU path still draws. This
// adds a lane, it does not remove the fallback.
Some(Box::new(
facett_map3d::Map3D::from_ways(title_of(v, "OSM 3D"), &ways, height).with_gpu(true),
))
}
/// **`geomap`** — [`facett_geomap::GeoMap`], points + counted hotspots.
#[must_use]
pub fn geomap(v: &Value) -> Option<Box<dyn Facet>> {
let points = points_of(v);
let hotspots: Vec<facett_geomap::Hotspot> = v
.get("hotspots")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|h| {
Some(facett_geomap::Hotspot::new(
h.get("lon")?.as_f64()?,
h.get("lat")?.as_f64()?,
h.get("count").and_then(Value::as_u64).unwrap_or(0) as usize,
h.get("label").and_then(Value::as_str).unwrap_or(""),
))
})
.collect()
})
.unwrap_or_default();
if points.is_empty() && hotspots.is_empty() {
return None;
}
// The GPU cloud lane (ways, points, hotspot discs; `lane` in the state says which drew).
Some(Box::new(facett_geomap::GeoMap::new(title_of(v, "Geo"), points, hotspots).with_gpu(true)))
}
/// **`map`** — [`facett_map::MapView`], the point/line viewer.
#[must_use]
pub fn map(v: &Value) -> Option<Box<dyn Facet>> {
let points = points_of(v);
if points.is_empty() {
return None;
}
Some(Box::new(facett_map::MapView::new(title_of(v, "Map"), points).with_gpu(true)))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// The wire shape geometry actually arrives in: flat coords + tags-as-JSON-string.
#[test]
fn osm2d_builds_from_flat_coords_and_string_tags() {
let v = json!({
"title": "Sweden",
"points": [[18.06, 59.33], [13.0, 55.6]],
"ways": [{ "osm_id": 7, "coords": [18.0, 59.3, 18.1, 59.4, 18.2, 59.5],
"tags": "{\"highway\":\"motorway\"}" }]
});
let f = osm2d(&v).expect("builds");
assert_eq!(f.title(), "Sweden");
let st = f.state_json();
assert!(st.is_object(), "{st}");
}
#[test]
fn ways_accept_object_tags_and_nested_coords() {
let v = json!({
"ways": [{ "osm_id": 1, "coords": [[9.5, 47.1], [9.6, 47.2]],
"tags": { "natural": "water" } }]
});
assert!(osm2d(&v).is_some());
}
/// The classification is the COMPONENT's: a motorway and a lake must not land on
/// the same style even though the caller never mentioned a colour.
#[test]
fn the_component_styles_the_way_from_its_tags() {
let theme = facett_core::Theme::default();
let road = facett_map::Way::from_lonlat(
1,
&[(18.0, 59.3), (18.1, 59.4)],
&[("highway", "motorway")],
&theme,
)
.expect("road");
let water = facett_map::Way::from_lonlat(
2,
&[(18.0, 59.3), (18.1, 59.4)],
&[("natural", "water")],
&theme,
)
.expect("water");
assert_ne!(road.kind, water.kind, "tags drove different feature classes");
assert!(road.stroke != water.stroke || road.fill != water.fill, "and different paint");
}
#[test]
fn geomap_and_map_build_from_points() {
let v = json!({ "points": [[18.06, 59.33], [13.0, 55.6]] });
assert!(geomap(&v).is_some());
assert!(map(&v).is_some());
assert!(geomap(&json!({
"hotspots": [{ "lon": 18.0, "lat": 59.3, "count": 5, "label": "Sthlm" }]
}))
.is_some());
}
#[test]
fn empty_or_degenerate_payloads_yield_none() {
assert!(osm2d(&json!({})).is_none());
assert!(osm2d(&json!({ "points": [], "ways": [] })).is_none());
assert!(osm2d(&json!({ "ways": [{ "coords": [1.0, 2.0] }] })).is_none(), "one vertex");
assert!(map(&json!({})).is_none());
assert!(geomap(&json!({})).is_none());
}
/// The 3D map takes the SAME payload as the 2D one — that is the whole point of
/// registering it: a host switches dimension by changing a name.
#[test]
fn osm3d_builds_from_the_very_same_payload_as_osm2d() {
let v = json!({
"title": "Sigtuna",
"points": [[17.72, 59.62]],
"ways": [
{ "osm_id": 1, "coords": [17.71, 59.61, 17.72, 59.61, 17.72, 59.62, 17.71, 59.61],
"tags": { "building": "yes" } },
{ "osm_id": 2, "coords": [17.70, 59.60, 17.73, 59.63],
"tags": "{\"highway\":\"primary\"}" }
]
});
assert!(osm2d(&v).is_some(), "the 2D navigator takes it");
let f = osm3d(&v).expect("and so does the 3D one");
assert_eq!(f.title(), "Sigtuna");
let st = f.state_json();
assert!(st.get("triangles").and_then(|t| t.as_u64()).is_some_and(|n| n > 0),
"the ways must have extruded into real geometry: {st}");
}
/// Points alone cannot make a city. `osm2d` still has a point cloud to draw;
/// `osm3d` would render an empty scene, so it must refuse instead.
#[test]
fn osm3d_refuses_a_payload_with_no_ways() {
let points_only = json!({ "points": [[18.06, 59.33], [13.0, 55.6]] });
assert!(osm2d(&points_only).is_some(), "2D can draw a point cloud");
assert!(osm3d(&points_only).is_none(), "3D must refuse rather than show an empty city");
assert!(osm3d(&json!({})).is_none());
}
/// A caller may tune the extrusion, but never out of range.
#[test]
fn osm3d_height_is_caller_tunable_and_clamped() {
let mut v = json!({
"ways": [{ "osm_id": 1, "coords": [17.71, 59.61, 17.72, 59.61, 17.72, 59.62, 17.71, 59.61],
"tags": { "building": "yes" } }]
});
assert!(osm3d(&v).is_some(), "default height");
v["height"] = json!(-5.0);
assert!(osm3d(&v).is_some(), "a negative lift clamps rather than panics");
v["height"] = json!(1e9);
assert!(osm3d(&v).is_some(), "an absurd lift clamps too");
}
#[test]
fn tags_parse_from_string_and_object_alike() {
let from_str = tags_of(Some(&json!("{\"highway\":\"primary\"}")));
assert_eq!(from_str, vec![("highway".to_string(), "primary".to_string())]);
assert_eq!(tags_of(Some(&json!({ "highway": "primary" }))), from_str);
assert!(tags_of(Some(&json!("not json"))).is_empty());
assert!(tags_of(None).is_empty());
}
}