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;
}
.app { display: grid; grid-template-rows: auto 1fr; height: 100vh; }
/* ── Top bar ───────────────────────────────────────────────────────── */
header {
padding: 9px 18px; background: var(--panel);
border-bottom: 1px solid var(--border);
display: flex; gap: 20px; align-items: center; flex-wrap: wrap;
}
.brand {
font-weight: 800; letter-spacing: .5px; font-size: 16px;
display: flex; align-items: center; gap: 8px; white-space: nowrap;
}
.brand .mark {
width: 10px; height: 10px; border-radius: 50%;
background: var(--accent); box-shadow: 0 0 10px var(--accent);
}
.brand .nm { color: var(--accent); }
#kpis { display: flex; gap: 22px; flex-wrap: wrap; align-items: center; }
.kpi { display: flex; flex-direction: column; min-width: 56px; }
.kpi b { font-size: 18px; line-height: 1.15; font-variant-numeric: tabular-nums; }
.kpi b.live { color: var(--live); }
.kpi b.parked { color: var(--parked); }
.kpi b.offline { color: var(--offline); }
.kpi b.accent { color: var(--accent); }
.kpi span {
font-size: 9.5px; color: var(--muted); text-transform: uppercase;
letter-spacing: .6px; margin-top: 2px;
}
.spacer { margin-left: auto; }
.stale-chip {
display: inline-flex; flex-direction: column; padding: 6px 10px;
background: rgba(45,212,167,.12); border: 1px solid rgba(45,212,167,.3);
color: var(--live); border-radius: 6px; min-width: 104px;
font-variant-numeric: tabular-nums;
}
.stale-chip.warn { background: rgba(240,169,59,.12); border-color: rgba(240,169,59,.4); color: var(--warn); }
.stale-chip.bad { background: rgba(239,91,91,.14); border-color: rgba(239,91,91,.45); color: var(--danger); }
.stale-chip span { font-size: 9.5px; opacity: .75; text-transform: uppercase; letter-spacing: .6px; }
.stale-chip b { font-size: 13px; font-weight: 600; }
/* Live pill — passive dot in live mode, clickable "return" control in trips mode */
.live-pill {
display: none; align-items: center; gap: 7px;
padding: 7px 13px; border-radius: 999px; cursor: pointer;
background: var(--accent); color: #1a1009; font-weight: 700; font-size: 13px;
border: 0;
}
.live-pill.show { display: inline-flex; }
.live-pill .dot { width: 8px; height: 8px; border-radius: 50%; background: #1a1009; }
.live-pill:hover { background: var(--accent-hover); }
.clock {
color: var(--text); font-size: 14px; font-variant-numeric: tabular-nums;
display: flex; flex-direction: column; align-items: flex-end;
}
.clock .label { font-size: 9.5px; color: var(--muted); text-transform: uppercase; letter-spacing: .6px; }
.clock b { font-weight: 600; }
/* ── Map + floating cards ──────────────────────────────────────────── */
#map { position: relative; min-height: 0; height: 100%; }
.placeholder {
position: absolute; inset: 0; display: grid; place-items: center;
color: var(--muted); font-size: 13px; text-align: center; padding: 32px;
pointer-events: none; z-index: 1;
}
.floating {
position: absolute; z-index: 5; background: rgba(30,35,46,.95);
border: 1px solid var(--border); border-radius: 10px;
box-shadow: 0 8px 28px rgba(0,0,0,.45); backdrop-filter: blur(6px);
}
.filters { bottom: 22px; right: 16px; width: 250px; padding: 14px 14px 16px; }
.filters h3, .triplist h3 {
font-size: 11px; text-transform: uppercase; letter-spacing: .6px;
color: var(--muted); margin: 0 0 12px; font-weight: 600;
}
.field { display: flex; flex-direction: column; margin-bottom: 11px; }
.field label {
font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px;
color: var(--muted); margin-bottom: 4px;
}
select, input[type=date] {
padding: 8px 10px; background: var(--bg); color: var(--text);
border: 1px solid var(--border); border-radius: 6px; font: 13px system-ui; width: 100%;
}
select:focus, input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
select[multiple] { min-height: 84px; max-height: 150px; padding: 5px; }
select[multiple] option { padding: 4px 6px; border-radius: 3px; }
select[multiple] option:checked { background: var(--accent); color: #1a1009; }
.hint { font-size: 9.5px; color: var(--muted); margin-top: 3px; line-height: 1.3; }
.custom { display: none; }
.custom.show { display: block; }
.btn {
width: 100%; padding: 9px; margin-top: 4px; background: var(--accent);
color: #1a1009; border: 0; border-radius: 6px; font: 600 13px system-ui; cursor: pointer;
}
.btn:hover { background: var(--accent-hover); }
.btn:disabled { background: #4b5563; color: #cbd5e1; cursor: wait; }
.triplist {
bottom: 22px; left: 16px; width: 280px; max-height: calc(100% - 44px);
display: none; flex-direction: column; padding: 12px 12px 8px;
}
.triplist.show { display: flex; }
.triplist .back {
display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
color: var(--accent); font: 600 12px system-ui; background: none; border: 0;
padding: 0 0 10px; margin-bottom: 6px; border-bottom: 1px solid var(--border);
}
.triplist .back:hover { color: var(--accent-hover); }
.triplist .scroll { overflow-y: auto; min-height: 0; }
.trip-row {
display: grid; grid-template-columns: 7px 24px 1fr auto; gap: 8px;
align-items: center; padding: 7px 0; border-bottom: 1px solid var(--border);
font-size: 12px; cursor: pointer;
}
.trip-row:hover { background: rgba(255,255,255,.03); }
.trip-row.active { background: rgba(232,149,74,.14); }
.trip-row.active .seq, .trip-row.active .time { color: var(--accent); }
.trip-row .swatch { width: 4px; height: 22px; border-radius: 2px; }
.trip-row .seq { color: var(--muted); font-variant-numeric: tabular-nums; text-align: right; }
.trip-row .veh { color: var(--text); font-weight: 500; }
.trip-row .time { color: var(--muted); font-size: 11px; }
.trip-row .km { font-variant-numeric: tabular-nums; color: var(--text); white-space: nowrap; }
.trips-empty { color: var(--muted); padding: 24px 0; text-align: center; font-size: 12px; }
.trips-more { color: var(--muted); padding: 10px 0 2px; text-align: center; font-size: 11px; }
.error {
position: absolute; top: 16px; left: 50%; transform: translateX(-50%);
background: var(--error-bg); border: 1px solid var(--danger); color: #fca5a5;
padding: 10px 16px; border-radius: 6px; z-index: 20; max-width: 600px;
box-shadow: 0 4px 12px rgba(0,0,0,.4);
}
/* ── Live vehicle DOM marker (locked look) ─────────────────────────── */
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:42:37 +00:00
department-coloured SQUARE, no arrow/dot, and rendered ~⅔ the size of a
moving-now circle 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: 6px; opacity: 1; transform: scale(0.66); 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); }
/* ── Persistent POI marker (Fireside HQ) ───────────────────────────── */
.hq-marker { display: flex; flex-direction: column; align-items: center; cursor: default; }
.hq-dot {
width: 13px; height: 13px; border-radius: 50%; background: var(--accent);
border: 2px solid #fff; box-shadow: 0 0 0 1px rgba(0,0,0,.35), 0 2px 6px rgba(0,0,0,.4);
}
.hq-label {
margin-top: 4px; background: rgba(15,18,23,.88); color: var(--accent);
font: 600 11px system-ui; padding: 3px 7px; border-radius: 4px;
border: 1px solid rgba(232,149,74,.4); white-space: nowrap;
}
/* ── MapLibre popup (warm) ─────────────────────────────────────────── */
.maplibregl-popup-content {
background: var(--panel) !important; color: var(--text) !important;
border: 1px solid var(--border); border-radius: 8px;
padding: 11px 13px !important; font: 12px/1.45 system-ui;
box-shadow: 0 6px 18px rgba(0,0,0,.55);
}
.maplibregl-popup-tip { border-top-color: var(--panel) !important; }
.pop b { display: block; margin-bottom: 4px; font-size: 13px; }
.pop .muted { color: var(--muted); font-size: 11px; }
.pop .row { margin-top: 4px; }
.pop .addr { margin-top: 4px; color: var(--text); font-size: 11.5px; line-height: 1.35; }
.pop .addr.loading { color: var(--muted); font-style: italic; }
.pop .addr.fail { color: var(--muted); font-size: 10px; }
.pop .badge {
display: inline-block; padding: 1px 6px; border-radius: 3px;
font-size: 10px; font-weight: 700; letter-spacing: .3px; text-transform: uppercase;
}
.pop .badge.moving { background: rgba(45,212,167,.18); color: var(--live); }
.pop .badge.idling { background: rgba(240,169,59,.18); color: var(--warn); }
.pop .badge.parked { background: rgba(107,114,128,.22); color: #d1d5db; }
.pop .badge.offline { background: rgba(180,121,31,.25); color: var(--offline); }
.pop .actions { display: flex; gap: 6px; margin-top: 9px; }
.pop .act {
flex: 1; padding: 5px 8px; border: 0; border-radius: 5px; cursor: pointer;
font: 600 11px system-ui; text-align: center;
}
.pop .act.primary { background: var(--accent); color: #1a1009; }
.pop .act.primary:hover { background: var(--accent-hover); }
.pop .act.ghost { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); }
.pop .act.ghost:hover { border-color: var(--accent); }
.maplibregl-ctrl-attrib { background: rgba(30,35,46,.7) !important; color: var(--muted); }
.maplibregl-ctrl-attrib a { color: var(--accent); }
< / style >
< link rel = "stylesheet" href = "https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css" / >
< script src = "https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js" > < / script >
< / head >
< body >
< div class = "app" >
< header >
< div class = "brand" > < span class = "mark" > < / span > FLEET< span class = "nm" > NOW< / span > < / div >
< div id = "kpis" > < / div >
< div class = "spacer" > < / div >
< button class = "live-pill" id = "live-pill" type = "button" title = "Return to live fleet" >
< span class = "dot" > < / span > Live
< / button >
< div class = "stale-chip" id = "stale-chip" > < span > Last batch< / span > < b id = "stale-text" > —< / b > < / div >
< div class = "clock" > < span class = "label" > EAT< / span > < b id = "clock-time" > —< / b > < / div >
< / header >
< div id = "map" >
< div class = "placeholder" id = "placeholder" > Loading live fleet…< / div >
<!-- Trip list (bottom - left, trips mode only) -->
< div class = "floating triplist" id = "triplist" >
< button class = "back" id = "trips-back" type = "button" > ◀ Live< / button >
< h3 > Trips < span id = "trip-count" > < / span > < / h3 >
< div class = "scroll" id = "trip-scroll" >
< div class = "trips-empty" > No trips.< / div >
< / div >
< / div >
<!-- Filters (bottom - right, always) -->
< div class = "floating filters" id = "filters" >
< h3 > Filters< / h3 >
< div class = "field" >
< label for = "f-vehicle" > Number plate < span class = "hint" style = "text-transform:none" > — ⌘/Ctrl-click for more< / span > < / label >
< select id = "f-vehicle" multiple size = "4" > < / select >
< / div >
< div class = "field" >
< label for = "f-cc" > Cost centre< / label >
< select id = "f-cc" > < option value = "" > All cost centres< / option > < / select >
< / div >
2026-06-05 20:23:57 +00:00
< div class = "field" >
< label for = "f-city" > Assigned city< / label >
< select id = "f-city" > < option value = "" > All cities< / option > < / select >
< / div >
2026-06-05 18:56:01 +00:00
< div class = "field" >
< label for = "f-period" > Time< / label >
< select id = "f-period" >
< option value = "today" selected > Today< / option >
< option value = "7d" > Last 1 week< / option >
< option value = "30d" > Last 1 month< / option >
< option value = "custom" > Custom range< / option >
< / select >
< / div >
< div class = "custom" id = "custom" >
< div class = "field" > < label for = "f-start" > Start date< / label > < input type = "date" id = "f-start" > < / div >
< div class = "field" > < label for = "f-end" > End date< / label > < input type = "date" id = "f-end" > < / div >
< / div >
< button class = "btn" id = "show-trips" type = "button" > Show trips< / button >
< / div >
< / div >
< / div >
< script >
// ============================================================================
// CONFIG
// ============================================================================
const API_BASE = 'https://fleetapi.rahamafresh.com';
const EP_LIVE = `${API_BASE}/webhook/live-positions`;
const EP_TRACK = `${API_BASE}/webhook/live-positions/track`;
const EP_FLEET = `${API_BASE}/webhook/fleet-dashboard`;
const POLL_INTERVAL_MS = 15000;
const EAST_AFRICA = { center: [37.5, -3.0], zoom: 5.2 };
const STALE_GPS_MS = 10 * 60 * 1000;
const OFFLINE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
const POIS = [{ name: 'Fireside HQ', lng: 36.728785, lat: -1.2411485 }];
// Cost-centre palette — categorical, brand colours lead. Stable per centre via hash.
const COST_CENTRE_PALETTE = [
'#E8954A', '#2dd4a7', '#3b82f6', '#a855f7', '#f43f5e', '#06b6d4',
'#84cc16', '#ec4899', '#fbbf24', '#14b8a6', '#8b5cf6', '#f97316', '#22c55e',
];
const UNKNOWN_CC_COLOR = '#9ca3af';
const PARKED_COLOR = '#6b7280';
const OFFLINE_COLOR = '#374151';
// Sequence palette for trip daily_seq colouring
const SEQ_PALETTE = [
'#E8954A', '#2dd4a7', '#3b82f6', '#a855f7', '#f43f5e',
'#06b6d4', '#84cc16', '#ec4899', '#fbbf24', '#14b8a6',
];
const seqColor = n => SEQ_PALETTE[((n || 1) - 1 + SEQ_PALETTE.length * 100) % SEQ_PALETTE.length];
function colorForCostCentre(cc) {
if (!cc) return UNKNOWN_CC_COLOR;
let h = 0;
for (let i = 0; i < cc.length ; i + + ) h = (h * 31 + cc . charCodeAt ( i ) ) | 0 ;
return COST_CENTRE_PALETTE[Math.abs(h) % COST_CENTRE_PALETTE.length];
}
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;
const liveMarkers = new Map(); // imei → maplibregl.Marker
const VEHICLE_META = new Map(); // plate → {cost_centre, assigned_city}
let openPopupImei = null, popupStuck = false, popupCloseTimer = null;
let trailedVehicle = null;
let animFrame = 0;
// ============================================================================
// EAT clock
// ============================================================================
const clockFmt = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Africa/Nairobi', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
});
function tickClock() { document.getElementById('clock-time').textContent = clockFmt.format(new Date()); }
setInterval(tickClock, 1000); tickClock();
// ============================================================================
// Map
// ============================================================================
function ensureMap() {
if (map) return map;
const el = document.getElementById('map');
map = new maplibregl.Map({
container: el,
style: 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json',
center: EAST_AFRICA.center, zoom: EAST_AFRICA.zoom,
attributionControl: { compact: true },
});
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
popup = new maplibregl.Popup({ closeButton: true, closeOnClick: false, offset: 20 });
popup.on('close', () => { openPopupImei = null; popupStuck = false; });
2026-06-05 20:01:30 +00:00
map.on('load', () => { POIS.forEach(addPoiMarker); updateVehScale(); });
map.on('zoom', updateVehScale);
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);
}
// ============================================================================
// LIVE MODE — polling + DOM markers
// ============================================================================
async function fetchLive() {
if (inFlight) inFlight.abort();
inFlight = new AbortController();
try {
const resp = await fetch(EP_LIVE, { signal: inFlight.signal });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (data.error) { showError(data.error.message || 'Live feed error'); return; }
hideError();
lastLivePayload = data;
if (mode === 'live') renderLive();
} catch (err) {
if (err.name === 'AbortError') return;
showError(`Couldn't load live positions: ${err.message}`);
} finally { inFlight = null; }
}
function startPolling() { stopPolling(); fetchLive(); pollTimer = setInterval(fetchLive, POLL_INTERVAL_MS); }
function stopPolling() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } }
let firstFit = false;
function renderLive() {
if (!lastLivePayload) return;
ensureMap();
const features = (lastLivePayload.geojson & & lastLivePayload.geojson.features) || [];
// KPIs (recomputed client-side so they reflect what's drawn)
const active = features.filter(f => isVehicleActive(f.properties));
const speeds = active.map(f => Number(f.properties.speed || 0)).filter(s => s > 0).sort((a, b) => a - b);
const median = speeds.length ? speeds[Math.floor(speeds.length / 2)] : null;
const offline = features.filter(f => vehicleState(f.properties) === 'offline').length;
renderLiveKPIs({
total: features.length, moving: active.length,
parked: features.length - active.length - offline, offline,
median, last_batch_utc: lastLivePayload.summary?.last_batch_utc,
});
// Populate filter dropdowns from the full fleet (never shrink them)
populateFiltersFromLive(features);
const drawMarkers = () => {
const seen = new Set();
features.forEach(f => {
const p = f.properties || {};
const c = f.geometry & & f.geometry.coordinates;
if (!Array.isArray(c)) return;
seen.add(p.imei);
upsertLiveMarker(p, c, f);
});
// Drop markers for vehicles no longer present
for (const [imei, m] of liveMarkers) { if (!seen.has(imei)) { m.remove(); liveMarkers.delete(imei); } }
if (!firstFit & & features.length) {
const b = new maplibregl.LngLatBounds();
features.forEach(f => Array.isArray(f.geometry?.coordinates) & & b.extend(f.geometry.coordinates));
if (!b.isEmpty()) map.fitBounds(b, { padding: 70, duration: 700, maxZoom: 11 });
firstFit = true;
}
// Re-attach a pinned popup to its (moved) vehicle
if (openPopupImei) {
const still = features.find(f => f.properties.imei === openPopupImei);
if (still) showLivePopup(still, true);
else { popup.remove(); openPopupImei = null; popupStuck = false; }
}
2026-06-05 20:01:30 +00:00
// Honour the current plate/cost-centre filter + size markers for this zoom.
applyLiveFilters();
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';
}
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);
const color = state === 'offline' ? OFFLINE_COLOR
: state === 'parked' ? pastelColor(ccColor)
: 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-05 18:56:01 +00:00
const pin = el.querySelector('.veh-pin');
pin.style.setProperty('--c', color);
2026-06-06 07:28:56 +00:00
// Direction arrow only for vehicles moving now. Parked = a clean pastel
// square (no arrow, no dot). Idling/offline keep the neutral dot.
2026-06-05 18:56:01 +00:00
const glyph = el.querySelector('.glyph');
2026-06-06 07:28:56 +00:00
if (state === 'active' & & speed > 0) {
2026-06-05 18:56:01 +00:00
glyph.className = 'glyph veh-arrow';
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
glyph.style.removeProperty('--dir');
2026-06-05 18:56:01 +00:00
} else {
glyph.className = 'glyph idle-dot';
glyph.style.removeProperty('--dir');
}
el.querySelector('.veh-plate').textContent = plateTail(p.vehicle_number);
el.style.zIndex = state === 'active' ? 3 : (state === 'parked' ? 2 : 1);
}
function currentLiveFeature(imei) {
return (lastLivePayload?.geojson?.features || []).find(f => f.properties.imei === imei);
}
// ── Live KPI bar ────────────────────────────────────────────────────────────
function renderLiveKPIs(s) {
document.getElementById('kpis').innerHTML = `
< div class = "kpi" > < b > ${formatNum(s.total)}< / b > < span > Vehicles< / span > < / div >
< div class = "kpi" > < b class = "live" > ${formatNum(s.moving)}< / b > < span > Moving< / span > < / div >
< div class = "kpi" > < b class = "parked" > ${formatNum(s.parked)}< / b > < span > Parked< / span > < / div >
< div class = "kpi" > < b class = "offline" > ${formatNum(s.offline)}< / b > < span > Offline 24h+< / span > < / div >
< div class = "kpi" > < b > ${s.median != null ? Number(s.median).toFixed(0) : '—'}< / b > < span > Median kmh< / span > < / div > `;
updateStaleChip(s.last_batch_utc);
document.getElementById('stale-chip').style.display = '';
}
// ── Live popup (locked field set + actions) ──────────────────────────────────
const POPUP_CLOSE_DELAY_MS = 180;
function schedulePopupClose() {
if (popupCloseTimer) return;
popupCloseTimer = setTimeout(() => { popupCloseTimer = null; if (popupStuck) return; openPopupImei = null; popup.remove(); }, POPUP_CLOSE_DELAY_MS);
}
function cancelPopupClose() { if (popupCloseTimer) { clearTimeout(popupCloseTimer); popupCloseTimer = null; } }
function showLivePopup(feature, silent) {
const p = feature.properties || {};
const coords = feature.geometry.coordinates;
const state = vehicleState(p);
const speed = Number(p.speed || 0);
const stateLabel = state === 'offline' ? `offline · ${staleAgeText(p)}`
: state === 'parked' ? 'parked'
: speed > 0 ? `moving · ${speed.toFixed(0)} kmh` : 'idling · engine on';
const stateClass = state === 'offline' ? 'offline' : state === 'parked' ? 'parked' : speed > 0 ? 'moving' : 'idling';
const cached = lookupCachedAddress(coords[1], coords[0]);
const addrHTML = cached ? `< div class = "addr" > ${escapeHtml(cached)}< / div > `
: `< div class = "addr loading" data-geocode-slot > looking up address…< / div > `;
popup.setLngLat(coords).setHTML(`
< div class = "pop" >
< b > ${escapeHtml(p.vehicle_number || '—')} < span class = "badge ${stateClass}" > ${stateLabel}< / span > < / b >
${p.driver ? `< div class = "row" > ${escapeHtml(p.driver)}< / div > ` : ''}
${p.cost_centre ? `< div class = "row muted" > ${escapeHtml(p.cost_centre)}${p.assigned_city ? ' · ' + escapeHtml(p.assigned_city) : ''}< / div > ` : ''}
${addrHTML}
< div class = "row muted" > heading ${Number(p.direction || 0).toFixed(0)}° · gps signal ${p.gps_signal ?? '?'}< / div >
${p.current_mileage ? `< div class = "row muted" > ${Number(p.current_mileage).toLocaleString()} km on the clock< / div > ` : ''}
< div class = "row muted" > last fix ${minutesAgoText(p.gps_time_utc)} · ${escapeHtml(p.gps_time || '')}< / div >
< div class = "row muted" > source ${escapeHtml(p.mc_type || '?')} · ${escapeHtml(p.device_kind || '?')}< / div >
< div class = "actions" >
< button class = "act primary" data-trips = "${escapeHtml(p.vehicle_number || '')}" > Show trips< / button >
< button class = "act ghost" data-trail = "${escapeHtml(p.vehicle_number || '')}" >
${trailedVehicle === p.vehicle_number ? 'Hide trail' : '1 h trail'}
< / button >
< / div >
< / div > `).addTo(map);
const root = popup.getElement();
root.querySelector('[data-trips]')?.addEventListener('click', () => enterTripsForVehicle(p.vehicle_number));
root.querySelector('[data-trail]')?.addEventListener('click', () => toggleTrail(p.vehicle_number));
root.addEventListener('mouseenter', cancelPopupClose);
root.addEventListener('mouseleave', () => { if (!popupStuck) schedulePopupClose(); });
cancelPendingGeocode();
if (!cached) {
pendingGeocodeTimer = setTimeout(() => {
pendingGeocodeTimer = null;
geocode(coords[1], coords[0]).then(addr => {
const slot = popup.getElement()?.querySelector('[data-geocode-slot]');
if (!slot) return;
slot.classList.remove('loading');
if (addr) slot.textContent = addr;
else { slot.classList.add('fail'); slot.textContent = 'address lookup failed'; }
});
}, 400);
}
}
// ── Staleness chip ───────────────────────────────────────────────────────────
function updateStaleChip(lastBatchUtc) {
const el = document.getElementById('stale-chip'), txt = document.getElementById('stale-text');
if (!lastBatchUtc) { el.className = 'stale-chip bad'; txt.textContent = 'no data'; return; }
const s = (Date.now() - new Date(lastBatchUtc)) / 1000;
txt.textContent = s < 60 ? ` $ { Math . round ( s ) } s ago ` : s < 3600 ? ` $ { Math . round ( s / 60 ) } min ago ` : ` $ { ( s / 3600 ) . toFixed ( 1 ) } h ago ` ;
el.className = s < 120 ? ' stale-chip ' : s < 600 ? ' stale-chip warn ' : ' stale-chip bad ' ;
}
setInterval(() => { if (mode === 'live' & & lastLivePayload?.summary?.last_batch_utc) updateStaleChip(lastLivePayload.summary.last_batch_utc); }, 1000);
// ============================================================================
// Trail overlay (1 h)
// ============================================================================
const TRAIL_SOURCE = 'trail', TRAIL_LAYER = 'trail-line';
async function toggleTrail(vehicleNumber) {
if (!vehicleNumber) return;
if (trailedVehicle === vehicleNumber) { clearTrail(); trailedVehicle = null; const f = currentLiveFeatureByPlate(vehicleNumber); if (f) showLivePopup(f); return; }
try {
const resp = await fetch(`${EP_TRACK}?vehicle_number=${encodeURIComponent(vehicleNumber)}&hours=1`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const feature = await resp.json();
drawTrail(feature); trailedVehicle = vehicleNumber;
const f = currentLiveFeatureByPlate(vehicleNumber); if (f) showLivePopup(f);
} catch (err) { showError(`Couldn't load trail: ${err.message}`); }
}
function currentLiveFeatureByPlate(plate) { return (lastLivePayload?.geojson?.features || []).find(f => f.properties.vehicle_number === plate); }
function drawTrail(feature) {
const fc = { type: 'FeatureCollection', features: [feature] };
const src = map.getSource(TRAIL_SOURCE);
if (src) src.setData(fc);
else {
map.addSource(TRAIL_SOURCE, { type: 'geojson', data: fc });
map.addLayer({ id: TRAIL_LAYER, type: 'line', source: TRAIL_SOURCE,
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': '#E8954A', 'line-width': 3, 'line-opacity': 0.75, 'line-dasharray': [0, 2, 2] } });
}
const coords = feature.geometry?.coordinates;
if (Array.isArray(coords) & & coords.length) {
const b = new maplibregl.LngLatBounds(); coords.forEach(c => b.extend(c));
if (!b.isEmpty()) map.fitBounds(b, { padding: 80, duration: 500, maxZoom: 14 });
}
}
function clearTrail() { if (map?.getSource(TRAIL_SOURCE)) map.getSource(TRAIL_SOURCE).setData({ type: 'FeatureCollection', features: [] }); }
// ============================================================================
// Filters card
// ============================================================================
async function loadFilters() {
try {
const resp = await fetch(EP_FLEET, { method: 'GET' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
fillVehicleSelect(data.vehicles || []);
fillSelect('f-cc', data.cost_centres || []);
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;
if (!plate || VEHICLE_META.has(plate)) return;
VEHICLE_META.set(plate, { cost_centre: v.cost_centre, assigned_city: v.assigned_city });
const opt = document.createElement('option');
opt.value = plate; opt.textContent = v.drivers ? `${plate} — ${v.drivers}` : plate;
sel.appendChild(opt);
});
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');
2026-06-05 18:56:01 +00:00
const havePlates = new Set(Array.from(vehSel.options).map(o => o.value));
2026-06-05 20:23:57 +00:00
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-05 18:56:01 +00:00
if (p.vehicle_number & & !havePlates.has(p.vehicle_number)) {
havePlates.add(p.vehicle_number);
VEHICLE_META.set(p.vehicle_number, { cost_centre: p.cost_centre, assigned_city: p.assigned_city });
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-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-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));
}
// Filter the LIVE markers by the selected plate(s) + cost centre, and recompute
// the header KPIs to match. Time period only applies to trips, not live.
function applyLiveFilters() {
if (mode !== 'live') return;
const plates = new Set(Array.from(document.getElementById('f-vehicle').selectedOptions).map(o => o.value).filter(Boolean));
const cc = document.getElementById('f-cc').value;
2026-06-05 20:23:57 +00:00
const city = document.getElementById('f-city').value;
2026-06-05 20:01:30 +00:00
let total = 0, moving = 0, parked = 0, offline = 0; const speeds = [];
(lastLivePayload?.geojson?.features || []).forEach(f => {
const p = f.properties; const m = liveMarkers.get(p.imei); if (!m) return;
2026-06-05 20:23:57 +00:00
const pass = (plates.size === 0 || plates.has(p.vehicle_number)) & & (!cc || p.cost_centre === cc) & & (!city || p.assigned_city === city);
2026-06-05 20:01:30 +00:00
m.getElement().style.display = pass ? '' : 'none';
if (!pass) return;
total++;
const st = vehicleState(p);
if (st === 'offline') offline++;
else if (st === 'active') { moving++; const sp = Number(p.speed || 0); if (sp > 0) speeds.push(sp); }
else parked++;
});
speeds.sort((a, b) => a - b);
renderLiveKPIs({ total, moving, parked, offline, median: speeds.length ? speeds[Math.floor(speeds.length / 2)] : null, last_batch_utc: lastLivePayload?.summary?.last_batch_utc });
}
2026-06-05 18:56:01 +00:00
document.getElementById('f-period').addEventListener('change', e => {
document.getElementById('custom').classList.toggle('show', e.target.value === 'custom');
});
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');
document.getElementById('triplist').classList.add('show');
document.getElementById('live-pill').classList.add('show');
document.getElementById('stale-chip').style.display = 'none';
const ph = document.getElementById('placeholder'); if (ph) ph.style.display = 'none';
}
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 > `;
const g = payload.geojson || { type: 'FeatureCollection', features: [] };
(g.features || []).forEach(f => { f.properties = f.properties || {}; f.properties.color = seqColor(f.properties.daily_seq); });
lastTripFeatures = g.features || [];
// endpoints FC
const endpoints = { type: 'FeatureCollection', features: [] };
(g.features || []).forEach(f => {
const c = f.geometry?.coordinates;
if (!Array.isArray(c) || c.length < 2 ) return ;
endpoints.features.push({ type: 'Feature', properties: { ...f.properties, marker: 'start' }, geometry: { type: 'Point', coordinates: c[0] } });
endpoints.features.push({ type: 'Feature', properties: { ...f.properties, marker: 'end' }, geometry: { type: 'Point', coordinates: c[c.length - 1] } });
});
renderTripList(g.features);
clearAnim();
if (!g.features.length) {
showTripPlaceholder('No trips match those filters.');
removeTripLayers();
return;
}
const ph = document.getElementById('placeholder'); if (ph) ph.style.display = 'none';
const draw = () => {
if (map.getSource(SRC_LINES)) map.getSource(SRC_LINES).setData(g);
else {
map.addSource(SRC_LINES, { type: 'geojson', data: g });
map.addLayer({ id: LYR_LINES, type: 'line', source: SRC_LINES,
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': ['get', 'color'], 'line-width': ['interpolate', ['linear'], ['zoom'], 9, 1.6, 14, 3.5], 'line-opacity': 0.85 } });
map.on('mouseenter', LYR_LINES, e => { map.getCanvas().style.cursor = 'pointer'; showTripPopup(e.lngLat, e.features[0].properties, null); });
map.on('mouseleave', LYR_LINES, () => { map.getCanvas().style.cursor = ''; popup.remove(); });
}
if (map.getSource(SRC_ENDS)) map.getSource(SRC_ENDS).setData(endpoints);
else {
map.addSource(SRC_ENDS, { type: 'geojson', data: endpoints });
map.addLayer({ id: LYR_START, type: 'circle', source: SRC_ENDS, filter: ['==', ['get', 'marker'], 'start'],
paint: { 'circle-color': ['get', 'color'], 'circle-radius': ['interpolate', ['linear'], ['zoom'], 9, 3, 14, 5.5], 'circle-stroke-color': '#fff', 'circle-stroke-width': 1.2, 'circle-opacity': 0.95 } });
map.addLayer({ id: LYR_END, type: 'circle', source: SRC_ENDS, filter: ['==', ['get', 'marker'], 'end'],
paint: { 'circle-color': '#1e232e', 'circle-radius': ['interpolate', ['linear'], ['zoom'], 9, 4, 14, 7], 'circle-stroke-color': ['get', 'color'], 'circle-stroke-width': 2.2 } });
[LYR_START, LYR_END].forEach(lyr => {
map.on('mouseenter', lyr, e => { map.getCanvas().style.cursor = 'pointer'; const p = e.features[0].properties || {}; showTripPopup(e.lngLat, p, p.marker); });
map.on('mouseleave', lyr, () => { map.getCanvas().style.cursor = ''; popup.remove(); });
});
}
const b = new maplibregl.LngLatBounds();
g.features.forEach(f => Array.isArray(f.geometry?.coordinates) & & f.geometry.coordinates.forEach(c => b.extend(c)));
if (!b.isEmpty()) map.fitBounds(b, { padding: { top: 50, right: 290, bottom: 50, left: 310 }, duration: 600, maxZoom: 14 });
};
if (map.isStyleLoaded()) draw(); else map.once('load', draw);
}
function showTripPopup(lngLat, p, marker) {
p = p || {};
const suffix = marker === 'start' ? ' · start' : marker === 'end' ? ' · end' : '';
const timeLine = marker === 'end' ? escapeHtml(p.end_time || '') : escapeHtml(p.start_time || '');
popup.setLngLat(lngLat).setHTML(`
< div class = "pop" >
< b > ${escapeHtml(p.vehicle_number || '—')} · trip ${p.daily_seq ?? '?'}${suffix}< / b >
${p.driver ? `${escapeHtml(p.driver)}< br > ` : ''}${timeLine}< br >
< span class = "muted" > ${p.distance_km ?? 0} km · ${p.duration_min ?? 0} min< / span >
< / div > `).addTo(map);
}
const MAX_TRIPS_IN_LIST = 150;
function renderTripList(features) {
const scroll = document.getElementById('trip-scroll');
const countEl = document.getElementById('trip-count');
scroll.innerHTML = '';
if (!features || !features.length) { countEl.textContent = ''; scroll.innerHTML = '< div class = "trips-empty" > No trips match those filters.< / div > '; return; }
countEl.textContent = `(${features.length})`;
const frag = document.createDocumentFragment();
features.slice(0, MAX_TRIPS_IN_LIST).forEach(f => {
const p = f.properties || {};
const row = document.createElement('div');
row.className = 'trip-row';
row.innerHTML = `
< span class = "swatch" style = "background:${p.color}" > < / span >
< span class = "seq" > #${p.daily_seq ?? '?'}< / span >
< span class = "meta" > < span class = "veh" > ${escapeHtml(p.vehicle_number || '—')}< / span >
< span class = "time" > ${escapeHtml((p.start_time || '').slice(11, 16))}→${escapeHtml((p.end_time || '').slice(11, 16))}< / span > < / span >
< span class = "km" > ${Number(p.distance_km ?? 0).toLocaleString()} km< / span > `;
row.addEventListener('click', () => {
const coords = f.geometry?.coordinates;
if (!Array.isArray(coords) || !coords.length || !map) return;
document.querySelectorAll('.trip-row.active').forEach(r => r.classList.remove('active'));
row.classList.add('active');
const b = new maplibregl.LngLatBounds(); coords.forEach(c => b.extend(c));
map.fitBounds(b, { padding: { top: 60, right: 300, bottom: 60, left: 320 }, duration: 500, maxZoom: 15 });
setTimeout(() => animateTrip(f), 550);
});
frag.appendChild(row);
});
scroll.appendChild(frag);
if (features.length > MAX_TRIPS_IN_LIST) {
const more = document.createElement('div'); more.className = 'trips-more';
more.textContent = `+ ${features.length - MAX_TRIPS_IN_LIST} more — narrow the filter`;
scroll.appendChild(more);
}
}
// ── Trip animation ───────────────────────────────────────────────────────────
function haversine(a, b) {
const R = 6371000, toRad = d => d * Math.PI / 180;
const dLat = toRad(b[1] - a[1]), dLon = toRad(b[0] - a[0]);
const x = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a[1])) * Math.cos(toRad(b[1])) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x));
}
function ensureAnimLayer() {
if (map.getSource(SRC_ANIM)) return;
map.addSource(SRC_ANIM, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addLayer({ id: LYR_ANIM, type: 'circle', source: SRC_ANIM,
paint: { 'circle-color': ['get', 'color'], 'circle-radius': 9, 'circle-stroke-color': '#fff', 'circle-stroke-width': 2, 'circle-blur': 0.15 } });
}
function animateTrip(feature) {
if (!map) return;
const coords = feature.geometry?.coordinates;
if (!Array.isArray(coords) || coords.length < 2 ) return ;
ensureAnimLayer(); cancelAnimationFrame(animFrame);
const cum = [0];
for (let i = 1; i < coords.length ; i + + ) cum . push ( cum [ i - 1 ] + haversine ( coords [ i - 1 ] , coords [ i ] ) ) ;
const total = cum[cum.length - 1]; if (total < = 0) return;
const duration = Math.min(6000, Math.max(2500, total * 6));
const color = feature.properties.color || '#E8954A';
const startTs = performance.now();
const src = map.getSource(SRC_ANIM);
src.setData(pointFC(coords[0], color));
function tick(now) {
const t = Math.min(1, (now - startTs) / duration), d = total * t;
let i = 1; while (i < cum.length & & cum [ i ] < d ) i + + ;
const a = coords[i - 1], bb = coords[i] || coords[coords.length - 1];
const segLen = cum[i] - cum[i - 1] || 1, segT = (d - cum[i - 1]) / segLen;
src.setData(pointFC([a[0] + (bb[0] - a[0]) * segT, a[1] + (bb[1] - a[1]) * segT], color));
if (t < 1 ) animFrame = requestAnimationFrame(tick);
}
animFrame = requestAnimationFrame(tick);
}
function pointFC(coord, color) { return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: { color }, geometry: { type: 'Point', coordinates: coord } }] }; }
function clearAnim() { cancelAnimationFrame(animFrame); if (map?.getSource(SRC_ANIM)) map.getSource(SRC_ANIM).setData({ type: 'FeatureCollection', features: [] }); }
function removeTripLayers() {
[LYR_ANIM, LYR_START, LYR_END, LYR_LINES].forEach(id => { if (map?.getLayer(id)) map.removeLayer(id); });
[SRC_ANIM, SRC_ENDS, SRC_LINES].forEach(id => { if (map?.getSource(id)) map.removeSource(id); });
}
function showTripPlaceholder(msg) {
let ph = document.getElementById('placeholder');
if (ph) { ph.style.display = 'grid'; ph.textContent = msg; }
}
// ── Return to live ───────────────────────────────────────────────────────────
function backToLive() {
mode = 'live';
clearAnim(); removeTripLayers();
document.getElementById('triplist').classList.remove('show');
document.getElementById('live-pill').classList.remove('show');
document.getElementById('stale-chip').style.display = '';
liveMarkers.forEach(m => m.getElement().style.display = '');
const ph = document.getElementById('placeholder'); if (ph) ph.style.display = 'none';
startPolling();
}
document.getElementById('live-pill').addEventListener('click', backToLive);
document.getElementById('trips-back').addEventListener('click', backToLive);
// ============================================================================
// Reverse-geocoding (Nominatim) — queued, 1 req/sec, in-memory cache
// ============================================================================
const GEOCODE_CACHE = new Map(), GEOCODE_QUEUE = [];
const GEOCODE_MIN_INTERVAL_MS = 1100;
let geocodeLastFiredAt = 0, geocodeTimer = null, pendingGeocodeTimer = null;
function geocodeKey(lat, lng) { return `${lat.toFixed(4)},${lng.toFixed(4)}`; }
function lookupCachedAddress(lat, lng) { const v = GEOCODE_CACHE.get(geocodeKey(lat, lng)); return (v & & v !== '__pending__' & & v !== '__fail__') ? v : null; }
function cancelPendingGeocode() { if (pendingGeocodeTimer) { clearTimeout(pendingGeocodeTimer); pendingGeocodeTimer = null; } }
function geocode(lat, lng) {
const key = geocodeKey(lat, lng), cached = GEOCODE_CACHE.get(key);
if (cached & & cached !== '__pending__' & & cached !== '__fail__') return Promise.resolve(cached);
if (cached === '__fail__') return Promise.resolve(null);
return new Promise(resolve => { GEOCODE_QUEUE.push({ key, lat, lng, resolve }); GEOCODE_CACHE.set(key, '__pending__'); pumpGeocodeQueue(); });
}
function pumpGeocodeQueue() {
if (geocodeTimer || !GEOCODE_QUEUE.length) return;
const wait = Math.max(0, GEOCODE_MIN_INTERVAL_MS - (Date.now() - geocodeLastFiredAt));
geocodeTimer = setTimeout(async () => {
geocodeTimer = null; const job = GEOCODE_QUEUE.shift(); if (!job) return;
geocodeLastFiredAt = Date.now();
try {
const url = `https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${job.lat}&lon=${job.lng}&zoom=17&accept-language=en`;
const resp = await fetch(url, { headers: { 'Accept': 'application/json' } });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json(); const addr = formatNominatimAddress(data) || null;
GEOCODE_CACHE.set(job.key, addr || '__fail__'); job.resolve(addr);
} catch (_e) { GEOCODE_CACHE.set(job.key, '__fail__'); job.resolve(null); }
finally { pumpGeocodeQueue(); }
}, wait);
}
function formatNominatimAddress(data) {
if (!data) return null; const a = data.address || {}; const parts = [];
const road = a.road || a.pedestrian || a.path || a.highway;
if (road) parts.push(`${a.house_number ? a.house_number + ' ' : ''}${road}`);
const local = a.neighbourhood || a.suburb || a.quarter || a.village || a.hamlet;
if (local & & local !== road) parts.push(local);
const city = a.city || a.town || a.municipality || a.county;
if (city & & !parts.includes(city)) parts.push(city);
if (parts.length) return parts.join(', ');
return data.display_name?.split(',').slice(0, 3).join(',').trim() || null;
}
// ============================================================================
// Helpers
// ============================================================================
function minutesAgoText(utcIso) {
if (!utcIso) return '';
const s = (Date.now() - new Date(utcIso)) / 1000;
if (s < 60 ) return ` $ { Math . round ( s ) } s ago ` ;
if (s < 3600 ) return ` $ { Math . round ( s / 60 ) } m ago ` ;
if (s < 86400 ) return ` $ { ( s / 3600 ) . toFixed ( 1 ) } h ago ` ;
return `${Math.round(s / 86400)}d ago`;
}
function escapeHtml(s) { return String(s).replace(/[& < >"']/g, c => ({ '& ': '& ', '< ': '< ', '>': '> ', '"': '" ', "'": '' ' }[c])); }
function formatNum(n) { if (n === null || n === undefined) return '—'; return typeof n === 'number' ? n.toLocaleString() : n; }
function showError(msg) {
let el = document.getElementById('err');
if (!el) { el = document.createElement('div'); el.id = 'err'; el.className = 'error'; document.getElementById('map').appendChild(el); }
el.textContent = msg;
}
function hideError() { const el = document.getElementById('err'); if (el) el.remove(); }
// ============================================================================
// Boot
// ============================================================================
ensureMap();
loadFilters();
startPolling();
< / script >
< / body >
< / html >