use std::collections::HashMap; use std::time::Duration; use api::{GuestAuth, GuestTrack, GuestView, MAX_TRACK_SECS, Person, Point, Trail}; use js_sys::{Array, Object, Reflect}; use leptos::prelude::*; use leptos::task::spawn_local; use wasm_bindgen::JsValue; use wasm_bindgen::prelude::*; use crate::{ago, fmt_time, http, now_secs}; const OSM: &str = "© OpenStreetMap contributors"; /// localStorage key for the chosen base map. const LAYER_KEY: &str = "mapLayer"; const POLL: Duration = Duration::from_secs(10); /// Positions older than this are drawn faded. const STALE_SECS: i64 = 3600; const COLORS: [&str; 8] = [ "#2563eb", "#dc2626", "#16a34a", "#9333ea", "#ea580c", "#0891b2", "#db2777", "#65a30d", ]; #[wasm_bindgen] extern "C" { type LMap; type Layer; #[wasm_bindgen(js_namespace = L, js_name = map)] fn leaflet_map(el: &web_sys::HtmlElement) -> LMap; #[wasm_bindgen(method, js_name = setView)] fn set_view(this: &LMap, center: &Array, zoom: f64); #[wasm_bindgen(method, js_name = fitBounds)] fn fit_bounds(this: &LMap, bounds: &Array, opts: &JsValue); #[wasm_bindgen(method, js_name = panTo)] fn pan_to(this: &LMap, center: &Array); #[wasm_bindgen(method, js_name = remove)] fn remove_map(this: &LMap); #[wasm_bindgen(method, js_name = on)] fn on_map(this: &LMap, event: &str, f: &JsValue); type Control; #[wasm_bindgen(js_namespace = ["L", "control"], js_name = layers)] fn layers_control(base: &JsValue, overlays: &JsValue, opts: &JsValue) -> Control; #[wasm_bindgen(method, js_name = addTo)] fn add_control_to(this: &Control, map: &LMap); #[wasm_bindgen(js_namespace = L, js_name = tileLayer)] fn tile_layer(url: &str, opts: &JsValue) -> Layer; #[wasm_bindgen(js_namespace = L, js_name = circleMarker)] fn circle_marker(at: &Array, opts: &JsValue) -> Layer; #[wasm_bindgen(js_namespace = L, js_name = circle)] fn circle(at: &Array, opts: &JsValue) -> Layer; #[wasm_bindgen(js_namespace = L, js_name = polyline)] fn polyline(points: &Array, opts: &JsValue) -> Layer; #[wasm_bindgen(method, js_name = addTo)] fn add_to(this: &Layer, map: &LMap); #[wasm_bindgen(method, js_name = remove)] fn remove_layer(this: &Layer); #[wasm_bindgen(method, js_name = setLatLng)] fn set_lat_lng(this: &Layer, at: &Array); #[wasm_bindgen(method, js_name = setRadius)] fn set_radius(this: &Layer, metres: f64); #[wasm_bindgen(method, js_name = setStyle)] fn set_style(this: &Layer, opts: &JsValue); #[wasm_bindgen(method, js_name = bindTooltip)] fn bind_tooltip(this: &Layer, text: &str, opts: &JsValue); #[wasm_bindgen(method)] fn on(this: &Layer, event: &str, f: &JsValue); } fn obj(pairs: &[(&str, JsValue)]) -> JsValue { let o = Object::new(); for (k, v) in pairs { Reflect::set(&o, &JsValue::from_str(k), v).unwrap(); } o.into() } /// Street maps get the `street` class, which the dark theme inverts. None of these sources has a dark style. fn tiles(url: &str, max_zoom: u8, attribution: &str, street: bool) -> Layer { tile_layer( url, &obj(&[ ("maxZoom", max_zoom.into()), ("attribution", attribution.into()), ("className", if street { "street" } else { "" }.into()), ]), ) } /// Adds the base maps and the control to switch them. Remembers the choice. fn add_base_maps(m: &LMap) { let storage = window().local_storage().ok().flatten(); let chosen = storage .as_ref() .and_then(|s| s.get_item(LAYER_KEY).ok().flatten()); let bases = [ ( "OpenStreetMap", tiles( "https://tile.openstreetmap.org/{z}/{x}/{y}.png", 19, OSM, true, ), ), ( "Humanitarian", tiles( "https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png", 19, &format!( "{OSM}, style by the Humanitarian OpenStreetMap Team, hosted by OpenStreetMap France" ), true, ), ), ( "Cycling", tiles( "https://{s}.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png", 20, &format!("CyclOSM, map data {OSM}"), true, ), ), ( "Topographic", tiles( "https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", 17, &format!( "Map data {OSM}, SRTM. Style © OpenTopoMap (CC-BY-SA)" ), true, ), ), ( "Satellite", tiles( "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", 19, "Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community", false, ), ), ]; let index = bases .iter() .position(|(name, _)| chosen.as_deref() == Some(*name)) .unwrap_or(0); bases[index].1.add_to(m); let control = Object::new(); for (name, layer) in &bases { Reflect::set(&control, &(*name).into(), layer).unwrap(); } layers_control(&control, &JsValue::UNDEFINED, &JsValue::UNDEFINED).add_control_to(m); let save = Closure::::new(move |e: JsValue| { let name = Reflect::get(&e, &"name".into()) .ok() .and_then(|n| n.as_string()); if let (Some(s), Some(name)) = (&storage, name) { let _ = s.set_item(LAYER_KEY, &name); } }); m.on_map("baselayerchange", &save.into_js_value()); } fn latlng(lat: f64, lon: f64) -> Array { Array::of2(&lat.into(), &lon.into()) } fn color(id: i64) -> &'static str { COLORS[id.rem_euclid(COLORS.len() as i64) as usize] } /// A person's position on the map: a dot and a circle for the accuracy. struct Marker { dot: Layer, accuracy: Layer, } fn sync_markers( map: &LMap, markers: &mut HashMap, people: &[Person], selected: RwSignal>, ) { markers.retain(|id, m| { let keep = people.iter().any(|p| p.id == *id && p.last().is_some()); if !keep { m.dot.remove_layer(); m.accuracy.remove_layer(); } keep }); for p in people { let Some(last) = p.last().map(|d| &d.last) else { continue; }; let at = latlng(last.lat, last.lon); let m = markers.entry(p.id).or_insert_with(|| { let c = color(p.id); let accuracy = circle( &at, &obj(&[ ("radius", 0.into()), ("color", c.into()), ("weight", 1.into()), ("fillOpacity", 0.1.into()), ("interactive", false.into()), ]), ); let dot = circle_marker( &at, &obj(&[ ("radius", 8.into()), ("color", "white".into()), ("weight", 2.into()), ("fillColor", c.into()), ("fillOpacity", 1.into()), ]), ); dot.bind_tooltip( &p.username, &obj(&[ ("permanent", true.into()), ("direction", "right".into()), ("offset", Array::of2(&10.into(), &0.into()).into()), ]), ); let id = p.id; dot.on( "click", &Closure::::new(move || selected.set(Some(id))).into_js_value(), ); accuracy.add_to(map); dot.add_to(map); Marker { dot, accuracy } }); m.dot.set_lat_lng(&at); m.accuracy.set_lat_lng(&at); m.accuracy.set_radius(last.acc.unwrap_or(0.0) as f64); let opacity = if now_secs() - last.ts > STALE_SECS { 0.35 } else { 1.0 }; m.dot.set_style(&obj(&[ ("fillOpacity", opacity.into()), ("opacity", opacity.into()), ])); } } /// `YYYY-MM-DD` and `YYYY-MM-DDTHH:MM` in local time, the formats of date and datetime-local inputs. fn input_values(d: &js_sys::Date) -> (String, String) { let day = format!( "{:04}-{:02}-{:02}", d.get_full_year(), d.get_month() + 1, d.get_date() ); let time = format!("{day}T{:02}:{:02}", d.get_hours(), d.get_minutes()); (day, time) } /// Local midnight to midnight, in unix seconds, for a `YYYY-MM-DD` string. fn day_range(day: &str) -> Option<(i64, i64)> { let mut it = day.split('-').map(|s| s.parse::().ok()); let (y, m, d) = (it.next()??, it.next()??, it.next()??); let start = js_sys::Date::new_with_year_month_day(y as u32, m - 1, d).get_time(); let end = js_sys::Date::new_with_year_month_day(y as u32, m - 1, d + 1).get_time(); Some(((start / 1000.0) as i64, (end / 1000.0) as i64 - 1)) } /// Unix seconds for a `YYYY-MM-DDTHH:MM` string. JavaScript reads that format as local time. pub fn local_time(value: &str) -> Option { let ms = js_sys::Date::new(&JsValue::from_str(value)).get_time(); (!ms.is_nan()).then_some((ms / 1000.0) as i64) } fn trail_range(span: &str, day: &str, from: &str, to: &str) -> Result<(i64, i64), &'static str> { match span { "day" => day_range(day).ok_or("Pick a day."), "range" => match (local_time(from), local_time(to)) { (Some(f), Some(t)) if f >= t => Err("The start must be before the end."), (Some(f), Some(t)) if t - f > MAX_TRACK_SECS => { Err("The range can be at most 31 days.") } (Some(f), Some(t)) => Ok((f, t)), _ => Err("Pick a start and an end."), }, _ => Ok((now_secs() - 86400, now_secs())), } } /// Whose view the map shows. #[derive(Clone)] pub enum Source { Account, Guest(GuestAuth), /// The app, with its device token. It sees only this device. Device(String), } #[component] pub fn MapPage(#[prop(default = Source::Account)] source: Source) -> impl IntoView { let is_account = matches!(source, Source::Account); let is_guest = matches!(source, Source::Guest(_)); let is_device = matches!(source, Source::Device(_)); let source = StoredValue::new(source); let expires = RwSignal::new(None::); let people = RwSignal::new(Vec::::new()); let selected = RwSignal::new(None::); let trail_device = RwSignal::new(None::); let person = move || { let id = selected.get()?; people.with(|list| list.iter().find(|p| p.id == id).cloned()) }; // The chosen device, or the newest one if the chosen one is gone. let device = move || { let p = person()?; let chosen = trail_device.get(); chosen .filter(|id| p.devices.iter().any(|d| d.id == *id)) .or(p.last().map(|d| d.id)) }; let (today, now) = input_values(&js_sys::Date::new_0()); let span = RwSignal::new("none".to_string()); let day = RwSignal::new(today.clone()); let from = RwSignal::new(format!("{today}T00:00")); let to = RwSignal::new(now); let range = move || trail_range(&span.get(), &day.get(), &from.get(), &to.get()); // Identifies the chosen filter. The last 24 hours move with the clock but stay one view. let filter_key = move || match span.get().as_str() { "day" => format!("day {}", day.get()), "range" => format!("range {} {}", from.get(), to.get()), s => s.to_string(), }; let track = RwSignal::new(Vec::::new()); let error = RwSignal::new(None::); let refresh = move || { if document().hidden() { return; } spawn_local(async move { let result = match source.get_value() { Source::Account => http::get::>("/api/people").await, Source::Guest(g) => http::post::("/api/guest", &g).await.map(|v| { expires.set(v.expires_at); vec![v.person] }), Source::Device(t) => http::get_bearer::("/api/device", &t) .await .map(|p| vec![p]), }; match result { Ok(p) => { // Guests and the app see one person, so their details open at once. if !is_account && selected.get_untracked().is_none() { selected.set(p.first().map(|p| p.id)); } people.set(p); error.set(None); } Err(e) if e.status == 404 && is_guest => { error.set(Some("This link is no longer valid.".into())) } Err(e) if e.status == 401 && is_device => error.set(Some( "This device was removed. Connect the app again.".into(), )), Err(e) => error.set(Some(e.to_string())), } }) }; refresh(); if let Ok(handle) = set_interval_with_handle(refresh, POLL) { on_cleanup(move || handle.clear()); } let visible = window_event_listener(leptos::ev::visibilitychange, move |_| refresh()); on_cleanup(move || visible.remove()); let el = NodeRef::::new(); let map = StoredValue::new_local(None::); let markers = StoredValue::new_local(HashMap::::new()); let trail = StoredValue::new_local(None::); let ready = RwSignal::new(false); let fitted = StoredValue::new(false); let trail_fitted_for = StoredValue::new(None::<(i64, String)>); Effect::new(move |_| { let Some(div) = el.get() else { return }; if ready.get_untracked() { return; } let m = leaflet_map(&div); m.set_view(&latlng(50.0, 10.0), 4.0); add_base_maps(&m); map.set_value(Some(m)); ready.set(true); }); on_cleanup(move || { map.with_value(|m| m.as_ref().map(LMap::remove_map)) .unwrap_or_default() }); Effect::new(move |_| { let list = people.get(); if !ready.get() { return; } map.with_value(|m| { let m = m.as_ref().unwrap(); markers.update_value(|mk| sync_markers(m, mk, &list, selected)); let positions: Vec<_> = list .iter() .filter_map(|p| p.last().map(|d| &d.last)) .collect(); if !fitted.get_value() && !positions.is_empty() { fitted.set_value(true); match positions[..] { [p] => m.set_view(&latlng(p.lat, p.lon), 15.0), _ => m.fit_bounds( &positions.iter().map(|p| latlng(p.lat, p.lon)).collect(), &fit_opts(), ), } } }); }); // Reloads on every poll too, so a current trail grows while you watch. Effect::new(move |_| { let (Some(p), Some(dev), Ok((from, to))) = (person(), device(), range()) else { track.set(Vec::new()); return; }; if p.trail == Trail::None || span.get() == "none" { track.set(Vec::new()); return; } let id = p.id; spawn_local(async move { let result = match source.get_value() { Source::Account => { http::get::>(&format!( "/api/people/{id}/track?from={from}&to={to}&device={dev}" )) .await } Source::Device(t) => { http::get_bearer::>( &format!("/api/device/track?from={from}&to={to}"), &t, ) .await } Source::Guest(auth) => { let body = GuestTrack { auth, device: dev, from, to, }; http::post::>("/api/guest/track", &body).await } }; match result { Ok(t) => track.set(t), Err(e) => error.set(Some(e.to_string())), } }); }); Effect::new(move |_| { let points = track.get(); if !ready.get() { return; } map.with_value(|m| { let m = m.as_ref().unwrap(); trail.update_value(|old| { if let Some(line) = old.take() { line.remove_layer(); } let (Some(id), Some(dev)) = (selected.get_untracked(), untrack(device)) else { return; }; if points.is_empty() { return; } let coords: Array = points.iter().map(|p| latlng(p.lat, p.lon)).collect(); let line = polyline( &coords, &obj(&[ ("color", color(id).into()), ("weight", 4.into()), ("opacity", 0.7.into()), ]), ); line.add_to(m); *old = Some(line); // Fit once per selection, not on every poll, so the user can pan freely. let key = Some((dev, untrack(filter_key))); if trail_fitted_for.get_value() != key { trail_fitted_for.set_value(key); m.fit_bounds(&coords, &fit_opts()); } }); }); }); let select = move |p: &Person| { let id = p.id; if selected.get_untracked() == Some(id) { selected.set(None); return; } selected.set(Some(id)); trail_device.set(p.last().map(|d| d.id)); if let Some(last) = p.last().map(|d| &d.last) { map.with_value(|m| m.as_ref().map(|m| m.pan_to(&latlng(last.lat, last.lon)))); } }; view! {
} } fn count(n: usize) -> String { match n { 1 => "1 point.".into(), n => format!("{n} points."), } } fn fit_opts() -> JsValue { obj(&[ ("padding", Array::of2(&40.into(), &40.into()).into()), ("maxZoom", 16.into()), ]) } fn describe(p: &Person) -> String { let Some(d) = p.last() else { return "no position yet".into(); }; let l = &d.last; let mut parts = vec![ago(l.ts)]; if p.devices.len() > 1 { parts.push(d.name.clone()); } match (p.precision_m, l.acc) { (0, Some(acc)) => parts.push(format!("±{acc:.0} m")), (0, None) => {} (m @ ..1000, _) => parts.push(format!("approximate, {m} m")), (m, _) => parts.push(format!("approximate, {} km", m / 1000)), } if let Some(speed) = l.speed { parts.push(format!("{:.0} km/h", speed * 3.6)); } if let Some(b) = l.battery { parts.push(format!("battery {b}%")); } parts.join(" · ") }