/* Live GPS dashboard — reactive Preact app (no build step). * * Preact + htm load as ES modules from a CDN. A tiny external store drives * reactivity: WebSocket / poll updates write to stores, components that read a * store re-render, and Preact diffs the result — only changed DOM is touched * (no innerHTML wipes → no flicker, no lag, no lost scroll). Leaflet maps are * managed imperatively in the `maps` controller, outside Preact's render. */ import { h, render, Fragment } from "https://esm.sh/preact@10.25.4"; import { useState, useEffect, useRef } from "https://esm.sh/preact@10.25.4/hooks"; import htmModule from "https://esm.sh/htm@3.1.1"; const html = htmModule.bind(h); /* ============ tiny reactive store ============ */ function createStore(initial) { let value = initial; const subs = new Set(); return { get: () => value, set: (next) => { value = typeof next === "function" ? next(value) : next; subs.forEach((fn) => fn()); }, subscribe: (fn) => { subs.add(fn); return () => subs.delete(fn); }, }; } function useStore(store) { const [, force] = useState(0); useEffect(() => store.subscribe(() => force((n) => n + 1)), [store]); return store.get(); } /* ============ stores ============ */ const devices$ = createStore([]); const alerts$ = createStore([]); const geofences$ = createStore([]); const accuracy$ = createStore([]); // [{t,v}] const connection$ = createStore("connecting"); const updated$ = createStore(""); const section$ = createStore("dashboard"); const theme$ = createStore(localStorage.getItem("gps-theme") || "light"); const search$ = createStore(""); /* ============ constants + helpers ============ */ const ROUTE_COLOR = "#6750A4"; // thin brand-purple route line const DONUT_C = 2 * Math.PI * 50; const PALETTE = ["#6750A4", "#386A20", "#B3261E", "#00658F", "#7D5260", "#8C4A00"]; function esc(s) { return String(s == null ? "" : s); } function timeAgo(iso) { const t = new Date(iso).getTime(); if (isNaN(t)) return "—"; const s = Math.max(0, (Date.now() - t) / 1000); if (s < 60) return Math.floor(s) + "s ago"; if (s < 3600) return Math.floor(s / 60) + "m ago"; if (s < 86400) return Math.floor(s / 3600) + "h ago"; return Math.floor(s / 86400) + "d ago"; } function colorFor(id) { let hsh = 0; for (let i = 0; i < id.length; i++) hsh = (hsh * 31 + id.charCodeAt(i)) | 0; return PALETTE[Math.abs(hsh) % PALETTE.length]; } function humanType(t) { return ( { low_battery: "Low Battery", offline: "No Signal", gps_off: "GPS Disabled" }[t] || t.replace(/_/g, " ").replace(/\b\w/g, (m) => m.toUpperCase()) ); } function alertGlyph(type) { return type === "low_battery" ? "\u{1F50B}" : type === "offline" ? "\u{1F4F6}" : "⚠"; } function alertColor(sev) { return sev === "high" ? "var(--sev-high)" : sev === "medium" ? "var(--sev-medium)" : "var(--primary)"; } function motionPill(d) { if (d.status === "inactive") return html`Inactive`; const moving = d.last_location && (d.last_location.speed || 0) >= 1; return moving ? html`Moving` : html`Stationary`; } /* ============ OSRM road snapping ============ */ async function snapToRoads(latlngs) { if (!latlngs || latlngs.length < 2) return latlngs || []; const pts = latlngs.slice(-95); const coords = pts.map((p) => p[1].toFixed(6) + "," + p[0].toFixed(6)).join(";"); const url = "https://router.project-osrm.org/match/v1/driving/" + coords + "?geometries=geojson&overview=full&tidy=true"; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 6000); try { const r = await fetch(url, { signal: ctrl.signal }); if (!r.ok) return pts; const data = await r.json(); if (!data.matchings || !data.matchings.length) return pts; const out = []; data.matchings.forEach((m) => { ((m.geometry && m.geometry.coordinates) || []).forEach((c) => out.push([c[1], c[0]])); }); return out.length ? out : pts; } catch (e) { return pts; // fall back to a straight (still thin purple) line } finally { clearTimeout(timer); } } /* ============ Leaflet map controller (imperative) ============ */ const maps = { dash: null, history: null, geo: null, markers: {}, routes: {}, histLayer: null, geoLayer: null, geoPending: null, geoPendingMarker: null, ready: false, tileUrl() { const dark = document.documentElement.getAttribute("data-theme") === "dark"; return ( "https://{s}.basemaps.cartocdn.com/" + (dark ? "dark_all" : "light_all") + "/{z}/{x}/{y}{r}.png" ); }, tiles(m) { if (!m) return; m.eachLayer((l) => { if (l instanceof L.TileLayer) m.removeLayer(l); }); L.tileLayer(this.tileUrl(), { maxZoom: 19, subdomains: "abcd", attribution: "© OpenStreetMap, © CARTO", }).addTo(m); }, refreshTiles() { [this.dash, this.history, this.geo].forEach((m) => this.tiles(m)); }, init() { if (this.ready) return; const center = [20.5937, 78.9629]; this.dash = L.map("map", { zoomControl: true }).setView(center, 5); this.history = L.map("map-history").setView(center, 5); this.geo = L.map("map-geofences").setView(center, 5); [this.dash, this.history, this.geo].forEach((m) => this.tiles(m)); this.geo.on("click", (e) => { this.geoPending = e.latlng; if (this.geoPendingMarker) this.geo.removeLayer(this.geoPendingMarker); this.geoPendingMarker = L.marker(e.latlng).addTo(this.geo); }); // A ResizeObserver makes a map render correctly the moment its (previously // hidden, 0-sized) section becomes visible — the robust fix for blank maps. [ ["map", this.dash], ["map-history", this.history], ["map-geofences", this.geo], ].forEach(([id, m]) => { const el = document.getElementById(id); if (el && window.ResizeObserver) { new ResizeObserver(() => m.invalidateSize()).observe(el); } }); this.ready = true; }, onSection(name) { setTimeout(() => { if (name === "dashboard" && this.dash) this.dash.invalidateSize(); if (name === "history" && this.history) this.history.invalidateSize(); if (name === "geofences" && this.geo) this.geo.invalidateSize(); }, 60); }, applyLocation(msg) { if (!this.dash || typeof msg.lat !== "number") return; const id = msg.device_id; const ll = [msg.lat, msg.lng]; let m = this.markers[id]; if (!m) { this.markers[id] = L.circleMarker(ll, { radius: 8, color: "#fff", weight: 3, fillColor: colorFor(id), fillOpacity: 1, }) .addTo(this.dash) .bindPopup(esc(id)); const pts = Object.keys(this.markers).map((k) => this.markers[k].getLatLng()); if (pts.length === 1) this.dash.setView(ll, 16); else this.dash.fitBounds(L.latLngBounds(pts).pad(0.25)); } else { m.setLatLng(ll); m.bringToFront(); } const r = this.routes[id] || (this.routes[id] = { pts: [], line: null, lastMatch: 0, busy: false }); const last = r.pts[r.pts.length - 1]; if (!last || last[0] !== ll[0] || last[1] !== ll[1]) { r.pts.push(ll); if (r.pts.length > 400) r.pts.shift(); } if (Date.now() - r.lastMatch > 4500) this.drawRoute(id); }, drawRoute(id) { const r = this.routes[id]; if (!r || r.busy || r.pts.length < 2) return; r.busy = true; r.lastMatch = Date.now(); snapToRoads(r.pts).then((snapped) => { const rr = this.routes[id]; if (!rr || !this.dash) return; rr.busy = false; if (rr.line) this.dash.removeLayer(rr.line); rr.line = L.polyline(snapped, { color: ROUTE_COLOR, weight: 3, opacity: 0.95, lineJoin: "round", lineCap: "round", }).addTo(this.dash); if (this.markers[id]) this.markers[id].bringToFront(); }); }, seedRoutes(devices) { devices.forEach((d) => { if (this.routes[d.id]) return; fetch("/api/devices/" + encodeURIComponent(d.id) + "/track?limit=300") .then((r) => (r.ok ? r.json() : Promise.reject())) .then((data) => { const pts = (data.points || []).map((p) => [p.lat, p.lng]); if (pts.length < 2) return; this.routes[d.id] = { pts, line: null, lastMatch: 0, busy: false }; this.drawRoute(d.id); }) .catch(() => {}); }); }, async loadHistory(id) { const r = await fetch("/api/devices/" + encodeURIComponent(id) + "/track?limit=5000") .then((x) => (x.ok ? x.json() : Promise.reject())) .catch(() => ({ points: [] })); const raw = (r.points || []).map((p) => [p.lat, p.lng]); let dist = 0; for (let i = 1; i < raw.length; i++) dist += haversine(raw[i - 1], raw[i]); if (this.histLayer) this.history.removeLayer(this.histLayer); this.histLayer = null; if (raw.length >= 2) { const snapped = await snapToRoads(raw); this.histLayer = L.polyline(snapped, { color: ROUTE_COLOR, weight: 3, opacity: 0.95, lineJoin: "round", }).addTo(this.history); this.history.fitBounds(this.histLayer.getBounds().pad(0.2)); } return { count: raw.length, distanceKm: dist / 1000 }; }, drawGeofences(list) { if (!this.geo) return; if (this.geoLayer) this.geo.removeLayer(this.geoLayer); this.geoLayer = L.layerGroup().addTo(this.geo); (list || []).forEach((g) => { L.circle([g.lat, g.lng], { radius: g.radius_m, color: ROUTE_COLOR, fillOpacity: 0.12 }) .addTo(this.geoLayer) .bindPopup(esc(g.name)); }); }, clearGeoPending() { if (this.geoPendingMarker && this.geo) this.geo.removeLayer(this.geoPendingMarker); this.geoPendingMarker = null; this.geoPending = null; }, }; function haversine(a, b) { const R = 6371000; const rad = Math.PI / 180; const dLat = (b[0] - a[0]) * rad; const dLng = (b[1] - a[1]) * rad; const s = Math.sin(dLat / 2) ** 2 + Math.cos(a[0] * rad) * Math.cos(b[0] * rad) * Math.sin(dLng / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s)); } /* ============ data layer ============ */ function refreshData() { fetch("/api/devices") .then((r) => (r.ok ? r.json() : Promise.reject())) .then((list) => { devices$.set(list || []); maps.seedRoutes(list || []); }) .catch(() => {}); fetch("/api/alerts") .then((r) => (r.ok ? r.json() : Promise.reject())) .then((list) => alerts$.set(list || [])) .catch(() => {}); } // Debounced refresh — called when a brand-new device appears over WebSocket, // so the canonical DTO fields (status, effective_interval, …) fill in within // half a second. A burst of new devices triggers only one extra fetch. let _refreshTimer = null; function scheduleSoonRefresh() { if (_refreshTimer) return; _refreshTimer = setTimeout(() => { _refreshTimer = null; refreshData(); }, 500); } function connectWebSocket() { let delay = 1000; function open() { const proto = location.protocol === "https:" ? "wss:" : "ws:"; connection$.set("connecting"); const ws = new WebSocket(proto + "//" + location.host + "/ws/dashboard"); ws.onopen = () => { delay = 1000; connection$.set("connected"); }; ws.onmessage = (ev) => { let msg; try { msg = JSON.parse(ev.data); } catch (e) { return; } if (msg.type === "snapshot") (msg.devices || []).forEach(applyLocation); else if (msg.type === "location") applyLocation(msg); updated$.set("Last update " + new Date().toLocaleTimeString()); }; ws.onerror = () => ws.close(); ws.onclose = () => { connection$.set("disconnected"); setTimeout(open, delay); delay = Math.min(delay * 2, 30000); }; } open(); } function applyLocation(msg) { if (typeof msg.lat !== "number") return; if (typeof msg.accuracy === "number") { accuracy$.set((a) => { const next = a.concat({ t: msg.timestamp, v: msg.accuracy }); return next.length > 6 ? next.slice(-6) : next; }); } maps.applyLocation(msg); // Merge the live fix into the device list so cards feel live too — including // *new* devices (multi-user), which were previously dropped here and only // showed up after the next 15-second poll. devices$.set((list) => { let found = false; const next = list.map((d) => { if (d.id !== msg.device_id) return d; found = true; return { ...d, last_seen_at: msg.received_at || msg.timestamp, last_location: { lat: msg.lat, lng: msg.lng, accuracy: msg.accuracy, speed: msg.speed, bearing: msg.bearing, timestamp: msg.timestamp, }, }; }); if (found) return next; // First time we see this device — show it live, then refresh to enrich. scheduleSoonRefresh(); return [ { id: msg.device_id, name: msg.device_name || "", status: "online", battery: msg.battery, effective_interval_sec: 120, first_seen_at: msg.received_at || msg.timestamp, last_seen_at: msg.received_at || msg.timestamp, last_location: { lat: msg.lat, lng: msg.lng, accuracy: msg.accuracy, speed: msg.speed, bearing: msg.bearing, timestamp: msg.timestamp, }, }, ...list, ]; }); } /* ============ components ============ */ function Sidebar() { const section = useStore(section$); const nav = [ ["dashboard", "Dashboard", "☉"], ["devices", "Devices", "◫"], ["history", "History", "↻"], ["geofences", "Geofences", "◎"], ["alerts", "Alerts", "⚠"], ["reports", "Reports", "☷"], ["settings", "Settings", "⚙"], ]; return html` `; } function Topbar() { const theme = useStore(theme$); const alerts = useStore(alerts$); const search = useStore(search$); const unacked = alerts.filter((a) => !a.acknowledged).length; return html`
A
`; } function Section({ id, pad, children }) { const section = useStore(section$); const cls = "section" + (section === id ? " active" : ""); return html`
${pad ? html`
${children}
` : children}
`; } /* ---- Dashboard ---- */ function Dashboard() { const devices = useStore(devices$); const alerts = useStore(alerts$); const accuracy = useStore(accuracy$); const [expanded, setExpanded] = useState(false); const online = devices.filter((d) => d.status === "online").length; const offline = devices.filter((d) => d.status === "offline").length; const inactive = devices.filter((d) => d.status === "inactive").length; const frac = devices.length ? online / devices.length : 0; const recent = devices .slice() .sort((a, b) => new Date(b.last_seen_at) - new Date(a.last_seen_at)) .slice(0, 3); const latestAcc = accuracy.length ? accuracy[accuracy.length - 1].v : null; const accMax = Math.max(1, ...accuracy.map((a) => a.v)); function toggleExpand() { setExpanded(!expanded); setTimeout(() => maps.dash && maps.dash.invalidateSize(), 260); } return html`

Live Status

${online} Online
${devices.length}
Total Devices
${online} Online ${offline} Offline ${inactive} Inactive
${recent.length === 0 ? html`
No devices yet.
` : recent.map((d) => { const loc = d.last_location; return html`
${d.id.slice(0, 18)}…
${loc ? "Lat " + loc.lat.toFixed(5) + ", Lng " + loc.lng.toFixed(5) : "No data"}
${motionPill(d)} ${" "} ${loc && loc.accuracy != null ? "Accuracy ±" + Math.round(loc.accuracy) + " m" : ""}
${timeAgo(d.last_seen_at)}
`; })}

Quick Actions

Location Accuracy

✓ ${latestAcc != null && latestAcc <= 15 ? "High accuracy" : "Tracking"}
${latestAcc != null ? "±" + Math.round(latestAcc) + " m" : "—"}
Current accuracy
${accuracy.map((a, i) => { const hgt = Math.max(8, (a.v / accMax) * 58); return html`
±${Math.round(a.v)}
`; })}
${alerts.length === 0 ? html`` : alerts.slice(0, 6).map( (a) => html` ` )}
TypeDeviceMessageTime
No alerts.
${alertGlyph(a.type)} ${humanType(a.type)}
${a.device_id.slice(0, 16)}… ${a.message} ${timeAgo(a.created_at)}
`; } /* ---- Devices ---- */ function Devices() { const devices = useStore(devices$); const q = useStore(search$).toLowerCase(); const list = q ? devices.filter((d) => d.id.toLowerCase().includes(q)) : devices; return html`

Devices

${list.length === 0 ? html`` : list.map((d) => { const loc = d.last_location; return html` `; })}
StatusDevicePositionBattery AccuracyIntervalLast seen
No devices yet.
${d.status} ${d.id} ${loc ? loc.lat.toFixed(5) + ", " + loc.lng.toFixed(5) : "—"} ${d.battery != null ? d.battery + "%" : "—"} ${loc && loc.accuracy != null ? "±" + Math.round(loc.accuracy) + " m" : "—"} every ${d.effective_interval_sec}s ${timeAgo(d.last_seen_at)}
`; } /* ---- Alerts ---- */ function Alerts() { const alerts = useStore(alerts$); function ack(id) { fetch("/api/alerts/" + id + "/ack", { method: "POST" }).then(refreshData); } return html`

Alerts

${alerts.length === 0 ? html`` : alerts.map( (a) => html` ` )}
TypeSeverityDeviceMessageTime
No alerts.
${alertGlyph(a.type)} ${humanType(a.type)}
${a.severity} ${a.device_id.slice(0, 18)}… ${a.message} ${timeAgo(a.created_at)} ${a.acknowledged ? html`Acknowledged` : html``}
`; } /* ---- History ---- */ function History() { const devices = useStore(devices$); const [deviceId, setDeviceId] = useState(""); const [summary, setSummary] = useState(null); useEffect(() => { if (!deviceId && devices.length) setDeviceId(devices[0].id); }, [devices]); function load() { const id = deviceId || (devices[0] && devices[0].id); if (!id) return; setSummary("loading"); maps.loadHistory(id).then((s) => setSummary(s)); } useEffect(() => { if (deviceId) load(); }, [deviceId]); return html`

History

${summary === "loading" ? html`Loading track…` : summary ? html`
Total points${summary.count}
Distance${summary.distanceKm.toFixed(2)} km
` : html`Pick a device to load its track.`}
`; } /* ---- Geofences ---- */ function Geofences() { const geofences = useStore(geofences$); const [name, setName] = useState(""); const [radius, setRadius] = useState("200"); useEffect(() => { reloadGeofences(); }, []); useEffect(() => { maps.drawGeofences(geofences); }, [geofences]); function create() { const rad = parseFloat(radius); if (!name.trim() || !maps.geoPending || !(rad > 0)) { alert("Enter a name, click the map for a centre, and set a radius."); return; } fetch("/api/geofences", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: name.trim(), lat: maps.geoPending.lat, lng: maps.geoPending.lng, radius_m: rad, }), }).then(() => { setName(""); maps.clearGeoPending(); reloadGeofences(); }); } function del(id) { fetch("/api/geofences/" + id, { method: "DELETE" }).then(reloadGeofences); } return html`

Geofences

Click the map to place a geofence centre.

setName(e.target.value)} /> setRadius(e.target.value)} />
${geofences.length === 0 ? html`
No geofences yet.
` : geofences.map( (g) => html`
${g.name}
Centre${g.lat.toFixed(4)}, ${g.lng.toFixed(4)}
Radius${Math.round(g.radius_m)} m
` )}
`; } function reloadGeofences() { fetch("/api/geofences") .then((r) => (r.ok ? r.json() : Promise.reject())) .then((list) => geofences$.set(list || [])) .catch(() => {}); } /* ---- Reports ---- */ function Reports() { const devices = useStore(devices$); const [deviceId, setDeviceId] = useState(""); useEffect(() => { if (!deviceId && devices.length) setDeviceId(devices[0].id); }, [devices]); return html`

Reports

Export a device's full location history as CSV.

`; } /* ---- Settings ---- */ function Settings() { const theme = useStore(theme$); const [intervalSec, setIntervalSec] = useState(null); const [msg, setMsg] = useState(""); useEffect(() => { fetch("/api/settings") .then((r) => r.json()) .then((s) => setIntervalSec(String(s.default_capture_interval_sec))) .catch(() => setIntervalSec("120")); }, []); function save() { const v = parseInt(intervalSec, 10); if (!(v >= 10)) { setMsg("Interval must be ≥ 10."); return; } fetch("/api/settings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ default_capture_interval_sec: v }), }).then((r) => { setMsg(r.ok ? "Saved." : "Save failed."); refreshData(); }); } return html`

Settings

Default capture interval
How often devices report location (seconds).
setIntervalSec(e.target.value)} />
Theme
Light or dark dashboard.
${" "}${msg}
`; } /* ---- Status bar ---- */ function StatusBar() { const conn = useStore(connection$); const updated = useStore(updated$); const text = conn === "connected" ? "Live — receiving updates" : conn === "connecting" ? "Connecting…" : "Disconnected — retrying…"; return html` `; } /* ---- App root ---- */ function App() { const section = useStore(section$); const theme = useStore(theme$); // Apply theme to and refresh map tiles when it changes. useEffect(() => { document.documentElement.setAttribute("data-theme", theme); localStorage.setItem("gps-theme", theme); if (maps.ready) maps.refreshTiles(); }, [theme]); // Create the Leaflet maps once, after the container divs are in the DOM. useEffect(() => { maps.init(); connectWebSocket(); refreshData(); const t = window.setInterval(refreshData, 15000); return () => window.clearInterval(t); }, []); // Whenever the visible section changes, make sure its map is sized. useEffect(() => { maps.onSection(section); }, [section]); return html`
<${Sidebar} />
<${Topbar} /> <${Section} id="dashboard"><${Dashboard} /> <${Section} id="devices" pad=${true}><${Devices} /> <${Section} id="history" pad=${true}><${History} /> <${Section} id="geofences" pad=${true}><${Geofences} /> <${Section} id="alerts" pad=${true}><${Alerts} /> <${Section} id="reports" pad=${true}><${Reports} /> <${Section} id="settings" pad=${true}><${Settings} /> <${StatusBar} />
`; } render(html`<${App} />`, document.getElementById("app"));