Maps · Layers: routes, GeoJSON, heat and clusters

MapView takes layers over its tiles and under its pins. RouteLine runs
along places the short way across the date line. read_geojson reads RFC
7946 to features, strictly: a position past the globe, a line of one
position or a ring that does not close fails with its reason, and a
feature placed nowhere keeps no geometry. GeoJsonLayer washes areas as
one path per polygon, so holes stay open, outlines them, strokes lines
and dots points. GeoHeatmap spreads weighted places in a quartic kernel
over small cells, scaled to the densest spot in view. Lines and areas
unwrap across the date line and repeat on every world in view.
cluster_markers gathers pins by squares of the world, so a pan keeps
each group; a cluster shows its count, and its press shows its members
whole, never farther out. A zoom that may regroup them hands a focused
pin's or cluster's focus to the map.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-27 22:35:40 -04:00
1 parent 91a0fc2252
commit e3a96b04e0
16 files changed
+1350 -48

No files matched your search

+1 -1
View File
@@ -196,7 +196,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- 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 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.
- 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.
## Decisions
+2 -2
View File
@@ -214,8 +214,8 @@ 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 (T41) · WorldMap → maps (T41)",
"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 come with the maps chapter.",
"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)",
"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,
)
.child(div().w(px(620.)).child(EventStream::new("dashboard-events", events, now())))
+128
View File
@@ -0,0 +1,128 @@
use ely_gpui_component::maps::{
GeoHeatmap, GeoJsonLayer, LatLon, MapMarker, MapView, MapViewport, RouteLine, read_geojson,
};
use gpui::{App, IntoElement, ParentElement, Styled, div, px};
use super::{ATTRIBUTION, tile};
use crate::probe::probe;
use crate::ui::{noise, section};
const ROUTE: [(f64, f64); 4] = [
(51.5072, -0.1276),
(64.1466, -21.9426),
(44.6488, -63.5752),
(40.7128, -74.006),
];
const SHAPES: &str = r#"{"type": "FeatureCollection", "features": [
{"type": "Feature", "properties": {"name": "The Bermuda Triangle"},
"geometry": {"type": "Polygon", "coordinates": [
[[-80.19, 25.76], [-64.78, 32.3], [-66.1, 18.47], [-80.19, 25.76]]]}},
{"type": "Feature", "properties": {"name": "A ring around the Azores"},
"geometry": {"type": "Polygon", "coordinates": [
[[-35, 33], [-20, 33], [-20, 42], [-35, 42], [-35, 33]],
[[-30, 36], [-25, 36], [-25, 39], [-30, 39], [-30, 36]]]}},
{"type": "Feature", "properties": {"name": "The equator"},
"geometry": {"type": "LineString", "coordinates": [[-50, 0], [10, 0]]}},
{"type": "Feature", "properties": {"name": "Ports"},
"geometry": {"type": "MultiPoint", "coordinates": [[-9.14, 38.72], [-17.44, 14.69], [-23.51, 14.93]]}}
]}"#;
const QUAKES: [(f64, f64); 5] = [
(35.68, 139.69),
(-33.45, -70.67),
(-6.21, 106.85),
(41.01, 28.98),
(37.77, -122.42),
];
const CAPITALS: [(f64, f64); 14] = [
(51.51, -0.13),
(48.86, 2.35),
(52.52, 13.4),
(40.42, -3.7),
(41.9, 12.5),
(48.21, 16.37),
(52.37, 4.9),
(50.85, 4.35),
(38.72, -9.14),
(50.08, 14.44),
(52.23, 21.01),
(59.33, 18.07),
(55.68, 12.57),
(47.5, 19.04),
];
fn map(id: &'static str, center: (f64, f64), zoom: f64) -> MapView {
MapView::new(id, MapViewport::new(LatLon::new(center.0, center.1), zoom))
.tiles(tile)
.tile_zooms(0, 3)
.attribution(ATTRIBUTION)
}
pub fn routes(cx: &App) -> impl IntoElement + use<> {
let features = read_geojson(SHAPES).expect("the demo's GeoJSON reads");
let route = RouteLine::new(ROUTE.map(|(lat, lon)| LatLon::new(lat, lon))).hue(0);
section(
"RouteLine · GeoJSON Layer",
"A route along places in order, the short way across the date line. GeoJSON reads to features: areas washed and outlined with their holes open, lines stroked, points dotted. Both repeat on each world in view.",
cx,
)
.child(
div().w(px(720.0)).h(px(360.0)).child(
map("routes-map", (38.0, -40.0), 2.3)
.layer(GeoJsonLayer::new(features).hue(3))
.layer(route),
),
)
}
pub fn heat(cx: &App) -> impl IntoElement + use<> {
let mut next = noise(41);
let points: Vec<(LatLon, f32)> = QUAKES
.iter()
.flat_map(|(lat, lon)| (0..30).map(move |_| (*lat, *lon)))
.map(|(lat, lon)| {
let place = LatLon::new(lat + (next() - 0.5) * 6.0, lon + (next() - 0.5) * 6.0);
(place, 0.5 + next() as f32 * 1.5)
})
.collect();
section(
"GeoHeatmap",
"Weighted places as heat, hotter where they crowd, scaled to the densest spot in view.",
cx,
)
.child(
div()
.w(px(720.0))
.h(px(360.0))
.child(map("heat-map", (20.0, 30.0), 1.0).layer(GeoHeatmap::new(points).hue(6))),
)
}
pub fn clusters(cx: &App) -> impl IntoElement + use<> {
let mut next = noise(7);
let pins: Vec<MapMarker> = CAPITALS
.iter()
.flat_map(|(lat, lon)| (0..3).map(move |_| (*lat, *lon)))
.enumerate()
.map(|(ix, (lat, lon))| {
let place = LatLon::new(lat + (next() - 0.5) * 1.2, lon + (next() - 0.5) * 1.8);
MapMarker::new(format!("pin-{ix}"), place).hue(ix % 8)
})
.collect();
section(
"MapCluster",
"Pins that crowd one square of the world gather into a count; a pan keeps each group, a press shows its members whole.",
cx,
)
.child(probe(
"cluster-map",
div().w(px(720.0)).h(px(360.0)).child(
map("cluster-map", (42.0, 5.0), 3.0)
.markers(pins)
.cluster_markers()
.on_marker(|key, _, _| log::info!("maps demo: pin {key}")),
),
))
}
+13 -3
View File
@@ -11,6 +11,8 @@ use gpui::{
AnyElement, App, ImageSource, IntoElement, ParentElement, SharedString, Styled, Window, div, px,
};
mod layers;
use super::Page;
use crate::probe::probe;
use crate::script::Step;
@@ -20,7 +22,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. The land is Natural Earth's.",
summary: "Tiles the host supplies, pins with a popup, a picker's crosshair, routes, GeoJSON, heat and clusters. The land is Natural Earth's.",
render,
script: &[
Step::Click("world-map"),
@@ -37,11 +39,16 @@ pub const PAGE: Page = Page {
Step::Key("down"),
Step::Wait(300),
Step::Shot("picked"),
Step::Click("cluster-map"),
Step::Key("tab"),
Step::Key("enter"),
Step::Wait(300),
Step::Shot("cluster-opened"),
],
};
const TILES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/gallery/assets/tiles");
const ATTRIBUTION: &str = "Land: Natural Earth";
pub(super) const ATTRIBUTION: &str = "Land: Natural Earth";
const CITIES: [(&str, &str, f64, f64, usize); 6] = [
("london", "London", 51.5072, -0.1276, 0),
@@ -53,7 +60,7 @@ const CITIES: [(&str, &str, f64, f64, usize); 6] = [
];
/// The gallery's tiles, cut from Natural Earth by `scripts/tiles.py`.
fn tile(tile: Tile) -> ImageSource {
pub(super) fn tile(tile: Tile) -> ImageSource {
PathBuf::from(format!("{TILES}/{}/{}/{}.png", tile.z, tile.x, tile.y)).into()
}
@@ -62,6 +69,9 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
.child(world(window, cx))
.child(coordinates(cx))
.child(picker(cx))
.child(layers::routes(cx))
.child(layers::heat(cx))
.child(layers::clusters(cx))
.into_any_element()
}
+1
View File
@@ -52,6 +52,7 @@ pub use mindmap::Topic;
pub use mindmap_view::MindMap;
pub use minimap::MiniMap;
pub use nodes::NodeGraph;
pub(crate) use paint::{at, finish, outline};
pub use palette::{BrushSettings, ToolPalette};
pub(crate) use panel::{OnEdit, caption, color_well, editing};
pub use plane::InfiniteCanvas;
+209
View File
@@ -0,0 +1,209 @@
use gpui::{
AnyElement, App, ElementId, FocusHandle, InteractiveElement, IntoElement, ParentElement,
Pixels, StatefulInteractiveElement, Styled, Window, div, prelude::*,
};
use super::{
MapViewport,
geo::{project, unproject},
marker::{MapMarker, OnMarker},
view::OnMapViewport,
};
use crate::{
primitives::{FocusRing, tab_stop},
theme::{ActiveTheme, TextSize},
};
/// Pins gathered by squares of the world `side` pixels wide at the view's zoom, so a pan keeps each group: each square that holds pins, keyed by its column and row, with its members in order.
pub(crate) fn gather(points: &[(f64, f64)], side: f64) -> Vec<((i64, i64), Vec<usize>)> {
assert!(side > 0.0, "a gathering square of {side}");
let mut groups: Vec<((i64, i64), Vec<usize>)> = Vec::new();
for (ix, (x, y)) in points.iter().enumerate() {
let key = ((x / side).floor() as i64, (y / side).floor() as i64);
match groups.iter_mut().find(|(each, _)| *each == key) {
Some((_, members)) => members.push(ix),
None => groups.push((key, vec![ix])),
}
}
groups
}
/// The map's pins as drawn, and when `gathered`, the focus of each pin and cluster drawn.
pub(crate) struct Pins {
pub elements: Vec<AnyElement>,
pub gathered: Vec<FocusHandle>,
}
/// The pins a map lays: its markers, what hears a pin's press, and whether pins that crowd gather.
pub(crate) struct Laying {
pub markers: Vec<MapMarker>,
pub on_marker: Option<OnMarker>,
pub gathered: bool,
}
impl Laying {
/// Lays the pins at their places, or gathers pins that crowd one square of the world into a cluster that shows their count. Each keeps its own focus in view and out, and takes Tab and presses while in view; a cluster's press shows its members whole.
pub(crate) fn lay(
self,
owner: &ElementId,
(view, size, tile): (MapViewport, (f32, f32), f32),
set: &OnMapViewport,
window: &mut Window,
cx: &mut App,
) -> Pins {
let Laying {
markers,
on_marker,
gathered,
} = self;
let seen = |(x, y): (f32, f32)| (0.0..=size.0).contains(&x) && (0.0..=size.1).contains(&y);
let rem = window.rem_size();
let sizes = cx.theme().maps();
let groups = match gathered {
true => {
let side = f64::from(tile) * view.zoom.exp2();
let points: Vec<(f64, f64)> = markers
.iter()
.map(|marker| {
let (x, y) = project(marker.at);
(x * side, y * side)
})
.collect();
gather(&points, f64::from(sizes.gather.to_pixels(rem)))
}
false => (0..markers.len())
.map(|ix| ((0, ix as i64), vec![ix]))
.collect(),
};
let mut pins = Pins {
elements: Vec::new(),
gathered: Vec::new(),
};
for (key, members) in groups {
if let [only] = members[..] {
let marker = markers[only].clone();
let at = view.to_view(marker.at, size, tile);
let press = on_marker.clone().map(|on_marker| {
let key = (owner.clone(), format!("pin-{}", marker.key));
let focus = tab_stop(key.into(), seen(at), window, cx);
if gathered {
pins.gathered.push(focus.clone());
}
(focus, on_marker)
});
pins.elements
.push(marker.place(owner, at, press, seen(at), cx));
continue;
}
let places: Vec<_> = members.iter().map(|ix| markers[*ix].at).collect();
let mean =
places
.iter()
.map(|place| project(*place))
.fold((0.0, 0.0), |sum, (x, y)| {
(
sum.0 + x / places.len() as f64,
sum.1 + y / places.len() as f64,
)
});
let at = view.to_view(unproject(mean), size, tile);
let id = (owner.clone(), format!("cluster-{}-{}", key.0, key.1));
let focus = tab_stop(id.clone().into(), seen(at), window, cx);
pins.gathered.push(focus.clone());
let margin = f32::from(sizes.margin.to_pixels(rem));
let set = set.clone();
let fit = move |window: &mut Window, cx: &mut App| {
set(view.fitting(&places, size, tile, margin * 4.0), window, cx)
};
pins.elements.push(bubble(
id.into(),
members.len(),
at,
focus,
seen(at),
fit,
cx,
));
}
pins
}
}
/// A round cluster at a view point, its count inside, pressable while `seen`.
fn bubble(
id: ElementId,
count: usize,
(x, y): (f32, f32),
focus: FocusHandle,
seen: bool,
fit: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> AnyElement {
let theme = cx.theme();
let side = theme.maps().cluster;
div()
.absolute()
.left(Pixels::from(x))
.top(Pixels::from(y))
.size_0()
.flex()
.items_center()
.justify_center()
.child(
div()
.id(id)
.track_focus(&focus)
.flex_none()
.size(side)
.flex()
.items_center()
.justify_center()
.rounded_full()
.bg(theme.colors.accent)
.border_1()
.border_color(theme.colors.on_accent)
.focus_ring(cx)
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.on_accent)
.child(count.to_string())
.when(seen, |bubble| {
bubble
.cursor_pointer()
.on_click(move |_, window, cx| fit(window, cx))
}),
)
.into_any_element()
}
#[cfg(test)]
mod tests {
use super::gather;
#[test]
fn pins_in_one_square_of_the_world_gather() {
let points = [
(10.0, 10.0),
(50.0, 20.0),
(70.0, 10.0),
(130.0, 10.0),
(20.0, 55.0),
(20.0, 65.0),
];
assert_eq!(
gather(&points, 60.0),
vec![
((0, 0), vec![0, 1, 4]),
((1, 0), vec![2]),
((2, 0), vec![3]),
((0, 1), vec![5])
],
"each square keeps its members in order, the squares in the order first met"
);
}
#[test]
#[should_panic(expected = "a gathering square of 0")]
fn a_square_of_nothing_fails() {
gather(&[(0.0, 0.0)], 0.0);
}
}
+139
View File
@@ -154,6 +154,78 @@ impl MapViewport {
}
}
/// Places along a line or ring in view pixels, each wrap around the globe undone against the place before, the first place the copy nearest the center.
pub(crate) fn run(self, places: &[LatLon], size: (f32, f32), tile: f32) -> Vec<(f32, f32)> {
let Some(first) = places.first() else {
return Vec::new();
};
let side = self.world(tile);
let (start, _) = self.to_view(*first, size, tile);
let (cy, begin) = (self.middle(tile).1, project(*first).0);
let mut last = begin;
places
.iter()
.map(|place| {
let (x, y) = project(*place);
let x = x + (last - x + 0.5).floor();
last = x;
(
start + ((x - begin) * side) as f32,
(y * side - cy + f64::from(size.1) / 2.0) as f32,
)
})
.collect()
}
/// The shifts by whole worlds that bring a span of view x, from `least` to `most`, into a view `w` wide.
pub(crate) fn copies(self, (least, most): (f32, f32), w: f32, tile: f32) -> Vec<f32> {
let side = self.world(tile) as f32;
let first = (-most / side).ceil() as i64;
let last = ((w - least) / side).floor() as i64;
(first..=last).map(|shift| shift as f32 * side).collect()
}
/// Shows `places` whole and centered, `margin` view pixels clear of each side, never farther out than now; places at one spot bring it two levels nearer.
pub(crate) fn fitting(
self,
places: &[LatLon],
size: (f32, f32),
tile: f32,
margin: f32,
) -> Self {
let unit = MapViewport { zoom: 0.0, ..self };
let run = unit.run(places, (0.0, 0.0), tile);
let span = |pick: fn(&(f32, f32)) -> f32| {
let values = run.iter().map(pick);
let least = values.clone().fold(f32::INFINITY, f32::min);
(least, values.fold(f32::NEG_INFINITY, f32::max))
};
let ((left, right), (top, bottom)) = (span(|point| point.0), span(|point| point.1));
let (w, h) = (right - left, bottom - top);
let room = (
(size.0 - margin * 2.0).max(1.0),
(size.1 - margin * 2.0).max(1.0),
);
let (least, most) = Self::ZOOMS;
let zoom = if w <= 0.0 && h <= 0.0 {
self.zoom + 2.0
} else {
f64::from((room.0 / w.max(f32::EPSILON)).min(room.1 / h.max(f32::EPSILON))).log2()
};
let middle = unit.to_geo(
((left + right) / 2.0, (top + bottom) / 2.0),
(0.0, 0.0),
tile,
);
let next = Self {
center: middle,
zoom: zoom.max(self.zoom).clamp(least, most),
};
let (x, y) = project(middle);
let side = next.world(tile);
next.centered((x * side, y * side), size, tile)
}
/// The tiles that cover a view, cut at the nearest zoom within `zooms` and scaled to the view's.
pub(crate) fn tiles(&self, (w, h): (f32, f32), tile: f32, zooms: (u8, u8)) -> Vec<Laid> {
if w <= 0.0 || h <= 0.0 {
@@ -329,6 +401,73 @@ mod tests {
}
}
#[test]
fn a_run_crosses_the_date_line_the_short_way() {
let view = MapViewport::new(LatLon::new(0.0, 180.0), 3.0);
let run = view.run(
&[LatLon::new(0.0, 170.0), LatLon::new(0.0, -170.0)],
VIEW,
TILE,
);
let degree = TILE * 8.0 / 360.0;
assert!((run[0].0 - (400.0 - 10.0 * degree)).abs() < 1e-2, "{run:?}");
assert!(
(run[1].0 - (400.0 + 10.0 * degree)).abs() < 1e-2,
"east across the line: {run:?}"
);
}
#[test]
fn copies_repeat_a_span_across_every_world_in_view() {
let whole = MapViewport::new(LatLon::new(0.0, 0.0), 0.0);
assert_eq!(
whole.copies((0.0, 10.0), 800.0, TILE),
[0.0, 256.0, 512.0, 768.0]
);
assert_eq!(
whole.copies((-300.0, -200.0), 800.0, TILE),
[256.0, 512.0, 768.0, 1024.0]
);
let near = MapViewport::new(LatLon::new(0.0, 0.0), 6.0);
assert_eq!(
near.copies((100.0, 200.0), 800.0, TILE),
[0.0],
"one world when it is wider than the view"
);
}
#[test]
fn a_fit_shows_its_places_whole_and_never_zooms_out() {
let view = MapViewport::new(LatLon::new(50.0, 0.0), 3.0);
let places = [
LatLon::new(51.5, -0.1),
LatLon::new(48.9, 2.35),
LatLon::new(52.5, 13.4),
];
let fit = view.fitting(&places, VIEW, TILE, 32.0);
assert!(fit.zoom > view.zoom, "nearer in: {}", fit.zoom);
for place in places {
let (x, y) = fit.to_view(place, VIEW, TILE);
assert!(
(31.9..=768.1).contains(&x) && (31.9..=568.1).contains(&y),
"{place:?} at {x}, {y}"
);
}
let one = view.fitting(
&[LatLon::new(1.0, 1.0), LatLon::new(1.0, 1.0)],
VIEW,
TILE,
32.0,
);
assert_eq!(one.zoom, 5.0, "places at one spot come two levels nearer");
let wide = [LatLon::new(60.0, -150.0), LatLon::new(-40.0, 150.0)];
assert_eq!(
view.fitting(&wide, VIEW, TILE, 32.0).zoom,
3.0,
"never farther out"
);
}
#[test]
fn tiles_past_the_hosts_zooms_scale() {
let view = MapViewport::new(LatLon::new(0.0, 0.0), 5.0);
+221
View File
@@ -0,0 +1,221 @@
use anyhow::{Context, Result, bail};
use serde_json::{Map, Value};
use super::LatLon;
/// A GeoJSON shape with its positions as places, as RFC 7946 has it.
#[derive(Clone, Debug, PartialEq)]
pub enum Geometry {
Point(LatLon),
MultiPoint(Vec<LatLon>),
LineString(Vec<LatLon>),
MultiLineString(Vec<Vec<LatLon>>),
/// Rings: the outer one first, then its holes.
Polygon(Vec<Vec<LatLon>>),
MultiPolygon(Vec<Vec<Vec<LatLon>>>),
Collection(Vec<Geometry>),
}
/// A GeoJSON feature: its id when it has one, its shape when it is placed, and its properties.
#[derive(Clone, Debug, PartialEq)]
pub struct Feature {
pub id: Option<String>,
pub geometry: Option<Geometry>,
pub properties: Map<String, Value>,
}
/// Reads GeoJSON: a FeatureCollection, a Feature or a bare geometry. Fails on text that is not JSON, an unknown type, a position past the globe, a line of one position or a ring that does not close.
pub fn read_geojson(text: &str) -> Result<Vec<Feature>> {
let json: Value = serde_json::from_str(text).context("the GeoJSON is not JSON")?;
match kind(&json)? {
"FeatureCollection" => json
.get("features")
.and_then(Value::as_array)
.context("a FeatureCollection without features")?
.iter()
.enumerate()
.map(|(ix, each)| feature(each).with_context(|| format!("feature {ix}")))
.collect(),
"Feature" => Ok(vec![feature(&json)?]),
_ => Ok(vec![Feature {
id: None,
geometry: Some(geometry(&json)?),
properties: Map::new(),
}]),
}
}
fn kind(json: &Value) -> Result<&str> {
json.get("type")
.and_then(Value::as_str)
.context("a GeoJSON object without a type")
}
fn feature(json: &Value) -> Result<Feature> {
let kind = kind(json)?;
if kind != "Feature" {
bail!("a {kind} where a Feature stands");
}
let id = match json.get("id") {
None | Some(Value::Null) => None,
Some(Value::String(text)) => Some(text.clone()),
Some(Value::Number(number)) => Some(number.to_string()),
Some(other) => bail!("an id {other} that is neither text nor a number"),
};
let geometry = match json.get("geometry") {
None => bail!("a Feature without a geometry member"),
Some(Value::Null) => None,
Some(shape) => Some(geometry(shape)?),
};
let properties = match json.get("properties") {
None | Some(Value::Null) => Map::new(),
Some(Value::Object(map)) => map.clone(),
Some(other) => bail!("properties {other} that are not an object"),
};
Ok(Feature {
id,
geometry,
properties,
})
}
fn geometry(json: &Value) -> Result<Geometry> {
let coordinates = || {
json.get("coordinates")
.context("a geometry without coordinates")
};
Ok(match kind(json)? {
"Point" => Geometry::Point(position(coordinates()?)?),
"MultiPoint" => Geometry::MultiPoint(list(coordinates()?, position)?),
"LineString" => Geometry::LineString(line(coordinates()?)?),
"MultiLineString" => Geometry::MultiLineString(list(coordinates()?, line)?),
"Polygon" => Geometry::Polygon(list(coordinates()?, ring)?),
"MultiPolygon" => {
Geometry::MultiPolygon(list(coordinates()?, |polygon| list(polygon, ring))?)
}
"GeometryCollection" => Geometry::Collection(
json.get("geometries")
.and_then(Value::as_array)
.context("a GeometryCollection without geometries")?
.iter()
.map(geometry)
.collect::<Result<_>>()?,
),
other => bail!("an unknown GeoJSON type {other:?}"),
})
}
fn list<T>(json: &Value, each: impl Fn(&Value) -> Result<T>) -> Result<Vec<T>> {
json.as_array()
.with_context(|| format!("coordinates {json} that are not a list"))?
.iter()
.map(each)
.collect()
}
/// A position: longitude, then latitude; an altitude past them is dropped.
fn position(json: &Value) -> Result<LatLon> {
let numbers = json
.as_array()
.with_context(|| format!("a position {json} that is not a list"))?;
let axis = |ix: usize| {
numbers
.get(ix)
.and_then(Value::as_f64)
.with_context(|| format!("a position {json} without a number at {ix}"))
};
let (lon, lat) = (axis(0)?, axis(1)?);
if !(-180.0..=180.0).contains(&lon) || !(-90.0..=90.0).contains(&lat) {
bail!("a position {json} past the globe");
}
Ok(LatLon::new(lat, lon))
}
fn line(json: &Value) -> Result<Vec<LatLon>> {
let line = list(json, position)?;
if line.len() < 2 {
bail!("a line of {} position", line.len());
}
Ok(line)
}
fn ring(json: &Value) -> Result<Vec<LatLon>> {
let ring = list(json, position)?;
if ring.len() < 4 || ring.first() != ring.last() {
bail!("a ring of {} positions that does not close", ring.len());
}
Ok(ring)
}
#[cfg(test)]
mod tests {
use super::{Geometry, read_geojson};
use crate::maps::LatLon;
const SAMPLE: &str = r#"{"type": "FeatureCollection", "features": [
{"type": "Feature", "id": 7, "properties": {"name": "Spot"},
"geometry": {"type": "Point", "coordinates": [2.35, 48.86, 35.0]}},
{"type": "Feature", "id": "path", "properties": null,
"geometry": {"type": "LineString", "coordinates": [[0, 0], [10, 5]]}},
{"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [
[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]],
[[1, 1], [2, 1], [2, 2], [1, 1]]]}},
{"type": "Feature", "geometry": null}
]}"#;
#[test]
fn a_collection_reads_its_features_in_order() {
let features = read_geojson(SAMPLE).expect("valid GeoJSON");
assert_eq!(features.len(), 4);
assert_eq!(features[0].id.as_deref(), Some("7"));
assert_eq!(features[0].properties["name"], "Spot");
assert_eq!(
features[0].geometry,
Some(Geometry::Point(LatLon::new(48.86, 2.35))),
"longitude first, the altitude dropped"
);
assert_eq!(features[1].id.as_deref(), Some("path"));
assert!(features[1].properties.is_empty());
match &features[2].geometry {
Some(Geometry::Polygon(rings)) => assert_eq!((rings.len(), rings[1].len()), (2, 4)),
other => panic!("a polygon with a hole, not {other:?}"),
}
assert_eq!(features[3].geometry, None, "a feature placed nowhere");
}
#[test]
fn bare_geometries_and_collections_read() {
let multi = r#"{"type": "MultiPolygon", "coordinates": [[[[0,0],[1,0],[1,1],[0,0]]]]}"#;
assert!(matches!(
read_geojson(multi).expect("valid")[0].geometry,
Some(Geometry::MultiPolygon(_))
));
let set = r#"{"type": "GeometryCollection", "geometries": [
{"type": "MultiPoint", "coordinates": [[1, 2]]},
{"type": "MultiLineString", "coordinates": [[[0, 0], [1, 1]]]}]}"#;
match &read_geojson(set).expect("valid")[0].geometry {
Some(Geometry::Collection(parts)) => assert_eq!(parts.len(), 2),
other => panic!("a collection, not {other:?}"),
}
}
#[test]
fn broken_geojson_fails_with_its_reason() {
let reason = |text: &str| format!("{:#}", read_geojson(text).expect_err("broken"));
assert!(reason("{").contains("not JSON"));
assert!(
reason(r#"{"type": "Circle", "coordinates": [0, 0]}"#).contains("unknown GeoJSON type")
);
assert!(reason(r#"{"type": "Point", "coordinates": [200, 0]}"#).contains("past the globe"));
assert!(reason(r#"{"type": "Point", "coordinates": [0, 95]}"#).contains("past the globe"));
assert!(reason(r#"{"type": "LineString", "coordinates": [[0, 0]]}"#).contains("line of 1"));
let open = r#"{"type": "Polygon", "coordinates": [[[0,0],[1,0],[1,1],[0,1]]]}"#;
assert!(reason(open).contains("does not close"));
let named = r#"{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point"}}]}"#;
let named = reason(named);
assert!(
named.contains("feature 0") && named.contains("without coordinates"),
"{named}"
);
}
}
+119
View File
@@ -0,0 +1,119 @@
/// Density over a view in square cells, the densest cell 1.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Heat {
pub columns: usize,
pub rows: usize,
pub cell: f32,
pub values: Vec<f32>,
}
impl Heat {
pub fn at(&self, column: usize, row: usize) -> f32 {
self.values[row * self.columns + column]
}
}
/// Each weighted view point spreads a quartic kernel `reach` pixels wide over cells `cell` pixels square across a view of `size`; the sum is scaled so its densest cell is 1. Fails on a negative or non-finite weight.
pub(crate) fn density(
points: &[((f32, f32), f32)],
(w, h): (f32, f32),
cell: f32,
reach: f32,
) -> Heat {
assert!(
cell > 0.0 && reach > 0.0,
"heat cells of {cell} reaching {reach}"
);
let (columns, rows) = (
(w / cell).ceil().max(0.0) as usize,
(h / cell).ceil().max(0.0) as usize,
);
let mut values = vec![0.0f32; columns * rows];
for &((x, y), weight) in points {
assert!(
weight.is_finite() && weight >= 0.0,
"a heat weight of {weight}"
);
let span = |at: f32, count: usize| {
let first = ((at - reach) / cell).floor().max(0.0) as usize;
let last = (((at + reach) / cell).ceil().max(0.0) as usize).min(count);
first..last
};
for row in span(y, rows) {
for column in span(x, columns) {
let (cx, cy) = ((column as f32 + 0.5) * cell, (row as f32 + 0.5) * cell);
let near = ((cx - x).powi(2) + (cy - y).powi(2)) / reach.powi(2);
if near < 1.0 {
values[row * columns + column] += weight * (1.0 - near).powi(2);
}
}
}
}
let most = values.iter().copied().fold(0.0f32, f32::max);
if most > 0.0 {
values.iter_mut().for_each(|value| *value /= most);
}
Heat {
columns,
rows,
cell,
values,
}
}
#[cfg(test)]
mod tests {
use super::density;
#[test]
fn heat_peaks_at_its_point_and_ends_at_its_reach() {
let heat = density(&[((25.0, 25.0), 2.0)], (100.0, 60.0), 10.0, 20.0);
assert_eq!((heat.columns, heat.rows), (10, 6));
assert_eq!(
heat.at(2, 2),
1.0,
"the cell under the point is the densest"
);
assert!(
heat.at(3, 2) > 0.0 && heat.at(3, 2) < 1.0,
"it falls off beside"
);
assert_eq!(heat.at(6, 2), 0.0, "past its reach nothing");
}
#[test]
fn heavier_points_run_hotter_and_heat_adds_up() {
let heat = density(
&[((15.0, 15.0), 1.0), ((85.0, 15.0), 3.0)],
(100.0, 30.0),
10.0,
15.0,
);
assert!(
heat.at(8, 1) > heat.at(1, 1) * 2.5,
"three times the weight"
);
let pair = density(
&[((45.0, 15.0), 1.0), ((55.0, 15.0), 1.0)],
(100.0, 30.0),
10.0,
15.0,
);
assert_eq!(
pair.at(4, 1),
pair.at(5, 1),
"two alike points meet in the middle"
);
let none = density(&[], (100.0, 30.0), 10.0, 15.0);
assert!(
none.values.iter().all(|value| *value == 0.0),
"no points, no heat"
);
}
#[test]
#[should_panic(expected = "a heat weight of -1")]
fn a_negative_weight_fails() {
density(&[((0.0, 0.0), -1.0)], (10.0, 10.0), 5.0, 5.0);
}
}
+312
View File
@@ -0,0 +1,312 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, Bounds, Hsla, IntoElement, PathBuilder, Pixels, Point, Styled, Window, canvas,
fill, size,
};
use super::{
LatLon, MapViewport,
geojson::{Feature, Geometry},
heat::density,
};
use crate::{
canvas::{at, finish, outline},
theme::{ActiveTheme, Palette},
};
/// What a layer paints on: where the map looks, the view's size and top left, the tile's side, and the theme's inks and measures in pixels.
struct Paper {
view: MapViewport,
size: (f32, f32),
origin: Point<Pixels>,
tile: f32,
colors: Palette,
route: Pixels,
line: Pixels,
dot: f32,
cell: f32,
reach: f32,
}
impl Paper {
fn ink(&self, hue: Option<usize>, owner: &str) -> Hsla {
match hue {
Some(hue) => self.colors.hue(hue, owner),
None => self.colors.accent,
}
}
/// The shifts by whole worlds that bring points spanning `x` into view.
fn copies(&self, points: &[(f32, f32)], pad: f32) -> Vec<f32> {
let least = points
.iter()
.map(|point| point.0)
.fold(f32::INFINITY, f32::min);
let most = points
.iter()
.map(|point| point.0)
.fold(f32::NEG_INFINITY, f32::max);
self.view
.copies((least - pad, most + pad), self.size.0, self.tile)
}
fn stroke(
&self,
points: &[(f32, f32)],
shift: f32,
width: Pixels,
ink: Hsla,
window: &mut Window,
) {
let shifted: Vec<_> = points.iter().map(|(x, y)| (x + shift, *y)).collect();
finish(outline(&shifted, self.origin, width, false), ink, window);
}
fn dot(&self, place: LatLon, ink: Hsla, window: &mut Window) {
let (x, y) = self.view.to_view(place, self.size, self.tile);
let half = self.dot / 2.0;
for shift in self.copies(&[(x, y)], half) {
let corner = at(self.origin, (x + shift - half, y - half));
let side = Pixels::from(self.dot);
window.paint_quad(
fill(Bounds::new(corner, size(side, side)), ink).corner_radii(side / 2.0),
);
}
}
/// 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) {
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 {
return;
};
for shift in self.copies(outer, 0.0) {
let mut path = PathBuilder::fill();
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();
}
}
finish(path, ink.alpha(0.25), window);
for run in &runs {
self.stroke(run, shift, self.line, ink, window);
}
}
}
fn line(&self, places: &[LatLon], width: Pixels, ink: Hsla, window: &mut Window) {
let run = self.view.run(places, self.size, self.tile);
for shift in self.copies(&run, 0.0) {
self.stroke(&run, shift, width, ink, window);
}
}
fn geometry(&self, geometry: &Geometry, ink: Hsla, window: &mut Window) {
match geometry {
Geometry::Point(place) => self.dot(*place, ink, window),
Geometry::MultiPoint(places) => places
.iter()
.for_each(|place| self.dot(*place, ink, window)),
Geometry::LineString(places) => self.line(places, self.line, ink, window),
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::Collection(parts) => parts
.iter()
.for_each(|part| self.geometry(part, ink, window)),
}
}
}
/// Something the map draws over its tiles and under its pins.
#[derive(Clone)]
pub enum MapLayer {
Route(RouteLine),
Features(GeoJsonLayer),
Heat(GeoHeatmap),
}
impl MapLayer {
fn paint(&self, paper: &Paper, window: &mut Window) {
match self {
MapLayer::Route(route) => {
let ink = paper.ink(route.hue, "route");
paper.line(&route.places, paper.route, ink, window);
}
MapLayer::Features(layer) => {
let ink = paper.ink(layer.hue, "features");
for feature in layer.features.iter() {
if let Some(geometry) = &feature.geometry {
paper.geometry(geometry, ink, window);
}
}
}
MapLayer::Heat(heat) => {
let ink = paper.ink(heat.hue, "heat");
let mut points = Vec::new();
for (place, weight) in heat.points.iter() {
let (x, y) = paper.view.to_view(*place, paper.size, paper.tile);
for shift in paper.copies(&[(x, y)], paper.reach) {
points.push(((x + shift, y), *weight));
}
}
let heat = density(&points, paper.size, paper.cell, paper.reach);
for row in 0..heat.rows {
for column in 0..heat.columns {
let value = heat.at(column, row);
if value > 0.01 {
let corner = (column as f32 * heat.cell, row as f32 * heat.cell);
let side = Pixels::from(heat.cell);
let bounds = Bounds::new(at(paper.origin, corner), size(side, side));
window.paint_quad(fill(bounds, ink.alpha(value * 0.7)));
}
}
}
}
}
}
}
/// A line along places in order, as a route runs; it crosses the date line the short way.
#[derive(Clone)]
pub struct RouteLine {
places: Vec<LatLon>,
hue: Option<usize>,
}
impl RouteLine {
/// Fails with fewer than two places.
pub fn new(places: impl IntoIterator<Item = LatLon>) -> Self {
let places: Vec<LatLon> = places.into_iter().collect();
assert!(places.len() >= 2, "a route of {} place", places.len());
Self { places, hue: None }
}
/// The chart hue it wears; the accent without one.
pub fn hue(mut self, hue: usize) -> Self {
self.hue = Some(hue);
self
}
}
/// 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]>,
hue: Option<usize>,
}
impl GeoJsonLayer {
pub fn new(features: impl Into<Rc<[Feature]>>) -> Self {
Self {
features: features.into(),
hue: None,
}
}
pub fn hue(mut self, hue: usize) -> Self {
self.hue = Some(hue);
self
}
}
/// Weighted places as heat, hotter where they crowd, scaled to the densest spot in view.
#[derive(Clone)]
pub struct GeoHeatmap {
points: Rc<[(LatLon, f32)]>,
hue: Option<usize>,
}
impl GeoHeatmap {
pub fn new(points: impl Into<Rc<[(LatLon, f32)]>>) -> Self {
Self {
points: points.into(),
hue: None,
}
}
pub fn hue(mut self, hue: usize) -> Self {
self.hue = Some(hue);
self
}
}
impl From<RouteLine> for MapLayer {
fn from(route: RouteLine) -> Self {
MapLayer::Route(route)
}
}
impl From<GeoJsonLayer> for MapLayer {
fn from(layer: GeoJsonLayer) -> Self {
MapLayer::Features(layer)
}
}
impl From<GeoHeatmap> for MapLayer {
fn from(heat: GeoHeatmap) -> Self {
MapLayer::Heat(heat)
}
}
/// A canvas that paints `layers` in order over a view of `size`; none without layers.
pub(crate) fn painted(
layers: Vec<MapLayer>,
view: MapViewport,
size: (f32, f32),
tile: f32,
window: &Window,
cx: &App,
) -> Option<AnyElement> {
if layers.is_empty() {
return None;
}
let theme = cx.theme();
let (sizes, rem) = (theme.maps(), window.rem_size());
let (colors, pixels) = (theme.colors.clone(), |rems: gpui::Rems| rems.to_pixels(rem));
let (route, line, dot) = (
pixels(sizes.route),
pixels(sizes.line),
f32::from(pixels(sizes.dot)),
);
let (cell, reach) = (
f32::from(pixels(sizes.heat_cell)),
f32::from(pixels(sizes.heat_reach)),
);
Some(
canvas(
|_, _, _| {},
move |bounds, _, window, _| {
let paper = Paper {
view,
size,
origin: bounds.origin,
tile,
colors,
route,
line,
dot,
cell,
reach,
};
layers.iter().for_each(|layer| layer.paint(&paper, window));
},
)
.absolute()
.top_0()
.left_0()
.size_full()
.into_any_element(),
)
}
+6
View File
@@ -1,5 +1,9 @@
mod cluster;
mod coords;
mod geo;
mod geojson;
mod heat;
mod layer;
mod marker;
mod picker;
#[cfg(all(test, feature = "test-support"))]
@@ -9,6 +13,8 @@ mod view;
pub use coords::{CoordFormat, CoordinateDisplay};
pub use geo::{LatLon, MAX_LAT, MapViewport, Tile};
pub use geojson::{Feature, Geometry, read_geojson};
pub use layer::{GeoHeatmap, GeoJsonLayer, MapLayer, RouteLine};
pub use marker::{MapMarker, MapPopup};
pub use picker::LocationPicker;
pub use view::MapView;
+109
View File
@@ -0,0 +1,109 @@
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,
};
const CROWD: [(&str, f64, f64); 3] = [
("london", 51.5, -0.1),
("paris", 48.9, 2.35),
("brussels", 50.85, 4.35),
];
fn europe() -> MapViewport {
MapViewport::new(LatLon::new(45.0, 0.0), 2.0)
}
fn crowd(_: &Stage, owner: Entity<Stage>) -> AnyElement {
let heard = owner.clone();
let near = CROWD.map(|(key, lat, lon)| MapMarker::new(key, LatLon::new(lat, lon)));
MapView::new("map", europe())
.markers(
near.into_iter()
.chain([MapMarker::new("tokyo", LatLon::new(35.7, 139.7))]),
)
.cluster_markers()
.on_marker(move |key, _, cx| say(&heard, key, cx))
.on_viewport(move |view, _, cx| hear(&owner, view, cx))
.into_any_element()
}
/// Three capitals in one square of the world gather; the cluster's press shows them whole, apart, and each pin then takes its own press.
#[gpui::test]
fn crowded_pins_gather_and_a_press_shows_them_whole(cx: &mut TestAppContext) {
let (host, cx) = stage(crowd, cx);
focus_map(cx);
press("tab", cx);
press("enter", cx);
assert_eq!(
said(&host, cx),
Vec::<gpui::SharedString>::new(),
"the first stop is the cluster"
);
let fit = last(&host, cx);
assert!(fit.zoom > europe().zoom, "nearer in: {}", fit.zoom);
for (key, lat, lon) in CROWD {
let (x, y) = fit.to_view(LatLon::new(lat, lon), SIZE, TILE);
assert!(
(0.0..=SIZE.0).contains(&x) && (0.0..=SIZE.1).contains(&y),
"{key} in view at {x}, {y}"
);
}
press("tab", cx);
press("enter", cx);
assert_eq!(
said(&host, cx),
["london"],
"apart, the first pin takes its press"
);
}
/// A zoom while a cluster holds focus regroups it; focus goes to the map, whose arrows still pan.
#[gpui::test]
fn a_zoom_hands_a_focused_clusters_focus_to_the_map(cx: &mut TestAppContext) {
let (host, cx) = stage(crowd, cx);
focus_map(cx);
press("tab", cx);
press("=", cx);
assert_eq!(last(&host, cx).zoom, 3.0);
let before = moves(&host, cx);
press("left", cx);
assert_eq!(moves(&host, cx), before + 1, "the map holds focus");
}
const SHAPES: &str = r#"{"type": "FeatureCollection", "features": [
{"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [
[[-10, 30], [30, 30], [30, 60], [-10, 60], [-10, 30]],
[[0, 40], [10, 40], [10, 50], [0, 40]]]}},
{"type": "Feature", "geometry": {"type": "LineString", "coordinates": [[170, 0], [-170, 5]]}},
{"type": "Feature", "geometry": {"type": "MultiPoint", "coordinates": [[0, 0], [120, -30]]}}
]}"#;
fn layered(_: &Stage, owner: Entity<Stage>) -> AnyElement {
let features = read_geojson(SHAPES).expect("valid GeoJSON");
MapView::new("map", MapViewport::new(LatLon::new(20.0, 0.0), 0.5))
.layer(RouteLine::new([LatLon::new(51.5, -0.1), LatLon::new(40.7, -74.0)]).hue(0))
.layer(GeoJsonLayer::new(features).hue(3))
.layer(GeoHeatmap::new(vec![
(LatLon::new(35.7, 139.7), 1.0),
(LatLon::new(-33.9, 18.4), 2.0),
]))
.on_viewport(move |view, _, cx| hear(&owner, view, cx))
.into_any_element()
}
/// A route, areas with a hole, a line over the date line, points and heat paint across the repeated worlds of a view zoomed out, and the map still moves.
#[gpui::test]
fn every_layer_paints_over_repeated_worlds(cx: &mut TestAppContext) {
let (host, cx) = stage(layered, cx);
focus_map(cx);
press("left", cx);
assert_eq!(moves(&host, cx), 1);
}
#[test]
#[should_panic(expected = "a route of 1 place")]
fn a_route_of_one_place_fails() {
RouteLine::new([LatLon::new(0.0, 0.0)]);
}
+1
View File
@@ -13,6 +13,7 @@ use crate::{
};
/// The view inside the map's 1px border, in pixels.
mod layers;
mod popup;
const SIZE: (f32, f32) = (398.0, 298.0);
+62 -36
View File
@@ -1,13 +1,15 @@
use std::{collections::HashSet, f64::consts::LOG2_E, rc::Rc};
use gpui::{
AnyElement, App, AppContext as _, Bounds, ElementId, EmptyView, Entity, EntityId, ImageSource,
InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce,
SharedString, StatefulInteractiveElement, Styled, Window, canvas, div,
AnyElement, App, AppContext as _, Bounds, ElementId, EmptyView, Entity, EntityId, FocusHandle,
ImageSource, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Point,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, canvas, div,
};
use super::{
MapViewport, Tile,
cluster::Laying,
layer::{MapLayer, painted},
marker::{MapMarker, MapPopup, OnMarker},
tile::laid_tile,
};
@@ -28,12 +30,13 @@ struct Pan {
owner: EntityId,
}
/// The map's box, the pointer and viewport at the press of a pan under way, and the focus a shown popup took.
/// The map's box, the pointer and viewport at the press of a pan under way, the focus a shown popup took, and the focus of each pin and cluster a zoom may regroup.
#[derive(Default)]
struct Stage {
bounds: Bounds<Pixels>,
grip: Option<(Point<Pixels>, MapViewport)>,
takeover: Option<Entity<Takeover>>,
gathered: Vec<FocusHandle>,
}
/// A map of tiles the host supplies by zoom, column and row, with pins and a popup over them; a pin that holds focus keeps it off view, but takes Tab and presses only while its place is in view. A drag or the wheel pans it; Command or Control with the wheel zooms about the pointer; focused, the arrows pan and + and - zoom, as its buttons do. The owner keeps the viewport; a new one from the owner shows at once. It fills its box; the host gives it a height.
@@ -44,7 +47,9 @@ pub struct MapView {
tiles: Option<TileSource>,
zooms: (u8, u8),
attribution: Option<SharedString>,
layers: Vec<MapLayer>,
markers: Vec<MapMarker>,
gathered: bool,
on_marker: Option<OnMarker>,
popup: Option<MapPopup>,
pub(crate) on_viewport: Option<OnMapViewport>,
@@ -58,7 +63,9 @@ impl MapView {
tiles: None,
zooms: (0, MapViewport::ZOOMS.1 as u8),
attribution: None,
layers: Vec::new(),
markers: Vec::new(),
gathered: false,
on_marker: None,
popup: None,
on_viewport: None,
@@ -87,6 +94,18 @@ impl MapView {
self
}
/// A route, GeoJSON features or heat over the tiles and under the pins, drawn in the order added.
pub fn layer(mut self, layer: impl Into<MapLayer>) -> Self {
self.layers.push(layer.into());
self
}
/// Gathers pins that crowd one square of the world into a cluster that shows their count; a cluster's press shows its members whole. A zoom that regroups them hands a focused pin's or cluster's focus to the map.
pub fn cluster_markers(mut self) -> Self {
self.gathered = true;
self
}
/// Pins keyed by their places; two with one key fail, as they would share a focus.
pub fn markers(mut self, markers: impl IntoIterator<Item = MapMarker>) -> Self {
self.markers = markers.into_iter().collect();
@@ -146,6 +165,35 @@ impl RenderOnce for MapView {
let view = local.read(cx).value;
let size = sized(stage.read(cx).bounds);
let tile = f32::from(cx.theme().maps().tile);
let set: OnMapViewport = {
let (local, on_viewport) = (local.clone(), self.on_viewport.clone());
let (regrouped, map) = (stage.clone(), focus.clone());
Rc::new(move |next, window, cx| {
log::debug!(
"map: {:.4}, {:.4} at zoom {:.2}",
next.center.lat,
next.center.lon,
next.zoom
);
let zoomed = next.zoom != local.read(cx).value.zoom;
let held = regrouped
.read(cx)
.gathered
.iter()
.any(|pin| pin.is_focused(window));
if zoomed && held {
log::info!("map: a zoom regroups the pins; focus goes to the map");
window.focus(&map);
}
local.update(cx, |local, cx| {
local.value = next;
cx.notify();
});
if let Some(on_viewport) = &on_viewport {
on_viewport(next, window, cx);
}
})
};
let tiles: Vec<AnyElement> = match &self.tiles {
Some(source) => view
.tiles(size, tile, self.zooms)
@@ -154,19 +202,14 @@ impl RenderOnce for MapView {
.collect(),
None => Vec::new(),
};
let markers: Vec<AnyElement> = self
.markers
.into_iter()
.map(|marker| {
let at = view.to_view(marker.at, size, tile);
let seen = inside(at, size);
let press = self.on_marker.clone().map(|on_marker| {
let key = (id.clone(), format!("pin-{}", marker.key));
(tab_stop(key.into(), seen, window, cx), on_marker)
});
marker.place(&id, at, press, seen, cx)
})
.collect();
let layers = painted(self.layers, view, size, tile, window, cx);
let pins = Laying {
markers: self.markers,
on_marker: self.on_marker,
gathered: self.gathered,
}
.lay(&id, (view, size, tile), &set, window, cx);
stage.update(cx, |stage, _| stage.gathered = pins.gathered);
let shown = self
.popup
.map(|popup| (view.to_view(popup.at, size, tile), popup))
@@ -188,24 +231,6 @@ impl RenderOnce for MapView {
};
let theme = cx.theme();
let colors = theme.colors.clone();
let set: OnMapViewport = {
let (local, on_viewport) = (local.clone(), self.on_viewport.clone());
Rc::new(move |next, window, cx| {
log::debug!(
"map: {:.4}, {:.4} at zoom {:.2}",
next.center.lat,
next.center.lon,
next.zoom
);
local.update(cx, |local, cx| {
local.value = next;
cx.notify();
});
if let Some(on_viewport) = &on_viewport {
on_viewport(next, window, cx);
}
})
};
let now: Now = {
let (local, stage) = (local.clone(), stage.clone());
Rc::new(move |cx| (local.read(cx).value, sized(stage.read(cx).bounds)))
@@ -356,7 +381,8 @@ impl RenderOnce for MapView {
})
.on_drop(move |_: &Pan, _, cx| dropped.update(cx, |stage, _| stage.grip = None))
.children(tiles)
.children(markers)
.children(layers)
.children(pins.elements)
.child(controls)
.children(attribution)
.children(popup)
+21
View File
@@ -11,6 +11,20 @@ pub struct MapSizes {
pub popup: Rems,
/// Room a popup keeps from the map's edges and from its point.
pub margin: Rems,
/// A route's stroke.
pub route: Rems,
/// A GeoJSON line's and outline's stroke.
pub line: Rems,
/// A GeoJSON point's side.
pub dot: Rems,
/// A heat cell's side.
pub heat_cell: Rems,
/// How far a heat point spreads.
pub heat_reach: Rems,
/// A cluster's side.
pub cluster: Rems,
/// The square of the world that gathers pins into one cluster.
pub gather: Rems,
}
impl Theme {
@@ -19,6 +33,13 @@ impl Theme {
tile: px(256.0),
popup: px_to_rems(240.0),
margin: px_to_rems(8.0),
route: px_to_rems(3.0),
line: px_to_rems(1.5),
dot: px_to_rems(8.0),
heat_cell: px_to_rems(4.0),
heat_reach: px_to_rems(28.0),
cluster: px_to_rems(32.0),
gather: px_to_rems(56.0),
}
}
}
+6 -6
View File
@@ -139,8 +139,8 @@
- [x] SystemMonitor → `devtools::ResourceMonitor`
- [x] ProcessTable → `terminal::ProcessList`
- [x] NetworkGraph (throughput) → `charts::RealtimeChart`
- [ ] MapView / GeoHeatmap → `maps::MapView`, `maps::GeoHeatmap` (T41)
- [ ] WorldMap → `maps::WorldMap` (T41)
- [x] MapView / GeoHeatmap → `maps::MapView`, `maps::GeoHeatmap` (T41a, T41b)
- [ ] WorldMap → `maps::WorldMap` (T41c)
- [x] RefreshIntervalSelector
- [x] DashboardFilterBar
@@ -298,10 +298,10 @@
## T41b Maps · Layers
- [ ] MapCluster
- [ ] RouteLine
- [ ] GeoJSON Layer
- [ ] GeoHeatmap (from T34)
- [x] MapCluster → `MapView::cluster_markers`: pins gathered by squares of the world, so a pan keeps each group; a cluster's press shows its members whole
- [x] RouteLine → `maps::RouteLine`, a layer that crosses the date line the short way
- [x] GeoJSON Layer → `maps::read_geojson` reads RFC 7946 to features; `maps::GeoJsonLayer` washes areas with their holes open, strokes lines, dots points
- [x] GeoHeatmap (from T34) → `maps::GeoHeatmap`, weighted places as heat scaled to the densest spot in view
## T41c Maps · World