Maps · World: choropleths and the world's countries
ChoroplethLayer washes each area by its value, keyed by its id or a property's text or number, on one hue's scale from its faintest at the least value to its strongest at the most; an area without a value reads as no data. ChoroplethMap lays it on the owner's map, with or without tiles, and puts a legend of the scale and of no data in the corner; a value that matches no area is logged once. WorldMap washes Natural Earth's 110m countries, bundled trimmed by scripts/countries.py, by ISO 3166 alpha-2 code, and fails on a key that is no code. A shape now paints as one fill and one edge path per world in view.
This commit is contained in:
1 parent
a64b78b673
commit
a4ca1f04d4
14 files changed
+612
-35
No files matched your search
@@ -0,0 +1,268 @@
|
||||
use std::{
|
||||
collections::{BTreeSet, HashMap, HashSet},
|
||||
rc::Rc,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
|
||||
linear_color_stop, linear_gradient,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{Feature, MapView};
|
||||
use crate::{
|
||||
theme::{ActiveTheme, Radius, TextSize},
|
||||
typography::format::{Separators, number},
|
||||
};
|
||||
|
||||
/// How a feature finds its value: by its id, or by a property's text or number.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ChoroplethKey {
|
||||
Id,
|
||||
Property(SharedString),
|
||||
}
|
||||
|
||||
/// Areas washed each by its value, from the hue's faintest at the least value to its strongest at the most; an area without a value reads as no data. Its points and lines draw in their wash.
|
||||
#[derive(Clone)]
|
||||
pub struct ChoroplethLayer {
|
||||
pub(crate) features: Arc<[Feature]>,
|
||||
values: Rc<HashMap<String, f64>>,
|
||||
key: ChoroplethKey,
|
||||
pub(crate) hue: usize,
|
||||
}
|
||||
|
||||
impl ChoroplethLayer {
|
||||
/// Values by each feature's key, its id unless `key_by` names a property; fails on a value that is not finite.
|
||||
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();
|
||||
Self {
|
||||
features: features.into(),
|
||||
values: Rc::new(values),
|
||||
key: ChoroplethKey::Id,
|
||||
hue: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds each feature's value by a property's text or number.
|
||||
pub fn key_by(mut self, property: impl Into<SharedString>) -> Self {
|
||||
self.key = ChoroplethKey::Property(property.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// The chart hue the scale runs to.
|
||||
pub fn hue(mut self, hue: usize) -> Self {
|
||||
self.hue = hue;
|
||||
self
|
||||
}
|
||||
|
||||
/// The least and the most value; none without values.
|
||||
pub(crate) fn span(&self) -> Option<(f64, f64)> {
|
||||
let mut values = self.values.values().copied();
|
||||
let first = values.next()?;
|
||||
Some(values.fold((first, first), |(least, most), value| {
|
||||
(least.min(value), most.max(value))
|
||||
}))
|
||||
}
|
||||
|
||||
/// A feature's key: its id, or its property's text or number.
|
||||
pub(crate) fn key_of(&self, feature: &Feature) -> Option<String> {
|
||||
match &self.key {
|
||||
ChoroplethKey::Id => feature.id.clone(),
|
||||
ChoroplethKey::Property(name) => match feature.properties.get(name.as_ref())? {
|
||||
Value::String(text) => Some(text.clone()),
|
||||
Value::Number(number) => Some(number.to_string()),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a feature's value lies in the span, 0 at the least and 1 at the most, 1 when they are one; none without a value.
|
||||
pub(crate) fn share(&self, feature: &Feature) -> Option<f64> {
|
||||
let value = *self.values.get(&self.key_of(feature)?)?;
|
||||
let (least, most) = self.span()?;
|
||||
Some(match most > least {
|
||||
true => (value - least) / (most - least),
|
||||
false => 1.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// The keys of values that match no feature, in order.
|
||||
pub(crate) fn unmatched(&self) -> BTreeSet<String> {
|
||||
let keys: HashSet<String> = self
|
||||
.features
|
||||
.iter()
|
||||
.filter_map(|feature| self.key_of(feature))
|
||||
.collect();
|
||||
self.values
|
||||
.keys()
|
||||
.filter(|key| !keys.contains(*key))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ChoroplethMap {
|
||||
id: ElementId,
|
||||
map: MapView,
|
||||
layer: ChoroplethLayer,
|
||||
}
|
||||
|
||||
impl ChoroplethMap {
|
||||
pub fn new(id: impl Into<ElementId>, map: MapView, layer: ChoroplethLayer) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
map,
|
||||
layer,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A legend number: tenths across a span under ten, whole numbers past it.
|
||||
fn label(value: f64, (least, most): (f64, f64)) -> String {
|
||||
number(
|
||||
value,
|
||||
if most - least < 10.0 { 1 } else { 0 },
|
||||
Separators::EN,
|
||||
)
|
||||
}
|
||||
|
||||
impl RenderOnce for ChoroplethMap {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let logged =
|
||||
window.use_keyed_state((self.id.clone(), "unmatched"), cx, |_, _| BTreeSet::new());
|
||||
let unmatched = self.layer.unmatched();
|
||||
if !unmatched.is_empty() && *logged.read(cx) != unmatched {
|
||||
log::warn!("choropleth {:?}: no area for {unmatched:?}", self.id);
|
||||
logged.update(cx, |logged, _| *logged = unmatched);
|
||||
}
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let ink = colors.hue(self.layer.hue, format_args!("choropleth {:?}", self.id));
|
||||
let small = |text: String| div().flex_none().child(text);
|
||||
let scale = self.layer.span().map(|span| {
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.child(small(label(span.0, span)))
|
||||
.child(
|
||||
div()
|
||||
.w(theme.maps().legend)
|
||||
.h(theme.maps().dot)
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.bg(linear_gradient(
|
||||
90.0,
|
||||
linear_color_stop(ink.alpha(0.15), 0.0),
|
||||
linear_color_stop(ink, 1.0),
|
||||
)),
|
||||
)
|
||||
.child(small(label(span.1, span)))
|
||||
});
|
||||
let legend = div()
|
||||
.absolute()
|
||||
.bottom_2()
|
||||
.left_2()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.p_2()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.bg(colors.overlay.alpha(0.9))
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_muted)
|
||||
.children(scale)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
div()
|
||||
.size(theme.maps().dot)
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.bg(colors.fg_disabled.alpha(0.2)),
|
||||
)
|
||||
.child("No data"),
|
||||
);
|
||||
div()
|
||||
.relative()
|
||||
.h_full()
|
||||
.child(self.map.layer(self.layer))
|
||||
.child(legend)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::{Map, json};
|
||||
|
||||
use super::ChoroplethLayer;
|
||||
use crate::maps::{Feature, Geometry, LatLon};
|
||||
|
||||
fn feature(id: Option<&str>, properties: serde_json::Value) -> Feature {
|
||||
let properties: Map<String, serde_json::Value> =
|
||||
serde_json::from_value(properties).expect("an object");
|
||||
Feature {
|
||||
id: id.map(str::to_string),
|
||||
geometry: Some(Geometry::Point(LatLon::new(0.0, 0.0))),
|
||||
properties,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_feature_finds_its_share_of_the_span_by_id_or_property() {
|
||||
let features = vec![
|
||||
feature(Some("a"), json!({"code": "AA", "rank": 3})),
|
||||
feature(Some("b"), json!({"code": "BB"})),
|
||||
feature(None, json!({"code": "CC"})),
|
||||
];
|
||||
let by_id = ChoroplethLayer::new(features.clone(), [("a", 10.0), ("b", 30.0)]);
|
||||
assert_eq!(by_id.span(), Some((10.0, 30.0)));
|
||||
assert_eq!(by_id.share(&features[0]), Some(0.0));
|
||||
assert_eq!(by_id.share(&features[1]), Some(1.0));
|
||||
assert_eq!(by_id.share(&features[2]), None, "no id, no value");
|
||||
let by_code =
|
||||
ChoroplethLayer::new(features.clone(), [("CC", 5.0), ("BB", 15.0), ("DD", 10.0)])
|
||||
.key_by("code");
|
||||
assert_eq!(by_code.share(&features[2]), Some(0.0));
|
||||
assert_eq!(
|
||||
by_code.share(&features[0]),
|
||||
None,
|
||||
"a feature without a value reads as no data"
|
||||
);
|
||||
assert_eq!(by_code.unmatched().into_iter().collect::<Vec<_>>(), ["DD"]);
|
||||
let by_rank = ChoroplethLayer::new(features.clone(), [("3", 1.0)]).key_by("rank");
|
||||
assert_eq!(
|
||||
by_rank.share(&features[0]),
|
||||
Some(1.0),
|
||||
"a number property keys as text; one value fills the scale"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legend_numbers_keep_tenths_across_a_narrow_span() {
|
||||
assert_eq!(super::label(2.5, (0.0, 5.0)), "2.5");
|
||||
assert_eq!(super::label(12_345.6, (0.0, 20_000.0)), "12,346");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "choropleth value NaN for a")]
|
||||
fn a_value_that_is_not_finite_fails() {
|
||||
ChoroplethLayer::new(Vec::<Feature>::new(), [("a", f64::NAN)]);
|
||||
}
|
||||
}
|
||||
+58
-26
@@ -1,4 +1,4 @@
|
||||
use std::rc::Rc;
|
||||
use std::{rc::Rc, sync::Arc};
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, Bounds, Hsla, IntoElement, PathBuilder, Pixels, Point, Styled, Window, canvas,
|
||||
@@ -7,6 +7,7 @@ use gpui::{
|
||||
|
||||
use super::{
|
||||
LatLon, MapViewport,
|
||||
choropleth::ChoroplethLayer,
|
||||
geojson::{Feature, Geometry},
|
||||
heat::density,
|
||||
};
|
||||
@@ -75,29 +76,39 @@ impl Paper {
|
||||
}
|
||||
}
|
||||
|
||||
/// An area's rings washed as one path, so holes stay open, and outlined.
|
||||
fn area(&self, rings: &[Vec<LatLon>], ink: Hsla, window: &mut Window) {
|
||||
/// A shape's rings washed as one path, so holes stay open, and edged as one more, on each world in view.
|
||||
fn area<'a>(
|
||||
&self,
|
||||
rings: impl Iterator<Item = &'a Vec<LatLon>>,
|
||||
wash: Hsla,
|
||||
edge: Hsla,
|
||||
window: &mut Window,
|
||||
) {
|
||||
let runs: Vec<Vec<(f32, f32)>> = rings
|
||||
.iter()
|
||||
.map(|ring| self.view.run(ring, self.size, self.tile))
|
||||
.collect();
|
||||
let Some(outer) = runs.first() else {
|
||||
let every: Vec<(f32, f32)> = runs.iter().flatten().copied().collect();
|
||||
if every.is_empty() {
|
||||
return;
|
||||
};
|
||||
for shift in self.copies(outer, 0.0) {
|
||||
let mut path = PathBuilder::fill();
|
||||
}
|
||||
for shift in self.copies(&every, 0.0) {
|
||||
let (mut wash_path, mut edge_path) =
|
||||
(PathBuilder::fill(), PathBuilder::stroke(self.line));
|
||||
for run in &runs {
|
||||
let mut points = run.iter().map(|(x, y)| at(self.origin, (x + shift, *y)));
|
||||
if let Some(first) = points.next() {
|
||||
path.move_to(first);
|
||||
points.for_each(|next| path.line_to(next));
|
||||
path.close();
|
||||
let points: Vec<_> = run
|
||||
.iter()
|
||||
.map(|(x, y)| at(self.origin, (x + shift, *y)))
|
||||
.collect();
|
||||
if let Some((first, rest)) = points.split_first() {
|
||||
for path in [&mut wash_path, &mut edge_path] {
|
||||
path.move_to(*first);
|
||||
rest.iter().for_each(|next| path.line_to(*next));
|
||||
path.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
finish(path, ink.alpha(0.25), window);
|
||||
for run in &runs {
|
||||
self.stroke(run, shift, self.line, ink, window);
|
||||
}
|
||||
finish(wash_path, wash, window);
|
||||
finish(edge_path, edge, window);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +119,8 @@ impl Paper {
|
||||
}
|
||||
}
|
||||
|
||||
fn geometry(&self, geometry: &Geometry, ink: Hsla, window: &mut Window) {
|
||||
/// 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) {
|
||||
match geometry {
|
||||
Geometry::Point(place) => self.dot(*place, ink, window),
|
||||
Geometry::MultiPoint(places) => places
|
||||
@@ -118,13 +130,13 @@ impl Paper {
|
||||
Geometry::MultiLineString(lines) => lines
|
||||
.iter()
|
||||
.for_each(|places| self.line(places, self.line, ink, window)),
|
||||
Geometry::Polygon(rings) => self.area(rings, ink, window),
|
||||
Geometry::MultiPolygon(polygons) => polygons
|
||||
.iter()
|
||||
.for_each(|rings| self.area(rings, ink, window)),
|
||||
Geometry::Polygon(rings) => self.area(rings.iter(), wash, ink, window),
|
||||
Geometry::MultiPolygon(polygons) => {
|
||||
self.area(polygons.iter().flatten(), wash, ink, window)
|
||||
}
|
||||
Geometry::Collection(parts) => parts
|
||||
.iter()
|
||||
.for_each(|part| self.geometry(part, ink, window)),
|
||||
.for_each(|part| self.geometry(part, wash, ink, window)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,6 +147,7 @@ pub enum MapLayer {
|
||||
Route(RouteLine),
|
||||
Features(GeoJsonLayer),
|
||||
Heat(GeoHeatmap),
|
||||
Choropleth(ChoroplethLayer),
|
||||
}
|
||||
|
||||
impl MapLayer {
|
||||
@@ -148,10 +161,23 @@ 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, window);
|
||||
paper.geometry(geometry, ink.alpha(0.25), ink, window);
|
||||
}
|
||||
}
|
||||
}
|
||||
MapLayer::Choropleth(layer) => {
|
||||
let ink = paper.colors.hue(layer.hue, "choropleth");
|
||||
for feature in layer.features.iter() {
|
||||
let Some(geometry) = &feature.geometry else {
|
||||
continue;
|
||||
};
|
||||
let wash = match layer.share(feature) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
MapLayer::Heat(heat) => {
|
||||
let ink = paper.ink(heat.hue, "heat");
|
||||
let mut points = Vec::new();
|
||||
@@ -203,12 +229,12 @@ impl RouteLine {
|
||||
/// GeoJSON features in one hue: areas washed and outlined with their holes open, lines stroked, points dotted.
|
||||
#[derive(Clone)]
|
||||
pub struct GeoJsonLayer {
|
||||
features: Rc<[Feature]>,
|
||||
features: Arc<[Feature]>,
|
||||
hue: Option<usize>,
|
||||
}
|
||||
|
||||
impl GeoJsonLayer {
|
||||
pub fn new(features: impl Into<Rc<[Feature]>>) -> Self {
|
||||
pub fn new(features: impl Into<Arc<[Feature]>>) -> Self {
|
||||
Self {
|
||||
features: features.into(),
|
||||
hue: None,
|
||||
@@ -254,6 +280,12 @@ impl From<GeoJsonLayer> for MapLayer {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ChoroplethLayer> for MapLayer {
|
||||
fn from(layer: ChoroplethLayer) -> Self {
|
||||
MapLayer::Choropleth(layer)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GeoHeatmap> for MapLayer {
|
||||
fn from(heat: GeoHeatmap) -> Self {
|
||||
MapLayer::Heat(heat)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod choropleth;
|
||||
mod cluster;
|
||||
mod coords;
|
||||
mod geo;
|
||||
@@ -10,7 +11,9 @@ mod picker;
|
||||
mod tests;
|
||||
mod tile;
|
||||
mod view;
|
||||
mod world;
|
||||
|
||||
pub use choropleth::{ChoroplethKey, ChoroplethLayer, ChoroplethMap};
|
||||
pub use coords::{CoordFormat, CoordinateDisplay};
|
||||
pub use geo::{LatLon, MAX_LAT, MapViewport, Tile};
|
||||
pub use geojson::{Feature, Geometry, read_geojson};
|
||||
@@ -18,3 +21,4 @@ pub use layer::{GeoHeatmap, GeoJsonLayer, MapLayer, RouteLine};
|
||||
pub use marker::{MapMarker, MapPopup};
|
||||
pub use picker::LocationPicker;
|
||||
pub use view::MapView;
|
||||
pub use world::WorldMap;
|
||||
@@ -2,7 +2,8 @@ use gpui::{AnyElement, Entity, IntoElement, TestAppContext};
|
||||
|
||||
use super::{SIZE, Stage, TILE, focus_map, hear, last, moves, press, said, say, stage};
|
||||
use crate::maps::{
|
||||
GeoHeatmap, GeoJsonLayer, LatLon, MapMarker, MapView, MapViewport, RouteLine, read_geojson,
|
||||
GeoHeatmap, GeoJsonLayer, LatLon, MapMarker, MapView, MapViewport, RouteLine, WorldMap,
|
||||
read_geojson,
|
||||
};
|
||||
|
||||
const CROWD: [(&str, f64, f64); 3] = [
|
||||
@@ -203,3 +204,35 @@ fn a_clusters_press_shows_far_members_whole(cx: &mut TestAppContext) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn world(_: &Stage, owner: Entity<Stage>) -> AnyElement {
|
||||
let map = MapView::new("map", MapViewport::new(LatLon::new(20.0, 0.0), 0.6))
|
||||
.on_viewport(move |view, _, cx| hear(&owner, view, cx));
|
||||
WorldMap::new("world", map, [("US", 120.0), ("FR", 30.0), ("SG", 8.0)])
|
||||
.hue(2)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The world's countries wash by their values, Singapore has no country at this scale, and the map still moves.
|
||||
#[gpui::test]
|
||||
fn a_world_map_washes_its_countries(cx: &mut TestAppContext) {
|
||||
let (host, cx) = stage(world, cx);
|
||||
focus_map(cx);
|
||||
press("left", cx);
|
||||
assert_eq!(moves(&host, cx), 1);
|
||||
}
|
||||
|
||||
fn misnamed(_: &Stage, _: Entity<Stage>) -> AnyElement {
|
||||
WorldMap::new(
|
||||
"world",
|
||||
MapView::new("map", MapViewport::new(LatLon::new(0.0, 0.0), 0.0)),
|
||||
[("ZZ", 1.0)],
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
#[should_panic(expected = "ZZ is no ISO 3166 alpha-2 code")]
|
||||
fn a_world_map_refuses_a_code_that_is_no_country(cx: &mut TestAppContext) {
|
||||
stage(misnamed, cx);
|
||||
}
|
||||
+1
-1
@@ -46,7 +46,7 @@ pub struct MapView {
|
||||
pub(crate) viewport: MapViewport,
|
||||
tiles: Option<TileSource>,
|
||||
zooms: (u8, u8),
|
||||
attribution: Option<SharedString>,
|
||||
pub(crate) attribution: Option<SharedString>,
|
||||
layers: Vec<MapLayer>,
|
||||
markers: Vec<MapMarker>,
|
||||
gathered: bool,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use gpui::{App, ElementId, IntoElement, RenderOnce, Window};
|
||||
use isocountry::CountryCode;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{ChoroplethLayer, ChoroplethMap, Feature, MapView, read_geojson};
|
||||
use crate::Assets;
|
||||
|
||||
/// Natural Earth's 110m countries, read once from the bundle: each keeps its name and, but for two, its ISO 3166 alpha-2 code as `iso`.
|
||||
pub(crate) static COUNTRIES: LazyLock<Arc<[Feature]>> = LazyLock::new(|| {
|
||||
let file = Assets::get("maps/countries.geojson").expect("the world's countries are bundled");
|
||||
let text = std::str::from_utf8(&file.data).expect("the countries are UTF-8");
|
||||
read_geojson(text)
|
||||
.expect("the bundled countries read")
|
||||
.into()
|
||||
});
|
||||
|
||||
fn iso(country: &Feature) -> Option<&str> {
|
||||
country.properties.get("iso").and_then(Value::as_str)
|
||||
}
|
||||
|
||||
/// The world's countries, washed each by its value keyed by ISO 3166 alpha-2 code, with a legend; Natural Earth is named in the corner unless the map names a source. Fails on a key that is no ISO code; a code with no country at this scale draws nowhere and is logged once.
|
||||
#[derive(IntoElement)]
|
||||
pub struct WorldMap {
|
||||
id: ElementId,
|
||||
map: MapView,
|
||||
values: Vec<(String, f64)>,
|
||||
hue: usize,
|
||||
}
|
||||
|
||||
impl WorldMap {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
map: MapView,
|
||||
values: impl IntoIterator<Item = (impl Into<String>, f64)>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
map,
|
||||
values: values
|
||||
.into_iter()
|
||||
.map(|(code, value)| (code.into(), value))
|
||||
.collect(),
|
||||
hue: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The chart hue the scale runs to.
|
||||
pub fn hue(mut self, hue: usize) -> Self {
|
||||
self.hue = hue;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{COUNTRIES, iso};
|
||||
|
||||
#[test]
|
||||
fn the_bundle_holds_the_worlds_countries_by_code() {
|
||||
assert_eq!(COUNTRIES.len(), 177);
|
||||
let named = |code: &str| {
|
||||
COUNTRIES
|
||||
.iter()
|
||||
.find(|country| iso(country) == Some(code))
|
||||
.and_then(|country| country.properties.get("name"))
|
||||
.and_then(|name| name.as_str())
|
||||
};
|
||||
assert_eq!(
|
||||
named("FR"),
|
||||
Some("France"),
|
||||
"France keeps its code though Natural Earth's ISO_A2 lacks it"
|
||||
);
|
||||
assert_eq!(named("JP"), Some("Japan"));
|
||||
assert_eq!(
|
||||
COUNTRIES
|
||||
.iter()
|
||||
.filter(|country| iso(country).is_none())
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ pub struct MapSizes {
|
||||
pub cluster: Rems,
|
||||
/// The square of the world that gathers pins into one cluster.
|
||||
pub gather: Rems,
|
||||
/// A choropleth legend's scale bar.
|
||||
pub legend: Rems,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
@@ -40,6 +42,7 @@ impl Theme {
|
||||
heat_reach: px_to_rems(28.0),
|
||||
cluster: px_to_rems(32.0),
|
||||
gather: px_to_rems(56.0),
|
||||
legend: px_to_rems(96.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user