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 TILES: &str = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
14const ATTRIBUTION: &str =
15 "&copy; <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors";
16const POLL: Duration = Duration::from_secs(10);
17/// Positions older than this are drawn faded.
18const STALE_SECS: i64 = 3600;
19const COLORS: [&str; 8] = [
20 "#2563eb", "#dc2626", "#16a34a", "#9333ea", "#ea580c", "#0891b2", "#db2777", "#65a30d",
21];
22
23#[wasm_bindgen]
24extern "C" {
25 type LMap;
26 type Layer;
27
28 #[wasm_bindgen(js_namespace = L, js_name = map)]
29 fn leaflet_map(el: &web_sys::HtmlElement) -> LMap;
30 #[wasm_bindgen(method, js_name = setView)]
31 fn set_view(this: &LMap, center: &Array, zoom: f64);
32 #[wasm_bindgen(method, js_name = fitBounds)]
33 fn fit_bounds(this: &LMap, bounds: &Array, opts: &JsValue);
34 #[wasm_bindgen(method, js_name = panTo)]
35 fn pan_to(this: &LMap, center: &Array);
36 #[wasm_bindgen(method, js_name = remove)]
37 fn remove_map(this: &LMap);
38
39 #[wasm_bindgen(js_namespace = L, js_name = tileLayer)]
40 fn tile_layer(url: &str, opts: &JsValue) -> Layer;
41 #[wasm_bindgen(js_namespace = L, js_name = circleMarker)]
42 fn circle_marker(at: &Array, opts: &JsValue) -> Layer;
43 #[wasm_bindgen(js_namespace = L, js_name = circle)]
44 fn circle(at: &Array, opts: &JsValue) -> Layer;
45 #[wasm_bindgen(js_namespace = L, js_name = polyline)]
46 fn polyline(points: &Array, opts: &JsValue) -> Layer;
47
48 #[wasm_bindgen(method, js_name = addTo)]
49 fn add_to(this: &Layer, map: &LMap);
50 #[wasm_bindgen(method, js_name = remove)]
51 fn remove_layer(this: &Layer);
52 #[wasm_bindgen(method, js_name = setLatLng)]
53 fn set_lat_lng(this: &Layer, at: &Array);
54 #[wasm_bindgen(method, js_name = setRadius)]
55 fn set_radius(this: &Layer, metres: f64);
56 #[wasm_bindgen(method, js_name = setStyle)]
57 fn set_style(this: &Layer, opts: &JsValue);
58 #[wasm_bindgen(method, js_name = bindTooltip)]
59 fn bind_tooltip(this: &Layer, text: &str, opts: &JsValue);
60 #[wasm_bindgen(method)]
61 fn on(this: &Layer, event: &str, f: &JsValue);
62}
63
64fn obj(pairs: &[(&str, JsValue)]) -> JsValue {
65 let o = Object::new();
66 for (k, v) in pairs {
67 Reflect::set(&o, &JsValue::from_str(k), v).unwrap();
68 }
69 o.into()
70}
71
72fn latlng(lat: f64, lon: f64) -> Array {
73 Array::of2(&lat.into(), &lon.into())
74}
75
76fn color(id: i64) -> &'static str {
77 COLORS[id.rem_euclid(COLORS.len() as i64) as usize]
78}
79
80/// A person's position on the map: a dot and a circle for the accuracy.
81struct Marker {
82 dot: Layer,
83 accuracy: Layer,
84}
85
86fn sync_markers(
87 map: &LMap,
88 markers: &mut HashMap<i64, Marker>,
89 people: &[Person],
90 selected: RwSignal<Option<i64>>,
91) {
92 markers.retain(|id, m| {
93 let keep = people.iter().any(|p| p.id == *id && p.last().is_some());
94 if !keep {
95 m.dot.remove_layer();
96 m.accuracy.remove_layer();
97 }
98 keep
99 });
100 for p in people {
101 let Some(last) = p.last().map(|d| &d.last) else {
102 continue;
103 };
104 let at = latlng(last.lat, last.lon);
105 let m = markers.entry(p.id).or_insert_with(|| {
106 let c = color(p.id);
107 let accuracy = circle(
108 &at,
109 &obj(&[
110 ("radius", 0.into()),
111 ("color", c.into()),
112 ("weight", 1.into()),
113 ("fillOpacity", 0.1.into()),
114 ("interactive", false.into()),
115 ]),
116 );
117 let dot = circle_marker(
118 &at,
119 &obj(&[
120 ("radius", 8.into()),
121 ("color", "white".into()),
122 ("weight", 2.into()),
123 ("fillColor", c.into()),
124 ("fillOpacity", 1.into()),
125 ]),
126 );
127 dot.bind_tooltip(
128 &p.username,
129 &obj(&[
130 ("permanent", true.into()),
131 ("direction", "right".into()),
132 ("offset", Array::of2(&10.into(), &0.into()).into()),
133 ]),
134 );
135 let id = p.id;
136 dot.on(
137 "click",
138 &Closure::<dyn Fn()>::new(move || selected.set(Some(id))).into_js_value(),
139 );
140 accuracy.add_to(map);
141 dot.add_to(map);
142 Marker { dot, accuracy }
143 });
144 m.dot.set_lat_lng(&at);
145 m.accuracy.set_lat_lng(&at);
146 m.accuracy.set_radius(last.acc.unwrap_or(0.0) as f64);
147 let opacity = if now_secs() - last.ts > STALE_SECS {
148 0.35
149 } else {
150 1.0
151 };
152 m.dot.set_style(&obj(&[
153 ("fillOpacity", opacity.into()),
154 ("opacity", opacity.into()),
155 ]));
156 }
157}
158
159/// `YYYY-MM-DD` and `YYYY-MM-DDTHH:MM` in local time, the formats of date and datetime-local inputs.
160fn input_values(d: &js_sys::Date) -> (String, String) {
161 let day = format!(
162 "{:04}-{:02}-{:02}",
163 d.get_full_year(),
164 d.get_month() + 1,
165 d.get_date()
166 );
167 let time = format!("{day}T{:02}:{:02}", d.get_hours(), d.get_minutes());
168 (day, time)
169}
170
171/// Local midnight to midnight, in unix seconds, for a `YYYY-MM-DD` string.
172fn day_range(day: &str) -> Option<(i64, i64)> {
173 let mut it = day.split('-').map(|s| s.parse::<i32>().ok());
174 let (y, m, d) = (it.next()??, it.next()??, it.next()??);
175 let start = js_sys::Date::new_with_year_month_day(y as u32, m - 1, d).get_time();
176 let end = js_sys::Date::new_with_year_month_day(y as u32, m - 1, d + 1).get_time();
177 Some(((start / 1000.0) as i64, (end / 1000.0) as i64 - 1))
178}
179
180/// Unix seconds for a `YYYY-MM-DDTHH:MM` string. JavaScript reads that format as local time.
181pub fn local_time(value: &str) -> Option<i64> {
182 let ms = js_sys::Date::new(&JsValue::from_str(value)).get_time();
183 (!ms.is_nan()).then_some((ms / 1000.0) as i64)
184}
185
186fn trail_range(span: &str, day: &str, from: &str, to: &str) -> Result<(i64, i64), &'static str> {
187 match span {
188 "day" => day_range(day).ok_or("Pick a day."),
189 "range" => match (local_time(from), local_time(to)) {
190 (Some(f), Some(t)) if f >= t => Err("The start must be before the end."),
191 (Some(f), Some(t)) if t - f > MAX_TRACK_SECS => {
192 Err("The range can be at most 31 days.")
193 }
194 (Some(f), Some(t)) => Ok((f, t)),
195 _ => Err("Pick a start and an end."),
196 },
197 _ => Ok((now_secs() - 86400, now_secs())),
198 }
199}
200
201/// Shows the map to a guest link instead of the signed-in user.
202#[component]
203pub fn MapPage(#[prop(optional)] guest: Option<GuestAuth>) -> impl IntoView {
204 let is_guest = guest.is_some();
205 let guest = StoredValue::new(guest);
206 let expires = RwSignal::new(None::<i64>);
207 let people = RwSignal::new(Vec::<Person>::new());
208 let selected = RwSignal::new(None::<i64>);
209 let trail_device = RwSignal::new(None::<i64>);
210 let person = move || {
211 let id = selected.get()?;
212 people.with(|list| list.iter().find(|p| p.id == id).cloned())
213 };
214 // The chosen device, or the newest one if the chosen one is gone.
215 let device = move || {
216 let p = person()?;
217 let chosen = trail_device.get();
218 chosen
219 .filter(|id| p.devices.iter().any(|d| d.id == *id))
220 .or(p.last().map(|d| d.id))
221 };
222 let (today, now) = input_values(&js_sys::Date::new_0());
223 let span = RwSignal::new("none".to_string());
224 let day = RwSignal::new(today.clone());
225 let from = RwSignal::new(format!("{today}T00:00"));
226 let to = RwSignal::new(now);
227 let range = move || trail_range(&span.get(), &day.get(), &from.get(), &to.get());
228 // Identifies the chosen filter. The last 24 hours move with the clock but stay one view.
229 let filter_key = move || match span.get().as_str() {
230 "day" => format!("day {}", day.get()),
231 "range" => format!("range {} {}", from.get(), to.get()),
232 s => s.to_string(),
233 };
234 let track = RwSignal::new(Vec::<Point>::new());
235 let error = RwSignal::new(None::<String>);
236
237 let refresh = move || {
238 if document().hidden() {
239 return;
240 }
241 spawn_local(async move {
242 let result = match guest.get_value() {
243 None => http::get::<Vec<Person>>("/api/people").await,
244 Some(g) => http::post::<GuestView>("/api/guest", &g).await.map(|v| {
245 expires.set(v.expires_at);
246 vec![v.person]
247 }),
248 };
249 match result {
250 Ok(p) => {
251 // A guest sees one person, so their details open at once.
252 if is_guest && selected.get_untracked().is_none() {
253 selected.set(p.first().map(|p| p.id));
254 }
255 people.set(p);
256 error.set(None);
257 }
258 Err(e) if e.status == 404 && is_guest => {
259 error.set(Some("This link is no longer valid.".into()))
260 }
261 Err(e) => error.set(Some(e.to_string())),
262 }
263 })
264 };
265 refresh();
266 if let Ok(handle) = set_interval_with_handle(refresh, POLL) {
267 on_cleanup(move || handle.clear());
268 }
269 let visible = window_event_listener(leptos::ev::visibilitychange, move |_| refresh());
270 on_cleanup(move || visible.remove());
271
272 let el = NodeRef::<leptos::html::Div>::new();
273 let map = StoredValue::new_local(None::<LMap>);
274 let markers = StoredValue::new_local(HashMap::<i64, Marker>::new());
275 let trail = StoredValue::new_local(None::<Layer>);
276 let ready = RwSignal::new(false);
277 let fitted = StoredValue::new(false);
278 let trail_fitted_for = StoredValue::new(None::<(i64, String)>);
279
280 Effect::new(move |_| {
281 let Some(div) = el.get() else { return };
282 if ready.get_untracked() {
283 return;
284 }
285 let m = leaflet_map(&div);
286 m.set_view(&latlng(50.0, 10.0), 4.0);
287 tile_layer(
288 TILES,
289 &obj(&[("maxZoom", 19.into()), ("attribution", ATTRIBUTION.into())]),
290 )
291 .add_to(&m);
292 map.set_value(Some(m));
293 ready.set(true);
294 });
295 on_cleanup(move || {
296 map.with_value(|m| m.as_ref().map(LMap::remove_map))
297 .unwrap_or_default()
298 });
299
300 Effect::new(move |_| {
301 let list = people.get();
302 if !ready.get() {
303 return;
304 }
305 map.with_value(|m| {
306 let m = m.as_ref().unwrap();
307 markers.update_value(|mk| sync_markers(m, mk, &list, selected));
308 let positions: Vec<_> = list
309 .iter()
310 .filter_map(|p| p.last().map(|d| &d.last))
311 .collect();
312 if !fitted.get_value() && !positions.is_empty() {
313 fitted.set_value(true);
314 match positions[..] {
315 [p] => m.set_view(&latlng(p.lat, p.lon), 15.0),
316 _ => m.fit_bounds(
317 &positions.iter().map(|p| latlng(p.lat, p.lon)).collect(),
318 &fit_opts(),
319 ),
320 }
321 }
322 });
323 });
324
325 // Reloads on every poll too, so a current trail grows while you watch.
326 Effect::new(move |_| {
327 let (Some(p), Some(dev), Ok((from, to))) = (person(), device(), range()) else {
328 track.set(Vec::new());
329 return;
330 };
331 if p.trail == Trail::None || span.get() == "none" {
332 track.set(Vec::new());
333 return;
334 }
335 let id = p.id;
336 spawn_local(async move {
337 let result = match guest.get_value() {
338 None => {
339 http::get::<Vec<Point>>(&format!(
340 "/api/people/{id}/track?from={from}&to={to}&device={dev}"
341 ))
342 .await
343 }
344 Some(auth) => {
345 let body = GuestTrack {
346 auth,
347 device: dev,
348 from,
349 to,
350 };
351 http::post::<Vec<Point>>("/api/guest/track", &body).await
352 }
353 };
354 match result {
355 Ok(t) => track.set(t),
356 Err(e) => error.set(Some(e.to_string())),
357 }
358 });
359 });
360
361 Effect::new(move |_| {
362 let points = track.get();
363 if !ready.get() {
364 return;
365 }
366 map.with_value(|m| {
367 let m = m.as_ref().unwrap();
368 trail.update_value(|old| {
369 if let Some(line) = old.take() {
370 line.remove_layer();
371 }
372 let (Some(id), Some(dev)) = (selected.get_untracked(), untrack(device)) else {
373 return;
374 };
375 if points.is_empty() {
376 return;
377 }
378 let coords: Array = points.iter().map(|p| latlng(p.lat, p.lon)).collect();
379 let line = polyline(
380 &coords,
381 &obj(&[
382 ("color", color(id).into()),
383 ("weight", 4.into()),
384 ("opacity", 0.7.into()),
385 ]),
386 );
387 line.add_to(m);
388 *old = Some(line);
389 // Fit once per selection, not on every poll, so the user can pan freely.
390 let key = Some((dev, untrack(filter_key)));
391 if trail_fitted_for.get_value() != key {
392 trail_fitted_for.set_value(key);
393 m.fit_bounds(&coords, &fit_opts());
394 }
395 });
396 });
397 });
398
399 let select = move |p: &Person| {
400 let id = p.id;
401 if selected.get_untracked() == Some(id) {
402 selected.set(None);
403 return;
404 }
405 selected.set(Some(id));
406 trail_device.set(p.last().map(|d| d.id));
407 if let Some(last) = p.last().map(|d| &d.last) {
408 map.with_value(|m| m.as_ref().map(|m| m.pan_to(&latlng(last.lat, last.lon))));
409 }
410 };
411
412 view! {
413 <div class="map-page">
414 <aside>
415 <p class="error">{move || error.get()}</p>
416 <ul class="people">
417 {move || {
418 people
419 .get()
420 .into_iter()
421 .enumerate()
422 .map(|(i, p)| {
423 let id = p.id;
424 let detail = describe(&p);
425 let name = if i == 0 && !is_guest { format!("{} (you)", p.username) } else { p.username.clone() };
426 view! {
427 <li class:selected=move || selected.get() == Some(id) on:click=move |_| select(&p)>
428 <span class="dot" style:background=color(id)></span>
429 <span class="name">{name}</span>
430 <div class="hint">{detail}</div>
431 </li>
432 }
433 })
434 .collect_view()
435 }}
436 </ul>
437 {move || {
438 let p = person().filter(|p| p.trail != Trail::None && !p.devices.is_empty())?;
439 let current = device();
440 Some(view! {
441 <label>
442 "Device"
443 <select on:change:target=move |ev| trail_device.set(ev.target().value().parse().ok())>
444 {p
445 .devices
446 .into_iter()
447 .map(|d| view! { <option value=d.id selected=current == Some(d.id)>{d.name}</option> })
448 .collect_view()}
449 </select>
450 </label>
451 })
452 }}
453 <Show when=move || person().is_none_or(|p| p.trail != Trail::None)>
454 <label>
455 "Trail"
456 <select bind:value=span>
457 <option value="none">"None, current position only"</option>
458 <option value="24h">"Last 24 hours"</option>
459 <option value="day">"One day"</option>
460 <option value="range">"Time range"</option>
461 </select>
462 </label>
463 <Show when=move || span.get() == "day">
464 <label>"Day" <input type="date" bind:value=day /></label>
465 </Show>
466 <Show when=move || span.get() == "range">
467 <label>"From" <input type="datetime-local" bind:value=from /></label>
468 <label>"To" <input type="datetime-local" bind:value=to /></label>
469 </Show>
470 </Show>
471 <p class="hint">
472 {move || match (person(), range()) {
473 (_, Err(msg)) => msg.to_string(),
474 (None, _) => "Select a person to show their trail.".to_string(),
475 (Some(p), _) if p.trail == Trail::None => format!("{} shares only their current position.", p.username),
476 _ if span.get() == "none" => String::new(),
477 (Some(Person { trail: Trail::Since(t), username, .. }), _) => {
478 format!("{} {username} shares the trail since {}.", count(track.get().len()), fmt_time(t))
479 }
480 (Some(_), _) => count(track.get().len()),
481 }}
482 </p>
483 {move || expires.get().map(|t| view! { <p class="hint">"This link works until " {fmt_time(t)} "."</p> })}
484 {(!is_guest).then(|| view! { <crate::locate::LocationControls /> })}
485 </aside>
486 <div class="map" node_ref=el></div>
487 </div>
488 }
489}
490
491fn count(n: usize) -> String {
492 match n {
493 1 => "1 point.".into(),
494 n => format!("{n} points."),
495 }
496}
497
498fn fit_opts() -> JsValue {
499 obj(&[
500 ("padding", Array::of2(&40.into(), &40.into()).into()),
501 ("maxZoom", 16.into()),
502 ])
503}
504
505fn describe(p: &Person) -> String {
506 let Some(d) = p.last() else {
507 return "no position yet".into();
508 };
509 let l = &d.last;
510 let mut parts = vec![ago(l.ts)];
511 if p.devices.len() > 1 {
512 parts.push(d.name.clone());
513 }
514 match (p.precision_m, l.acc) {
515 (0, Some(acc)) => parts.push(format!("±{acc:.0} m")),
516 (0, None) => {}
517 (m @ ..1000, _) => parts.push(format!("approximate, {m} m")),
518 (m, _) => parts.push(format!("approximate, {} km", m / 1000)),
519 }
520 if let Some(speed) = l.speed {
521 parts.push(format!("{:.0} km/h", speed * 3.6));
522 }
523 if let Some(b) = l.battery {
524 parts.push(format!("battery {b}%"));
525 }
526 parts.join(" · ")
527}
528