From d52ca0e6a967ed2810dd55d97f27d97d174282a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 27 Sep 2026 23:35:04 -0400 Subject: [PATCH] =?UTF-8?q?Maps=20=C2=B7=20World:=20marks=20in=20their=20w?= =?UTF-8?q?ash,=20the=20legend=20clear=20of=20the=20credit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A choropleth's points and lines now draw in their wash, as its doc says, while its areas keep the page's color at their edges. The legend moves to the top left, so the attribution a license asks for stays clear at any width. A key given twice fails. WorldMap's keying by ISO code, its Natural Earth credit and its acceptance of the bundle's codes, as Kosovo's XK, each have a test; so does an endless value. --- src/maps/choropleth.rs | 38 ++++++++++++++------- src/maps/layer.rs | 47 ++++++++++++++++++++------ src/maps/world.rs | 77 +++++++++++++++++++++++++++++++++++------- 3 files changed, 127 insertions(+), 35 deletions(-) diff --git a/src/maps/choropleth.rs b/src/maps/choropleth.rs index ec81f53..274d71c 100644 --- a/src/maps/choropleth.rs +++ b/src/maps/choropleth.rs @@ -33,19 +33,21 @@ pub struct ChoroplethLayer { } impl ChoroplethLayer { - /// Values by each feature's key, its id unless `key_by` names a property; fails on a value that is not finite. + /// Values by each feature's key, its id unless `key_by` names a property; fails on a value that is not finite or a key given twice. pub fn new( features: impl Into>, values: impl IntoIterator, f64)>, ) -> Self { - let values: HashMap = values - .into_iter() - .map(|(key, value)| { - let key = key.into(); - assert!(value.is_finite(), "choropleth value {value} for {key}"); - (key, value) - }) - .collect(); + let mut kept = HashMap::new(); + for (key, value) in values { + let key: String = key.into(); + assert!(value.is_finite(), "choropleth value {value} for {key}"); + assert!( + kept.insert(key.clone(), value).is_none(), + "choropleth values repeat {key}" + ); + } + let values = kept; Self { features: features.into(), values: Rc::new(values), @@ -112,7 +114,7 @@ impl ChoroplethLayer { } } -/// A map of areas washed by value, with a legend of the scale and of no data in its corner. It takes the owner's map, with or without tiles, and lays the layer on it; values that match no area are logged once. +/// A map of areas washed by value, with a legend of the scale and of no data at its top left, clear of the zoom buttons and the attribution. It takes the owner's map, with or without tiles, and lays the layer on it; values that match no area are logged once. #[derive(IntoElement)] pub struct ChoroplethMap { id: ElementId, @@ -173,7 +175,7 @@ impl RenderOnce for ChoroplethMap { }); let legend = div() .absolute() - .bottom_2() + .top_2() .left_2() .flex() .flex_col() @@ -262,7 +264,19 @@ mod tests { #[test] #[should_panic(expected = "choropleth value NaN for a")] - fn a_value_that_is_not_finite_fails() { + fn a_value_that_is_not_a_number_fails() { ChoroplethLayer::new(Vec::::new(), [("a", f64::NAN)]); } + + #[test] + #[should_panic(expected = "choropleth value inf for a")] + fn an_endless_value_fails() { + ChoroplethLayer::new(Vec::::new(), [("a", f64::INFINITY)]); + } + + #[test] + #[should_panic(expected = "choropleth values repeat US")] + fn a_key_given_twice_fails() { + ChoroplethLayer::new(Vec::::new(), [("US", 1.0), ("US", 100.0)]); + } } diff --git a/src/maps/layer.rs b/src/maps/layer.rs index 1edea10..ec6b2a8 100644 --- a/src/maps/layer.rs +++ b/src/maps/layer.rs @@ -119,28 +119,36 @@ impl Paper { } } - /// A shape: areas washed in `wash` and edged in `ink`, points and lines in `ink`. - fn geometry(&self, geometry: &Geometry, wash: Hsla, ink: Hsla, window: &mut Window) { + /// A shape: areas washed and edged, points and lines in their mark. + fn geometry(&self, geometry: &Geometry, inks: Inks, window: &mut Window) { match geometry { - Geometry::Point(place) => self.dot(*place, ink, window), + Geometry::Point(place) => self.dot(*place, inks.mark, window), Geometry::MultiPoint(places) => places .iter() - .for_each(|place| self.dot(*place, ink, window)), - Geometry::LineString(places) => self.line(places, self.line, ink, window), + .for_each(|place| self.dot(*place, inks.mark, window)), + Geometry::LineString(places) => self.line(places, self.line, inks.mark, window), Geometry::MultiLineString(lines) => lines .iter() - .for_each(|places| self.line(places, self.line, ink, window)), - Geometry::Polygon(rings) => self.area(rings.iter(), wash, ink, window), + .for_each(|places| self.line(places, self.line, inks.mark, window)), + Geometry::Polygon(rings) => self.area(rings.iter(), inks.wash, inks.edge, window), Geometry::MultiPolygon(polygons) => { - self.area(polygons.iter().flatten(), wash, ink, window) + self.area(polygons.iter().flatten(), inks.wash, inks.edge, window) } Geometry::Collection(parts) => parts .iter() - .for_each(|part| self.geometry(part, wash, ink, window)), + .for_each(|part| self.geometry(part, inks, window)), } } } +/// How a shape is colored: its areas' wash and edge, and the mark of its points and lines. +#[derive(Clone, Copy)] +struct Inks { + wash: Hsla, + edge: Hsla, + mark: Hsla, +} + /// Something the map draws over its tiles and under its pins. #[derive(Clone)] pub enum MapLayer { @@ -161,7 +169,15 @@ impl MapLayer { let ink = paper.ink(layer.hue, "features"); for feature in layer.features.iter() { if let Some(geometry) = &feature.geometry { - paper.geometry(geometry, ink.alpha(0.25), ink, window); + paper.geometry( + geometry, + Inks { + wash: ink.alpha(0.25), + edge: ink, + mark: ink, + }, + window, + ); } } } @@ -175,7 +191,16 @@ impl MapLayer { Some(share) => ink.alpha(0.15 + 0.85 * share as f32), None => paper.colors.fg_disabled.alpha(0.2), }; - paper.geometry(geometry, wash, paper.colors.bg, window); + let edge = paper.colors.bg; + paper.geometry( + geometry, + Inks { + wash, + edge, + mark: wash, + }, + window, + ); } } MapLayer::Heat(heat) => { diff --git a/src/maps/world.rs b/src/maps/world.rs index fd10ddd..e29951f 100644 --- a/src/maps/world.rs +++ b/src/maps/world.rs @@ -53,31 +53,44 @@ impl WorldMap { } } +/// Whether a key names a country: an ISO 3166 alpha-2 code, or a code the bundle carries, as Kosovo's XK. +fn known(code: &str) -> bool { + CountryCode::for_alpha2(code).is_ok() + || COUNTRIES.iter().any(|country| iso(country) == Some(code)) +} + +/// The map with Natural Earth named in its corner, unless it names a source of its own. +fn sourced(map: MapView) -> MapView { + match map.attribution.is_some() { + true => map, + false => map.attribution("Countries: Natural Earth"), + } +} + +/// The bundled countries keyed by their ISO codes. +fn countries(values: Vec<(String, f64)>, hue: usize) -> ChoroplethLayer { + ChoroplethLayer::new(COUNTRIES.clone(), values) + .key_by("iso") + .hue(hue) +} + impl RenderOnce for WorldMap { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { for (code, _) in &self.values { - let known = CountryCode::for_alpha2(code).is_ok() - || COUNTRIES.iter().any(|country| iso(country) == Some(code)); assert!( - known, + known(code), "world map {:?}: {code} is no ISO 3166 alpha-2 code", self.id ); } - let map = match self.map.attribution.is_some() { - true => self.map, - false => self.map.attribution("Countries: Natural Earth"), - }; - let layer = ChoroplethLayer::new(COUNTRIES.clone(), self.values) - .key_by("iso") - .hue(self.hue); - ChoroplethMap::new(self.id, map, layer) + ChoroplethMap::new(self.id, sourced(self.map), countries(self.values, self.hue)) } } #[cfg(test)] mod tests { - use super::{COUNTRIES, iso}; + use super::{COUNTRIES, countries, iso, known, sourced}; + use crate::maps::{LatLon, MapView, MapViewport}; #[test] fn the_bundle_holds_the_worlds_countries_by_code() { @@ -103,4 +116,44 @@ mod tests { 2 ); } + + #[test] + fn countries_wash_by_iso_code() { + let layer = countries( + vec![("FR".into(), 10.0), ("JP".into(), 30.0), ("SG".into(), 5.0)], + 0, + ); + let france = COUNTRIES + .iter() + .find(|country| iso(country) == Some("FR")) + .expect("France"); + assert_eq!(layer.share(france), Some(0.2), "France keys by its code"); + assert_eq!( + layer.unmatched().into_iter().collect::>(), + ["SG"], + "Singapore has no country here" + ); + } + + #[test] + fn a_code_is_a_country_by_iso_or_by_the_bundle() { + assert!(known("FR")); + assert!(known("XK"), "Kosovo, in the bundle though not in ISO 3166"); + assert!( + known("SG"), + "Singapore, in ISO 3166 though not in the bundle" + ); + assert!(!known("ZZ")); + } + + #[test] + fn natural_earth_is_named_unless_the_map_names_its_source() { + let map = || MapView::new("map", MapViewport::new(LatLon::new(0.0, 0.0), 0.0)); + let source = |map: MapView| sourced(map).attribution.map(|text| text.to_string()); + assert_eq!(source(map()), Some("Countries: Natural Earth".into())); + assert_eq!( + source(map().attribution("Tiles: Ely")), + Some("Tiles: Ely".into()) + ); + } }