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:
1 parent
91a0fc2252
commit
e3a96b04e0
16 files changed
+1350
-48
No files matched your search
@@ -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
@@ -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);
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)]);
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user