Trips view now shows a second bar under the header: the active filter (vehicle/cost centre/city) plus first-trip and last-trip bookends, each with the server's reverse-geocoded location + timestamp (first_trip_start_*, last_trip_end_*). Pinned grid rows so a hidden tripbar doesn't collapse the map. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1204 lines
61 KiB
HTML
1204 lines
61 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
<!-- Keep full Referer for Mapbox URL-restricted tokens on HTTPS→HTTPS cross-origin. -->
|
|
<meta name="referrer" content="no-referrer-when-downgrade" />
|
|
<title>FleetNow — Live + Trips</title>
|
|
|
|
<!--
|
|
fleetnow.html — single-file SPA merging the Live Position and Fleet Trips
|
|
dashboards into one console. Pairs with the dashboard read-API:
|
|
GET <API_BASE>/webhook/live-positions → live snapshot {summary, geojson}
|
|
GET <API_BASE>/webhook/live-positions/track → 1 h trail (LineString Feature)
|
|
GET <API_BASE>/webhook/fleet-dashboard → filter options
|
|
POST <API_BASE>/webhook/fleet-dashboard → trips {summary, geojson}
|
|
|
|
Mode model: land on LIVE (full fleet, polled). Pick a vehicle (map dot or
|
|
plate dropdown) or apply cost-centre + period → TRIPS for that selection.
|
|
The "● Live" pill returns to the live snapshot.
|
|
-->
|
|
|
|
<style>
|
|
:root {
|
|
/* Warm dark ops palette (ref screenshot 2026-06-05) */
|
|
--bg: #161a23;
|
|
--panel: #1e232e;
|
|
--panel-2: #232a36;
|
|
--border: #2c333f;
|
|
--text: #ECEFF4;
|
|
--muted: #93a0b4;
|
|
--accent: #E8954A; /* amber/orange — primary actions, Live pill, focus */
|
|
--accent-hover:#d97b2c;
|
|
--live: #2dd4a7; /* teal-green — online / moving / active */
|
|
--parked: #6b7280;
|
|
--offline: #b4791f; /* deep amber warning */
|
|
--warn: #f0a93b;
|
|
--danger: #ef5b5b;
|
|
--error-bg: #2a0a0a;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
html, body { margin: 0; height: 100%; }
|
|
body {
|
|
font: 14px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
background: var(--bg); color: var(--text); overflow: hidden;
|
|
}
|
|
.app { display: grid; grid-template-rows: auto auto 1fr; height: 100vh; }
|
|
|
|
/* Trips context bar (second row, trips mode only): active filter + first/last
|
|
trip bookends with reverse-geocoded location + timestamp. */
|
|
.tripbar { display: none; }
|
|
.tripbar.show {
|
|
display: flex; align-items: stretch;
|
|
background: var(--panel); border-bottom: 1px solid var(--border); font-size: 12px;
|
|
}
|
|
.tripbar > div { padding: 7px 16px; display: flex; gap: 9px; align-items: baseline; min-width: 0; }
|
|
.tripbar > div + div { border-left: 1px solid var(--border); }
|
|
.tripbar .ctx { flex: 0 0 auto; color: var(--accent); font-weight: 700; align-items: center; }
|
|
.tripbar .ctx .lbl { color: var(--muted); }
|
|
.tripbar .bookend { flex: 1 1 0; }
|
|
.tripbar .lbl { font-size: 9.5px; text-transform: uppercase; letter-spacing: .6px; color: var(--muted); flex: none; }
|
|
.tripbar .when { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; flex: none; }
|
|
.tripbar .veh { color: var(--accent); font-weight: 600; flex: none; }
|
|
.tripbar .where { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1 1 0; min-width: 0; }
|
|
|
|
/* ── Top bar ───────────────────────────────────────────────────────── */
|
|
header {
|
|
padding: 9px 18px; background: var(--panel);
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex; gap: 20px; align-items: center; flex-wrap: wrap;
|
|
}
|
|
.brand {
|
|
font-weight: 800; letter-spacing: .5px; font-size: 16px;
|
|
display: flex; align-items: center; gap: 8px; white-space: nowrap;
|
|
}
|
|
.brand .mark {
|
|
width: 10px; height: 10px; border-radius: 50%;
|
|
background: var(--accent); box-shadow: 0 0 10px var(--accent);
|
|
}
|
|
.brand .nm { color: var(--accent); }
|
|
#kpis { display: flex; gap: 22px; flex-wrap: wrap; align-items: center; }
|
|
.kpi { display: flex; flex-direction: column; min-width: 56px; }
|
|
.kpi b { font-size: 18px; line-height: 1.15; font-variant-numeric: tabular-nums; }
|
|
.kpi b.live { color: var(--live); }
|
|
.kpi b.parked { color: var(--parked); }
|
|
.kpi b.offline { color: var(--offline); }
|
|
.kpi b.accent { color: var(--accent); }
|
|
.kpi span {
|
|
font-size: 9.5px; color: var(--muted); text-transform: uppercase;
|
|
letter-spacing: .6px; margin-top: 2px;
|
|
}
|
|
.spacer { margin-left: auto; }
|
|
.stale-chip {
|
|
display: inline-flex; flex-direction: column; padding: 6px 10px;
|
|
background: rgba(45,212,167,.12); border: 1px solid rgba(45,212,167,.3);
|
|
color: var(--live); border-radius: 6px; min-width: 104px;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.stale-chip.warn { background: rgba(240,169,59,.12); border-color: rgba(240,169,59,.4); color: var(--warn); }
|
|
.stale-chip.bad { background: rgba(239,91,91,.14); border-color: rgba(239,91,91,.45); color: var(--danger); }
|
|
.stale-chip span { font-size: 9.5px; opacity: .75; text-transform: uppercase; letter-spacing: .6px; }
|
|
.stale-chip b { font-size: 13px; font-weight: 600; }
|
|
/* Live pill — passive dot in live mode, clickable "return" control in trips mode */
|
|
.live-pill {
|
|
display: none; align-items: center; gap: 7px;
|
|
padding: 7px 13px; border-radius: 999px; cursor: pointer;
|
|
background: var(--accent); color: #1a1009; font-weight: 700; font-size: 13px;
|
|
border: 0;
|
|
}
|
|
.live-pill.show { display: inline-flex; }
|
|
.live-pill .dot { width: 8px; height: 8px; border-radius: 50%; background: #1a1009; }
|
|
.live-pill:hover { background: var(--accent-hover); }
|
|
.clock {
|
|
color: var(--text); font-size: 14px; font-variant-numeric: tabular-nums;
|
|
display: flex; flex-direction: column; align-items: flex-end;
|
|
}
|
|
.clock .label { font-size: 9.5px; color: var(--muted); text-transform: uppercase; letter-spacing: .6px; }
|
|
.clock b { font-weight: 600; }
|
|
|
|
/* ── Map + floating cards ──────────────────────────────────────────── */
|
|
/* Pin to row 3 so the map always takes the 1fr track whether or not the
|
|
tripbar (row 2) is shown — otherwise a hidden tripbar lets the map fall
|
|
into the auto row and collapse. */
|
|
#map { position: relative; min-height: 0; height: 100%; grid-row: 3; }
|
|
header { grid-row: 1; }
|
|
.tripbar { grid-row: 2; }
|
|
.placeholder {
|
|
position: absolute; inset: 0; display: grid; place-items: center;
|
|
color: var(--muted); font-size: 13px; text-align: center; padding: 32px;
|
|
pointer-events: none; z-index: 1;
|
|
}
|
|
.floating {
|
|
position: absolute; z-index: 5; background: rgba(30,35,46,.95);
|
|
border: 1px solid var(--border); border-radius: 10px;
|
|
box-shadow: 0 8px 28px rgba(0,0,0,.45); backdrop-filter: blur(6px);
|
|
}
|
|
.filters { bottom: 22px; right: 16px; width: 250px; padding: 14px 14px 16px; }
|
|
.filters h3, .triplist h3 {
|
|
font-size: 11px; text-transform: uppercase; letter-spacing: .6px;
|
|
color: var(--muted); margin: 0 0 12px; font-weight: 600;
|
|
}
|
|
.field { display: flex; flex-direction: column; margin-bottom: 11px; }
|
|
.field label {
|
|
font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px;
|
|
color: var(--muted); margin-bottom: 4px;
|
|
}
|
|
select, input[type=date] {
|
|
padding: 8px 10px; background: var(--bg); color: var(--text);
|
|
border: 1px solid var(--border); border-radius: 6px; font: 13px system-ui; width: 100%;
|
|
}
|
|
select:focus, input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
|
select[multiple] { min-height: 84px; max-height: 150px; padding: 5px; }
|
|
select[multiple] option { padding: 4px 6px; border-radius: 3px; }
|
|
select[multiple] option:checked { background: var(--accent); color: #1a1009; }
|
|
.hint { font-size: 9.5px; color: var(--muted); margin-top: 3px; line-height: 1.3; }
|
|
.custom { display: none; }
|
|
.custom.show { display: block; }
|
|
.btn {
|
|
width: 100%; padding: 9px; margin-top: 4px; background: var(--accent);
|
|
color: #1a1009; border: 0; border-radius: 6px; font: 600 13px system-ui; cursor: pointer;
|
|
}
|
|
.btn:hover { background: var(--accent-hover); }
|
|
.btn:disabled { background: #4b5563; color: #cbd5e1; cursor: wait; }
|
|
|
|
.triplist {
|
|
bottom: 22px; left: 16px; width: 280px; max-height: calc(100% - 44px);
|
|
display: none; flex-direction: column; padding: 12px 12px 8px;
|
|
}
|
|
.triplist.show { display: flex; }
|
|
.triplist .back {
|
|
display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
|
|
color: var(--accent); font: 600 12px system-ui; background: none; border: 0;
|
|
padding: 0 0 10px; margin-bottom: 6px; border-bottom: 1px solid var(--border);
|
|
}
|
|
.triplist .back:hover { color: var(--accent-hover); }
|
|
.triplist .scroll { overflow-y: auto; min-height: 0; }
|
|
.trip-row {
|
|
display: grid; grid-template-columns: 7px 24px 1fr auto; gap: 8px;
|
|
align-items: center; padding: 7px 0; border-bottom: 1px solid var(--border);
|
|
font-size: 12px; cursor: pointer;
|
|
}
|
|
.trip-row:hover { background: rgba(255,255,255,.03); }
|
|
.trip-row.active { background: rgba(232,149,74,.14); }
|
|
.trip-row.active .seq, .trip-row.active .time { color: var(--accent); }
|
|
.trip-row .swatch { width: 4px; height: 22px; border-radius: 2px; }
|
|
.trip-row .seq { color: var(--muted); font-variant-numeric: tabular-nums; text-align: right; }
|
|
.trip-row .veh { color: var(--text); font-weight: 500; }
|
|
.trip-row .time { color: var(--muted); font-size: 11px; }
|
|
.trip-row .km { font-variant-numeric: tabular-nums; color: var(--text); white-space: nowrap; }
|
|
.trips-empty { color: var(--muted); padding: 24px 0; text-align: center; font-size: 12px; }
|
|
.trips-more { color: var(--muted); padding: 10px 0 2px; text-align: center; font-size: 11px; }
|
|
|
|
.error {
|
|
position: absolute; top: 16px; left: 50%; transform: translateX(-50%);
|
|
background: var(--error-bg); border: 1px solid var(--danger); color: #fca5a5;
|
|
padding: 10px 16px; border-radius: 6px; z-index: 20; max-width: 600px;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,.4);
|
|
}
|
|
|
|
/* ── Live vehicle DOM marker (locked look) ─────────────────────────── */
|
|
/* NOTE: never set position on .veh-marker — MapLibre's own .maplibregl-marker
|
|
class supplies position:absolute and drives placement. The plate's
|
|
positioning context is the inner wrapper below, so a class change can't
|
|
reflow the markers. */
|
|
.veh-marker { cursor: pointer; will-change: transform; }
|
|
/* Scaled by zoom via --veh-scale (set in updateVehScale). transform-origin
|
|
centre keeps the pin anchored on its coordinate as it grows/shrinks. */
|
|
.veh-inner {
|
|
position: relative; width: 32px; height: 32px;
|
|
transform: scale(var(--veh-scale, 1)); transform-origin: center center;
|
|
}
|
|
.veh-pin {
|
|
width: 32px; height: 32px; border-radius: 50%;
|
|
background: var(--c, var(--parked));
|
|
border: 2px solid rgba(255,255,255,.92);
|
|
box-shadow: 0 2px 7px rgba(0,0,0,.5);
|
|
display: grid; place-items: center;
|
|
}
|
|
.veh-arrow {
|
|
width: 0; height: 0;
|
|
border-left: 6px solid transparent; border-right: 6px solid transparent;
|
|
border-bottom: 12px solid #fff;
|
|
transform: rotate(var(--dir, 0deg));
|
|
filter: drop-shadow(0 0 1px rgba(0,0,0,.65));
|
|
}
|
|
.veh-pin .idle-dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(255,255,255,.92); }
|
|
/* Parked = reported within 24h ("active within 24h"): a clean PASTEL
|
|
department-coloured SQUARE, no arrow/dot, rendered at HALF the size of a
|
|
moving-now circle (9px vs 18px) so it reads as a quieter "recent activity"
|
|
marker. The scale compounds with the zoom scaling on .veh-inner;
|
|
transform-origin centre keeps it on its coordinate. */
|
|
.veh-marker.parked .veh-pin { border-radius: 4px; opacity: 1; transform: scale(0.5); transform-origin: center center; }
|
|
.veh-marker.offline .veh-pin { opacity: .5; border-color: rgba(255,255,255,.4); }
|
|
.veh-plate {
|
|
position: absolute; top: 33px; left: 50%; transform: translateX(-50%);
|
|
background: rgba(15,18,23,.92); color: #fff; font: 600 10px system-ui;
|
|
padding: 1px 6px; border-radius: 4px; white-space: nowrap;
|
|
border: 1px solid var(--border);
|
|
}
|
|
.veh-marker.offline .veh-plate { color: var(--muted); }
|
|
|
|
/* ── Persistent POI marker (Fireside HQ) ───────────────────────────── */
|
|
.hq-marker { display: flex; flex-direction: column; align-items: center; cursor: default; }
|
|
.hq-dot {
|
|
width: 13px; height: 13px; border-radius: 50%; background: var(--accent);
|
|
border: 2px solid #fff; box-shadow: 0 0 0 1px rgba(0,0,0,.35), 0 2px 6px rgba(0,0,0,.4);
|
|
}
|
|
.hq-label {
|
|
margin-top: 4px; background: rgba(15,18,23,.88); color: var(--accent);
|
|
font: 600 11px system-ui; padding: 3px 7px; border-radius: 4px;
|
|
border: 1px solid rgba(232,149,74,.4); white-space: nowrap;
|
|
}
|
|
|
|
/* ── MapLibre popup (warm) ─────────────────────────────────────────── */
|
|
.maplibregl-popup-content {
|
|
background: var(--panel) !important; color: var(--text) !important;
|
|
border: 1px solid var(--border); border-radius: 8px;
|
|
padding: 11px 13px !important; font: 12px/1.45 system-ui;
|
|
box-shadow: 0 6px 18px rgba(0,0,0,.55);
|
|
}
|
|
.maplibregl-popup-tip { border-top-color: var(--panel) !important; }
|
|
.pop b { display: block; margin-bottom: 4px; font-size: 13px; }
|
|
.pop .muted { color: var(--muted); font-size: 11px; }
|
|
.pop .row { margin-top: 4px; }
|
|
.pop .addr { margin-top: 4px; color: var(--text); font-size: 11.5px; line-height: 1.35; }
|
|
.pop .addr.loading { color: var(--muted); font-style: italic; }
|
|
.pop .addr.fail { color: var(--muted); font-size: 10px; }
|
|
.pop .badge {
|
|
display: inline-block; padding: 1px 6px; border-radius: 3px;
|
|
font-size: 10px; font-weight: 700; letter-spacing: .3px; text-transform: uppercase;
|
|
}
|
|
.pop .badge.moving { background: rgba(45,212,167,.18); color: var(--live); }
|
|
.pop .badge.idling { background: rgba(240,169,59,.18); color: var(--warn); }
|
|
.pop .badge.parked { background: rgba(107,114,128,.22); color: #d1d5db; }
|
|
.pop .badge.offline { background: rgba(180,121,31,.25); color: var(--offline); }
|
|
.pop .actions { display: flex; gap: 6px; margin-top: 9px; }
|
|
.pop .act {
|
|
flex: 1; padding: 5px 8px; border: 0; border-radius: 5px; cursor: pointer;
|
|
font: 600 11px system-ui; text-align: center;
|
|
}
|
|
.pop .act.primary { background: var(--accent); color: #1a1009; }
|
|
.pop .act.primary:hover { background: var(--accent-hover); }
|
|
.pop .act.ghost { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); }
|
|
.pop .act.ghost:hover { border-color: var(--accent); }
|
|
.maplibregl-ctrl-attrib { background: rgba(30,35,46,.7) !important; color: var(--muted); }
|
|
.maplibregl-ctrl-attrib a { color: var(--accent); }
|
|
</style>
|
|
|
|
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css" />
|
|
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="app">
|
|
<header>
|
|
<div class="brand"><span class="mark"></span>FLEET<span class="nm">NOW</span></div>
|
|
<div id="kpis"></div>
|
|
<div class="spacer"></div>
|
|
<button class="live-pill" id="live-pill" type="button" title="Return to live fleet">
|
|
<span class="dot"></span> Live
|
|
</button>
|
|
<div class="stale-chip" id="stale-chip"><span>Last batch</span><b id="stale-text">—</b></div>
|
|
<div class="clock"><span class="label">EAT</span><b id="clock-time">—</b></div>
|
|
</header>
|
|
|
|
<!-- Trips context bar (shown only in trips mode) -->
|
|
<div class="tripbar" id="tripbar">
|
|
<div class="ctx"><span class="lbl">Filter:</span> <span id="trip-ctx"></span></div>
|
|
<div class="bookend">
|
|
<span class="lbl">First trip</span>
|
|
<span class="when" id="be-first-when">—</span>
|
|
<span class="veh" id="be-first-veh"></span>
|
|
<span class="where" id="be-first-where"></span>
|
|
</div>
|
|
<div class="bookend">
|
|
<span class="lbl">Last trip</span>
|
|
<span class="when" id="be-last-when">—</span>
|
|
<span class="veh" id="be-last-veh"></span>
|
|
<span class="where" id="be-last-where"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="map">
|
|
<div class="placeholder" id="placeholder">Loading live fleet…</div>
|
|
|
|
<!-- Trip list (bottom-left, trips mode only) -->
|
|
<div class="floating triplist" id="triplist">
|
|
<button class="back" id="trips-back" type="button">◀ Live</button>
|
|
<h3>Trips <span id="trip-count"></span></h3>
|
|
<div class="scroll" id="trip-scroll">
|
|
<div class="trips-empty">No trips.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filters (bottom-right, always) -->
|
|
<div class="floating filters" id="filters">
|
|
<h3>Filters</h3>
|
|
<div class="field">
|
|
<label for="f-vehicle">Number plate <span class="hint" style="text-transform:none">— ⌘/Ctrl-click for more</span></label>
|
|
<select id="f-vehicle" multiple size="4"></select>
|
|
</div>
|
|
<div class="field">
|
|
<label for="f-cc">Cost centre</label>
|
|
<select id="f-cc"><option value="">All cost centres</option></select>
|
|
</div>
|
|
<div class="field">
|
|
<label for="f-city">Assigned city</label>
|
|
<select id="f-city"><option value="">All cities</option></select>
|
|
</div>
|
|
<div class="field">
|
|
<label for="f-period">Time</label>
|
|
<select id="f-period">
|
|
<option value="today" selected>Today</option>
|
|
<option value="7d">Last 1 week</option>
|
|
<option value="30d">Last 1 month</option>
|
|
<option value="custom">Custom range</option>
|
|
</select>
|
|
</div>
|
|
<div class="custom" id="custom">
|
|
<div class="field"><label for="f-start">Start date</label><input type="date" id="f-start"></div>
|
|
<div class="field"><label for="f-end">End date</label><input type="date" id="f-end"></div>
|
|
</div>
|
|
<button class="btn" id="show-trips" type="button">Show trips</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// ============================================================================
|
|
// CONFIG
|
|
// ============================================================================
|
|
const API_BASE = 'https://fleetapi.rahamafresh.com';
|
|
const EP_LIVE = `${API_BASE}/webhook/live-positions`;
|
|
const EP_TRACK = `${API_BASE}/webhook/live-positions/track`;
|
|
const EP_FLEET = `${API_BASE}/webhook/fleet-dashboard`;
|
|
const POLL_INTERVAL_MS = 15000;
|
|
const EAST_AFRICA = { center: [37.5, -3.0], zoom: 5.2 };
|
|
const STALE_GPS_MS = 10 * 60 * 1000;
|
|
const OFFLINE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
|
|
const POIS = [{ name: 'Fireside HQ', lng: 36.728785, lat: -1.2411485 }];
|
|
|
|
// Cost-centre palette — categorical, brand colours lead. Stable per centre via hash.
|
|
const COST_CENTRE_PALETTE = [
|
|
'#E8954A', '#2dd4a7', '#3b82f6', '#a855f7', '#f43f5e', '#06b6d4',
|
|
'#84cc16', '#ec4899', '#fbbf24', '#14b8a6', '#8b5cf6', '#f97316', '#22c55e',
|
|
];
|
|
const UNKNOWN_CC_COLOR = '#9ca3af';
|
|
const PARKED_COLOR = '#6b7280';
|
|
const OFFLINE_COLOR = '#374151';
|
|
// Sequence palette for trip daily_seq colouring
|
|
const SEQ_PALETTE = [
|
|
'#E8954A', '#2dd4a7', '#3b82f6', '#a855f7', '#f43f5e',
|
|
'#06b6d4', '#84cc16', '#ec4899', '#fbbf24', '#14b8a6',
|
|
];
|
|
const seqColor = n => SEQ_PALETTE[((n || 1) - 1 + SEQ_PALETTE.length * 100) % SEQ_PALETTE.length];
|
|
|
|
function colorForCostCentre(cc) {
|
|
if (!cc) return UNKNOWN_CC_COLOR;
|
|
let h = 0;
|
|
for (let i = 0; i < cc.length; i++) h = (h * 31 + cc.charCodeAt(i)) | 0;
|
|
return COST_CENTRE_PALETTE[Math.abs(h) % COST_CENTRE_PALETTE.length];
|
|
}
|
|
// Pastel tint of a #rrggbb colour — blended toward white. Used for recently-
|
|
// parked vehicles so they read as a softer version of their department colour.
|
|
function pastelColor(hex, mix = 0.58) {
|
|
const h = hex.replace('#', '');
|
|
const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
|
|
const t = c => Math.round(c + (255 - c) * mix);
|
|
return `rgb(${t(r)}, ${t(g)}, ${t(b)})`;
|
|
}
|
|
|
|
// ============================================================================
|
|
// State
|
|
// ============================================================================
|
|
let mode = 'live'; // 'live' | 'trips'
|
|
let map = null, popup = null;
|
|
let pollTimer = null, inFlight = null;
|
|
let lastLivePayload = null;
|
|
const liveMarkers = new Map(); // imei → maplibregl.Marker
|
|
const VEHICLE_META = new Map(); // plate → {cost_centre, assigned_city}
|
|
let openPopupImei = null, popupStuck = false, popupCloseTimer = null;
|
|
let trailedVehicle = null;
|
|
let animFrame = 0;
|
|
|
|
// ============================================================================
|
|
// EAT clock
|
|
// ============================================================================
|
|
const clockFmt = new Intl.DateTimeFormat('en-GB', {
|
|
timeZone: 'Africa/Nairobi', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
|
|
});
|
|
function tickClock() { document.getElementById('clock-time').textContent = clockFmt.format(new Date()); }
|
|
setInterval(tickClock, 1000); tickClock();
|
|
|
|
// ============================================================================
|
|
// Map
|
|
// ============================================================================
|
|
function ensureMap() {
|
|
if (map) return map;
|
|
const el = document.getElementById('map');
|
|
map = new maplibregl.Map({
|
|
container: el,
|
|
style: 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json',
|
|
center: EAST_AFRICA.center, zoom: EAST_AFRICA.zoom,
|
|
attributionControl: { compact: true },
|
|
});
|
|
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
|
|
popup = new maplibregl.Popup({ closeButton: true, closeOnClick: false, offset: 20 });
|
|
popup.on('close', () => { openPopupImei = null; popupStuck = false; });
|
|
map.on('load', () => { POIS.forEach(addPoiMarker); updateVehScale(); });
|
|
map.on('zoom', updateVehScale);
|
|
map.on('click', e => {
|
|
if (mode !== 'live') return;
|
|
if (!popupStuck) return;
|
|
// background click closes a pinned popup
|
|
popupStuck = false; openPopupImei = null; popup.remove();
|
|
});
|
|
return map;
|
|
}
|
|
function addPoiMarker(poi) {
|
|
const el = document.createElement('div');
|
|
el.className = 'hq-marker'; el.title = poi.name;
|
|
el.innerHTML = `<div class="hq-dot"></div><div class="hq-label">${escapeHtml(poi.name)}</div>`;
|
|
new maplibregl.Marker({ element: el, anchor: 'bottom' }).setLngLat([poi.lng, poi.lat]).addTo(map);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Vehicle state (tri-state)
|
|
// ============================================================================
|
|
function vehicleState(p) {
|
|
if (!p) return 'offline';
|
|
const ageMs = (typeof p.source_age_hours === 'number')
|
|
? p.source_age_hours * 3600 * 1000
|
|
: (p.gps_time_utc ? Date.now() - new Date(p.gps_time_utc).getTime() : 0);
|
|
if (ageMs >= OFFLINE_THRESHOLD_MS) return 'offline';
|
|
if (p.acc_status !== '1') return 'parked';
|
|
if (ageMs >= STALE_GPS_MS) return 'parked';
|
|
return 'active';
|
|
}
|
|
function isVehicleActive(p) { return vehicleState(p) === 'active'; }
|
|
function staleAgeText(p) {
|
|
const ageMs = (typeof p.source_age_hours === 'number')
|
|
? p.source_age_hours * 3600 * 1000
|
|
: (p.gps_time_utc ? Date.now() - new Date(p.gps_time_utc).getTime() : 0);
|
|
const days = ageMs / 86400000;
|
|
if (days >= 1) return `${days.toFixed(days >= 10 ? 0 : 1)}d`;
|
|
const hours = ageMs / 3600000;
|
|
if (hours >= 1) return `${hours.toFixed(0)}h`;
|
|
return `${Math.max(0, Math.round(ageMs / 60000))}m`;
|
|
}
|
|
function plateTail(plate) {
|
|
if (!plate) return '';
|
|
return String(plate).replace(/\s+/g, '').slice(-4);
|
|
}
|
|
|
|
// ============================================================================
|
|
// LIVE MODE — polling + DOM markers
|
|
// ============================================================================
|
|
async function fetchLive() {
|
|
if (inFlight) inFlight.abort();
|
|
inFlight = new AbortController();
|
|
try {
|
|
const resp = await fetch(EP_LIVE, { signal: inFlight.signal });
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
const data = await resp.json();
|
|
if (data.error) { showError(data.error.message || 'Live feed error'); return; }
|
|
hideError();
|
|
lastLivePayload = data;
|
|
if (mode === 'live') renderLive();
|
|
} catch (err) {
|
|
if (err.name === 'AbortError') return;
|
|
showError(`Couldn't load live positions: ${err.message}`);
|
|
} finally { inFlight = null; }
|
|
}
|
|
function startPolling() { stopPolling(); fetchLive(); pollTimer = setInterval(fetchLive, POLL_INTERVAL_MS); }
|
|
function stopPolling() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } }
|
|
|
|
let firstFit = false;
|
|
function renderLive() {
|
|
if (!lastLivePayload) return;
|
|
ensureMap();
|
|
const features = (lastLivePayload.geojson && lastLivePayload.geojson.features) || [];
|
|
|
|
// KPIs (recomputed client-side so they reflect what's drawn)
|
|
const active = features.filter(f => isVehicleActive(f.properties));
|
|
const speeds = active.map(f => Number(f.properties.speed || 0)).filter(s => s > 0).sort((a, b) => a - b);
|
|
const median = speeds.length ? speeds[Math.floor(speeds.length / 2)] : null;
|
|
const offline = features.filter(f => vehicleState(f.properties) === 'offline').length;
|
|
renderLiveKPIs({
|
|
total: features.length, moving: active.length,
|
|
parked: features.length - active.length - offline, offline,
|
|
median, last_batch_utc: lastLivePayload.summary?.last_batch_utc,
|
|
});
|
|
|
|
// Populate filter dropdowns from the full fleet (never shrink them)
|
|
populateFiltersFromLive(features);
|
|
|
|
const drawMarkers = () => {
|
|
const seen = new Set();
|
|
features.forEach(f => {
|
|
const p = f.properties || {};
|
|
const c = f.geometry && f.geometry.coordinates;
|
|
if (!Array.isArray(c)) return;
|
|
seen.add(p.imei);
|
|
upsertLiveMarker(p, c, f);
|
|
});
|
|
// Drop markers for vehicles no longer present
|
|
for (const [imei, m] of liveMarkers) { if (!seen.has(imei)) { m.remove(); liveMarkers.delete(imei); } }
|
|
|
|
if (!firstFit && features.length) {
|
|
const b = new maplibregl.LngLatBounds();
|
|
features.forEach(f => Array.isArray(f.geometry?.coordinates) && b.extend(f.geometry.coordinates));
|
|
if (!b.isEmpty()) map.fitBounds(b, { padding: 70, duration: 700, maxZoom: 11 });
|
|
firstFit = true;
|
|
}
|
|
// Re-attach a pinned popup to its (moved) vehicle
|
|
if (openPopupImei) {
|
|
const still = features.find(f => f.properties.imei === openPopupImei);
|
|
if (still) showLivePopup(still, true);
|
|
else { popup.remove(); openPopupImei = null; popupStuck = false; }
|
|
}
|
|
// Honour the current plate/cost-centre filter + size markers for this zoom.
|
|
applyLiveFilters();
|
|
updateVehScale();
|
|
};
|
|
if (map.isStyleLoaded()) drawMarkers(); else map.once('load', drawMarkers);
|
|
|
|
const ph = document.getElementById('placeholder'); if (ph) ph.style.display = 'none';
|
|
}
|
|
|
|
function upsertLiveMarker(p, coords, feature) {
|
|
const state = vehicleState(p);
|
|
// Active (moving now) = full department colour. Parked (reported within 24h)
|
|
// = a PASTEL of that department colour. Offline (>24h silent) = grey. Lets
|
|
// high-level viewers read fleet activity by department at a glance.
|
|
const ccColor = colorForCostCentre(p.cost_centre);
|
|
const color = state === 'offline' ? OFFLINE_COLOR
|
|
: state === 'parked' ? pastelColor(ccColor)
|
|
: ccColor;
|
|
const speed = Number(p.speed || 0);
|
|
const dir = Number(p.direction || 0);
|
|
let m = liveMarkers.get(p.imei);
|
|
if (!m) {
|
|
const el = document.createElement('div');
|
|
el.className = 'veh-marker';
|
|
el.innerHTML = `<div class="veh-inner"><div class="veh-pin"><span class="glyph"></span></div><div class="veh-plate"></div></div>`;
|
|
el.addEventListener('mouseenter', () => { cancelPopupClose(); openPopupImei = p.imei; const f = currentLiveFeature(p.imei); if (f) showLivePopup(f); });
|
|
el.addEventListener('mouseleave', () => { if (!popupStuck) schedulePopupClose(); });
|
|
el.addEventListener('click', e => {
|
|
e.stopPropagation();
|
|
cancelPopupClose(); popupStuck = true; openPopupImei = p.imei;
|
|
const f = currentLiveFeature(p.imei); if (f) showLivePopup(f);
|
|
});
|
|
m = new maplibregl.Marker({ element: el, anchor: 'center' }).setLngLat(coords).addTo(map);
|
|
liveMarkers.set(p.imei, m);
|
|
} else {
|
|
m.setLngLat(coords);
|
|
}
|
|
// Style the (possibly reused) element to current state. Use classList so we
|
|
// never wipe MapLibre's own `maplibregl-marker` class (doing so dropped
|
|
// position:absolute and made markers stack in document flow — FIX).
|
|
const el = m.getElement();
|
|
el.classList.add('veh-marker');
|
|
el.classList.remove('active', 'parked', 'offline');
|
|
el.classList.add(state);
|
|
const pin = el.querySelector('.veh-pin');
|
|
pin.style.setProperty('--c', color);
|
|
// Direction arrow only for vehicles moving now. Parked = a clean pastel
|
|
// square (no arrow, no dot). Idling/offline keep the neutral dot.
|
|
const glyph = el.querySelector('.glyph');
|
|
if (state === 'active' && speed > 0) {
|
|
glyph.className = 'glyph veh-arrow';
|
|
glyph.style.setProperty('--dir', dir + 'deg');
|
|
} else if (state === 'parked') {
|
|
glyph.className = 'glyph'; // empty — just the pastel square
|
|
glyph.style.removeProperty('--dir');
|
|
} else {
|
|
glyph.className = 'glyph idle-dot';
|
|
glyph.style.removeProperty('--dir');
|
|
}
|
|
el.querySelector('.veh-plate').textContent = plateTail(p.vehicle_number);
|
|
el.style.zIndex = state === 'active' ? 3 : (state === 'parked' ? 2 : 1);
|
|
}
|
|
function currentLiveFeature(imei) {
|
|
return (lastLivePayload?.geojson?.features || []).find(f => f.properties.imei === imei);
|
|
}
|
|
|
|
// ── Live KPI bar ────────────────────────────────────────────────────────────
|
|
function renderLiveKPIs(s) {
|
|
document.getElementById('kpis').innerHTML = `
|
|
<div class="kpi"><b>${formatNum(s.total)}</b><span>Vehicles</span></div>
|
|
<div class="kpi"><b class="live">${formatNum(s.moving)}</b><span>Moving</span></div>
|
|
<div class="kpi"><b class="parked">${formatNum(s.parked)}</b><span>Parked</span></div>
|
|
<div class="kpi"><b class="offline">${formatNum(s.offline)}</b><span>Offline 24h+</span></div>
|
|
<div class="kpi"><b>${s.median != null ? Number(s.median).toFixed(0) : '—'}</b><span>Median kmh</span></div>`;
|
|
updateStaleChip(s.last_batch_utc);
|
|
document.getElementById('stale-chip').style.display = '';
|
|
}
|
|
|
|
// ── Live popup (locked field set + actions) ──────────────────────────────────
|
|
const POPUP_CLOSE_DELAY_MS = 180;
|
|
function schedulePopupClose() {
|
|
if (popupCloseTimer) return;
|
|
popupCloseTimer = setTimeout(() => { popupCloseTimer = null; if (popupStuck) return; openPopupImei = null; popup.remove(); }, POPUP_CLOSE_DELAY_MS);
|
|
}
|
|
function cancelPopupClose() { if (popupCloseTimer) { clearTimeout(popupCloseTimer); popupCloseTimer = null; } }
|
|
|
|
function showLivePopup(feature, silent) {
|
|
const p = feature.properties || {};
|
|
const coords = feature.geometry.coordinates;
|
|
const state = vehicleState(p);
|
|
const speed = Number(p.speed || 0);
|
|
const stateLabel = state === 'offline' ? `offline · ${staleAgeText(p)}`
|
|
: state === 'parked' ? 'parked'
|
|
: speed > 0 ? `moving · ${speed.toFixed(0)} kmh` : 'idling · engine on';
|
|
const stateClass = state === 'offline' ? 'offline' : state === 'parked' ? 'parked' : speed > 0 ? 'moving' : 'idling';
|
|
const cached = lookupCachedAddress(coords[1], coords[0]);
|
|
const addrHTML = cached ? `<div class="addr">${escapeHtml(cached)}</div>`
|
|
: `<div class="addr loading" data-geocode-slot>looking up address…</div>`;
|
|
popup.setLngLat(coords).setHTML(`
|
|
<div class="pop">
|
|
<b>${escapeHtml(p.vehicle_number || '—')} <span class="badge ${stateClass}">${stateLabel}</span></b>
|
|
${p.driver ? `<div class="row">${escapeHtml(p.driver)}</div>` : ''}
|
|
${p.cost_centre ? `<div class="row muted">${escapeHtml(p.cost_centre)}${p.assigned_city ? ' · ' + escapeHtml(p.assigned_city) : ''}</div>` : ''}
|
|
${addrHTML}
|
|
<div class="row muted">heading ${Number(p.direction || 0).toFixed(0)}° · gps signal ${p.gps_signal ?? '?'}</div>
|
|
${p.current_mileage ? `<div class="row muted">${Number(p.current_mileage).toLocaleString()} km on the clock</div>` : ''}
|
|
<div class="row muted">last fix ${minutesAgoText(p.gps_time_utc)} · ${escapeHtml(p.gps_time || '')}</div>
|
|
<div class="row muted">source ${escapeHtml(p.mc_type || '?')} · ${escapeHtml(p.device_kind || '?')}</div>
|
|
<div class="actions">
|
|
<button class="act primary" data-trips="${escapeHtml(p.vehicle_number || '')}">Show trips</button>
|
|
<button class="act ghost" data-trail="${escapeHtml(p.vehicle_number || '')}">
|
|
${trailedVehicle === p.vehicle_number ? 'Hide trail' : '1 h trail'}
|
|
</button>
|
|
</div>
|
|
</div>`).addTo(map);
|
|
|
|
const root = popup.getElement();
|
|
root.querySelector('[data-trips]')?.addEventListener('click', () => enterTripsForVehicle(p.vehicle_number));
|
|
root.querySelector('[data-trail]')?.addEventListener('click', () => toggleTrail(p.vehicle_number));
|
|
root.addEventListener('mouseenter', cancelPopupClose);
|
|
root.addEventListener('mouseleave', () => { if (!popupStuck) schedulePopupClose(); });
|
|
|
|
cancelPendingGeocode();
|
|
if (!cached) {
|
|
pendingGeocodeTimer = setTimeout(() => {
|
|
pendingGeocodeTimer = null;
|
|
geocode(coords[1], coords[0]).then(addr => {
|
|
const slot = popup.getElement()?.querySelector('[data-geocode-slot]');
|
|
if (!slot) return;
|
|
slot.classList.remove('loading');
|
|
if (addr) slot.textContent = addr;
|
|
else { slot.classList.add('fail'); slot.textContent = 'address lookup failed'; }
|
|
});
|
|
}, 400);
|
|
}
|
|
}
|
|
|
|
// ── Staleness chip ───────────────────────────────────────────────────────────
|
|
function updateStaleChip(lastBatchUtc) {
|
|
const el = document.getElementById('stale-chip'), txt = document.getElementById('stale-text');
|
|
if (!lastBatchUtc) { el.className = 'stale-chip bad'; txt.textContent = 'no data'; return; }
|
|
const s = (Date.now() - new Date(lastBatchUtc)) / 1000;
|
|
txt.textContent = s < 60 ? `${Math.round(s)} s ago` : s < 3600 ? `${Math.round(s / 60)} min ago` : `${(s / 3600).toFixed(1)} h ago`;
|
|
el.className = s < 120 ? 'stale-chip' : s < 600 ? 'stale-chip warn' : 'stale-chip bad';
|
|
}
|
|
setInterval(() => { if (mode === 'live' && lastLivePayload?.summary?.last_batch_utc) updateStaleChip(lastLivePayload.summary.last_batch_utc); }, 1000);
|
|
|
|
// ============================================================================
|
|
// Trail overlay (1 h)
|
|
// ============================================================================
|
|
const TRAIL_SOURCE = 'trail', TRAIL_LAYER = 'trail-line';
|
|
async function toggleTrail(vehicleNumber) {
|
|
if (!vehicleNumber) return;
|
|
if (trailedVehicle === vehicleNumber) { clearTrail(); trailedVehicle = null; const f = currentLiveFeatureByPlate(vehicleNumber); if (f) showLivePopup(f); return; }
|
|
try {
|
|
const resp = await fetch(`${EP_TRACK}?vehicle_number=${encodeURIComponent(vehicleNumber)}&hours=1`);
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
const feature = await resp.json();
|
|
drawTrail(feature); trailedVehicle = vehicleNumber;
|
|
const f = currentLiveFeatureByPlate(vehicleNumber); if (f) showLivePopup(f);
|
|
} catch (err) { showError(`Couldn't load trail: ${err.message}`); }
|
|
}
|
|
function currentLiveFeatureByPlate(plate) { return (lastLivePayload?.geojson?.features || []).find(f => f.properties.vehicle_number === plate); }
|
|
function drawTrail(feature) {
|
|
const fc = { type: 'FeatureCollection', features: [feature] };
|
|
const src = map.getSource(TRAIL_SOURCE);
|
|
if (src) src.setData(fc);
|
|
else {
|
|
map.addSource(TRAIL_SOURCE, { type: 'geojson', data: fc });
|
|
map.addLayer({ id: TRAIL_LAYER, type: 'line', source: TRAIL_SOURCE,
|
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
|
paint: { 'line-color': '#E8954A', 'line-width': 3, 'line-opacity': 0.75, 'line-dasharray': [0, 2, 2] } });
|
|
}
|
|
const coords = feature.geometry?.coordinates;
|
|
if (Array.isArray(coords) && coords.length) {
|
|
const b = new maplibregl.LngLatBounds(); coords.forEach(c => b.extend(c));
|
|
if (!b.isEmpty()) map.fitBounds(b, { padding: 80, duration: 500, maxZoom: 14 });
|
|
}
|
|
}
|
|
function clearTrail() { if (map?.getSource(TRAIL_SOURCE)) map.getSource(TRAIL_SOURCE).setData({ type: 'FeatureCollection', features: [] }); }
|
|
|
|
// ============================================================================
|
|
// Filters card
|
|
// ============================================================================
|
|
async function loadFilters() {
|
|
try {
|
|
const resp = await fetch(EP_FLEET, { method: 'GET' });
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
const data = await resp.json();
|
|
fillVehicleSelect(data.vehicles || []);
|
|
fillSelect('f-cc', data.cost_centres || []);
|
|
fillSelect('f-city', data.cities || []);
|
|
} catch (e) { console.error('loadFilters failed:', e); }
|
|
}
|
|
function fillVehicleSelect(vehicles) {
|
|
const sel = document.getElementById('f-vehicle');
|
|
vehicles.forEach(v => {
|
|
const plate = v.vehicle_number;
|
|
if (!plate || VEHICLE_META.has(plate)) return;
|
|
VEHICLE_META.set(plate, { cost_centre: v.cost_centre, assigned_city: v.assigned_city });
|
|
const opt = document.createElement('option');
|
|
opt.value = plate; opt.textContent = v.drivers ? `${plate} — ${v.drivers}` : plate;
|
|
sel.appendChild(opt);
|
|
});
|
|
sortSelect('f-vehicle');
|
|
sel.addEventListener('change', applyVehicleAutoFilter);
|
|
}
|
|
function fillSelect(id, values) {
|
|
const sel = document.getElementById(id);
|
|
const have = new Set(Array.from(sel.options).map(o => o.value));
|
|
let added = false;
|
|
values.forEach(v => { if (have.has(v)) return; const o = document.createElement('option'); o.value = v; o.textContent = v; sel.appendChild(o); added = true; });
|
|
if (added) sortSelect(id);
|
|
}
|
|
// Reorder a select's options A→Z by value (natural/numeric), keeping any
|
|
// blank-value placeholder ("All …") pinned at the top and preserving selection.
|
|
function sortSelect(id) {
|
|
const sel = document.getElementById(id);
|
|
const selected = new Set(Array.from(sel.selectedOptions).map(o => o.value));
|
|
const opts = Array.from(sel.options);
|
|
const head = opts.filter(o => o.value === '');
|
|
const body = opts.filter(o => o.value !== '')
|
|
.sort((a, b) => a.value.localeCompare(b.value, undefined, { numeric: true, sensitivity: 'base' }));
|
|
sel.replaceChildren(...head, ...body);
|
|
Array.from(sel.options).forEach(o => { o.selected = selected.has(o.value); });
|
|
}
|
|
// Add any plate/cc/city seen in the live feed that the filter-options endpoint missed
|
|
function populateFiltersFromLive(features) {
|
|
const ccs = new Set(), cities = new Set(), vehSel = document.getElementById('f-vehicle');
|
|
const havePlates = new Set(Array.from(vehSel.options).map(o => o.value));
|
|
let addedPlate = false;
|
|
features.forEach(f => {
|
|
const p = f.properties;
|
|
if (p.cost_centre) ccs.add(p.cost_centre);
|
|
if (p.assigned_city) cities.add(p.assigned_city);
|
|
if (p.vehicle_number && !havePlates.has(p.vehicle_number)) {
|
|
havePlates.add(p.vehicle_number);
|
|
VEHICLE_META.set(p.vehicle_number, { cost_centre: p.cost_centre, assigned_city: p.assigned_city });
|
|
const o = document.createElement('option'); o.value = p.vehicle_number; o.textContent = p.vehicle_number; vehSel.appendChild(o); addedPlate = true;
|
|
}
|
|
});
|
|
if (addedPlate) sortSelect('f-vehicle');
|
|
fillSelect('f-cc', Array.from(ccs).sort());
|
|
fillSelect('f-city', Array.from(cities).sort());
|
|
}
|
|
function applyVehicleAutoFilter() {
|
|
const sel = document.getElementById('f-vehicle');
|
|
const selected = Array.from(sel.selectedOptions).map(o => o.value);
|
|
const metas = selected.map(p => VEHICLE_META.get(p)).filter(Boolean);
|
|
setSelectValue('f-cc', collapse(metas.map(m => m.cost_centre)) ?? '');
|
|
setSelectValue('f-city', collapse(metas.map(m => m.assigned_city)) ?? '');
|
|
applyLiveFilters(); // reflect the plate selection on the live map immediately
|
|
}
|
|
function collapse(values) { if (!values.length) return null; const f = values[0]; return values.every(v => v === f) ? f : null; }
|
|
function setSelectValue(id, value) { const el = document.getElementById(id); const opt = Array.from(el.options).find(o => o.value === value); el.value = opt ? value : ''; }
|
|
|
|
// Scale the live markers with the zoom level so they don't bloat at country
|
|
// zoom or vanish when zoomed in. Linear from z5 → z14.
|
|
function updateVehScale() {
|
|
if (!map) return;
|
|
const t = Math.max(0, Math.min(1, (map.getZoom() - 5) / 9));
|
|
document.getElementById('map').style.setProperty('--veh-scale', (0.42 + t * 0.78).toFixed(3));
|
|
}
|
|
|
|
// Filter the LIVE markers by the selected plate(s) + cost centre, and recompute
|
|
// the header KPIs to match. Time period only applies to trips, not live.
|
|
function applyLiveFilters() {
|
|
if (mode !== 'live') return;
|
|
const plates = new Set(Array.from(document.getElementById('f-vehicle').selectedOptions).map(o => o.value).filter(Boolean));
|
|
const cc = document.getElementById('f-cc').value;
|
|
const city = document.getElementById('f-city').value;
|
|
let total = 0, moving = 0, parked = 0, offline = 0; const speeds = [];
|
|
(lastLivePayload?.geojson?.features || []).forEach(f => {
|
|
const p = f.properties; const m = liveMarkers.get(p.imei); if (!m) return;
|
|
const pass = (plates.size === 0 || plates.has(p.vehicle_number)) && (!cc || p.cost_centre === cc) && (!city || p.assigned_city === city);
|
|
m.getElement().style.display = pass ? '' : 'none';
|
|
if (!pass) return;
|
|
total++;
|
|
const st = vehicleState(p);
|
|
if (st === 'offline') offline++;
|
|
else if (st === 'active') { moving++; const sp = Number(p.speed || 0); if (sp > 0) speeds.push(sp); }
|
|
else parked++;
|
|
});
|
|
speeds.sort((a, b) => a - b);
|
|
renderLiveKPIs({ total, moving, parked, offline, median: speeds.length ? speeds[Math.floor(speeds.length / 2)] : null, last_batch_utc: lastLivePayload?.summary?.last_batch_utc });
|
|
}
|
|
|
|
document.getElementById('f-period').addEventListener('change', e => {
|
|
document.getElementById('custom').classList.toggle('show', e.target.value === 'custom');
|
|
});
|
|
// Selecting a cost centre or assigned city filters the live map immediately.
|
|
document.getElementById('f-cc').addEventListener('change', applyLiveFilters);
|
|
document.getElementById('f-city').addEventListener('change', applyLiveFilters);
|
|
function periodToRange(period, cs, ce) {
|
|
const today = new Date(); const fmt = d => d.toISOString().slice(0, 10);
|
|
const minus = n => { const x = new Date(today); x.setDate(x.getDate() - n); return x; };
|
|
switch (period) {
|
|
case 'today': return { start_date: fmt(today), end_date: fmt(today) };
|
|
case '7d': return { start_date: fmt(minus(6)), end_date: fmt(today) };
|
|
case '30d': return { start_date: fmt(minus(29)), end_date: fmt(today) };
|
|
case 'custom': return { start_date: cs || fmt(today), end_date: ce || fmt(today) };
|
|
default: return { start_date: fmt(today), end_date: fmt(today) };
|
|
}
|
|
}
|
|
function currentFilterSelection() {
|
|
const vehSel = document.getElementById('f-vehicle');
|
|
const vehicles = Array.from(vehSel.selectedOptions).map(o => o.value).filter(Boolean);
|
|
const period = document.getElementById('f-period').value;
|
|
return {
|
|
vehicles, cost_centre: document.getElementById('f-cc').value || '',
|
|
assigned_city: document.getElementById('f-city').value || '',
|
|
period, start: document.getElementById('f-start').value, end: document.getElementById('f-end').value,
|
|
};
|
|
}
|
|
// "Show trips" button → fleet-wide / multi-vehicle trips from the filter card
|
|
document.getElementById('show-trips').addEventListener('click', () => {
|
|
const sel = currentFilterSelection();
|
|
enterTrips({ vehicle_numbers: sel.vehicles, cost_centre: sel.cost_centre, assigned_city: sel.assigned_city, period: sel.period, start: sel.start, end: sel.end });
|
|
});
|
|
// Map-dot path → single vehicle, current period in the card
|
|
function enterTripsForVehicle(plate) {
|
|
if (!plate) return;
|
|
const sel = currentFilterSelection();
|
|
enterTrips({ vehicle_numbers: [plate], cost_centre: '', assigned_city: '', period: sel.period, start: sel.start, end: sel.end });
|
|
}
|
|
|
|
// ============================================================================
|
|
// TRIPS MODE
|
|
// ============================================================================
|
|
const SRC_LINES = 'trips', LYR_LINES = 'trip-lines';
|
|
const SRC_ENDS = 'trip-endpoints', LYR_START = 'trip-starts', LYR_END = 'trip-ends';
|
|
const SRC_ANIM = 'trip-anim', LYR_ANIM = 'trip-anim-marker';
|
|
let lastTripFeatures = [];
|
|
|
|
async function enterTrips(sel) {
|
|
ensureMap();
|
|
const btn = document.getElementById('show-trips');
|
|
btn.disabled = true; btn.textContent = 'Loading…';
|
|
hideError();
|
|
const range = periodToRange(sel.period, sel.start, sel.end);
|
|
const params = new URLSearchParams({
|
|
vehicle_numbers: (sel.vehicle_numbers || []).join(','),
|
|
cost_centre: sel.cost_centre || '',
|
|
assigned_city: sel.assigned_city || '',
|
|
period: sel.period, start_date: range.start_date, end_date: range.end_date,
|
|
});
|
|
try {
|
|
const resp = await fetch(EP_FLEET, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString() });
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
const data = await resp.json();
|
|
if (data.error) { showError(data.error.message || 'Trip feed error'); return; }
|
|
switchToTripsMode();
|
|
renderTrips(data, sel);
|
|
} catch (err) { showError(`Couldn't load trips: ${err.message}`); }
|
|
finally { btn.disabled = false; btn.textContent = 'Show trips'; }
|
|
}
|
|
|
|
function switchToTripsMode() {
|
|
mode = 'trips';
|
|
stopPolling();
|
|
cancelPopupClose(); popup.remove(); openPopupImei = null; popupStuck = false;
|
|
clearTrail(); trailedVehicle = null;
|
|
liveMarkers.forEach(m => m.getElement().style.display = 'none');
|
|
document.getElementById('triplist').classList.add('show');
|
|
document.getElementById('tripbar').classList.add('show');
|
|
document.getElementById('live-pill').classList.add('show');
|
|
document.getElementById('stale-chip').style.display = 'none';
|
|
const ph = document.getElementById('placeholder'); if (ph) ph.style.display = 'none';
|
|
}
|
|
|
|
// "YYYY-MM-DD HH:MM:SS" → "DD Mon HH:MM" (EAT, compact) for the bookends.
|
|
function fmtTripWhen(ts) {
|
|
if (!ts) return '—';
|
|
const m = String(ts).match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})/);
|
|
if (!m) return ts;
|
|
const mon = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][+m[2] - 1];
|
|
return `${+m[3]} ${mon} ${m[4]}:${m[5]}`;
|
|
}
|
|
|
|
function renderTrips(payload, sel) {
|
|
const s = payload.summary || {};
|
|
document.getElementById('kpis').innerHTML = `
|
|
<div class="kpi"><b class="accent">${formatNum(s.trip_count)}</b><span>Trips</span></div>
|
|
<div class="kpi"><b>${formatNum(s.total_km)}</b><span>km</span></div>
|
|
<div class="kpi"><b>${formatNum(s.driving_hours)}</b><span>Driving h</span></div>
|
|
<div class="kpi"><b>${formatNum(s.idle_hours)}</b><span>Idle h</span></div>
|
|
<div class="kpi"><b>${formatNum(s.unique_vehicles)}</b><span>Vehicles</span></div>
|
|
<div class="kpi"><b>${formatNum(s.unique_drivers)}</b><span>Drivers</span></div>
|
|
<div class="kpi"><b style="font-size:13px">${s.date_min ?? '?'} → ${s.date_max ?? '?'}</b><span>Range</span></div>`;
|
|
|
|
// Context bar: what was filtered + first/last trip bookends (reverse-geocoded
|
|
// location + timestamp, straight from the server summary).
|
|
const ctxParts = [];
|
|
const vlist = (sel && sel.vehicle_numbers) || [];
|
|
if (vlist.length === 1) ctxParts.push(vlist[0]);
|
|
else if (vlist.length > 1) ctxParts.push(`${vlist.length} vehicles`);
|
|
if (sel && sel.cost_centre) ctxParts.push(sel.cost_centre);
|
|
if (sel && sel.assigned_city) ctxParts.push(sel.assigned_city);
|
|
document.getElementById('trip-ctx').textContent = ctxParts.length ? ctxParts.join(' · ') : 'Whole fleet';
|
|
|
|
const multiVeh = (s.unique_vehicles || 0) > 1;
|
|
document.getElementById('be-first-when').textContent = fmtTripWhen(s.first_trip_start_time);
|
|
document.getElementById('be-first-veh').textContent = multiVeh ? (s.first_trip_vehicle || '') : '';
|
|
document.getElementById('be-first-where').textContent = s.first_trip_start_address || 'location not available';
|
|
document.getElementById('be-last-when').textContent = fmtTripWhen(s.last_trip_end_time);
|
|
document.getElementById('be-last-veh').textContent = multiVeh ? (s.last_trip_vehicle || '') : '';
|
|
document.getElementById('be-last-where').textContent = s.last_trip_end_address || 'location not available';
|
|
|
|
const g = payload.geojson || { type: 'FeatureCollection', features: [] };
|
|
(g.features || []).forEach(f => { f.properties = f.properties || {}; f.properties.color = seqColor(f.properties.daily_seq); });
|
|
lastTripFeatures = g.features || [];
|
|
|
|
// endpoints FC
|
|
const endpoints = { type: 'FeatureCollection', features: [] };
|
|
(g.features || []).forEach(f => {
|
|
const c = f.geometry?.coordinates;
|
|
if (!Array.isArray(c) || c.length < 2) return;
|
|
endpoints.features.push({ type: 'Feature', properties: { ...f.properties, marker: 'start' }, geometry: { type: 'Point', coordinates: c[0] } });
|
|
endpoints.features.push({ type: 'Feature', properties: { ...f.properties, marker: 'end' }, geometry: { type: 'Point', coordinates: c[c.length - 1] } });
|
|
});
|
|
|
|
renderTripList(g.features);
|
|
clearAnim();
|
|
|
|
if (!g.features.length) {
|
|
showTripPlaceholder('No trips match those filters.');
|
|
removeTripLayers();
|
|
return;
|
|
}
|
|
const ph = document.getElementById('placeholder'); if (ph) ph.style.display = 'none';
|
|
|
|
const draw = () => {
|
|
if (map.getSource(SRC_LINES)) map.getSource(SRC_LINES).setData(g);
|
|
else {
|
|
map.addSource(SRC_LINES, { type: 'geojson', data: g });
|
|
map.addLayer({ id: LYR_LINES, type: 'line', source: SRC_LINES,
|
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
|
paint: { 'line-color': ['get', 'color'], 'line-width': ['interpolate', ['linear'], ['zoom'], 9, 1.6, 14, 3.5], 'line-opacity': 0.85 } });
|
|
map.on('mouseenter', LYR_LINES, e => { map.getCanvas().style.cursor = 'pointer'; showTripPopup(e.lngLat, e.features[0].properties, null); });
|
|
map.on('mouseleave', LYR_LINES, () => { map.getCanvas().style.cursor = ''; popup.remove(); });
|
|
}
|
|
if (map.getSource(SRC_ENDS)) map.getSource(SRC_ENDS).setData(endpoints);
|
|
else {
|
|
map.addSource(SRC_ENDS, { type: 'geojson', data: endpoints });
|
|
map.addLayer({ id: LYR_START, type: 'circle', source: SRC_ENDS, filter: ['==', ['get', 'marker'], 'start'],
|
|
paint: { 'circle-color': ['get', 'color'], 'circle-radius': ['interpolate', ['linear'], ['zoom'], 9, 3, 14, 5.5], 'circle-stroke-color': '#fff', 'circle-stroke-width': 1.2, 'circle-opacity': 0.95 } });
|
|
map.addLayer({ id: LYR_END, type: 'circle', source: SRC_ENDS, filter: ['==', ['get', 'marker'], 'end'],
|
|
paint: { 'circle-color': '#1e232e', 'circle-radius': ['interpolate', ['linear'], ['zoom'], 9, 4, 14, 7], 'circle-stroke-color': ['get', 'color'], 'circle-stroke-width': 2.2 } });
|
|
[LYR_START, LYR_END].forEach(lyr => {
|
|
map.on('mouseenter', lyr, e => { map.getCanvas().style.cursor = 'pointer'; const p = e.features[0].properties || {}; showTripPopup(e.lngLat, p, p.marker); });
|
|
map.on('mouseleave', lyr, () => { map.getCanvas().style.cursor = ''; popup.remove(); });
|
|
});
|
|
}
|
|
const b = new maplibregl.LngLatBounds();
|
|
g.features.forEach(f => Array.isArray(f.geometry?.coordinates) && f.geometry.coordinates.forEach(c => b.extend(c)));
|
|
if (!b.isEmpty()) map.fitBounds(b, { padding: { top: 50, right: 290, bottom: 50, left: 310 }, duration: 600, maxZoom: 14 });
|
|
};
|
|
if (map.isStyleLoaded()) draw(); else map.once('load', draw);
|
|
}
|
|
|
|
function showTripPopup(lngLat, p, marker) {
|
|
p = p || {};
|
|
const suffix = marker === 'start' ? ' · start' : marker === 'end' ? ' · end' : '';
|
|
const timeLine = marker === 'end' ? escapeHtml(p.end_time || '') : escapeHtml(p.start_time || '');
|
|
popup.setLngLat(lngLat).setHTML(`
|
|
<div class="pop">
|
|
<b>${escapeHtml(p.vehicle_number || '—')} · trip ${p.daily_seq ?? '?'}${suffix}</b>
|
|
${p.driver ? `${escapeHtml(p.driver)}<br>` : ''}${timeLine}<br>
|
|
<span class="muted">${p.distance_km ?? 0} km · ${p.duration_min ?? 0} min</span>
|
|
</div>`).addTo(map);
|
|
}
|
|
|
|
const MAX_TRIPS_IN_LIST = 150;
|
|
function renderTripList(features) {
|
|
const scroll = document.getElementById('trip-scroll');
|
|
const countEl = document.getElementById('trip-count');
|
|
scroll.innerHTML = '';
|
|
if (!features || !features.length) { countEl.textContent = ''; scroll.innerHTML = '<div class="trips-empty">No trips match those filters.</div>'; return; }
|
|
countEl.textContent = `(${features.length})`;
|
|
const frag = document.createDocumentFragment();
|
|
features.slice(0, MAX_TRIPS_IN_LIST).forEach(f => {
|
|
const p = f.properties || {};
|
|
const row = document.createElement('div');
|
|
row.className = 'trip-row';
|
|
row.innerHTML = `
|
|
<span class="swatch" style="background:${p.color}"></span>
|
|
<span class="seq">#${p.daily_seq ?? '?'}</span>
|
|
<span class="meta"><span class="veh">${escapeHtml(p.vehicle_number || '—')}</span>
|
|
<span class="time"> ${escapeHtml((p.start_time || '').slice(11, 16))}→${escapeHtml((p.end_time || '').slice(11, 16))}</span></span>
|
|
<span class="km">${Number(p.distance_km ?? 0).toLocaleString()} km</span>`;
|
|
row.addEventListener('click', () => {
|
|
const coords = f.geometry?.coordinates;
|
|
if (!Array.isArray(coords) || !coords.length || !map) return;
|
|
document.querySelectorAll('.trip-row.active').forEach(r => r.classList.remove('active'));
|
|
row.classList.add('active');
|
|
const b = new maplibregl.LngLatBounds(); coords.forEach(c => b.extend(c));
|
|
map.fitBounds(b, { padding: { top: 60, right: 300, bottom: 60, left: 320 }, duration: 500, maxZoom: 15 });
|
|
setTimeout(() => animateTrip(f), 550);
|
|
});
|
|
frag.appendChild(row);
|
|
});
|
|
scroll.appendChild(frag);
|
|
if (features.length > MAX_TRIPS_IN_LIST) {
|
|
const more = document.createElement('div'); more.className = 'trips-more';
|
|
more.textContent = `+ ${features.length - MAX_TRIPS_IN_LIST} more — narrow the filter`;
|
|
scroll.appendChild(more);
|
|
}
|
|
}
|
|
|
|
// ── Trip animation ───────────────────────────────────────────────────────────
|
|
function haversine(a, b) {
|
|
const R = 6371000, toRad = d => d * Math.PI / 180;
|
|
const dLat = toRad(b[1] - a[1]), dLon = toRad(b[0] - a[0]);
|
|
const x = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a[1])) * Math.cos(toRad(b[1])) * Math.sin(dLon / 2) ** 2;
|
|
return 2 * R * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x));
|
|
}
|
|
function ensureAnimLayer() {
|
|
if (map.getSource(SRC_ANIM)) return;
|
|
map.addSource(SRC_ANIM, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
|
|
map.addLayer({ id: LYR_ANIM, type: 'circle', source: SRC_ANIM,
|
|
paint: { 'circle-color': ['get', 'color'], 'circle-radius': 9, 'circle-stroke-color': '#fff', 'circle-stroke-width': 2, 'circle-blur': 0.15 } });
|
|
}
|
|
function animateTrip(feature) {
|
|
if (!map) return;
|
|
const coords = feature.geometry?.coordinates;
|
|
if (!Array.isArray(coords) || coords.length < 2) return;
|
|
ensureAnimLayer(); cancelAnimationFrame(animFrame);
|
|
const cum = [0];
|
|
for (let i = 1; i < coords.length; i++) cum.push(cum[i - 1] + haversine(coords[i - 1], coords[i]));
|
|
const total = cum[cum.length - 1]; if (total <= 0) return;
|
|
const duration = Math.min(6000, Math.max(2500, total * 6));
|
|
const color = feature.properties.color || '#E8954A';
|
|
const startTs = performance.now();
|
|
const src = map.getSource(SRC_ANIM);
|
|
src.setData(pointFC(coords[0], color));
|
|
function tick(now) {
|
|
const t = Math.min(1, (now - startTs) / duration), d = total * t;
|
|
let i = 1; while (i < cum.length && cum[i] < d) i++;
|
|
const a = coords[i - 1], bb = coords[i] || coords[coords.length - 1];
|
|
const segLen = cum[i] - cum[i - 1] || 1, segT = (d - cum[i - 1]) / segLen;
|
|
src.setData(pointFC([a[0] + (bb[0] - a[0]) * segT, a[1] + (bb[1] - a[1]) * segT], color));
|
|
if (t < 1) animFrame = requestAnimationFrame(tick);
|
|
}
|
|
animFrame = requestAnimationFrame(tick);
|
|
}
|
|
function pointFC(coord, color) { return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: { color }, geometry: { type: 'Point', coordinates: coord } }] }; }
|
|
function clearAnim() { cancelAnimationFrame(animFrame); if (map?.getSource(SRC_ANIM)) map.getSource(SRC_ANIM).setData({ type: 'FeatureCollection', features: [] }); }
|
|
|
|
function removeTripLayers() {
|
|
[LYR_ANIM, LYR_START, LYR_END, LYR_LINES].forEach(id => { if (map?.getLayer(id)) map.removeLayer(id); });
|
|
[SRC_ANIM, SRC_ENDS, SRC_LINES].forEach(id => { if (map?.getSource(id)) map.removeSource(id); });
|
|
}
|
|
function showTripPlaceholder(msg) {
|
|
let ph = document.getElementById('placeholder');
|
|
if (ph) { ph.style.display = 'grid'; ph.textContent = msg; }
|
|
}
|
|
|
|
// ── Return to live ───────────────────────────────────────────────────────────
|
|
function backToLive() {
|
|
mode = 'live';
|
|
clearAnim(); removeTripLayers();
|
|
document.getElementById('triplist').classList.remove('show');
|
|
document.getElementById('tripbar').classList.remove('show');
|
|
document.getElementById('live-pill').classList.remove('show');
|
|
document.getElementById('stale-chip').style.display = '';
|
|
liveMarkers.forEach(m => m.getElement().style.display = '');
|
|
const ph = document.getElementById('placeholder'); if (ph) ph.style.display = 'none';
|
|
startPolling();
|
|
}
|
|
document.getElementById('live-pill').addEventListener('click', backToLive);
|
|
document.getElementById('trips-back').addEventListener('click', backToLive);
|
|
|
|
// ============================================================================
|
|
// Reverse-geocoding (Nominatim) — queued, 1 req/sec, in-memory cache
|
|
// ============================================================================
|
|
const GEOCODE_CACHE = new Map(), GEOCODE_QUEUE = [];
|
|
const GEOCODE_MIN_INTERVAL_MS = 1100;
|
|
let geocodeLastFiredAt = 0, geocodeTimer = null, pendingGeocodeTimer = null;
|
|
function geocodeKey(lat, lng) { return `${lat.toFixed(4)},${lng.toFixed(4)}`; }
|
|
function lookupCachedAddress(lat, lng) { const v = GEOCODE_CACHE.get(geocodeKey(lat, lng)); return (v && v !== '__pending__' && v !== '__fail__') ? v : null; }
|
|
function cancelPendingGeocode() { if (pendingGeocodeTimer) { clearTimeout(pendingGeocodeTimer); pendingGeocodeTimer = null; } }
|
|
function geocode(lat, lng) {
|
|
const key = geocodeKey(lat, lng), cached = GEOCODE_CACHE.get(key);
|
|
if (cached && cached !== '__pending__' && cached !== '__fail__') return Promise.resolve(cached);
|
|
if (cached === '__fail__') return Promise.resolve(null);
|
|
return new Promise(resolve => { GEOCODE_QUEUE.push({ key, lat, lng, resolve }); GEOCODE_CACHE.set(key, '__pending__'); pumpGeocodeQueue(); });
|
|
}
|
|
function pumpGeocodeQueue() {
|
|
if (geocodeTimer || !GEOCODE_QUEUE.length) return;
|
|
const wait = Math.max(0, GEOCODE_MIN_INTERVAL_MS - (Date.now() - geocodeLastFiredAt));
|
|
geocodeTimer = setTimeout(async () => {
|
|
geocodeTimer = null; const job = GEOCODE_QUEUE.shift(); if (!job) return;
|
|
geocodeLastFiredAt = Date.now();
|
|
try {
|
|
const url = `https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${job.lat}&lon=${job.lng}&zoom=17&accept-language=en`;
|
|
const resp = await fetch(url, { headers: { 'Accept': 'application/json' } });
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
const data = await resp.json(); const addr = formatNominatimAddress(data) || null;
|
|
GEOCODE_CACHE.set(job.key, addr || '__fail__'); job.resolve(addr);
|
|
} catch (_e) { GEOCODE_CACHE.set(job.key, '__fail__'); job.resolve(null); }
|
|
finally { pumpGeocodeQueue(); }
|
|
}, wait);
|
|
}
|
|
function formatNominatimAddress(data) {
|
|
if (!data) return null; const a = data.address || {}; const parts = [];
|
|
const road = a.road || a.pedestrian || a.path || a.highway;
|
|
if (road) parts.push(`${a.house_number ? a.house_number + ' ' : ''}${road}`);
|
|
const local = a.neighbourhood || a.suburb || a.quarter || a.village || a.hamlet;
|
|
if (local && local !== road) parts.push(local);
|
|
const city = a.city || a.town || a.municipality || a.county;
|
|
if (city && !parts.includes(city)) parts.push(city);
|
|
if (parts.length) return parts.join(', ');
|
|
return data.display_name?.split(',').slice(0, 3).join(',').trim() || null;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helpers
|
|
// ============================================================================
|
|
function minutesAgoText(utcIso) {
|
|
if (!utcIso) return '';
|
|
const s = (Date.now() - new Date(utcIso)) / 1000;
|
|
if (s < 60) return `${Math.round(s)}s ago`;
|
|
if (s < 3600) return `${Math.round(s / 60)}m ago`;
|
|
if (s < 86400) return `${(s / 3600).toFixed(1)}h ago`;
|
|
return `${Math.round(s / 86400)}d ago`;
|
|
}
|
|
function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
|
|
function formatNum(n) { if (n === null || n === undefined) return '—'; return typeof n === 'number' ? n.toLocaleString() : n; }
|
|
function showError(msg) {
|
|
let el = document.getElementById('err');
|
|
if (!el) { el = document.createElement('div'); el.id = 'err'; el.className = 'error'; document.getElementById('map').appendChild(el); }
|
|
el.textContent = msg;
|
|
}
|
|
function hideError() { const el = document.getElementById('err'); if (el) el.remove(); }
|
|
|
|
// ============================================================================
|
|
// Boot
|
|
// ============================================================================
|
|
ensureMap();
|
|
loadFilters();
|
|
startPolling();
|
|
</script>
|
|
</body>
|
|
</html>
|