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