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

+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)