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/// Shows the map to a guest link instead of the signed-in user.
302#[component]
303pub fn MapPage(#[prop(optional)] guest: Option<GuestAuth>) -> impl IntoView {
304 let is_guest = guest.is_some();
305 let guest = StoredValue::new(guest);
306 let expires = RwSignal::new(None::<i64>);
307 let people = RwSignal::new(Vec::<Person>::new());
308 let selected = RwSignal::new(None::<i64>);
309 let trail_device = RwSignal::new(None::<i64>);
310 let person = move || {
311 let id = selected.get()?;
312 people.with(|list| list.iter().find(|p| p.id == id).cloned())
313 };
314 // The chosen device, or the newest one if the chosen one is gone.
315 let device = move || {
316 let p = person()?;
317 let chosen = trail_device.get();
318 chosen
319 .filter(|id| p.devices.iter().any(|d| d.id == *id))
320 .or(p.last().map(|d| d.id))
321 };
322 let (today, now) = input_values(&js_sys::Date::new_0());
323 let span = RwSignal::new("none".to_string());
324 let day = RwSignal::new(today.clone());
325 let from = RwSignal::new(format!("{today}T00:00"));
326 let to = RwSignal::new(now);
327 let range = move || trail_range(&span.get(), &day.get(), &from.get(), &to.get());
328 // Identifies the chosen filter. The last 24 hours move with the clock but stay one view.
329 let filter_key = move || match span.get().as_str() {
330 "day" => format!("day {}", day.get()),
331 "range" => format!("range {} {}", from.get(), to.get()),
332 s => s.to_string(),
333 };
334 let track = RwSignal::new(Vec::<Point>::new());
335 let error = RwSignal::new(None::<String>);
336
337 let refresh = move || {
338 if document().hidden() {
339 return;
340 }
341 spawn_local(async move {
342 let result = match guest.get_value() {
343 None => http::get::<Vec<Person>>("/api/people").await,
344 Some(g) => http::post::<GuestView>("/api/guest", &g).await.map(|v| {
345 expires.set(v.expires_at);
346 vec![v.person]
347 }),
348 };
349 match result {
350 Ok(p) => {
351 // A guest sees one person, so their details open at once.
352 if is_guest && selected.get_untracked().is_none() {
353 selected.set(p.first().map(|p| p.id));
354 }
355 people.set(p);
356 error.set(None);
357 }
358 Err(e) if e.status == 404 && is_guest => {
359 error.set(Some("This link is no longer valid.".into()))
360 }
361 Err(e) => error.set(Some(e.to_string())),
362 }
363 })
364 };
365 refresh();
366 if let Ok(handle) = set_interval_with_handle(refresh, POLL) {
367 on_cleanup(move || handle.clear());
368 }
369 let visible = window_event_listener(leptos::ev::visibilitychange, move |_| refresh());
370 on_cleanup(move || visible.remove());
371
372 let el = NodeRef::<leptos::html::Div>::new();
373 let map = StoredValue::new_local(None::<LMap>);
374 let markers = StoredValue::new_local(HashMap::<i64, Marker>::new());
375 let trail = StoredValue::new_local(None::<Layer>);
376 let ready = RwSignal::new(false);
377 let fitted = StoredValue::new(false);
378 let trail_fitted_for = StoredValue::new(None::<(i64, String)>);
379
380 Effect::new(move |_| {
381 let Some(div) = el.get() else { return };
382 if ready.get_untracked() {
383 return;
384 }
385 let m = leaflet_map(&div);
386 m.set_view(&latlng(50.0, 10.0), 4.0);
387 add_base_maps(&m);
388 map.set_value(Some(m));
389 ready.set(true);
390 });
391 on_cleanup(move || {
392 map.with_value(|m| m.as_ref().map(LMap::remove_map))
393 .unwrap_or_default()
394 });
395
396 Effect::new(move |_| {
397 let list = people.get();
398 if !ready.get() {
399 return;
400 }
401 map.with_value(|m| {
402 let m = m.as_ref().unwrap();
403 markers.update_value(|mk| sync_markers(m, mk, &list, selected));
404 let positions: Vec<_> = list
405 .iter()
406 .filter_map(|p| p.last().map(|d| &d.last))
407 .collect();
408 if !fitted.get_value() && !positions.is_empty() {
409 fitted.set_value(true);
410 match positions[..] {
411 [p] => m.set_view(&latlng(p.lat, p.lon), 15.0),
412 _ => m.fit_bounds(
413 &positions.iter().map(|p| latlng(p.lat, p.lon)).collect(),
414 &fit_opts(),
415 ),
416 }
417 }
418 });
419 });
420
421 // Reloads on every poll too, so a current trail grows while you watch.
422 Effect::new(move |_| {
423 let (Some(p), Some(dev), Ok((from, to))) = (person(), device(), range()) else {
424 track.set(Vec::new());
425 return;
426 };
427 if p.trail == Trail::None || span.get() == "none" {
428 track.set(Vec::new());
429 return;
430 }
431 let id = p.id;
432 spawn_local(async move {
433 let result = match guest.get_value() {
434 None => {
435 http::get::<Vec<Point>>(&format!(
436 "/api/people/{id}/track?from={from}&to={to}&device={dev}"
437 ))
438 .await
439 }
440 Some(auth) => {
441 let body = GuestTrack {
442 auth,
443 device: dev,
444 from,
445 to,
446 };
447 http::post::<Vec<Point>>("/api/guest/track", &body).await
448 }
449 };
450 match result {
451 Ok(t) => track.set(t),
452 Err(e) => error.set(Some(e.to_string())),
453 }
454 });
455 });
456
457 Effect::new(move |_| {
458 let points = track.get();
459 if !ready.get() {
460 return;
461 }
462 map.with_value(|m| {
463 let m = m.as_ref().unwrap();
464 trail.update_value(|old| {
465 if let Some(line) = old.take() {
466 line.remove_layer();
467 }
468 let (Some(id), Some(dev)) = (selected.get_untracked(), untrack(device)) else {
469 return;
470 };
471 if points.is_empty() {
472 return;
473 }
474 let coords: Array = points.iter().map(|p| latlng(p.lat, p.lon)).collect();
475 let line = polyline(
476 &coords,
477 &obj(&[
478 ("color", color(id).into()),
479 ("weight", 4.into()),
480 ("opacity", 0.7.into()),
481 ]),
482 );
483 line.add_to(m);
484 *old = Some(line);
485 // Fit once per selection, not on every poll, so the user can pan freely.
486 let key = Some((dev, untrack(filter_key)));
487 if trail_fitted_for.get_value() != key {
488 trail_fitted_for.set_value(key);
489 m.fit_bounds(&coords, &fit_opts());
490 }
491 });
492 });
493 });
494
495 let select = move |p: &Person| {
496 let id = p.id;
497 if selected.get_untracked() == Some(id) {
498 selected.set(None);
499 return;
500 }
501 selected.set(Some(id));
502 trail_device.set(p.last().map(|d| d.id));
503 if let Some(last) = p.last().map(|d| &d.last) {
504 map.with_value(|m| m.as_ref().map(|m| m.pan_to(&latlng(last.lat, last.lon))));
505 }
506 };
507
508 view! {
509 <div class="map-page">
510 <aside>
511 <p class="error">{move || error.get()}</p>
512 <ul class="people">
513 {move || {
514 people
515 .get()
516 .into_iter()
517 .enumerate()
518 .map(|(i, p)| {
519 let id = p.id;
520 let detail = describe(&p);
521 let name = if i == 0 && !is_guest { format!("{} (you)", p.username) } else { p.username.clone() };
522 view! {
523 <li class:selected=move || selected.get() == Some(id) on:click=move |_| select(&p)>
524 <span class="dot" style:background=color(id)></span>
525 <span class="name">{name}</span>
526 <div class="hint">{detail}</div>
527 </li>
528 }
529 })
530 .collect_view()
531 }}
532 </ul>
533 {move || {
534 let p = person().filter(|p| p.trail != Trail::None && !p.devices.is_empty())?;
535 let current = device();
536 Some(view! {
537 <label>
538 "Device"
539 <select on:change:target=move |ev| trail_device.set(ev.target().value().parse().ok())>
540 {p
541 .devices
542 .into_iter()
543 .map(|d| view! { <option value=d.id selected=current == Some(d.id)>{d.name}</option> })
544 .collect_view()}
545 </select>
546 </label>
547 })
548 }}
549 <Show when=move || person().is_none_or(|p| p.trail != Trail::None)>
550 <label>
551 "Trail"
552 <select bind:value=span>
553 <option value="none">"None, current position only"</option>
554 <option value="24h">"Last 24 hours"</option>
555 <option value="day">"One day"</option>
556 <option value="range">"Time range"</option>
557 </select>
558 </label>
559 <Show when=move || span.get() == "day">
560 <label>"Day" <input type="date" bind:value=day /></label>
561 </Show>
562 <Show when=move || span.get() == "range">
563 <label>"From" <input type="datetime-local" bind:value=from /></label>
564 <label>"To" <input type="datetime-local" bind:value=to /></label>
565 </Show>
566 </Show>
567 <p class="hint">
568 {move || match (person(), range()) {
569 (_, Err(msg)) => msg.to_string(),
570 (None, _) => "Select a person to show their trail.".to_string(),
571 (Some(p), _) if p.trail == Trail::None => format!("{} shares only their current position.", p.username),
572 _ if span.get() == "none" => String::new(),
573 (Some(Person { trail: Trail::Since(t), username, .. }), _) => {
574 format!("{} {username} shares the trail since {}.", count(track.get().len()), fmt_time(t))
575 }
576 (Some(_), _) => count(track.get().len()),
577 }}
578 </p>
579 {move || expires.get().map(|t| view! { <p class="hint">"This link works until " {fmt_time(t)} "."</p> })}
580 {(!is_guest).then(|| view! { <crate::locate::LocationControls /> })}
581 </aside>
582 <div class="map" node_ref=el></div>
583 </div>
584 }
585}
586
587fn count(n: usize) -> String {
588 match n {
589 1 => "1 point.".into(),
590 n => format!("{n} points."),
591 }
592}
593
594fn fit_opts() -> JsValue {
595 obj(&[
596 ("padding", Array::of2(&40.into(), &40.into()).into()),
597 ("maxZoom", 16.into()),
598 ])
599}
600
601fn describe(p: &Person) -> String {
602 let Some(d) = p.last() else {
603 return "no position yet".into();
604 };
605 let l = &d.last;
606 let mut parts = vec![ago(l.ts)];
607 if p.devices.len() > 1 {
608 parts.push(d.name.clone());
609 }
610 match (p.precision_m, l.acc) {
611 (0, Some(acc)) => parts.push(format!("±{acc:.0} m")),
612 (0, None) => {}
613 (m @ ..1000, _) => parts.push(format!("approximate, {m} m")),
614 (m, _) => parts.push(format!("approximate, {} km", m / 1000)),
615 }
616 if let Some(speed) = l.speed {
617 parts.push(format!("{:.0} km/h", speed * 3.6));
618 }
619 if let Some(b) = l.battery {
620 parts.push(format!("battery {b}%"));
621 }
622 parts.join(" · ")
623}
624