2026-06-05 18:56:01 +00:00
<!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;
}
2026-06-06 21:07:05 +00:00
.app {
display: grid; height: 100vh; width: 100vw; overflow: hidden;
2026-06-06 21:35:34 +00:00
grid-template-rows: auto auto minmax(0, 1fr) auto; /* header · context · map · bottom dock */
2026-06-06 21:07:05 +00:00
grid-template-columns: minmax(0, 1fr); /* clamp so the trip-card scroller can't widen the page */
}
2026-06-06 08:14:16 +00:00
/* 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; }
2026-06-05 18:56:01 +00:00
/* ── 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; }
2026-06-06 21:35:34 +00:00
/* ── Layout: header · context · map · two-tier bottom dock ───────────── */
2026-06-06 08:14:16 +00:00
header { grid-row: 1; }
.tripbar { grid-row: 2; }
2026-06-06 21:35:34 +00:00
#map { grid-row: 3; position: relative; min-height: 0; }
2026-06-05 18:56:01 +00:00
.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;
}
2026-06-06 21:35:34 +00:00
.dockbar {
grid-row: 4; min-width: 0; display: flex; flex-direction: column;
background: var(--panel); border-top: 1px solid var(--border);
2026-06-05 18:56:01 +00:00
}
2026-06-06 21:35:34 +00:00
/* Filter tier — expanded form (live / editing) ⇄ collapsed summary (trips) */
.filter-summary { display: none; align-items: center; gap: 12px; padding: 9px 16px; }
.filter-tier.collapsed .filter-summary { display: flex; }
.filter-tier.collapsed .filter-form { display: none; }
.fs-label { font-size: 10px; text-transform: uppercase; letter-spacing: .6px; color: var(--muted); font-weight: 600; }
.fs-text { color: var(--text); font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fs-edit {
margin-left: auto; background: var(--panel-2); color: var(--accent);
border: 1px solid var(--border); border-radius: 6px; padding: 5px 12px;
font: 600 12px system-ui; cursor: pointer; white-space: nowrap;
2026-06-05 18:56:01 +00:00
}
2026-06-06 21:35:34 +00:00
.fs-edit:hover { border-color: var(--accent); }
.filter-form { display: flex; align-items: flex-end; flex-wrap: wrap; gap: 12px; padding: 10px 16px 12px; }
.ff-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.ff-field > label {
font-size: 10px; text-transform: uppercase; letter-spacing: .5px; color: var(--muted);
2026-06-05 18:56:01 +00:00
}
2026-06-06 21:35:34 +00:00
.ff-plate { width: 230px; }
2026-06-05 18:56:01 +00:00
select, input[type=date] {
2026-06-06 21:21:16 +00:00
padding: 7px 9px; background: var(--bg); color: var(--text);
2026-06-05 18:56:01 +00:00
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; }
2026-06-06 21:35:34 +00:00
.ff-field.custom { display: none; }
.ff-field.custom.show { display: flex; }
.ff-go { width: auto; margin: 0; align-self: flex-end; padding: 8px 18px; }
2026-06-06 21:21:16 +00:00
2026-06-06 21:35:34 +00:00
/* Searchable plate combobox + chips — dropdown opens UPWARD (bar is at bottom) */
2026-06-06 21:21:16 +00:00
.plate-box { position: relative; }
2026-06-06 21:35:34 +00:00
.plate-chips { display: flex; flex-wrap: wrap; gap: 4px; }
.plate-chips:not(:empty) { margin-bottom: 5px; }
2026-06-06 21:21:16 +00:00
.plate-chip {
display: inline-flex; align-items: center; gap: 5px;
background: var(--accent); color: #1a1009; font: 600 11px system-ui;
padding: 2px 4px 2px 8px; border-radius: 999px;
}
.plate-chip button { background: none; border: 0; color: #1a1009; cursor: pointer; font-size: 13px; line-height: 1; padding: 0 2px; opacity: .75; }
.plate-chip button:hover { opacity: 1; }
.plate-search { width: 100%; padding: 7px 9px; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 6px; font: 13px system-ui; }
.plate-search:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
.plate-results {
2026-06-06 21:35:34 +00:00
display: none; position: absolute; z-index: 20; left: 0; right: 0; bottom: 100%; margin-bottom: 4px;
max-height: 240px; overflow-y: auto; background: var(--panel-2);
border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 -8px 22px rgba(0,0,0,.5);
2026-06-06 21:21:16 +00:00
}
.plate-results.show { display: block; }
.plate-opt { padding: 7px 10px; font-size: 12.5px; cursor: pointer; color: var(--text); }
.plate-opt:hover, .plate-opt.hi { background: rgba(232,149,74,.18); }
.plate-opt.sel { color: var(--accent); }
.plate-opt .pd { color: var(--muted); font-size: 11px; }
.plate-none { padding: 8px 10px; color: var(--muted); font-size: 11.5px; }
2026-06-05 18:56:01 +00:00
.btn {
2026-06-06 21:35:34 +00:00
padding: 10px; background: var(--accent);
2026-06-05 18:56:01 +00:00
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; }
2026-06-06 21:35:34 +00:00
/* Card tier (beneath the filter tier; trips mode only) */
.cards-tier { display: none; flex-direction: column; min-width: 0; border-top: 1px solid var(--border); }
.cards-tier.show { display: flex; }
.cards-tier-head { padding: 7px 16px 0; }
.tripsbar-title { font-size: 11px; text-transform: uppercase; letter-spacing: .6px; color: var(--muted); font-weight: 600; }
2026-06-06 21:07:05 +00:00
.tripsbar-title span { color: var(--text); }
.trip-cards {
display: flex; gap: 10px; overflow-x: auto; overflow-y: hidden;
padding: 10px 16px 14px; scrollbar-width: thin; min-width: 0;
}
.trip-card {
flex: 0 0 auto; width: 150px; cursor: pointer;
background: var(--bg); border: 1px solid var(--border);
border-left: 4px solid var(--swatch, var(--muted)); border-radius: 8px;
padding: 8px 10px; display: flex; flex-direction: column; gap: 3px;
}
.trip-card:hover { border-color: var(--accent); background: var(--panel-2); }
.trip-card.active { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
.trip-card .tc-top { display: flex; justify-content: space-between; align-items: baseline; gap: 6px; }
.trip-card .tc-seq { color: var(--muted); font-variant-numeric: tabular-nums; font-size: 11px; }
.trip-card .tc-km { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; font-size: 12px; white-space: nowrap; }
.trip-card .tc-veh { color: var(--text); font-weight: 600; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.trip-card .tc-time { color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
.trips-empty { color: var(--muted); padding: 18px 4px; font-size: 12px; }
.trips-more { flex: 0 0 auto; align-self: center; color: var(--muted); font-size: 11px; padding: 0 12px; white-space: nowrap; }
2026-06-05 18:56:01 +00:00
.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) ─────────────────────────── */
2026-06-05 19:45:41 +00:00
/* 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; }
2026-06-05 20:01:30 +00:00
/* 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;
}
2026-06-05 18:56:01 +00:00
.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); }
2026-06-06 07:28:56 +00:00
/* Parked = reported within 24h ("active within 24h"): a clean PASTEL
2026-06-06 07:57:54 +00:00
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; }
2026-06-05 18:56:01 +00:00
.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); }
2026-06-08 11:39:46 +00:00
/* Specialist vehicle icon (crane / motorbike / pick-up) — white silhouette
centred in the department-coloured pin. */
.veh-type { display: grid; place-items: center; width: 22px; height: 22px; }
.veh-type svg { width: 20px; height: 20px; display: block; }
/* Keep specialists full-size + circular even when parked so the icon stays
legible (overrides the parked half-size square). */
.veh-marker.has-type.parked .veh-pin { transform: scale(1); border-radius: 50%; }
.veh-marker.has-type.offline .veh-type svg { opacity: .85; }
2026-06-08 12:10:12 +00:00
/* ── Cost-centre colour key (collapsible, tidy) ────────────────────── */
#legend { position: absolute; left: 10px; bottom: 12px; z-index: 5;
font: 600 11px system-ui; color: #fff; user-select: none; }
.legend-toggle { cursor: pointer; border: 1px solid var(--border);
background: rgba(15,18,23,.92); color: #fff; font: 600 11px system-ui;
padding: 4px 10px; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,.45); }
.legend-toggle::before { content: '◑'; color: var(--accent); margin-right: 5px; }
.legend-body { margin-top: 6px; background: rgba(15,18,23,.92);
border: 1px solid var(--border); border-radius: 8px; padding: 7px 9px;
box-shadow: 0 4px 14px rgba(0,0,0,.5); max-height: 38vh; overflow-y: auto;
display: grid; gap: 4px; min-width: 124px; }
#legend.collapsed .legend-body { display: none; }
.legend-row { display: flex; align-items: center; gap: 7px; }
.legend-dot { width: 11px; height: 11px; border-radius: 3px; flex: 0 0 auto;
border: 1px solid rgba(255,255,255,.5); }
.legend-lbl { flex: 1; text-transform: uppercase; letter-spacing: .02em; }
.legend-n { color: var(--muted); font-weight: 700; }
2026-06-05 18:56:01 +00:00
/* ── Persistent POI marker (Fireside HQ) ───────────────────────────── */
2026-06-06 20:44:09 +00:00
/* Cluster bubble (zoomed-out): amber circle + white count, tiered by size.
Click zooms to expand into the individual pins. */
.cluster-bubble {
display: grid; place-items: center; cursor: pointer;
border-radius: 50%; color: #1a1009; font-weight: 800;
background: var(--accent);
border: 2px solid rgba(255,255,255,.92);
box-shadow: 0 2px 10px rgba(0,0,0,.5);
transition: transform .08s ease;
}
.cluster-bubble:hover { transform: scale(1.08); }
.cluster-bubble.t1 { width: 34px; height: 34px; font-size: 13px; }
.cluster-bubble.t2 { width: 42px; height: 42px; font-size: 14px; }
.cluster-bubble.t3 { width: 52px; height: 52px; font-size: 15px; }
.cluster-bubble.t4 { width: 62px; height: 62px; font-size: 16px; }
2026-06-05 18:56:01 +00:00
.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 >
2026-06-06 20:44:09 +00:00
<!-- Supercluster: Folium - style marker clustering while keeping our DOM pins. -->
< script src = "https://unpkg.com/supercluster@8.0.1/dist/supercluster.min.js" > < / script >
2026-06-05 18:56:01 +00:00
< / 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 >
2026-06-06 08:14:16 +00:00
<!-- 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 >
2026-06-06 21:35:34 +00:00
< div id = "map" >
< div class = "placeholder" id = "placeholder" > Loading live fleet…< / div >
2026-06-08 12:10:12 +00:00
<!-- Cost - centre colour key — collapsed by default to keep the map tidy.
Lists only centres currently on screen; safe to delete this block + its
CSS/JS (renderLegend) to remove the feature entirely. -->
< div id = "legend" class = "collapsed" aria-label = "Cost-centre colour key" >
< button type = "button" class = "legend-toggle" id = "legend-toggle" > Key< / button >
< div class = "legend-body" id = "legend-body" > < / div >
< / div >
2026-06-06 21:35:34 +00:00
< / div >
2026-06-05 18:56:01 +00:00
2026-06-06 21:35:34 +00:00
<!-- Bottom dock: two tiers — filter tier (top) + trip - card tier (beneath) -->
< div class = "dockbar" id = "dockbar" >
<!-- Filter tier: collapsed summary (trips) ⇄ expanded form -->
< div class = "filter-tier" id = "filter-tier" >
< div class = "filter-summary" id = "filter-summary" >
< span class = "fs-label" > Filters< / span >
< span class = "fs-text" id = "fs-text" > Whole fleet · Today< / span >
< button class = "fs-edit" id = "filter-edit" type = "button" > ✎ Edit< / button >
2026-06-05 18:56:01 +00:00
< / div >
2026-06-06 21:35:34 +00:00
< div class = "filter-form" id = "filter-form" >
< div class = "ff-field ff-plate" >
< label for = "plate-search" > Number plate< / label >
< div class = "plate-box" >
< div class = "plate-chips" id = "plate-chips" > < / div >
< input type = "text" id = "plate-search" class = "plate-search" placeholder = "Search plate…" autocomplete = "off" >
< div class = "plate-results" id = "plate-results" > < / div >
< / div >
< select id = "f-vehicle" multiple hidden > < / select >
< / div >
< div class = "ff-field" >
< label for = "f-cc" > Cost centre< / label >
< select id = "f-cc" > < option value = "" > All cost centres< / option > < / select >
< / div >
< div class = "ff-field" >
< label for = "f-city" > Assigned city< / label >
< select id = "f-city" > < option value = "" > All cities< / option > < / select >
< / div >
< div class = "ff-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 = "ff-field custom" id = "custom" >
< label for = "f-start" > Start< / label > < input type = "date" id = "f-start" >
< / div >
< div class = "ff-field custom" id = "custom-end" >
< label for = "f-end" > End< / label > < input type = "date" id = "f-end" >
< / div >
< button class = "btn ff-go" id = "show-trips" type = "button" > Show trips< / button >
2026-06-05 18:56:01 +00:00
< / div >
2026-06-06 21:35:34 +00:00
< / div >
2026-06-06 21:07:05 +00:00
2026-06-06 21:35:34 +00:00
<!-- Card tier: trips (trips mode only) -->
< div class = "cards-tier" id = "cards-tier" >
< div class = "cards-tier-head" >
2026-06-06 21:07:05 +00:00
< span class = "tripsbar-title" > TRIPS < span id = "trip-count" > < / span > < / span >
< / div >
< div class = "trip-cards" id = "trip-scroll" >
< div class = "trips-empty" > No trips.< / div >
< / div >
2026-06-05 18:56:01 +00:00
< / 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 }];
2026-06-08 12:10:12 +00:00
// Deliberate, distinct colour per cost centre so all vehicles in a centre share
// one colour and different centres are easy to tell apart at a glance. Keys are
// normalised (lowercase, trimmed). Anything not listed falls back to a stable
// hash of COST_CENTRE_PALETTE.
const COST_CENTRE_COLORS = {
'isp': '#3b82f6', // blue
'osp': '#E8954A', // brand amber
'osp patrol': '#f97316', // orange (OSP sibling)
'fds': '#22c55e', // green
'roll out': '#a855f7', // purple
'general': '#fbbf24', // gold
'regional': '#ec4899', // pink
'planning': '#06b6d4', // cyan
'deliveries': '#84cc16', // lime
'qehs': '#14b8a6', // teal
'airtel': '#ef4444', // brand red
};
// Fallback palette for any centre not in COST_CENTRE_COLORS (stable per name via hash).
2026-06-05 18:56:01 +00:00
const COST_CENTRE_PALETTE = [
2026-06-08 12:10:12 +00:00
'#2dd4a7', '#8b5cf6', '#f43f5e', '#0ea5e9', '#eab308', '#d946ef', '#10b981',
2026-06-05 18:56:01 +00:00
];
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;
2026-06-08 12:10:12 +00:00
const key = String(cc).trim().toLowerCase();
if (COST_CENTRE_COLORS[key]) return COST_CENTRE_COLORS[key];
2026-06-05 18:56:01 +00:00
let h = 0;
2026-06-08 12:10:12 +00:00
for (let i = 0; i < key.length ; i + + ) h = (h * 31 + key . charCodeAt ( i ) ) | 0 ;
2026-06-05 18:56:01 +00:00
return COST_CENTRE_PALETTE[Math.abs(h) % COST_CENTRE_PALETTE.length];
}
2026-06-06 07:28:56 +00:00
// 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)})`;
}
2026-06-05 18:56:01 +00:00
// ============================================================================
// State
// ============================================================================
let mode = 'live'; // 'live' | 'trips'
let map = null, popup = null;
let pollTimer = null, inFlight = null;
let lastLivePayload = null;
2026-06-06 20:44:09 +00:00
const liveMarkers = new Map(); // imei → maplibregl.Marker (individual vehicles)
const clusterMarkers = new Map(); // cluster_id → maplibregl.Marker (count bubbles)
2026-06-06 20:23:44 +00:00
let liveFeatures = []; // deduped (one device per vehicle) — see dedupeLiveFeatures
2026-06-06 20:44:09 +00:00
let cluster = null; // Supercluster index of the currently-filtered fleet
2026-06-08 12:59:47 +00:00
let liveSpecialists = []; // crane/motorbike/pick-up — drawn individually, never clustered
2026-06-06 20:44:09 +00:00
const CLUSTER_RADIUS = 60; // px cluster radius
const CLUSTER_MAXZOOM = 11; // above this, clusters disband into individual pins (~city zoom)
2026-06-05 18:56:01 +00:00
const VEHICLE_META = new Map(); // plate → {cost_centre, assigned_city}
2026-06-06 20:23:44 +00:00
const PLATE_KEYS = new Set(); // normalised plate keys already in the dropdown (tracker+camera collapse to one)
2026-06-05 18:56:01 +00:00
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; });
2026-06-05 20:01:30 +00:00
map.on('load', () => { POIS.forEach(addPoiMarker); updateVehScale(); });
map.on('zoom', updateVehScale);
2026-06-06 20:44:09 +00:00
// Re-cluster after any pan/zoom settles (live mode only).
map.on('moveend', () => { if (mode === 'live') renderClusters(); });
2026-06-05 18:56:01 +00:00
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);
}
2026-06-06 20:23:44 +00:00
// Normalised plate key for pairing a vehicle's tracker + camera (which share
// the same plate, sometimes with a stray space e.g. "KDS 453 Y" vs "KDS 453Y").
// Strip all whitespace + uppercase so the two devices collapse to one vehicle.
function normPlate(p) { return p ? String(p).replace(/\s+/g, '').toUpperCase() : ''; }
function ageMsOf(p) {
return (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() : Infinity);
}
// Every vehicle carries a tracker (X3/GT06E/AT4) AND a camera (JC400P) under the
// same plate. The tracker is primary; the camera is the fallback when the tracker
// isn't reporting. Collapse the live feed to ONE device per vehicle (per plate):
// functioning (< 24h ) beats offline → tracker beats camera → freshest fix .
// Devices with no plate can't be paired, so they pass through individually.
function deviceKindRank(p) {
const k = p.device_kind || (p.mc_type === 'JC400P' ? 'camera' : 'tracker');
return k === 'camera' ? 2 : (k === 'tracker' ? 0 : 1);
}
function dedupeLiveFeatures(features) {
const groups = new Map(); // normPlate → [features]
const loose = []; // no-plate features (kept as-is)
features.forEach(f => {
const key = normPlate(f.properties & & f.properties.vehicle_number);
if (!key) { loose.push(f); return; }
(groups.get(key) || groups.set(key, []).get(key)).push(f);
});
const winners = [];
for (const [, group] of groups) {
group.sort((a, b) => {
const pa = a.properties, pb = b.properties;
const oa = vehicleState(pa) === 'offline' ? 1 : 0, ob = vehicleState(pb) === 'offline' ? 1 : 0;
if (oa !== ob) return oa - ob; // functioning first
const ka = deviceKindRank(pa), kb = deviceKindRank(pb);
if (ka !== kb) return ka - kb; // tracker beats camera
return ageMsOf(pa) - ageMsOf(pb); // freshest fix
});
winners.push(group[0]);
}
return winners.concat(loose);
}
2026-06-05 18:56:01 +00:00
// ============================================================================
// 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();
2026-06-06 20:23:44 +00:00
const raw = (lastLivePayload.geojson & & lastLivePayload.geojson.features) || [];
// Collapse tracker+camera pairs to one device per vehicle (tracker default,
// camera fallback). Everything below operates on the deduped list.
const features = liveFeatures = dedupeLiveFeatures(raw);
2026-06-05 18:56:01 +00:00
// Populate filter dropdowns from the full fleet (never shrink them)
populateFiltersFromLive(features);
const drawMarkers = () => {
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;
}
2026-06-06 20:44:09 +00:00
// Build the cluster index from the filtered fleet + draw bubbles/pins.
applyLiveFilters();
// Re-attach a pinned popup to its (moved) vehicle, if it's still an
// individual pin on the map (not absorbed into a cluster).
2026-06-05 18:56:01 +00:00
if (openPopupImei) {
2026-06-06 20:44:09 +00:00
const m = liveMarkers.get(openPopupImei);
const still = m & & m.getElement().style.display !== 'none' & & features.find(f => f.properties.imei === openPopupImei);
2026-06-05 18:56:01 +00:00
if (still) showLivePopup(still, true);
else { popup.remove(); openPopupImei = null; popupStuck = false; }
}
2026-06-05 20:01:30 +00:00
updateVehScale();
2026-06-05 18:56:01 +00:00
};
if (map.isStyleLoaded()) drawMarkers(); else map.once('load', drawMarkers);
const ph = document.getElementById('placeholder'); if (ph) ph.style.display = 'none';
}
2026-06-06 20:44:09 +00:00
// Features matching the current plate/cost-centre/city filter (live mode).
function filteredLiveFeatures() {
const plates = new Set(Array.from(document.getElementById('f-vehicle').selectedOptions).map(o => normPlate(o.value)).filter(Boolean));
const cc = document.getElementById('f-cc').value;
const city = document.getElementById('f-city').value;
return liveFeatures.filter(f => {
const p = f.properties;
if (!Array.isArray(f.geometry?.coordinates)) return false;
if (plates.size & & !plates.has(normPlate(p.vehicle_number))) return false;
if (cc & & p.cost_centre !== cc) return false;
if (city & & p.assigned_city !== city) return false;
return true;
});
}
2026-06-08 12:59:47 +00:00
// A vehicle is exempt from clustering exactly when it carries a specialist icon
// (crane / motorbike / pick-up) — keyed off the feed's vehicle_type / fleet_segment.
function isSpecialist(p) {
return !!(p & & (SPECIALIST_ICONS[p.vehicle_type] || p.fleet_segment === 'specialist'));
}
2026-06-06 20:44:09 +00:00
// Load the filtered fleet into supercluster, redraw bubbles+pins, recompute KPIs.
function applyLiveFilters() {
if (mode !== 'live') return;
const filtered = filteredLiveFeatures();
2026-06-08 12:59:47 +00:00
// Specialists are never clustered — they always render as individual icons so
// they stand out. Only the rest of the fleet feeds supercluster.
liveSpecialists = filtered.filter(f => isSpecialist(f.properties));
const clusterable = filtered.filter(f => !isSpecialist(f.properties));
2026-06-06 20:44:09 +00:00
cluster = new Supercluster({ radius: CLUSTER_RADIUS, maxZoom: CLUSTER_MAXZOOM });
2026-06-08 12:59:47 +00:00
cluster.load(clusterable.map(f => ({
2026-06-06 20:44:09 +00:00
type: 'Feature',
properties: { ...f.properties },
geometry: { type: 'Point', coordinates: f.geometry.coordinates },
})));
renderClusters();
// KPIs from the filtered set.
let moving = 0, parked = 0, offline = 0; const speeds = [];
filtered.forEach(f => {
const st = vehicleState(f.properties);
if (st === 'offline') offline++;
else if (st === 'active') { moving++; const sp = Number(f.properties.speed || 0); if (sp > 0) speeds.push(sp); }
else parked++;
});
speeds.sort((a, b) => a - b);
renderLiveKPIs({ total: filtered.length, moving, parked, offline, median: speeds.length ? speeds[Math.floor(speeds.length / 2)] : null, last_batch_utc: lastLivePayload?.summary?.last_batch_utc });
2026-06-08 12:10:12 +00:00
renderLegend(filtered);
}
// Compact colour key — lists only the cost centres currently on the map (respects
// the active filter), sorted by count. Rebuilt on every live render so it stays
// in sync. Collapsed by default; toggled by the "Key" pill.
function renderLegend(features) {
const body = document.getElementById('legend-body');
const wrap = document.getElementById('legend');
if (!body || !wrap) return;
const counts = new Map();
(features || []).forEach(f => {
const key = ((f.properties & & f.properties.cost_centre) || '').trim() || '(none)';
counts.set(key, (counts.get(key) || 0) + 1);
});
const rows = [...counts.entries()].sort((a, b) => b[1] - a[1]);
wrap.style.display = rows.length ? '' : 'none';
body.innerHTML = rows.map(([cc, n]) => {
const color = cc === '(none)' ? UNKNOWN_CC_COLOR : colorForCostCentre(cc);
return `< div class = "legend-row" > < span class = "legend-dot" style = "background:${color}" > < / span > < span class = "legend-lbl" > ${escapeHtml(cc)}< / span > < span class = "legend-n" > ${n}< / span > < / div > `;
}).join('');
2026-06-06 20:44:09 +00:00
}
// Query the cluster index for the current viewport+zoom and draw either count
// bubbles (clusters) or individual vehicle pins (leaves).
function renderClusters() {
if (!cluster || mode !== 'live' || !map) return;
const b = map.getBounds();
const bbox = [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()];
const z = Math.round(map.getZoom());
const items = cluster.getClusters(bbox, z);
const seenVeh = new Set(), seenClu = new Set();
items.forEach(it => {
const c = it.geometry.coordinates;
if (it.properties.cluster) {
seenClu.add(it.properties.cluster_id);
upsertClusterMarker(it.properties.cluster_id, it.properties.point_count, c);
} else {
seenVeh.add(it.properties.imei);
upsertLiveMarker(it.properties, c, it);
}
});
2026-06-08 12:59:47 +00:00
// Specialists: always drawn individually, at every zoom (never clustered).
liveSpecialists.forEach(f => {
if (!Array.isArray(f.geometry & & f.geometry.coordinates)) return;
seenVeh.add(f.properties.imei);
upsertLiveMarker(f.properties, f.geometry.coordinates, f);
});
2026-06-06 20:44:09 +00:00
for (const [imei, m] of liveMarkers) { if (!seenVeh.has(imei)) { m.remove(); liveMarkers.delete(imei); } }
for (const [id, m] of clusterMarkers) { if (!seenClu.has(id)) { m.remove(); clusterMarkers.delete(id); } }
// If the popped-open vehicle got absorbed into a cluster, close its popup.
if (openPopupImei & & !liveMarkers.has(openPopupImei)) { popup.remove(); openPopupImei = null; popupStuck = false; }
updateVehScale();
}
function upsertClusterMarker(id, count, coords) {
let m = clusterMarkers.get(id);
const tier = count < 10 ? ' t1 ' : count < 25 ? ' t2 ' : count < 60 ? ' t3 ' : ' t4 ' ;
if (!m) {
const el = document.createElement('div');
el.className = 'cluster-bubble ' + tier;
el.textContent = count;
el.addEventListener('click', () => {
const expZoom = Math.min(cluster.getClusterExpansionZoom(id), 18);
map.easeTo({ center: coords, zoom: expZoom, duration: 500 });
});
m = new maplibregl.Marker({ element: el, anchor: 'center' }).setLngLat(coords).addTo(map);
clusterMarkers.set(id, m);
} else {
m.setLngLat(coords);
m.getElement().className = 'cluster-bubble ' + tier;
m.getElement().textContent = count;
}
}
2026-06-08 11:39:46 +00:00
// Specialist vehicle types get their own marker icon (white silhouette inside
// the department-coloured pin). All other types (field-service + unassigned)
// fall through to the default arrow/square/dot marker, unchanged. Keys must
// match fn_live_positions' `vehicle_type` exactly (migration 16).
const SPECIALIST_ICONS = {
'Crane': '< svg viewBox = "0 0 24 24" fill = "none" stroke = "#fff" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < path d = "M12 21V6" / > < path d = "M3 6h18" / > < path d = "M9 3l3 3 3-3" / > < path d = "M18 6v4" / > < path d = "M17 10h2v1.6h-2z" / > < path d = "M8 21h8" / > < / svg > ',
'Motorbike': '< svg viewBox = "0 0 24 24" fill = "none" stroke = "#fff" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < circle cx = "5.5" cy = "16" r = "3.3" / > < circle cx = "18.5" cy = "16" r = "3.3" / > < path d = "M5.5 16h5l3-5h3.5" / > < path d = "M10.5 16 14 11" / > < path d = "M14.5 8H18l1 3" / > < / svg > ',
'Pick-Up': '< svg viewBox = "0 0 24 24" fill = "none" stroke = "#fff" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < path d = "M2 15V8h7l3 3h8v4" / > < path d = "M2 15h2m5 0h6m4 0h2" / > < circle cx = "6.5" cy = "16.5" r = "1.9" / > < circle cx = "17.5" cy = "16.5" r = "1.9" / > < / svg > ',
};
2026-06-05 18:56:01 +00:00
function upsertLiveMarker(p, coords, feature) {
const state = vehicleState(p);
2026-06-06 07:28:56 +00:00
// 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);
2026-06-08 11:39:46 +00:00
const typeIcon = SPECIALIST_ICONS[p & & p.vehicle_type];
let color = state === 'offline' ? OFFLINE_COLOR
: state === 'parked' ? pastelColor(ccColor)
: ccColor;
// Specialists keep the full department colour (not the parked pastel) so the
// white icon stays legible and they stand out from the field-service swarm.
if (typeIcon & & state !== 'offline') color = ccColor;
2026-06-05 18:56:01 +00:00
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';
2026-06-05 19:45:41 +00:00
el.innerHTML = `< div class = "veh-inner" > < div class = "veh-pin" > < span class = "glyph" > < / span > < / div > < div class = "veh-plate" > < / div > < / div > `;
2026-06-05 18:56:01 +00:00
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);
}
2026-06-05 19:45:41 +00:00
// 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).
2026-06-05 18:56:01 +00:00
const el = m.getElement();
2026-06-05 19:45:41 +00:00
el.classList.add('veh-marker');
el.classList.remove('active', 'parked', 'offline');
el.classList.add(state);
2026-06-08 11:39:46 +00:00
el.classList.toggle('has-type', !!typeIcon);
2026-06-05 18:56:01 +00:00
const pin = el.querySelector('.veh-pin');
pin.style.setProperty('--c', color);
2026-06-08 11:39:46 +00:00
// Specialist vehicles (crane/motorbike/pick-up) show their own icon instead of
// the heading arrow. Everything else: direction arrow only when moving now,
// a clean pastel square when parked, a neutral dot when idling/offline.
2026-06-05 18:56:01 +00:00
const glyph = el.querySelector('.glyph');
2026-06-08 11:39:46 +00:00
if (typeIcon) {
glyph.className = 'glyph veh-type';
glyph.style.removeProperty('--dir');
glyph.innerHTML = typeIcon;
} else if (state === 'active' & & speed > 0) {
2026-06-05 18:56:01 +00:00
glyph.className = 'glyph veh-arrow';
2026-06-08 11:39:46 +00:00
glyph.innerHTML = '';
2026-06-05 18:56:01 +00:00
glyph.style.setProperty('--dir', dir + 'deg');
2026-06-06 07:28:56 +00:00
} else if (state === 'parked') {
glyph.className = 'glyph'; // empty — just the pastel square
2026-06-08 11:39:46 +00:00
glyph.innerHTML = '';
2026-06-06 07:28:56 +00:00
glyph.style.removeProperty('--dir');
2026-06-05 18:56:01 +00:00
} else {
glyph.className = 'glyph idle-dot';
2026-06-08 11:39:46 +00:00
glyph.innerHTML = '';
2026-06-05 18:56:01 +00:00
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) {
2026-06-06 20:23:44 +00:00
return liveFeatures.find(f => f.properties.imei === imei);
2026-06-05 18:56:01 +00:00
}
// ── 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}`); }
}
2026-06-06 20:23:44 +00:00
function currentLiveFeatureByPlate(plate) { return liveFeatures.find(f => normPlate(f.properties.vehicle_number) === normPlate(plate)); }
2026-06-05 18:56:01 +00:00
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 || []);
2026-06-05 20:23:57 +00:00
fillSelect('f-city', data.cities || []);
2026-06-05 18:56:01 +00:00
} catch (e) { console.error('loadFilters failed:', e); }
}
function fillVehicleSelect(vehicles) {
const sel = document.getElementById('f-vehicle');
vehicles.forEach(v => {
const plate = v.vehicle_number;
2026-06-06 20:23:44 +00:00
const key = normPlate(plate);
if (!plate || PLATE_KEYS.has(key)) return; // one entry per vehicle (tracker+camera share a plate)
PLATE_KEYS.add(key);
2026-06-05 18:56:01 +00:00
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);
});
2026-06-05 20:23:57 +00:00
sortSelect('f-vehicle');
2026-06-05 18:56:01 +00:00
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));
2026-06-05 20:23:57 +00:00
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); });
2026-06-05 18:56:01 +00:00
}
2026-06-05 20:23:57 +00:00
// Add any plate/cc/city seen in the live feed that the filter-options endpoint missed
2026-06-05 18:56:01 +00:00
function populateFiltersFromLive(features) {
2026-06-05 20:23:57 +00:00
const ccs = new Set(), cities = new Set(), vehSel = document.getElementById('f-vehicle');
let addedPlate = false;
2026-06-05 18:56:01 +00:00
features.forEach(f => {
const p = f.properties;
if (p.cost_centre) ccs.add(p.cost_centre);
2026-06-05 20:23:57 +00:00
if (p.assigned_city) cities.add(p.assigned_city);
2026-06-06 20:23:44 +00:00
const key = normPlate(p.vehicle_number);
if (p.vehicle_number & & !PLATE_KEYS.has(key)) { // dedup by normalised plate (tracker+camera = one)
PLATE_KEYS.add(key);
2026-06-05 18:56:01 +00:00
VEHICLE_META.set(p.vehicle_number, { cost_centre: p.cost_centre, assigned_city: p.assigned_city });
2026-06-05 20:23:57 +00:00
const o = document.createElement('option'); o.value = p.vehicle_number; o.textContent = p.vehicle_number; vehSel.appendChild(o); addedPlate = true;
2026-06-05 18:56:01 +00:00
}
});
2026-06-05 20:23:57 +00:00
if (addedPlate) sortSelect('f-vehicle');
2026-06-05 18:56:01 +00:00
fillSelect('f-cc', Array.from(ccs).sort());
2026-06-05 20:23:57 +00:00
fillSelect('f-city', Array.from(cities).sort());
2026-06-05 18:56:01 +00:00
}
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);
2026-06-05 20:23:57 +00:00
setSelectValue('f-cc', collapse(metas.map(m => m.cost_centre)) ?? '');
setSelectValue('f-city', collapse(metas.map(m => m.assigned_city)) ?? '');
2026-06-06 21:21:16 +00:00
renderPlateChips();
2026-06-05 20:01:30 +00:00
applyLiveFilters(); // reflect the plate selection on the live map immediately
2026-06-05 18:56:01 +00:00
}
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 : ''; }
2026-06-06 21:21:16 +00:00
// ── Searchable plate combobox — drives the hidden #f-vehicle multi-select ────
const _plateSearch = document.getElementById('plate-search');
const _plateResults = document.getElementById('plate-results');
const _plateChips = document.getElementById('plate-chips');
function _plateOpts() { return Array.from(document.getElementById('f-vehicle').options); }
function _fireVehChange() { document.getElementById('f-vehicle').dispatchEvent(new Event('change')); }
function renderPlateChips() {
_plateChips.innerHTML = '';
_plateOpts().filter(o => o.selected).forEach(o => {
const chip = document.createElement('span');
chip.className = 'plate-chip';
chip.innerHTML = `${escapeHtml(o.value)} < button type = "button" aria-label = "remove" > × < / button > `;
chip.querySelector('button').addEventListener('click', () => { o.selected = false; _fireVehChange(); });
_plateChips.appendChild(chip);
});
}
function renderPlateResults(q) {
q = (q || '').trim().toLowerCase();
const matches = _plateOpts().filter(o => o.value & & (!q || o.textContent.toLowerCase().includes(q))).slice(0, 60);
_plateResults.innerHTML = '';
if (!matches.length) { _plateResults.innerHTML = '< div class = "plate-none" > No matching plate< / div > '; _plateResults.classList.add('show'); return; }
matches.forEach(o => {
const div = document.createElement('div');
div.className = 'plate-opt' + (o.selected ? ' sel' : '');
const [plate, driver] = o.textContent.split(' — ');
div.innerHTML = driver ? `${escapeHtml(plate)} < span class = "pd" > — ${escapeHtml(driver)}< / span > ` : escapeHtml(plate);
div.addEventListener('mousedown', e => { // mousedown: fires before the input's blur
e.preventDefault();
o.selected = !o.selected;
_fireVehChange();
_plateSearch.value = '';
renderPlateResults('');
_plateSearch.focus();
});
_plateResults.appendChild(div);
});
_plateResults.classList.add('show');
}
_plateSearch.addEventListener('focus', () => renderPlateResults(_plateSearch.value));
_plateSearch.addEventListener('input', () => renderPlateResults(_plateSearch.value));
_plateSearch.addEventListener('blur', () => setTimeout(() => _plateResults.classList.remove('show'), 150));
document.addEventListener('click', e => { if (!e.target.closest('.plate-box')) _plateResults.classList.remove('show'); });
2026-06-05 20:01:30 +00:00
// 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));
}
2026-06-05 18:56:01 +00:00
document.getElementById('f-period').addEventListener('change', e => {
2026-06-06 21:35:34 +00:00
const show = e.target.value === 'custom';
document.getElementById('custom').classList.toggle('show', show);
document.getElementById('custom-end').classList.toggle('show', show);
2026-06-05 18:56:01 +00:00
});
2026-06-06 21:35:34 +00:00
// Filter tier: collapse to a one-line summary (trips) vs the full form.
function setFilterExpanded(expanded) {
document.getElementById('filter-tier').classList.toggle('collapsed', !expanded);
if (map) setTimeout(() => map.resize(), 0); // dock height changed
}
function renderFilterSummary() {
const sel = currentFilterSelection();
const parts = [];
if (sel.vehicles.length === 1) parts.push(sel.vehicles[0]);
else if (sel.vehicles.length > 1) parts.push(`${sel.vehicles.length} vehicles`);
if (sel.cost_centre) parts.push(sel.cost_centre);
if (sel.assigned_city) parts.push(sel.assigned_city);
if (!parts.length) parts.push('Whole fleet');
const periodLabel = { today: 'Today', '7d': 'Last 1 week', '30d': 'Last 1 month', custom: `${sel.start || '?'} → ${sel.end || '?'}` }[sel.period] || sel.period;
parts.push(periodLabel);
document.getElementById('fs-text').textContent = parts.join(' · ');
}
document.getElementById('filter-edit').addEventListener('click', () => setFilterExpanded(true));
2026-06-05 20:23:57 +00:00
// Selecting a cost centre or assigned city filters the live map immediately.
2026-06-05 20:01:30 +00:00
document.getElementById('f-cc').addEventListener('change', applyLiveFilters);
2026-06-05 20:23:57 +00:00
document.getElementById('f-city').addEventListener('change', applyLiveFilters);
2026-06-05 18:56:01 +00:00
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 || '',
2026-06-05 20:23:57 +00:00
assigned_city: document.getElementById('f-city').value || '',
2026-06-05 18:56:01 +00:00
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();
2026-06-05 20:23:57 +00:00
enterTrips({ vehicle_numbers: sel.vehicles, cost_centre: sel.cost_centre, assigned_city: sel.assigned_city, period: sel.period, start: sel.start, end: sel.end });
2026-06-05 18:56:01 +00:00
});
// Map-dot path → single vehicle, current period in the card
function enterTripsForVehicle(plate) {
if (!plate) return;
const sel = currentFilterSelection();
2026-06-05 20:23:57 +00:00
enterTrips({ vehicle_numbers: [plate], cost_centre: '', assigned_city: '', period: sel.period, start: sel.start, end: sel.end });
2026-06-05 18:56:01 +00:00
}
// ============================================================================
// 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 || '',
2026-06-05 20:23:57 +00:00
assigned_city: sel.assigned_city || '',
2026-06-05 18:56:01 +00:00
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');
2026-06-06 20:44:09 +00:00
clusterMarkers.forEach(m => m.remove()); clusterMarkers.clear();
2026-06-06 21:35:34 +00:00
document.getElementById('cards-tier').classList.add('show');
2026-06-06 08:14:16 +00:00
document.getElementById('tripbar').classList.add('show');
2026-06-05 18:56:01 +00:00
document.getElementById('live-pill').classList.add('show');
document.getElementById('stale-chip').style.display = 'none';
2026-06-06 21:35:34 +00:00
renderFilterSummary();
setFilterExpanded(false); // collapse filters to the one-line summary
2026-06-05 18:56:01 +00:00
const ph = document.getElementById('placeholder'); if (ph) ph.style.display = 'none';
2026-06-06 21:07:05 +00:00
if (map) setTimeout(() => map.resize(), 0); // map column shrank — let it reflow
2026-06-05 18:56:01 +00:00
}
2026-06-06 08:14:16 +00:00
// "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]}`;
}
2026-06-05 18:56:01 +00:00
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 > `;
2026-06-06 08:14:16 +00:00
// 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';
2026-06-05 18:56:01 +00:00
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)));
2026-06-06 21:07:05 +00:00
if (!b.isEmpty()) map.fitBounds(b, { padding: 50, duration: 600, maxZoom: 14 });
2026-06-05 18:56:01 +00:00
};
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 || {};
2026-06-06 21:07:05 +00:00
const card = document.createElement('div');
card.className = 'trip-card';
card.style.setProperty('--swatch', p.color || 'var(--muted)');
card.innerHTML = `
< div class = "tc-top" > < span class = "tc-seq" > #${p.daily_seq ?? '?'}< / span >
< span class = "tc-km" > ${Number(p.distance_km ?? 0).toLocaleString()} km< / span > < / div >
< div class = "tc-veh" > ${escapeHtml(p.vehicle_number || '—')}< / div >
< div class = "tc-time" > ${escapeHtml((p.start_time || '').slice(11, 16))} → ${escapeHtml((p.end_time || '').slice(11, 16))}< / div > `;
card.addEventListener('click', () => {
2026-06-05 18:56:01 +00:00
const coords = f.geometry?.coordinates;
if (!Array.isArray(coords) || !coords.length || !map) return;
2026-06-06 21:07:05 +00:00
document.querySelectorAll('.trip-card.active').forEach(r => r.classList.remove('active'));
card.classList.add('active');
2026-06-05 18:56:01 +00:00
const b = new maplibregl.LngLatBounds(); coords.forEach(c => b.extend(c));
2026-06-06 21:07:05 +00:00
map.fitBounds(b, { padding: 60, duration: 500, maxZoom: 15 });
2026-06-05 18:56:01 +00:00
setTimeout(() => animateTrip(f), 550);
});
2026-06-06 21:07:05 +00:00
frag.appendChild(card);
2026-06-05 18:56:01 +00:00
});
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();
2026-06-06 21:35:34 +00:00
document.getElementById('cards-tier').classList.remove('show');
2026-06-06 08:14:16 +00:00
document.getElementById('tripbar').classList.remove('show');
2026-06-05 18:56:01 +00:00
document.getElementById('live-pill').classList.remove('show');
document.getElementById('stale-chip').style.display = '';
2026-06-06 21:35:34 +00:00
setFilterExpanded(true); // back to the full filter form for live
2026-06-05 18:56:01 +00:00
liveMarkers.forEach(m => m.getElement().style.display = '');
2026-06-06 20:44:09 +00:00
if (liveFeatures.length) applyLiveFilters(); // rebuild cluster bubbles/pins
2026-06-05 18:56:01 +00:00
const ph = document.getElementById('placeholder'); if (ph) ph.style.display = 'none';
startPolling();
}
document.getElementById('live-pill').addEventListener('click', backToLive);
2026-06-08 12:10:12 +00:00
document.getElementById('legend-toggle').addEventListener('click', () => {
document.getElementById('legend').classList.toggle('collapsed');
});
2026-06-05 18:56:01 +00:00
// ============================================================================
// 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 >