map.rs
⎇
Raw
1use std::collections::HashMap;
2use std::time::Duration;
3
4use api::{GuestAuth, GuestTrack, GuestView, MAX_TRACK_SECS, Person, Point};
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 escape(text: &str) -> String {
173 text.replace('&', "&amp;")
174 .replace('<', "&lt;")
175 .replace('>', "&gt;")
176 .replace('"', "&quot;")
177}
178
179fn latlng(lat: f64, lon: f64) -> Array {
180 Array::of2(&lat.into(), &lon.into())
181}
182
183fn color(id: i64) -> &'static str {
184 COLORS[id.rem_euclid(COLORS.len() as i64) as usize]
185}
186
187/// A person's position on the map: a dot and a circle for the accuracy.
188struct Marker {
189 dot: Layer,
190 accuracy: Layer,
191}
192
193fn sync_markers(
194 map: &LMap,
195 markers: &mut HashMap<i64, Marker>,
196 people: &[Person],
197 selected: RwSignal<Option<i64>>,
198) {
199 markers.retain(|id, m| {
200 let keep = people.iter().any(|p| p.id == *id && p.last().is_some());
201 if !keep {
202 m.dot.remove_layer();
203 m.accuracy.remove_layer();
204 }
205 keep
206 });
207 for p in people {
208 let Some(last) = p.last().map(|d| &d.last) else {
209 continue;
210 };
211 let at = latlng(last.lat, last.lon);
212 let m = markers.entry(p.id).or_insert_with(|| {
213 let c = color(p.id);
214 let accuracy = circle(
215 &at,
216 &obj(&[
217 ("radius", 0.into()),
218 ("color", c.into()),
219 ("weight", 1.into()),
220 ("fillOpacity", 0.1.into()),
221 ("interactive", false.into()),
222 ]),
223 );
224 let dot = circle_marker(
225 &at,
226 &obj(&[
227 ("radius", 8.into()),
228 ("color", "white".into()),
229 ("weight", 2.into()),
230 ("fillColor", c.into()),
231 ("fillOpacity", 1.into()),
232 ]),
233 );
234 // Leaflet inserts tooltip text as HTML.
235 dot.bind_tooltip(
236 &escape(&p.username),
237 &obj(&[
238 ("permanent", true.into()),
239 ("direction", "right".into()),
240 ("offset", Array::of2(&10.into(), &0.into()).into()),
241 ]),
242 );
243 let id = p.id;
244 dot.on(
245 "click",
246 &Closure::<dyn Fn()>::new(move || selected.set(Some(id))).into_js_value(),
247 );
248 accuracy.add_to(map);
249 dot.add_to(map);
250 Marker { dot, accuracy }
251 });
252 m.dot.set_lat_lng(&at);
253 m.accuracy.set_lat_lng(&at);
254 m.accuracy.set_radius(last.acc.unwrap_or(0.0) as f64);
255 let opacity = if now_secs() - last.ts > STALE_SECS {
256 0.35
257 } else {
258 1.0
259 };
260 m.dot.set_style(&obj(&[
261 ("fillOpacity", opacity.into()),
262 ("opacity", opacity.into()),
263 ]));
264 }
265}
266
267/// `YYYY-MM-DD` and `YYYY-MM-DDTHH:MM` in local time, the formats of date and datetime-local inputs.
268pub fn input_values(d: &js_sys::Date) -> (String, String) {
269 let day = format!(
270 "{:04}-{:02}-{:02}",
271 d.get_full_year(),
272 d.get_month() + 1,
273 d.get_date()
274 );
275 let time = format!("{day}T{:02}:{:02}", d.get_hours(), d.get_minutes());
276 (day, time)
277}
278
279/// Local midnight to midnight, in unix seconds, for a `YYYY-MM-DD` string.
280fn day_range(day: &str) -> Option<(i64, i64)> {
281 let mut it = day.split('-').map(|s| s.parse::<i32>().ok());
282 let (y, m, d) = (it.next()??, it.next()??, it.next()??);
283 let start = js_sys::Date::new_with_year_month_day(y as u32, m - 1, d).get_time();
284 let end = js_sys::Date::new_with_year_month_day(y as u32, m - 1, d + 1).get_time();
285 Some(((start / 1000.0) as i64, (end / 1000.0) as i64 - 1))
286}
287
288/// Unix seconds for a `YYYY-MM-DDTHH:MM` string. JavaScript reads that format as local time.
289pub fn local_time(value: &str) -> Option<i64> {
290 let ms = js_sys::Date::new(&JsValue::from_str(value)).get_time();
291 (!ms.is_nan()).then_some((ms / 1000.0) as i64)
292}
293
294fn trail_range(span: &str, day: &str, from: &str, to: &str) -> Result<(i64, i64), &'static str> {
295 match span {
296 "day" => day_range(day).ok_or("Pick a day."),
297 "range" => match (local_time(from), local_time(to)) {
298 (Some(f), Some(t)) if f >= t => Err("The start must be before the end."),
299 (Some(f), Some(t)) if t - f > MAX_TRACK_SECS => {
300 Err("The range can be at most 31 days.")
301 }
302 (Some(f), Some(t)) => Ok((f, t)),
303 _ => Err("Pick a start and an end."),
304 },
305 _ => Ok((now_secs() - 86400, now_secs())),
306 }
307}
308
309/// Whose view the map shows.
310#[derive(Clone)]
311pub enum Source {
312 Account,
313 Guest(GuestAuth),
314 /// The app, with its device token. It sees only this device.
315 Device(String),
316}
317
318#[component]
319pub fn MapPage(#[prop(default = Source::Account)] source: Source) -> impl IntoView {
320 let is_account = matches!(source, Source::Account);
321 let is_guest = matches!(source, Source::Guest(_));
322 let is_device = matches!(source, Source::Device(_));
323 let source = StoredValue::new(source);
324 let expires = RwSignal::new(None::<i64>);
325 let people = RwSignal::new(Vec::<Person>::new());
326 let selected = RwSignal::new(None::<i64>);
327 let trail_device = RwSignal::new(None::<i64>);
328 let person = move || {
329 let id = selected.get()?;
330 people.with(|list| list.iter().find(|p| p.id == id).cloned())
331 };
332 // The chosen device, or the newest one if the chosen one is gone.
333 let device = move || {
334 let p = person()?;
335 let chosen = trail_device.get();
336 chosen
337 .filter(|id| p.devices.iter().any(|d| d.id == *id))
338 .or(p.last().map(|d| d.id))
339 };
340 let (today, now) = input_values(&js_sys::Date::new_0());
341 let span = RwSignal::new("none".to_string());
342 let day = RwSignal::new(today.clone());
343 let from = RwSignal::new(format!("{today}T00:00"));
344 let to = RwSignal::new(now);
345 let range = move || trail_range(&span.get(), &day.get(), &from.get(), &to.get());
346 // Identifies the chosen filter. The last 24 hours move with the clock but stay one view.
347 let filter_key = move || match span.get().as_str() {
348 "day" => format!("day {}", day.get()),
349 "range" => format!("range {} {}", from.get(), to.get()),
350 s => s.to_string(),
351 };
352 let track = RwSignal::new(Vec::<Point>::new());
353 let error = RwSignal::new(None::<String>);
354
355 let refresh = move || {
356 if document().hidden() {
357 return;
358 }
359 spawn_local(async move {
360 let result = match source.get_value() {
361 Source::Account => http::get::<Vec<Person>>("/api/people").await,
362 Source::Guest(g) => http::post::<GuestView>("/api/guest", &g).await.map(|v| {
363 expires.set(v.expires_at);
364 vec![v.person]
365 }),
366 Source::Device(t) => http::get_bearer::<Person>("/api/device", &t)
367 .await
368 .map(|p| vec![p]),
369 };
370 match result {
371 Ok(p) => {
372 // Guests and the app see one person, so their details open at once.
373 if !is_account && selected.get_untracked().is_none() {
374 selected.set(p.first().map(|p| p.id));
375 }
376 people.set(p);
377 error.set(None);
378 }
379 Err(e) if e.status == 404 && is_guest => {
380 error.set(Some("This link is no longer valid.".into()))
381 }
382 Err(e) if e.status == 401 && is_device => error.set(Some(
383 "This device was removed. Connect the app again.".into(),
384 )),
385 Err(e) => error.set(Some(e.to_string())),
386 }
387 })
388 };
389 refresh();
390 if let Ok(handle) = set_interval_with_handle(refresh, POLL) {
391 on_cleanup(move || handle.clear());
392 }
393 let visible = window_event_listener(leptos::ev::visibilitychange, move |_| refresh());
394 on_cleanup(move || visible.remove());
395
396 let el = NodeRef::<leptos::html::Div>::new();
397 let map = StoredValue::new_local(None::<LMap>);
398 let markers = StoredValue::new_local(HashMap::<i64, Marker>::new());
399 let trail = StoredValue::new_local(None::<Layer>);
400 let ready = RwSignal::new(false);
401 let fitted = StoredValue::new(false);
402 let trail_fitted_for = StoredValue::new(None::<(i64, String)>);
403
404 Effect::new(move |_| {
405 let Some(div) = el.get() else { return };
406 if ready.get_untracked() {
407 return;
408 }
409 let m = leaflet_map(&div);
410 m.set_view(&latlng(50.0, 10.0), 4.0);
411 add_base_maps(&m);
412 map.set_value(Some(m));
413 ready.set(true);
414 });
415 on_cleanup(move || {
416 map.with_value(|m| m.as_ref().map(LMap::remove_map))
417 .unwrap_or_default()
418 });
419
420 Effect::new(move |_| {
421 let list = people.get();
422 if !ready.get() {
423 return;
424 }
425 map.with_value(|m| {
426 let m = m.as_ref().unwrap();
427 markers.update_value(|mk| sync_markers(m, mk, &list, selected));
428 let positions: Vec<_> = list
429 .iter()
430 .filter_map(|p| p.last().map(|d| &d.last))
431 .collect();
432 if !fitted.get_value() && !positions.is_empty() {
433 fitted.set_value(true);
434 match positions[..] {
435 [p] => m.set_view(&latlng(p.lat, p.lon), 15.0),
436 _ => m.fit_bounds(
437 &positions.iter().map(|p| latlng(p.lat, p.lon)).collect(),
438 &fit_opts(),
439 ),
440 }
441 }
442 });
443 });
444
445 // The trail on screen, or being loaded: person, device and filter.
446 let track_key = StoredValue::new(None::<String>);
447 Effect::new(move |_| {
448 let (Some(p), Some(dev), Ok((from, to))) = (person(), device(), range()) else {
449 track_key.set_value(None);
450 track.set(Vec::new());
451 return;
452 };
453 if p.trail_since.is_none() || span.get() == "none" {
454 track_key.set_value(None);
455 track.set(Vec::new());
456 return;
457 }
458 let id = p.id;
459 let key = format!("{id} {dev} {}", filter_key());
460 // A range that ends now grows with each poll. A past range does not change, so it loads once.
461 let live = to >= now_secs() - 2 * POLL.as_secs() as i64;
462 if !live && track_key.get_value().as_deref() == Some(key.as_str()) {
463 return;
464 }
465 track_key.set_value(Some(key.clone()));
466 spawn_local(async move {
467 let result = match source.get_value() {
468 Source::Account => {
469 http::get::<Vec<Point>>(&format!(
470 "/api/people/{id}/track?from={from}&to={to}&device={dev}"
471 ))
472 .await
473 }
474 Source::Device(t) => {
475 http::get_bearer::<Vec<Point>>(
476 &format!("/api/device/track?from={from}&to={to}"),
477 &t,
478 )
479 .await
480 }
481 Source::Guest(auth) => {
482 let body = GuestTrack {
483 auth,
484 device: dev,
485 from,
486 to,
487 };
488 http::post::<Vec<Point>>("/api/guest/track", &body).await
489 }
490 };
491 // A slow answer for an earlier choice must not replace the current trail.
492 if track_key.get_value().as_deref() != Some(key.as_str()) {
493 return;
494 }
495 match result {
496 Ok(t) => track.set(t),
497 Err(e) => error.set(Some(e.to_string())),
498 }
499 });
500 });
501
502 Effect::new(move |_| {
503 let points = track.get();
504 if !ready.get() {
505 return;
506 }
507 map.with_value(|m| {
508 let m = m.as_ref().unwrap();
509 trail.update_value(|old| {
510 if let Some(line) = old.take() {
511 line.remove_layer();
512 }
513 let (Some(id), Some(dev)) = (selected.get_untracked(), untrack(device)) else {
514 return;
515 };
516 if points.is_empty() {
517 return;
518 }
519 let coords: Array = points.iter().map(|p| latlng(p.lat, p.lon)).collect();
520 let line = polyline(
521 &coords,
522 &obj(&[
523 ("color", color(id).into()),
524 ("weight", 4.into()),
525 ("opacity", 0.7.into()),
526 ]),
527 );
528 line.add_to(m);
529 *old = Some(line);
530 // Fit once per selection, not on every poll, so the user can pan freely.
531 let key = Some((dev, untrack(filter_key)));
532 if trail_fitted_for.get_value() != key {
533 trail_fitted_for.set_value(key);
534 m.fit_bounds(&coords, &fit_opts());
535 }
536 });
537 });
538 });
539
540 let select = move |p: &Person| {
541 let id = p.id;
542 if selected.get_untracked() == Some(id) {
543 selected.set(None);
544 return;
545 }
546 selected.set(Some(id));
547 trail_device.set(p.last().map(|d| d.id));
548 if let Some(last) = p.last().map(|d| &d.last) {
549 map.with_value(|m| m.as_ref().map(|m| m.pan_to(&latlng(last.lat, last.lon))));
550 }
551 };
552
553 view! {
554 <div class="map-page">
555 <aside>
556 <p class="error">{move || error.get()}</p>
557 <ul class="people">
558 {move || {
559 people
560 .get()
561 .into_iter()
562 .enumerate()
563 .map(|(i, p)| {
564 let id = p.id;
565 let detail = describe(&p);
566 let name = if i == 0 && !is_guest { format!("{} (you)", p.username) } else { p.username.clone() };
567 view! {
568 <li class:selected=move || selected.get() == Some(id) on:click=move |_| select(&p)>
569 <span class="dot" style:background=color(id)></span>
570 <span class="name">{name}</span>
571 <div class="hint">{detail}</div>
572 </li>
573 }
574 })
575 .collect_view()
576 }}
577 </ul>
578 {move || {
579 let p = person().filter(|p| !is_device && p.trail_since.is_some() && !p.devices.is_empty())?;
580 let current = device();
581 Some(view! {
582 <label>
583 "Device"
584 <select on:change:target=move |ev| trail_device.set(ev.target().value().parse().ok())>
585 {p
586 .devices
587 .into_iter()
588 .map(|d| view! { <option value=d.id selected=current == Some(d.id)>{d.name}</option> })
589 .collect_view()}
590 </select>
591 </label>
592 })
593 }}
594 <Show when=move || person().is_none_or(|p| p.trail_since.is_some())>
595 <label>
596 "Trail"
597 <select bind:value=span>
598 <option value="none">"None, current position only"</option>
599 <option value="24h">"Last 24 hours"</option>
600 <option value="day">"One day"</option>
601 <option value="range">"Time range"</option>
602 </select>
603 </label>
604 <Show when=move || span.get() == "day">
605 <label>"Day" <input type="date" bind:value=day /></label>
606 </Show>
607 <Show when=move || span.get() == "range">
608 <label>"From" <input type="datetime-local" bind:value=from /></label>
609 <label>"To" <input type="datetime-local" bind:value=to /></label>
610 </Show>
611 </Show>
612 <p class="hint">
613 {move || match (person(), range()) {
614 (_, Err(msg)) => msg.to_string(),
615 (None, _) => "Select a person to show their trail.".to_string(),
616 (Some(p), _) if p.trail_since.is_none() => format!("{} shares only their current position.", p.username),
617 _ if span.get() == "none" => String::new(),
618 (Some(Person { trail_since: Some(t @ 1..), username, .. }), _) => {
619 format!("{} {username} shares the trail since {}.", count(track.get().len()), fmt_time(t))
620 }
621 (Some(_), _) => count(track.get().len()),
622 }}
623 </p>
624 {move || expires.get().map(|t| view! { <p class="hint">"This link works until " {fmt_time(t)} "."</p> })}
625 {is_account.then(|| view! { <crate::locate::LocationControls /> })}
626 </aside>
627 <div class="map" node_ref=el></div>
628 </div>
629 }
630}
631
632fn count(n: usize) -> String {
633 match n {
634 1 => "1 point.".into(),
635 n => format!("{n} points."),
636 }
637}
638
639fn fit_opts() -> JsValue {
640 obj(&[
641 ("padding", Array::of2(&40.into(), &40.into()).into()),
642 ("maxZoom", 16.into()),
643 ])
644}
645
646fn describe(p: &Person) -> String {
647 let Some(d) = p.last() else {
648 return "no position yet".into();
649 };
650 let l = &d.last;
651 let mut parts = vec![ago(l.ts)];
652 if p.devices.len() > 1 {
653 parts.push(d.name.clone());
654 }
655 match (p.precision_m, l.acc) {
656 (0, Some(acc)) => parts.push(format!("±{acc:.0} m")),
657 (0, None) => {}
658 (m @ ..1000, _) => parts.push(format!("approximate, {m} m")),
659 (m, _) => parts.push(format!("approximate, {} km", m / 1000)),
660 }
661 if let Some(speed) = l.speed {
662 parts.push(format!("{:.0} km/h", speed * 3.6));
663 }
664 if let Some(b) = l.battery {
665 parts.push(format!("battery {b}%"));
666 }
667 parts.join(" · ")
668}
669