Maps · World: marks in their wash, the legend clear of the credit
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.
This commit is contained in:
1 parent
a4ca1f04d4
commit
d52ca0e6a9
3 files changed
+127
-35
No files matched your search
+26
-12
@@ -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<Arc<[Feature]>>,
|
||||
values: impl IntoIterator<Item = (impl Into<String>, f64)>,
|
||||
) -> Self {
|
||||
let values: HashMap<String, f64> = 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::<Feature>::new(), [("a", f64::NAN)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "choropleth value inf for a")]
|
||||
fn an_endless_value_fails() {
|
||||
ChoroplethLayer::new(Vec::<Feature>::new(), [("a", f64::INFINITY)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "choropleth values repeat US")]
|
||||
fn a_key_given_twice_fails() {
|
||||
ChoroplethLayer::new(Vec::<Feature>::new(), [("US", 1.0), ("US", 100.0)]);
|
||||
}
|
||||
}
|
||||
+36
-11
@@ -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) => {
|
||||
|
||||
+65
-12
@@ -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::<Vec<_>>(),
|
||||
["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())
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user