map.rs
⎇
Raw
1use std::collections::HashMap;
2use std::time::Duration;
3
4use api::{GuestAuth, GuestTrack, GuestView, MAX_TRACK_SECS, Person, Point, Trail};
5use js_sys::{Array, Object, Reflect};
6use leptos::prelude::*;
7use leptos::task::spawn_local;
8use wasm_bindgen::JsValue;
9use wasm_bindgen::prelude::*;
10
11use crate::{ago, fmt_time, http, now_secs};
12
13const OSM: &str =
14 "&copy; <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors";
15/// localStorage key for the chosen base map.
16const LAYER_KEY: &str = "mapLayer";
17const POLL: Duration = Duration::from_secs(10);
18/// Positions older than this are drawn faded.
19const STALE_SECS: i64 = 3600;
20const COLORS: [&str; 8] = [
21 "#2563eb", "#dc2626", "#16a34a", "#9333ea", "#ea580c", "#0891b2", "#db2777", "#65a30d",
22];
23
24#[wasm_bindgen]
25extern "C" {
26 type LMap;
27 type Layer;
28
29 #[wasm_bindgen(js_namespace = L, js_name = map)]
30 fn leaflet_map(el: &web_sys::HtmlElement) -> LMap;
31 #[wasm_bindgen(method, js_name = setView)]
32 fn set_view(this: &LMap, center: &Array, zoom: f64);
33 #[wasm_bindgen(method, js_name = fitBounds)]
34 fn fit_bounds(this: &LMap, bounds: &Array, opts: &JsValue);
35 #[wasm_bindgen(method, js_name = panTo)]
36 fn pan_to(this: &LMap, center: &Array);
37 #[wasm_bindgen(method, js_name = remove)]
38 fn remove_map(this: &LMap);
39
40 #[wasm_bindgen(method, js_name = on)]
41 fn on_map(this: &LMap, event: &str, f: &JsValue);
42
43 type Control;
44 #[wasm_bindgen(js_namespace = ["L", "control"], js_name = layers)]
45 fn layers_control(base: &JsValue, overlays: &JsValue, opts: &JsValue) -> Control;
46 #[wasm_bindgen(method, js_name = addTo)]
47 fn add_control_to(this: &Control, map: &LMap);
48
49 #[wasm_bindgen(js_namespace = L, js_name = tileLayer)]
50 fn tile_layer(url: &str, opts: &JsValue) -> Layer;
51 #[wasm_bindgen(js_namespace = L, js_name = circleMarker)]
52 fn circle_marker(at: &Array, opts: &JsValue) -> Layer;
53 #[wasm_bindgen(js_namespace = L, js_name = circle)]
54 fn circle(at: &Array, opts: &JsValue) -> Layer;
55 #[wasm_bindgen(js_namespace = L, js_name = polyline)]
56 fn polyline(points: &Array, opts: &JsValue) -> Layer;
57
58 #[wasm_bindgen(method, js_name = addTo)]
59 fn add_to(this: &Layer, map: &LMap);
60 #[wasm_bindgen(method, js_name = remove)]
61 fn remove_layer(this: &Layer);
62 #[wasm_bindgen(method, js_name = setLatLng)]
63 fn set_lat_lng(this: &Layer, at: &Array);
64 #[wasm_bindgen(method, js_name = setRadius)]
65 fn set_radius(this: &Layer, metres: f64);
66 #[wasm_bindgen(method, js_name = setStyle)]
67 fn set_style(this: &Layer, opts: &JsValue);
68 #[wasm_bindgen(method, js_name = bindTooltip)]
69 fn bind_tooltip(this: &Layer, text: &str, opts: &JsValue);
70 #[wasm_bindgen(method)]
71 fn on(this: &Layer, event: &str, f: &JsValue);
72}
73
74fn obj(pairs: &[(&str, JsValue)]) -> JsValue {
75 let o = Object::new();
76 for (k, v) in pairs {
77 Reflect::set(&o, &JsValue::from_str(k), v).unwrap();
78 }
79 o.into()
80}
81
82/// Street maps get the `street` class, which the dark theme inverts. None of these sources has a dark style.
83fn tiles(url: &str, max_zoom: u8, attribution: &str, street: bool) -> Layer {
84 tile_layer(
85 url,
86 &obj(&[
87 ("maxZoom", max_zoom.into()),
88 ("attribution", attribution.into()),
89 ("className", if street { "street" } else { "" }.into()),
90 ]),
91 )
92}
93
94/// Adds the base maps and the control to switch them. Remembers the choice.
95fn add_base_maps(m: &LMap) {
96 let storage = window().local_storage().ok().flatten();
97 let chosen = storage
98 .as_ref()
99 .and_then(|s| s.get_item(LAYER_KEY).ok().flatten());
100 let bases = [
101 (
102 "OpenStreetMap",
103 tiles(
104 "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
105 19,
106 OSM,
107 true,
108 ),
109 ),
110 (
111 "Humanitarian",
112 tiles(
113 "https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png",
114 19,
115 &format!(
116 "{OSM}, style by the Humanitarian OpenStreetMap Team, hosted by OpenStreetMap France"
117 ),
118 true,
119 ),
120 ),
121 (
122 "Cycling",
123 tiles(
124 "https://{s}.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png",
125 20,
126 &format!("<a href=\"https://www.cyclosm.org\">CyclOSM</a>, map data {OSM}"),
127 true,
128 ),
129 ),
130 (
131 "Topographic",
132 tiles(
133 "https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png",
134 17,
135 &format!(
136 "Map data {OSM}, SRTM. Style &copy; <a href=\"https://opentopomap.org\">OpenTopoMap</a> (CC-BY-SA)"
137 ),
138 true,
139 ),
140 ),
141 (
142 "Satellite",
143 tiles(
144 "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
145 19,
146 "Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community",
147 false,
148 ),
149 ),
150 ];
151 let index = bases
152 .iter()
153 .position(|(name, _)| chosen.as_deref() == Some(*name))
154 .unwrap_or(0);
155 bases[index].1.add_to(m);
156 let control = Object::new();
157 for (name, layer) in &bases {
158 Reflect::set(&control, &(*name).into(), layer).unwrap();
159 }
160 layers_control(&control, &JsValue::UNDEFINED, &JsValue::UNDEFINED).add_control_to(m);
161 let save = Closure::<dyn Fn(JsValue)>::new(move |e: JsValue| {
162 let name = Reflect::get(&e, &"name".into())
163 .ok()
164 .and_then(|n| n.as_string());
165 if let (Some(s), Some(name)) = (&storage, name) {
166 let _ = s.set_item(LAYER_KEY, &name);
167 }
168 });
169 m.on_map("baselayerchange", &save.into_js_value());
170}
171
172fn latlng(lat: f64, lon: f64) -> Array {
173 Array::of2(&lat.into(), &lon.into())
174}
175
176fn color(id: i64) -> &'static str {
177 COLORS[id.rem_euclid(COLORS.len() as i64) as usize]
178}
179
180/// A person's position on the map: a dot and a circle for the accuracy.
181struct Marker {
182 dot: Layer,
183 accuracy: Layer,
184}
185
186fn sync_markers(
187 map: &LMap,
188 markers: &mut HashMap<i64, Marker>,
189 people: &[Person],
190 selected: RwSignal<Option<i64>>,
191) {
192 markers.retain(|id, m| {
193 let keep = people.iter().any(|p| p.id == *id && p.last().is_some());
194 if !keep {
195 m.dot.remove_layer();
196 m.accuracy.remove_layer();
197 }
198 keep
199 });
200 for p in people {
201 let Some(last) = p.last().map(|d| &d.last) else {
202 continue;
203 };
204 let at = latlng(last.lat, last.lon);
205 let m = markers.entry(p.id).or_insert_with(|| {
206 let c = color(p.id);
207 let accuracy = circle(
208 &at,
209 &obj(&[
210 ("radius", 0.into()),
211 ("color", c.into()),
212 ("weight", 1.into()),
213 ("fillOpacity", 0.1.into()),
214 ("interactive", false.into()),
215 ]),
216 );
217 let dot = circle_marker(
218 &at,
219 &obj(&[
220 ("radius", 8.into()),
221 ("color", "white".into()),
222 ("weight", 2.into()),
223 ("fillColor", c.into()),
224 ("fillOpacity", 1.into()),
225 ]),
226 );
227 dot.bind_tooltip(
228 &p.username,
229 &obj(&[
230 ("permanent", true.into()),
231 ("direction", "right".into()),
232 ("offset", Array::of2(&10.into(), &0.into()).into()),
233 ]),
234 );
235 let id = p.id;
236 dot.on(
237 "click",
238 &Closure::<dyn Fn()>::new(move || selected.set(Some(id))).into_js_value(),
239 );
240 accuracy.add_to(map);
241 dot.add_to(map);
242 Marker { dot, accuracy }
243 });
244 m.dot.set_lat_lng(&at);
245 m.accuracy.set_lat_lng(&at);
246 m.accuracy.set_radius(last.acc.unwrap_or(0.0) as f64);
247 let opacity = if now_secs() - last.ts > STALE_SECS {
248 0.35
249 } else {
250 1.0
251 };
252 m.dot.set_style(&obj(&[
253 ("fillOpacity", opacity.into()),
254 ("opacity", opacity.into()),
255 ]));
256 }
257}
258
259/// `YYYY-MM-DD` and `YYYY-MM-DDTHH:MM` in local time, the formats of date and datetime-local inputs.
260fn input_values(d: &js_sys::Date) -> (String, String) {
261 let day = format!(
262 "{:04}-{:02}-{:02}",
263 d.get_full_year(),
264 d.get_month() + 1,
265 d.get_date()
266 );
267 let time = format!("{day}T{:02}:{:02}", d.get_hours(), d.get_minutes());
268 (day, time)
269}
270
271/// Local midnight to midnight, in unix seconds, for a `YYYY-MM-DD` string.
272fn day_range(day: &str) -> Option<(i64, i64)> {
273 let mut it = day.split('-').map(|s| s.parse::<i32>().ok());
274 let (y, m, d) = (it.next()??, it.next()??, it.next()??);
275 let start = js_sys::Date::new_with_year_month_day(y as u32, m - 1, d).get_time();
276 let end = js_sys::Date::new_with_year_month_day(y as u32, m - 1, d + 1).get_time();
277 Some(((start / 1000.0) as i64, (end / 1000.0) as i64 - 1))
278}
279
280/// Unix seconds for a `YYYY-MM-DDTHH:MM` string. JavaScript reads that format as local time.
281pub fn local_time(value: &str) -> Option<i64> {
282 let ms = js_sys::Date::new(&JsValue::from_str(value)).get_time();
283 (!ms.is_nan()).then_some((ms / 1000.0) as i64)
284}
285
286fn trail_range(span: &str, day: &str, from: &str, to: &str) -> Result<(i64, i64), &'static str> {
287 match span {
288 "day" => day_range(day).ok_or("Pick a day."),
289 "range" => match (local_time(from), local_time(to)) {
290 (Some(f), Some(t)) if f >= t => Err("The start must be before the end."),
291 (Some(f), Some(t)) if t - f > MAX_TRACK_SECS => {
292 Err("The range can be at most 31 days.")
293 }
294 (Some(f), Some(t)) => Ok((f, t)),
295 _ => Err("Pick a start and an end."),
296 },
297 _ => Ok((now_secs() - 86400, now_secs())),
298 }
299}
300
301/// Whose view the map shows.
302#[derive(Clone)]
303pub enum Source {
304 Account,
305 Guest(GuestAuth),
306 /// The app, with its device token. It sees only this device.
307 Device(String),
308}
309
310#[component]
311pub fn MapPage(#[prop(default = Source::Account)] source: Source) -> impl IntoView {
312 let is_account = matches!(source, Source::Account);
313 let is_guest = matches!(source, Source::Guest(_));
314 let is_device = matches!(source, Source::Device(_));
315 let source = StoredValue::new(source);
316 let expires = RwSignal::new(None::<i64>);
317 let people = RwSignal::new(Vec::<Person>::new());
318 let selected = RwSignal::new(None::<i64>);
319 let trail_device = RwSignal::new(None::<i64>);
320 let person = move || {
321 let id = selected.get()?;
322 people.with(|list| list.iter().find(|p| p.id == id).cloned())
323 };
324 // The chosen device, or the newest one if the chosen one is gone.
325 let device = move || {
326 let p = person()?;
327 let chosen = trail_device.get();
328 chosen
329 .filter(|id| p.devices.iter().any(|d| d.id == *id))
330 .or(p.last().map(|d| d.id))
331 };
332 let (today, now) = input_values(&js_sys::Date::new_0());
333 let span = RwSignal::new("none".to_string());
334 let day = RwSignal::new(today.clone());
335 let from = RwSignal::new(format!("{today}T00:00"));
336 let to = RwSignal::new(now);
337 let range = move || trail_range(&span.get(), &day.get(), &from.get(), &to.get());
338 // Identifies the chosen filter. The last 24 hours move with the clock but stay one view.
339 let filter_key = move || match span.get().as_str() {
340 "day" => format!("day {}", day.get()),
341 "range" => format!("range {} {}", from.get(), to.get()),
342 s => s.to_string(),
343 };
344 let track = RwSignal::new(Vec::<Point>::new());
345 let error = RwSignal::new(None::<String>);
346
347 let refresh = move || {
348 if document().hidden() {
349 return;
350 }
351 spawn_local(async move {
352 let result = match source.get_value() {
353 Source::Account => http::get::<Vec<Person>>("/api/people").await,
354 Source::Guest(g) => http::post::<GuestView>("/api/guest", &g).await.map(|v| {
355 expires.set(v.expires_at);
356 vec![v.person]
357 }),
358 Source::Device(t) => http::get_bearer::<Person>("/api/device", &t)
359 .await
360 .map(|p| vec![p]),
361 };
362 match result {
363 Ok(p) => {
364 // Guests and the app see one person, so their details open at once.
365 if !is_account && selected.get_untracked().is_none() {
366 selected.set(p.first().map(|p| p.id));
367 }
368 people.set(p);
369 error.set(None);
370 }
371 Err(e) if e.status == 404 && is_guest => {
372 error.set(Some("This link is no longer valid.".into()))
373 }
374 Err(e) if e.status == 401 && is_device => error.set(Some(
375 "This device was removed. Connect the app again.".into(),
376 )),
377 Err(e) => error.set(Some(e.to_string())),
378 }
379 })
380 };
381 refresh();
382 if let Ok(handle) = set_interval_with_handle(refresh, POLL) {
383 on_cleanup(move || handle.clear());
384 }
385 let visible = window_event_listener(leptos::ev::visibilitychange, move |_| refresh());
386 on_cleanup(move || visible.remove());
387
388 let el = NodeRef::<leptos::html::Div>::new();
389 let map = StoredValue::new_local(None::<LMap>);
390 let markers = StoredValue::new_local(HashMap::<i64, Marker>::new());
391 let trail = StoredValue::new_local(None::<Layer>);
392 let ready = RwSignal::new(false);
393 let fitted = StoredValue::new(false);
394 let trail_fitted_for = StoredValue::new(None::<(i64, String)>);
395
396 Effect::new(move |_| {
397 let Some(div) = el.get() else { return };
398 if ready.get_untracked() {
399 return;
400 }
401 let m = leaflet_map(&div);
402 m.set_view(&latlng(50.0, 10.0), 4.0);
403 add_base_maps(&m);
404 map.set_value(Some(m));
405 ready.set(true);
406 });
407 on_cleanup(move || {
408 map.with_value(|m| m.as_ref().map(LMap::remove_map))
409 .unwrap_or_default()
410 });
411
412 Effect::new(move |_| {
413 let list = people.get();
414 if !ready.get() {
415 return;
416 }
417 map.with_value(|m| {
418 let m = m.as_ref().unwrap();
419 markers.update_value(|mk| sync_markers(m, mk, &list, selected));
420 let positions: Vec<_> = list
421 .iter()
422 .filter_map(|p| p.last().map(|d| &d.last))
423 .collect();
424 if !fitted.get_value() && !positions.is_empty() {
425 fitted.set_value(true);
426 match positions[..] {
427 [p] => m.set_view(&latlng(p.lat, p.lon), 15.0),
428 _ => m.fit_bounds(
429 &positions.iter().map(|p| latlng(p.lat, p.lon)).collect(),
430 &fit_opts(),
431 ),
432 }
433 }
434 });
435 });
436
437 // Reloads on every poll too, so a current trail grows while you watch.
438 Effect::new(move |_| {
439 let (Some(p), Some(dev), Ok((from, to))) = (person(), device(), range()) else {
440 track.set(Vec::new());
441 return;
442 };
443 if p.trail == Trail::None || span.get() == "none" {
444 track.set(Vec::new());
445 return;
446 }
447 let id = p.id;
448 spawn_local(async move {
449 let result = match source.get_value() {
450 Source::Account => {
451 http::get::<Vec<Point>>(&format!(
452 "/api/people/{id}/track?from={from}&to={to}&device={dev}"
453 ))
454 .await
455 }
456 Source::Device(t) => {
457 http::get_bearer::<Vec<Point>>(
458 &format!("/api/device/track?from={from}&to={to}"),
459 &t,
460 )
461 .await
462 }
463 Source::Guest(auth) => {
464 let body = GuestTrack {
465 auth,
466 device: dev,
467 from,
468 to,
469 };
470 http::post::<Vec<Point>>("/api/guest/track", &body).await
471 }
472 };
473 match result {
474 Ok(t) => track.set(t),
475 Err(e) => error.set(Some(e.to_string())),
476 }
477 });
478 });
479
480 Effect::new(move |_| {
481 let points = track.get();
482 if !ready.get() {
483 return;
484 }
485 map.with_value(|m| {
486 let m = m.as_ref().unwrap();
487 trail.update_value(|old| {
488 if let Some(line) = old.take() {
489 line.remove_layer();
490 }
491 let (Some(id), Some(dev)) = (selected.get_untracked(), untrack(device)) else {
492 return;
493 };
494 if points.is_empty() {
495 return;
496 }
497 let coords: Array = points.iter().map(|p| latlng(p.lat, p.lon)).collect();
498 let line = polyline(
499 &coords,
500 &obj(&[
501 ("color", color(id).into()),
502 ("weight", 4.into()),
503 ("opacity", 0.7.into()),
504 ]),
505 );
506 line.add_to(m);
507 *old = Some(line);
508 // Fit once per selection, not on every poll, so the user can pan freely.
509 let key = Some((dev, untrack(filter_key)));
510 if trail_fitted_for.get_value() != key {
511 trail_fitted_for.set_value(key);
512 m.fit_bounds(&coords, &fit_opts());
513 }
514 });
515 });
516 });
517
518 let select = move |p: &Person| {
519 let id = p.id;
520 if selected.get_untracked() == Some(id) {
521 selected.set(None);
522 return;
523 }
524 selected.set(Some(id));
525 trail_device.set(p.last().map(|d| d.id));
526 if let Some(last) = p.last().map(|d| &d.last) {
527 map.with_value(|m| m.as_ref().map(|m| m.pan_to(&latlng(last.lat, last.lon))));
528 }
529 };
530
531 view! {
532 <div class="map-page">
533 <aside>
534 <p class="error">{move || error.get()}</p>
535 <ul class="people">
536 {move || {
537 people
538 .get()
539 .into_iter()
540 .enumerate()
541 .map(|(i, p)| {
542 let id = p.id;
543 let detail = describe(&p);
544 let name = if i == 0 && !is_guest { format!("{} (you)", p.username) } else { p.username.clone() };
545 view! {
546 <li class:selected=move || selected.get() == Some(id) on:click=move |_| select(&p)>
547 <span class="dot" style:background=color(id)></span>
548 <span class="name">{name}</span>
549 <div class="hint">{detail}</div>
550 </li>
551 }
552 })
553 .collect_view()
554 }}
555 </ul>
556 {move || {
557 let p = person().filter(|p| !is_device && p.trail != Trail::None && !p.devices.is_empty())?;
558 let current = device();
559 Some(view! {
560 <label>
561 "Device"
562 <select on:change:target=move |ev| trail_device.set(ev.target().value().parse().ok())>
563 {p
564 .devices
565 .into_iter()
566 .map(|d| view! { <option value=d.id selected=current == Some(d.id)>{d.name}</option> })
567 .collect_view()}
568 </select>
569 </label>
570 })
571 }}
572 <Show when=move || person().is_none_or(|p| p.trail != Trail::None)>
573 <label>
574 "Trail"
575 <select bind:value=span>
576 <option value="none">"None, current position only"</option>
577 <option value="24h">"Last 24 hours"</option>
578 <option value="day">"One day"</option>
579 <option value="range">"Time range"</option>
580 </select>
581 </label>
582 <Show when=move || span.get() == "day">
583 <label>"Day" <input type="date" bind:value=day /></label>
584 </Show>
585 <Show when=move || span.get() == "range">
586 <label>"From" <input type="datetime-local" bind:value=from /></label>
587 <label>"To" <input type="datetime-local" bind:value=to /></label>
588 </Show>
589 </Show>
590 <p class="hint">
591 {move || match (person(), range()) {
592 (_, Err(msg)) => msg.to_string(),
593 (None, _) => "Select a person to show their trail.".to_string(),
594 (Some(p), _) if p.trail == Trail::None => format!("{} shares only their current position.", p.username),
595 _ if span.get() == "none" => String::new(),
596 (Some(Person { trail: Trail::Since(t), username, .. }), _) => {
597 format!("{} {username} shares the trail since {}.", count(track.get().len()), fmt_time(t))
598 }
599 (Some(_), _) => count(track.get().len()),
600 }}
601 </p>
602 {move || expires.get().map(|t| view! { <p class="hint">"This link works until " {fmt_time(t)} "."</p> })}
603 {is_account.then(|| view! { <crate::locate::LocationControls /> })}
604 </aside>
605 <div class="map" node_ref=el></div>
606 </div>
607 }
608}
609
610fn count(n: usize) -> String {
611 match n {
612 1 => "1 point.".into(),
613 n => format!("{n} points."),
614 }
615}
616
617fn fit_opts() -> JsValue {
618 obj(&[
619 ("padding", Array::of2(&40.into(), &40.into()).into()),
620 ("maxZoom", 16.into()),
621 ])
622}
623
624fn describe(p: &Person) -> String {
625 let Some(d) = p.last() else {
626 return "no position yet".into();
627 };
628 let l = &d.last;
629 let mut parts = vec![ago(l.ts)];
630 if p.devices.len() > 1 {
631 parts.push(d.name.clone());
632 }
633 match (p.precision_m, l.acc) {
634 (0, Some(acc)) => parts.push(format!("±{acc:.0} m")),
635 (0, None) => {}
636 (m @ ..1000, _) => parts.push(format!("approximate, {m} m")),
637 (m, _) => parts.push(format!("approximate, {} km", m / 1000)),
638 }
639 if let Some(speed) = l.speed {
640 parts.push(format!("{:.0} km/h", speed * 3.6));
641 }
642 if let Some(b) = l.battery {
643 parts.push(format!("battery {b}%"));
644 }
645 parts.join(" · ")
646}
647