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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
811a187a3e
commit
752266c36d
14 files changed
+612
-35
No files matched your search
@@ -14,7 +14,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- Diffs: `similar` 3.2 (Apache-2.0) for line and word diffs and three-way merges; its `unicode` feature splits words at punctuation.
|
||||
- Markdown: `pulldown-cmark` 0.13 (MIT, default features off) for CommonMark with tables, tasks, strikethrough, footnotes and math.
|
||||
- Pictures: `image` 0.25 (MIT OR Apache-2.0), the crate gpui decodes with, default features off; it turns decoded frames and rims map tiles.
|
||||
- Maps: the gallery's tiles are Natural Earth's 110m countries (public domain), cut z0 to z3 by `scripts/tiles.py` with Pillow.
|
||||
- Maps: Natural Earth's 110m countries (public domain). `assets/maps/countries.geojson`, trimmed by `scripts/countries.py` to an ISO code, a name and positions to 0.01°, backs `maps::WorldMap`; the gallery's tiles are cut from the same file, z0 to z3, by `scripts/tiles.py` with Pillow.
|
||||
- Gallery: `examples/gallery`. Website: `frontend/` (Vite 8, pnpm), built after the components.
|
||||
|
||||
## Commands
|
||||
@@ -24,7 +24,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- `cargo test --lib --features test-support`, `cargo clippy --all-targets --features test-support -- -D warnings`, `cargo fmt --check`. `scripts/check.sh` runs them with the house rules.
|
||||
- `rm -rf target/debug/incremental` and `cargo clean -p ely-gpui-component` after each task item keep the disk lean. Cargo keeps every old build of the crate and its tests under a new hash; by T25 they had filled the disk.
|
||||
- `scripts/icons.sh <lucide-name>...` adds icons. Then add a line to `IconName` in `src/primitives/icon.rs`.
|
||||
- `python3 scripts/tiles.py <ne_110m_admin_0_countries.geojson>` writes the gallery's map tiles.
|
||||
- `python3 scripts/countries.py <ne_110m_admin_0_countries.geojson>` writes the bundled world; `python3 scripts/tiles.py <same file>` writes the gallery's map tiles.
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -195,6 +195,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- Messages go through `i18n::I18n`, a global the app sets: `cx.global::<I18n>().text(key, args)` fills each `{name}`. Every catalog holds the first one's keys; a missing key, argument or catalog fails. A right-to-left locale mirrors a stack with `Direction::row` and sets its text with `Direction::align`; CoreText orders mixed Hebrew and Latin runs on macOS, but gpui sets no paragraph direction, so a line takes its first strong character's: `text` leads a right-to-left message with U+200F, so its words suit labels, not a field's value, and `Locale::number` a right-to-left minus with U+200E, as CLDR does. gpui 0.2.2 wraps shaped glyphs in the order they are drawn, so a right-to-left line that wraps comes out garbled: right-to-left text stays on one line, an `Ellipsis`, which cuts by characters and sits by its text's alignment.
|
||||
- gpui orders Tab stops by `tab_index` before paint order, and registers none for an `invisible()` element. A stop that must come first where it sits, yet draw over what paints after it, stays in flow at zero size and raises what it shows (`i18n::SkipLink`): a lower `tab_index` would sort it before the root scope's own handle, so Tab from the root would pass it.
|
||||
- Color-blind safe charts swap Ely's chart hues for a set fitted to stay apart under Machado's protanopia, deuteranopia and tritanopia; a test holds every pair 0.10 apart in Oklab and each hue 3:1 on its page (`Theme::set_color_blind_safe`, in Ely's own palettes alone).
|
||||
- gpui 0.2.2's Metal renderer starts its instance buffer at 2 MB; a frame that outgrows it logs "failed to render … retrying with larger instance buffer size" and the buffer doubles, up to 256 MB. A world map's first frame grows it to 16 MB, then draws: the log is gpui's recovery, not a failure.
|
||||
- gpui 0.2.2 packs pictures in its atlas without padding and samples them with a linear filter, so a scaled picture's edge takes color from its atlas neighbor, and pictures laid edge to edge show seams. A map tile carries a one-pixel rim copied from its own edge and draws one of its pixels larger each side, clipped to its box (`maps::tile`).
|
||||
- A map keeps places in `f64`: at zoom 19 the world is 134 million pixels across. Its wheel pans and Command or Control with the wheel zooms about the pointer, as the canvas's does. Its tiles stay in gpui's asset cache, as every `img()` picture does. Its popup is an overlay that is not modal: it takes focus when it shows, and the frame it stops showing, by any way out, hands focus back. Its layers, lines and areas unwrap across the date line and repeat on every world in view. Clustered pins gather by squares of the world, not of the view, so a pan never regroups them; a zoom may, so it hands a focused pin's or cluster's focus to the map.
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"name":"Fiji","iso":"FJ"},"geometry":{"type":"MultiPolygon","coordinates":[[[[180,-16.07],[180,-16.56],[179.36,-16.8],[178.73,-17.01],[178.6,-16.64],[179.1,-16.43],[179.41,-16.38],[180,-16.07]]],[[[178.13,-17.5],[178.37,-17.34],[178.72,-17.63],[178.55,-18.15],[177.93,-18.29],[177.38,-18.16],[177.29,-17.72],[177.67,-17.38],[178.13,-17.5]]],[[[-179.79,-16.02],[-179.92,-16.5],[-180,-16.56],[-180,-16.07],[-179.79,-16.02]]]]}},{"type":"Feature","properties":{"name":"Tanzania","iso":"TZ"},"geometry":{"type":"MultiPolygon","coordinates":[[[[33.9,-0.95],[34.07,-1.06],[37.7,-3.1],[37.77,-3.68],[39.2,-4.68],[38.74,-5.91],[38.8,-6.48],[39.44,-6.84],[39.47,-7.1],[39.19,-7.7],[39.25,-8.01],[39.19,-8.49],[39.54,-9.11],[39.95,-10.1],[40.32,-10.32],[39.52,-10.9],[38.43,-11.29],[37.83,-11.27],[37.47,-11.57],[36.78,-11.59],[36.51,-11.72],[35.31,-11.44],[34.56,-11.52],[34.28,-10.16],[33.94,-9.69],[33.74,-9.42],[32.76,-9.23],[32.19,-8.93],[31.56,-8.76],[31.16,-8.59],[30.74,-8.34],[30.2,-7.08],[29.62,-6.52],[29.42,-5.94],[29.52,-5.42],[29.34,-4.5],[29.75,-4.45],[30.12,-4.09],[30.51,-3.57],[30.75,-3.36],[30.74,-3.03],[30.53,-2.81],[30.47,-2.41],[30.76,-2.29],[30.82,-1.7],[30.42,-1.13],[30.77,-1.01],[31.87,-1.03],[33.9,-0.95]]]]}},{"type":"Feature","properties":{"name":"W. Sahara","iso":"EH"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-8.67,27.66],[-8.67,27.59],[-8.68,27.4],[-8.69,25.88],[-11.97,25.93],[-11.94,23.37],[-12.87,23.28],[-13.12,22.77],[-12.93,21.33],[-16.85,21.33],[-17.06,21.0],[-17.02,21.42],[-17.0,21.42],[-14.75,21.5],[-14.63,21.86],[-14.22,22.31],[-13.89,23.69],[-12.5,24.77],[-12.03,26.03],[-11.72,26.1],[-11.39,26.88],[-10.55,26.99],[-10.19,26.86],[-9.74,26.86],[-9.41,27.09],[-8.79,27.12],[-8.82,27.66],[-8.67,27.66]]]]}},{"type":"Feature","properties":{"name":"Canada","iso":"CA"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-122.84,49],[-122.97,49.0],[-124.91,49.98],[-125.62,50.42],[-127.44,50.83],[-127.99,51.72],[-127.85,52.33],[-129.13,52.76],[-129.31,53.56],[-130.51,54.29],[-130.54,54.8],[-129.98,55.28],[-130.01,55.92],[-131.71,56.55],[-132.73,57.69],[-133.36,58.41],[-134.27,58.86],[-134.94,59.27],[-135.48,59.79],[-136.48,59.46],[-137.45,58.91],[-138.34,59.56],[-139.04,60],[-140.01,60.28],[-141.0,60.31],[-140.99,66.0],[-140.99,69.71],[-139.12,69.47],[-137.55,68.99],[-136.5,68.9],[-135.63,69.32],[-134.41,69.63],[-132.93,69.51],[-131.43,69.94],[-129.79,70.19],[-129.11,69.78],[-128.36,70.01],[-128.14,70.48],[-127.45,70.38],[-125.76,69.48],[-124.42,70.16],[-124.29,69.4],[-123.06,69.56],[-122.68,69.86],[-121.47,69.8],[-119.94,69.38],[-117.6,69.01],[-116.23,68.84],[-115.25,68.91],[-113.9,68.4],[-115.3,67.9],[-113.5,67.69],[-110.8,67.81],[-109.95,67.98],[-108.88,67.38],[-107.79,67.89],[-108.81,68.31],[-108.17,68.65],[-106.95,68.7],[-106.15,68.8],[-105.34,68.56],[-104.34,68.02],[-103.22,68.1],[-101.45,67.65],[-99.9,67.81],[-98.44,67.78],[-98.56,68.4],[-97.67,68.58],[-96.12,68.24],[-96.13,67.29],[-95.49,68.09],[-94.69,68.06],[-94.23,69.07],[-95.3,69.69],[-96.47,70.09],[-96.39,71.19],[-95.21,71.92],[-93.89,71.76],[-92.88,71.32],[-91.52,70.19],[-92.41,69.7],[-90.55,69.5],[-90.55,68.47],[-89.22,69.26],[-88.02,68.62],[-88.32,67.87],[-87.35,67.2],[-86.31,67.92],[-85.58,68.78],[-85.52,69.88],[-84.1,69.81],[-82.62,69.66],[-81.28,69.16],[-81.22,68.67],[-81.96,68.13],[-81.26,67.6],[-81.39,67.11],[-83.34,66.41],[-84.74,66.26],[-85.77,66.56],[-86.07,66.06],[-87.03,65.21],[-87.32,64.78],[-88.48,64.1],[-89.91,64.03],[-90.7,63.61],[-90.77,62.96],[-91.93,62.84],[-93.16,62.02],[-94.24,60.9],[-94.63,60.11],[-94.68,58.95],[-93.22,58.78],[-92.76,57.85],[-92.3,57.09],[-90.9,57.28],[-89.04,56.85],[-88.04,56.47],[-87.32,56.0],[-86.07,55.72],[-85.01,55.3],[-83.36,55.24],[-82.27,55.15],[-82.44,54.28],[-82.13,53.28],[-81.4,52.16],[-79.91,51.21],[-79.14,51.53],[-78.6,52.56],[-79.12,54.14],[-79.83,54.67],[-78.23,55.14],[-77.1,55.84],[-76.54,56.53],[-76.62,57.2],[-77.3,58.05],[-78.52,58.8],[-77.34,59.85],[-77.77,60.76],[-78.11,62.32],[-77.41,62.55],[-75.7,62.28],[-74.67,62.18],[-73.84,62.44],[-72.91,62.11],[-71.68,61.53],[-71.37,61.14],[-69.59,61.06],[-69.62,60.22],[-69.29,58.96],[-68.37,58.8],[-67.65,58.21],[-66.2,58.77],[-65.25,59.87],[-64.58,60.34],[-63.8,59.44],[-62.5,58.17],[-61.4,56.97],[-61.8,56.34],[-60.47,55.78],[-59.57,55.2],[-57.98,54.95],[-57.33,54.63],[-56.94,53.78],[-56.16,53.65],[-55.76,53.27],[-55.68,52.15],[-56.41,51.77],[-57.13,51.42],[-58.77,51.06],[-60.03,50.24],[-61.72,50.08],[-63.86,50.29],[-65.36,50.3],[-66.4,50.23],[-67.24,49.51],[-68.51,49.07],[-69.95,47.74],[-71.1,46.82],[-70.26,46.99],[-68.65,48.3],[-66.55,49.13],[-65.06,49.23],[-64.17,48.74],[-65.12,48.07],[-64.8,46.99],[-64.47,46.24],[-63.17,45.74],[-61.52,45.88],[-60.52,47.01],[-60.45,46.28],[-59.8,45.92],[-61.04,45.27],[-63.25,44.67],[-64.25,44.27],[-65.36,43.55],[-66.12,43.62],[-66.16,44.47],[-64.43,45.29],[-66.03,45.26],[-67.14,45.14],[-67.79,45.7],[-67.79,47.07],[-68.23,47.35],Line truncated
|
||||
@@ -214,7 +214,7 @@ pub fn stream(_: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
),
|
||||
];
|
||||
section(
|
||||
"EventStream · LogStream → terminal::LogViewer · TraceWaterfall → debug::TimelineProfiler · SystemMonitor → devtools::ResourceMonitor · ProcessTable → terminal::ProcessList · NetworkGraph (throughput) → charts::RealtimeChart · MapView / GeoHeatmap → maps::MapView, maps::GeoHeatmap · WorldMap → maps (T41c)",
|
||||
"EventStream · LogStream → terminal::LogViewer · TraceWaterfall → debug::TimelineProfiler · SystemMonitor → devtools::ResourceMonitor · ProcessTable → terminal::ProcessList · NetworkGraph (throughput) → charts::RealtimeChart · MapView / GeoHeatmap → maps::MapView, maps::GeoHeatmap · WorldMap → maps::WorldMap",
|
||||
"Events as they come, newest first, kept by kind, with Pause to hold the list while new ones count up. Logs, traces, a machine's readings, its processes and live throughput have their homes in the terminal, agent, dev tools and charts chapters; the maps live in the maps chapter.",
|
||||
cx,
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ use gpui::{
|
||||
};
|
||||
|
||||
mod layers;
|
||||
mod world;
|
||||
|
||||
use super::Page;
|
||||
use crate::probe::probe;
|
||||
@@ -22,7 +23,7 @@ pub const PAGE: Page = Page {
|
||||
number: 41,
|
||||
slug: "maps",
|
||||
title: "Maps",
|
||||
summary: "Tiles the host supplies, pins with a popup, a picker's crosshair, routes, GeoJSON, heat and clusters. The land is Natural Earth's.",
|
||||
summary: "Tiles the host supplies, pins with a popup, a picker's crosshair, routes, GeoJSON, heat, clusters and choropleths. The land is Natural Earth's.",
|
||||
render,
|
||||
script: &[
|
||||
Step::Click("world-map"),
|
||||
@@ -72,6 +73,7 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
.child(layers::routes(cx))
|
||||
.child(layers::heat(cx))
|
||||
.child(layers::clusters(cx))
|
||||
.child(world::world(cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
use ely_gpui_component::maps::{
|
||||
ChoroplethLayer, ChoroplethMap, LatLon, MapView, MapViewport, WorldMap, read_geojson,
|
||||
};
|
||||
use gpui::{App, IntoElement, ParentElement, Styled, div, px};
|
||||
|
||||
use super::{ATTRIBUTION, tile};
|
||||
use crate::ui::section;
|
||||
|
||||
const VISITORS: [(&str, f64); 20] = [
|
||||
("US", 120_400.0),
|
||||
("IN", 51_300.0),
|
||||
("GB", 45_100.0),
|
||||
("DE", 38_200.0),
|
||||
("JP", 30_800.0),
|
||||
("FR", 26_400.0),
|
||||
("BR", 22_100.0),
|
||||
("CA", 18_300.0),
|
||||
("AU", 14_700.0),
|
||||
("ES", 13_200.0),
|
||||
("CN", 12_600.0),
|
||||
("IT", 12_100.0),
|
||||
("KR", 11_400.0),
|
||||
("MX", 9_100.0),
|
||||
("SG", 8_300.0),
|
||||
("RU", 7_200.0),
|
||||
("AR", 6_300.0),
|
||||
("ZA", 5_400.0),
|
||||
("EG", 4_100.0),
|
||||
("NG", 3_200.0),
|
||||
];
|
||||
|
||||
const REGIONS: &str = r#"{"type": "FeatureCollection", "features": [
|
||||
{"type": "Feature", "properties": {"region": "west"},
|
||||
"geometry": {"type": "Polygon", "coordinates": [[[-9, 36], [3, 36], [3, 43.5], [-9, 43.5], [-9, 36]]]}},
|
||||
{"type": "Feature", "properties": {"region": "middle"},
|
||||
"geometry": {"type": "Polygon", "coordinates": [[[-4, 43.5], [8, 43.5], [8, 51], [-4, 51], [-4, 43.5]]]}},
|
||||
{"type": "Feature", "properties": {"region": "south"},
|
||||
"geometry": {"type": "Polygon", "coordinates": [[[7, 37], [18, 37], [18, 46], [7, 46], [7, 37]]]}},
|
||||
{"type": "Feature", "properties": {"region": "north"},
|
||||
"geometry": {"type": "Polygon", "coordinates": [[[5, 55], [15, 55], [15, 60], [5, 60], [5, 55]]]}}
|
||||
]}"#;
|
||||
|
||||
pub fn world(cx: &App) -> impl IntoElement + use<> {
|
||||
let regions = read_geojson(REGIONS).expect("the demo's regions read");
|
||||
let rainfall = ChoroplethLayer::new(
|
||||
regions,
|
||||
[("west", 540.0), ("middle", 810.0), ("south", 700.0)],
|
||||
)
|
||||
.key_by("region")
|
||||
.hue(1);
|
||||
let over = MapView::new("rain-map", MapViewport::new(LatLon::new(46.0, 5.0), 3.2))
|
||||
.tiles(tile)
|
||||
.tile_zooms(0, 3)
|
||||
.attribution(ATTRIBUTION);
|
||||
let visits = MapView::new(
|
||||
"world-visits",
|
||||
MapViewport::new(LatLon::new(25.0, 10.0), 1.4),
|
||||
);
|
||||
section(
|
||||
"ChoroplethMap · WorldMap",
|
||||
"Areas washed by value on one hue's scale, with a legend; an area without a value reads as no data. WorldMap washes Natural Earth's countries by ISO code: Singapore, too small at this scale, draws nowhere and is logged once.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w(px(720.0))
|
||||
.h(px(360.0))
|
||||
.child(WorldMap::new("world-visits", visits, VISITORS).hue(0)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w(px(480.0))
|
||||
.h(px(320.0))
|
||||
.child(ChoroplethMap::new("rain", over, rainfall)),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Writes assets/maps/countries.geojson, Ely's world, from Natural Earth's 110m countries.
|
||||
|
||||
Usage: python3 scripts/countries.py <ne_110m_admin_0_countries.geojson>
|
||||
Keeps each country's ISO 3166 alpha-2 code and name; positions round to 0.01 degrees.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
OUT = Path(__file__).resolve().parent.parent / "assets/maps/countries.geojson"
|
||||
|
||||
|
||||
def ring(points):
|
||||
kept = []
|
||||
for lon, lat in points:
|
||||
point = [round(lon, 2), round(lat, 2)]
|
||||
if not kept or kept[-1] != point:
|
||||
kept.append(point)
|
||||
if kept[0] != kept[-1]:
|
||||
kept.append(kept[0])
|
||||
return kept if len(kept) >= 4 else None
|
||||
|
||||
|
||||
def polygon(rings):
|
||||
kept = [ring(each) for each in rings]
|
||||
if kept[0] is None:
|
||||
return None
|
||||
return [each for each in kept if each is not None]
|
||||
|
||||
|
||||
def main(source):
|
||||
features = []
|
||||
for feature in json.load(open(source))["features"]:
|
||||
props, shape = feature["properties"], feature["geometry"]
|
||||
polygons = [shape["coordinates"]] if shape["type"] == "Polygon" else shape["coordinates"]
|
||||
kept = [each for each in (polygon(rings) for rings in polygons) if each]
|
||||
iso = props["ISO_A2_EH"]
|
||||
properties = {"name": props["NAME"]} | ({"iso": iso} if iso != "-99" else {})
|
||||
features.append({
|
||||
"type": "Feature",
|
||||
"properties": properties,
|
||||
"geometry": {"type": "MultiPolygon", "coordinates": kept},
|
||||
})
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT.write_text(json.dumps({"type": "FeatureCollection", "features": features}, separators=(",", ":")))
|
||||
print(f"wrote {len(features)} countries, {OUT.stat().st_size} bytes, to {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1])
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -140,7 +140,7 @@
|
||||
- [x] ProcessTable → `terminal::ProcessList`
|
||||
- [x] NetworkGraph (throughput) → `charts::RealtimeChart`
|
||||
- [x] MapView / GeoHeatmap → `maps::MapView`, `maps::GeoHeatmap` (T41a, T41b)
|
||||
- [ ] WorldMap → `maps::WorldMap` (T41c)
|
||||
- [x] WorldMap → `maps::WorldMap` (T41c)
|
||||
- [x] RefreshIntervalSelector
|
||||
- [x] DashboardFilterBar
|
||||
|
||||
@@ -305,8 +305,8 @@
|
||||
|
||||
## T41c Maps · World
|
||||
|
||||
- [ ] ChoroplethMap
|
||||
- [ ] WorldMap (from T34)
|
||||
- [x] ChoroplethMap → `maps::ChoroplethMap` on a `ChoroplethLayer`: areas washed by value on one hue's scale, keyed by id or a property, with a legend; an area without a value reads as no data, a value without an area is logged once
|
||||
- [x] WorldMap (from T34) → `maps::WorldMap`, Natural Earth's 110m countries bundled and washed by ISO 3166 alpha-2 code; a key that is no code fails
|
||||
|
||||
## T42 Misc — `src/misc`
|
||||
|
||||
|
||||
Reference in new issue
Block a user