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.name()),
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.default_device)
352 .or(p.last().map(|d| d.id))
353 };
354 let (today, now) = input_values(&js_sys::Date::new_0());
355 let span = RwSignal::new("none".to_string());
356 let day = RwSignal::new(today.clone());
357 let from = RwSignal::new(format!("{today}T00:00"));
358 let to = RwSignal::new(now);
359 let range = move || trail_range(&span.get(), &day.get(), &from.get(), &to.get());
360 // Identifies the chosen filter. The last 24 hours move with the clock but stay one view.
361 let filter_key = move || match span.get().as_str() {
362 "day" => format!("day {}", day.get()),
363 "range" => format!("range {} {}", from.get(), to.get()),
364 s => s.to_string(),
365 };
366 let track = RwSignal::new(Vec::<Point>::new());
367 let error = RwSignal::new(None::<String>);
368
369 let refresh = move || {
370 if document().hidden() {
371 return;
372 }
373 spawn_local(async move {
374 let result = match source.get_value() {
375 Source::Account => http::get::<Vec<Person>>("/api/people").await,
376 Source::Guest(g) => http::post::<GuestView>("/api/guest", &g).await.map(|v| {
377 expires.set(v.expires_at);
378 vec![v.person]
379 }),
380 Source::Device(t) => http::get_bearer::<Person>("/api/device", &t)
381 .await
382 .map(|p| vec![p]),
383 };
384 match result {
385 Ok(p) => {
386 // Guests and the app see one person, so their details open at once.
387 if !is_account && selected.get_untracked().is_none() {
388 selected.set(p.first().map(|p| p.id));
389 }
390 people.set(p);
391 error.set(None);
392 }
393 Err(e) if e.status == 404 && is_guest => {
394 error.set(Some(tr("This link is no longer valid.")))
395 }
396 Err(e) if e.status == 401 && is_device => {
397 error.set(Some(tr("This device was removed. Connect the app again.")))
398 }
399 Err(e) => error.set(Some(e.to_string())),
400 }
401 })
402 };
403 refresh();
404 if let Ok(handle) = set_interval_with_handle(refresh, POLL) {
405 on_cleanup(move || handle.clear());
406 }
407 let visible = window_event_listener(leptos::ev::visibilitychange, move |_| refresh());
408 on_cleanup(move || visible.remove());
409
410 let el = NodeRef::<leptos::html::Div>::new();
411 let map = StoredValue::new_local(None::<LMap>);
412 let markers = StoredValue::new_local(HashMap::<i64, Marker>::new());
413 let trail = StoredValue::new_local(None::<Layer>);
414 let ready = RwSignal::new(false);
415 let fitted = StoredValue::new(false);
416 let trail_fitted_for = StoredValue::new(None::<(i64, String)>);
417
418 Effect::new(move |_| {
419 let Some(div) = el.get() else { return };
420 if ready.get_untracked() {
421 return;
422 }
423 // Our own zoom control, for translated button titles.
424 let m = leaflet_map(&div, &obj(&[("zoomControl", false.into())]));
425 zoom_control(&obj(&[
426 ("zoomInTitle", tr("Zoom in").into()),
427 ("zoomOutTitle", tr("Zoom out").into()),
428 ]))
429 .add_control_to(&m);
430 m.set_view(&latlng(50.0, 10.0), 4.0);
431 add_base_maps(&m);
432 map.set_value(Some(m));
433 ready.set(true);
434 });
435 on_cleanup(move || {
436 map.with_value(|m| m.as_ref().map(LMap::remove_map))
437 .unwrap_or_default()
438 });
439
440 Effect::new(move |_| {
441 let list = people.get();
442 if !ready.get() {
443 return;
444 }
445 map.with_value(|m| {
446 let m = m.as_ref().unwrap();
447 markers.update_value(|mk| sync_markers(m, mk, &list, selected));
448 let positions: Vec<_> = list
449 .iter()
450 .filter_map(|p| p.last().map(|d| &d.last))
451 .collect();
452 if !fitted.get_value() && !positions.is_empty() {
453 fitted.set_value(true);
454 match positions[..] {
455 [p] => m.set_view(&latlng(p.lat, p.lon), 15.0),
456 _ => m.fit_bounds(
457 &positions.iter().map(|p| latlng(p.lat, p.lon)).collect(),
458 &fit_opts(),
459 ),
460 }
461 }
462 });
463 });
464
465 // The trail on screen, or being loaded: person, device and filter.
466 let track_key = StoredValue::new(None::<String>);
467 Effect::new(move |_| {
468 let (Some(p), Some(dev), Ok((from, to))) = (person(), device(), range()) else {
469 track_key.set_value(None);
470 track.set(Vec::new());
471 return;
472 };
473 if p.trail_since.is_none() || span.get() == "none" {
474 track_key.set_value(None);
475 track.set(Vec::new());
476 return;
477 }
478 let id = p.id;
479 let key = format!("{id} {dev} {}", filter_key());
480 // A range that ends now grows with each poll. A past range does not change, so it loads once.
481 let live = to >= now_secs() - 2 * POLL.as_secs() as i64;
482 if !live && track_key.get_value().as_deref() == Some(key.as_str()) {
483 return;
484 }
485 track_key.set_value(Some(key.clone()));
486 spawn_local(async move {
487 let result = match source.get_value() {
488 Source::Account => {
489 http::get::<Vec<Point>>(&format!(
490 "/api/people/{id}/track?from={from}&to={to}&device={dev}"
491 ))
492 .await
493 }
494 Source::Device(t) => {
495 http::get_bearer::<Vec<Point>>(
496 &format!("/api/device/track?from={from}&to={to}"),
497 &t,
498 )
499 .await
500 }
501 Source::Guest(auth) => {
502 let body = GuestTrack {
503 auth,
504 device: dev,
505 from,
506 to,
507 };
508 http::post::<Vec<Point>>("/api/guest/track", &body).await
509 }
510 };
511 // A slow answer for an earlier choice must not replace the current trail.
512 if track_key.get_value().as_deref() != Some(key.as_str()) {
513 return;
514 }
515 match result {
516 Ok(t) => track.set(t),
517 Err(e) => error.set(Some(e.to_string())),
518 }
519 });
520 });
521
522 Effect::new(move |_| {
523 let points = track.get();
524 if !ready.get() {
525 return;
526 }
527 map.with_value(|m| {
528 let m = m.as_ref().unwrap();
529 trail.update_value(|old| {
530 if let Some(line) = old.take() {
531 line.remove_layer();
532 }
533 let (Some(id), Some(dev)) = (selected.get_untracked(), untrack(device)) else {
534 return;
535 };
536 if points.is_empty() {
537 return;
538 }
539 let coords: Array = points.iter().map(|p| latlng(p.lat, p.lon)).collect();
540 let line = polyline(
541 &coords,
542 &obj(&[
543 ("color", color(id).into()),
544 ("weight", 4.into()),
545 ("opacity", 0.7.into()),
546 ]),
547 );
548 line.add_to(m);
549 *old = Some(line);
550 // Fit once per selection, not on every poll, so the user can pan freely.
551 let key = Some((dev, untrack(filter_key)));
552 if trail_fitted_for.get_value() != key {
553 trail_fitted_for.set_value(key);
554 m.fit_bounds(&coords, &fit_opts());
555 }
556 });
557 });
558 });
559
560 let select = move |p: &Person| {
561 let id = p.id;
562 if selected.get_untracked() == Some(id) {
563 selected.set(None);
564 return;
565 }
566 selected.set(Some(id));
567 trail_device.set(p.default_device.or(p.last().map(|d| d.id)));
568 if let Some(last) = p.last().map(|d| &d.last) {
569 map.with_value(|m| m.as_ref().map(|m| m.pan_to(&latlng(last.lat, last.lon))));
570 }
571 };
572
573 view! {
574 <div class="map-page">
575 <aside>
576 <p class="error">{move || error.get()}</p>
577 <ul class="people">
578 {move || {
579 people
580 .get()
581 .into_iter()
582 .enumerate()
583 .map(|(i, p)| {
584 let id = p.id;
585 let detail = describe(&p);
586 let name = if i == 0 && !is_guest { tr(&format!("{} (you)", p.name())) } else { p.name().to_owned() };
587 view! {
588 <li class:selected=move || selected.get() == Some(id) on:click=move |_| select(&p)>
589 <span class="dot" style:background=color(id)></span>
590 <span class="name">{name}</span>
591 <div class="hint">{detail.into_iter().map(|line| view! { <div>{line}</div> }).collect_view()}</div>
592 </li>
593 }
594 })
595 .collect_view()
596 }}
597 </ul>
598 {move || {
599 let p = person().filter(|p| !is_device && p.trail_since.is_some() && !p.devices.is_empty())?;
600 let current = device();
601 Some(view! {
602 <label>
603 {tr("Device")}
604 <select on:change:target=move |ev| trail_device.set(ev.target().value().parse().ok())>
605 {p
606 .devices
607 .into_iter()
608 .map(|d| view! { <option value=d.id selected=current == Some(d.id)>{d.name}</option> })
609 .collect_view()}
610 </select>
611 </label>
612 })
613 }}
614 <Show when=move || person().is_none_or(|p| p.trail_since.is_some())>
615 <label>
616 {tr("Trail")}
617 <select bind:value=span>
618 <option value="none">{tr("None, current position only")}</option>
619 <option value="24h">{tr("Last 24 hours")}</option>
620 <option value="day">{tr("One day")}</option>
621 <option value="range">{tr("Time range")}</option>
622 </select>
623 </label>
624 <Show when=move || span.get() == "day">
625 <label>{tr("Day")} <input type="date" bind:value=day /></label>
626 </Show>
627 <Show when=move || span.get() == "range">
628 <label>{tr("From")} <input type="datetime-local" bind:value=from /></label>
629 <label>{tr("To")} <input type="datetime-local" bind:value=to /></label>
630 </Show>
631 </Show>
632 <p class="hint">
633 {move || match (person(), range()) {
634 (_, Err(msg)) => tr(msg),
635 (None, _) => tr("Select a person to show their trail."),
636 (Some(p), _) if p.trail_since.is_none() => tr(&format!("{} shares only their current position.", p.name())),
637 _ if span.get() == "none" => String::new(),
638 (Some(p @ Person { trail_since: Some(t @ 1..), .. }), _) => {
639 format!("{} {}", count(track.get().len()), tr(&format!("{} shares the trail since {}.", p.name(), fmt_time(t))))
640 }
641 (Some(_), _) => count(track.get().len()),
642 }}
643 </p>
644 {move || expires.get().map(|t| view! { <p class="hint">{tr(&format!("This link works until {}.", fmt_time(t)))}</p> })}
645 {is_account.then(|| view! { <crate::locate::LocationControls /> })}
646 </aside>
647 <div class="map" node_ref=el></div>
648 </div>
649 }
650}
651
652fn count(n: usize) -> String {
653 match n {
654 1 => tr("1 point."),
655 n => tr(&format!("{n} points.")),
656 }
657}
658
659fn fit_opts() -> JsValue {
660 obj(&[
661 ("padding", Array::of2(&40.into(), &40.into()).into()),
662 ("maxZoom", 16.into()),
663 ])
664}
665
666fn describe(p: &Person) -> Vec<String> {
667 let Some(d) = p.last() else {
668 return vec![tr("no position yet")];
669 };
670 let l = &d.last;
671 let mut parts = vec![format!("{} ({})", since(l.ts), fmt_time(l.ts))];
672 if p.devices.len() > 1 {
673 parts.push(d.name.clone());
674 }
675 match (p.precision_m, l.acc) {
676 (0, Some(acc)) => parts.push(format!("±{acc:.0} m")),
677 (0, None) => {}
678 (m @ ..1000, _) => parts.push(tr(&format!("approximate, {m} m"))),
679 (m, _) => parts.push(tr(&format!("approximate, {} km", m / 1000))),
680 }
681 if let Some(speed) = l.speed {
682 parts.push(format!("{:.0} km/h", speed * 3.6));
683 }
684 if let Some(b) = l.battery {
685 parts.push(tr(&format!("battery {b}%")));
686 }
687 parts
688}
689