/* 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`
| Type | Device | Message | Time |
|---|---|---|---|
| No alerts. | |||
|
${alertGlyph(a.type)}
${humanType(a.type)}
|
${a.device_id.slice(0, 16)}… | ${a.message} | ${timeAgo(a.created_at)} |
| Status | Device | Position | Battery | Accuracy | Interval | Last 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)} |
| Type | Severity | Device | Message | Time | |
|---|---|---|---|---|---|
| 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``} |
Click the map to place a geofence centre.
Export a device's full location history as CSV.