fleetnow/index.html

1678 lines
86 KiB
HTML
Raw Permalink Normal View History

<!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; height: 100vh; width: 100vw; overflow: hidden;
grid-template-rows: auto auto minmax(0, 1fr) auto; /* header · context · map · bottom dock */
grid-template-columns: minmax(0, 1fr); /* clamp so the trip-card scroller can't widen the page */
}
/* 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; }
/* ── Layout: header · context · map · two-tier bottom dock ───────────── */
header { grid-row: 1; }
.tripbar { grid-row: 2; }
#map { grid-row: 3; position: relative; min-height: 0; }
.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;
}
.dockbar {
grid-row: 4; min-width: 0; display: flex; flex-direction: column;
background: var(--panel); border-top: 1px solid var(--border);
}
/* 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;
}
.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);
}
.ff-plate { width: 230px; }
select, input[type=date] {
padding: 7px 9px; 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; }
.ff-field.custom { display: none; }
.ff-field.custom.show { display: flex; }
.ff-go { width: auto; margin: 0; align-self: flex-end; padding: 8px 18px; }
/* Searchable plate combobox + chips — dropdown opens UPWARD (bar is at bottom) */
.plate-box { position: relative; }
.plate-chips { display: flex; flex-wrap: wrap; gap: 4px; }
.plate-chips:not(:empty) { margin-bottom: 5px; }
.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 {
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);
}
.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; }
.btn {
padding: 10px; 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; }
/* 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; }
.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; }
.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); }
/* 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; }
/* ── 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; }
/* ── Map layers control (toggleable overlays: gas stations, …) ──────── */
#layers { position: absolute; right: 10px; top: 10px; z-index: 5;
font: 600 11px system-ui; color: #fff; user-select: none; }
.layers-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); }
.layers-toggle::before { content: '▣'; color: var(--accent); margin-right: 5px; }
.layers-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); display: grid; gap: 5px; min-width: 150px; }
#layers.collapsed .layers-body { display: none; }
.layers-row { display: flex; align-items: center; gap: 7px; cursor: pointer; }
.layers-row input { accent-color: var(--accent); margin: 0; }
.layers-n { margin-left: auto; color: var(--muted); font-weight: 700; }
.ov-pop b { color: #fff; }
.ov-pop .ov-sub { color: var(--muted); font-weight: 600; font-size: 10px; margin-top: 2px; }
/* ── Persistent POI marker (Fireside HQ) ───────────────────────────── */
/* 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; }
.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>
<!-- Supercluster: Folium-style marker clustering while keeping our DOM pins. -->
<script src="https://unpkg.com/supercluster@8.0.1/dist/supercluster.min.js"></script>
<!-- Runtime config: nginx entrypoint renders ${API_BASE} into /env.js at start. -->
<script src="/env.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>
<!-- 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>
<!-- Toggleable map overlays (gas stations, …); collapsed by default. -->
<div id="layers" class="collapsed" aria-label="Map layers">
<button type="button" class="layers-toggle" id="layers-toggle">Layers</button>
<div class="layers-body" id="layers-body"></div>
</div>
</div>
<!-- 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>
</div>
<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>
</div>
</div>
<!-- Card tier: trips (trips mode only) -->
<div class="cards-tier" id="cards-tier">
<div class="cards-tier-head">
<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>
</div>
</div>
</div>
<script>
// ============================================================================
// CONFIG
// ============================================================================
// API base is injected per-environment via /env.js (nginx renders ${API_BASE}
// at container start). Falls back to the prod API when unset, so a build with no
// API_BASE env (e.g. prod/main) is unchanged. Staging sets API_BASE=https://fleetapi.fivetitude.com.
const API_BASE = (window.FLEETNOW_API_BASE && /^https?:\/\//.test(window.FLEETNOW_API_BASE))
? window.FLEETNOW_API_BASE.replace(/\/$/, '')
: '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 },
{ name: 'Safaricom HQ', lng: 36.7854625, lat: -1.2589726 }, // Safaricom House, Waiyaki Way (OSM)
];
// 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).
const COST_CENTRE_PALETTE = [
'#2dd4a7', '#8b5cf6', '#f43f5e', '#0ea5e9', '#eab308', '#d946ef', '#10b981',
];
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;
const key = String(cc).trim().toLowerCase();
if (COST_CENTRE_COLORS[key]) return COST_CENTRE_COLORS[key];
let h = 0;
for (let i = 0; i < key.length; i++) h = (h * 31 + key.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 (individual vehicles)
const clusterMarkers = new Map(); // cluster_id → maplibregl.Marker (count bubbles)
let liveFeatures = []; // deduped (one device per vehicle) — see dedupeLiveFeatures
let cluster = null; // Supercluster index of the currently-filtered fleet
let liveSpecialists = []; // crane/motorbike/pick-up — drawn individually, never clustered
const CLUSTER_RADIUS = 60; // px cluster radius
const CLUSTER_MAXZOOM = 11; // above this, clusters disband into individual pins (~city zoom)
const VEHICLE_META = new Map(); // plate → {cost_centre, assigned_city}
const PLATE_KEYS = new Set(); // normalised plate keys already in the dropdown (tracker+camera collapse to one)
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); OVERLAYS.forEach(addOverlay); buildLayersControl(); updateVehScale(); });
map.on('zoom', updateVehScale);
// Re-cluster after any pan/zoom settles (live mode only).
map.on('moveend', () => { if (mode === 'live') renderClusters(); });
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);
}
// ============================================================================
// Toggleable map overlays (reference layers: gas stations, …)
// ----------------------------------------------------------------------------
// Each entry = a static GeoJSON in /layers/ rendered as a MapLibre SYMBOL layer
// that auto-declutters (icon-allow-overlap:false) and is OFF until toggled in
// the "Layers" control. To add another layer: drop its .geojson in layers/ and
// add one entry here (+ an iconSvg). Vehicle/cluster markers (DOM) stay on top.
// ============================================================================
const GAS_PUMP_PATH = 'M19.77 7.23l.01-.01-3.72-3.72L15 4.56l2.11 2.11c-.94.36-1.61 1.26-1.61 2.33 0 1.38 1.12 2.5 2.5 2.5.36 0 .69-.08 1-.21v7.21c0 .55-.45 1-1 1s-1-.45-1-1V14c0-1.1-.9-2-2-2h-1V5c0-1.1-.9-2-2-2H6c-1.1 0-2 .9-2 2v16h10v-7.5h1.5v5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V9c0-.69-.28-1.32-.73-1.77zM12 10H6V5h6v5z';
const SHELL_ICON_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">' +
'<circle cx="20" cy="20" r="18" fill="#FBCE07" stroke="#7a5f00" stroke-width="1.5"/>' +
'<g transform="translate(8,8)" fill="#b3121f"><path d="' + GAS_PUMP_PATH + '"/></g></svg>';
const OVERLAYS = [
{ id: 'shell', label: 'Shell stations', url: 'layers/shell_stations.geojson',
iconSvg: SHELL_ICON_SVG, nameKey: 'name', defaultOn: false },
// future layers: add { id, label, url, iconSvg, nameKey, defaultOn } here.
];
let overlayPopup = null; // single reused hover popup for overlay points (only one ever shown)
function registerOverlayIcon(def) {
return new Promise(resolve => {
const imgId = 'ov-icon-' + def.id;
if (map.hasImage(imgId)) return resolve(imgId);
const img = new Image(40, 40);
img.onload = () => { if (!map.hasImage(imgId)) map.addImage(imgId, img, { pixelRatio: 2 }); resolve(imgId); };
img.onerror = () => resolve(imgId);
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(def.iconSvg);
});
}
async function addOverlay(def) {
const srcId = 'ov-' + def.id, lyrId = 'ov-layer-' + def.id;
let data;
try { data = await (await fetch(def.url)).json(); }
catch (e) { console.warn('overlay load failed:', def.id, e); return; }
def._count = (data.features || []).length;
const imgId = await registerOverlayIcon(def);
if (!map.getSource(srcId)) map.addSource(srcId, { type: 'geojson', data });
if (!map.getLayer(lyrId)) {
map.addLayer({
id: lyrId, type: 'symbol', source: srcId,
layout: {
'icon-image': imgId,
// ~8px zoomed out → ~16px zoomed in (image is 20 CSS px at icon-size 1).
'icon-size': ['interpolate', ['linear'], ['zoom'], 6, 0.42, 11, 0.55, 16, 0.8],
'icon-allow-overlap': false, // auto-declutter: hide overlapping icons at low zoom
'icon-ignore-placement': false,
'visibility': def.defaultOn ? 'visible' : 'none',
},
});
// Hover (not click) shows a single label — one reused popup, so only ever
// one is visible; mousemove keeps it on whichever station is under the cursor.
map.on('mouseenter', lyrId, () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mousemove', lyrId, e => {
const f = e.features[0]; if (!f) return;
const p = f.properties || {};
if (!overlayPopup) overlayPopup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 12 });
overlayPopup.setLngLat(f.geometry.coordinates)
.setHTML(`<div class="ov-pop"><b>${escapeHtml(p[def.nameKey] || def.label)}</b><div class="ov-sub">${escapeHtml(p.brand || 'fuel station')}</div></div>`)
.addTo(map);
});
map.on('mouseleave', lyrId, () => { map.getCanvas().style.cursor = ''; if (overlayPopup) overlayPopup.remove(); });
}
buildLayersControl();
}
function buildLayersControl() {
const body = document.getElementById('layers-body');
const wrap = document.getElementById('layers');
if (!body || !wrap) return;
wrap.style.display = OVERLAYS.length ? '' : 'none';
body.innerHTML = OVERLAYS.map(d => {
const lyrId = 'ov-layer-' + d.id;
const on = map.getLayer(lyrId) && map.getLayoutProperty(lyrId, 'visibility') === 'visible';
const n = d._count != null ? ` <span class="layers-n">${d._count}</span>` : '';
return `<label class="layers-row"><input type="checkbox" data-lyr="${lyrId}"${on ? ' checked' : ''}><span>${escapeHtml(d.label)}</span>${n}</label>`;
}).join('');
body.querySelectorAll('input[type=checkbox]').forEach(cb => {
cb.addEventListener('change', () => {
const lyrId = cb.getAttribute('data-lyr');
if (map.getLayer(lyrId)) map.setLayoutProperty(lyrId, 'visibility', cb.checked ? 'visible' : 'none');
});
});
}
// ============================================================================
// 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);
}
// 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);
}
// ============================================================================
// 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 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);
// 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;
}
// 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).
if (openPopupImei) {
const m = liveMarkers.get(openPopupImei);
const still = m && m.getElement().style.display !== 'none' && features.find(f => f.properties.imei === openPopupImei);
if (still) showLivePopup(still, true);
else { popup.remove(); openPopupImei = null; popupStuck = false; }
}
updateVehScale();
};
if (map.isStyleLoaded()) drawMarkers(); else map.once('load', drawMarkers);
const ph = document.getElementById('placeholder'); if (ph) ph.style.display = 'none';
}
// 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;
});
}
// 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'));
}
// Load the filtered fleet into supercluster, redraw bubbles+pins, recompute KPIs.
function applyLiveFilters() {
if (mode !== 'live') return;
const filtered = filteredLiveFeatures();
// 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));
cluster = new Supercluster({ radius: CLUSTER_RADIUS, maxZoom: CLUSTER_MAXZOOM });
cluster.load(clusterable.map(f => ({
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 });
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('');
}
// 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);
}
});
// 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);
});
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;
}
}
// 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>',
};
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 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;
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);
el.classList.toggle('has-type', !!typeIcon);
const pin = el.querySelector('.veh-pin');
pin.style.setProperty('--c', color);
// 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.
const glyph = el.querySelector('.glyph');
if (typeIcon) {
glyph.className = 'glyph veh-type';
glyph.style.removeProperty('--dir');
glyph.innerHTML = typeIcon;
} else if (state === 'active' && speed > 0) {
glyph.className = 'glyph veh-arrow';
glyph.innerHTML = '';
glyph.style.setProperty('--dir', dir + 'deg');
} else if (state === 'parked') {
glyph.className = 'glyph'; // empty — just the pastel square
glyph.innerHTML = '';
glyph.style.removeProperty('--dir');
} else {
glyph.className = 'glyph idle-dot';
glyph.innerHTML = '';
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 liveFeatures.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 liveFeatures.find(f => normPlate(f.properties.vehicle_number) === normPlate(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;
const key = normPlate(plate);
if (!plate || PLATE_KEYS.has(key)) return; // one entry per vehicle (tracker+camera share a plate)
PLATE_KEYS.add(key);
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');
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);
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);
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)) ?? '');
renderPlateChips();
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 : ''; }
// ── 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">&times;</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'); });
// 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));
}
document.getElementById('f-period').addEventListener('change', e => {
const show = e.target.value === 'custom';
document.getElementById('custom').classList.toggle('show', show);
document.getElementById('custom-end').classList.toggle('show', show);
});
// 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));
// 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');
clusterMarkers.forEach(m => m.remove()); clusterMarkers.clear();
document.getElementById('cards-tier').classList.add('show');
document.getElementById('tripbar').classList.add('show');
document.getElementById('live-pill').classList.add('show');
document.getElementById('stale-chip').style.display = 'none';
renderFilterSummary();
setFilterExpanded(false); // collapse filters to the one-line summary
const ph = document.getElementById('placeholder'); if (ph) ph.style.display = 'none';
if (map) setTimeout(() => map.resize(), 0); // map column shrank — let it reflow
}
// "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: 50, 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 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', () => {
const coords = f.geometry?.coordinates;
if (!Array.isArray(coords) || !coords.length || !map) return;
document.querySelectorAll('.trip-card.active').forEach(r => r.classList.remove('active'));
card.classList.add('active');
const b = new maplibregl.LngLatBounds(); coords.forEach(c => b.extend(c));
map.fitBounds(b, { padding: 60, duration: 500, maxZoom: 15 });
setTimeout(() => animateTrip(f), 550);
});
frag.appendChild(card);
});
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('cards-tier').classList.remove('show');
document.getElementById('tripbar').classList.remove('show');
document.getElementById('live-pill').classList.remove('show');
document.getElementById('stale-chip').style.display = '';
setFilterExpanded(true); // back to the full filter form for live
liveMarkers.forEach(m => m.getElement().style.display = '');
if (liveFeatures.length) applyLiveFilters(); // rebuild cluster bubbles/pins
const ph = document.getElementById('placeholder'); if (ph) ph.style.display = 'none';
startPolling();
}
document.getElementById('live-pill').addEventListener('click', backToLive);
document.getElementById('legend-toggle').addEventListener('click', () => {
document.getElementById('legend').classList.toggle('collapsed');
});
document.getElementById('layers-toggle').addEventListener('click', () => {
document.getElementById('layers').classList.toggle('collapsed');
});
// ============================================================================
// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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>