- Markers now scale with zoom (--veh-scale, ~0.42 at z5 → 1.20 at z14) via a transform on .veh-inner, so they no longer bloat at country zoom; pins stay anchored on their coordinate (verified 0px drift). - Selecting a plate or cost centre now filters the LIVE markers immediately and recomputes the header KPIs (previously the filter card only fed Show trips, so selections didn't reflect on the live map). Time period still applies to trips. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1085 lines
54 KiB
HTML
1085 lines
54 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
<!-- Keep full Referer for Mapbox URL-restricted tokens on HTTPS→HTTPS cross-origin. -->
|
|
<meta name="referrer" content="no-referrer-when-downgrade" />
|
|
<title>FleetNow — Live + Trips</title>
|
|
|
|
<!--
|
|
fleetnow.html — single-file SPA merging the Live Position and Fleet Trips
|
|
dashboards into one console. Pairs with the dashboard read-API:
|
|
GET <API_BASE>/webhook/live-positions → live snapshot {summary, geojson}
|
|
GET <API_BASE>/webhook/live-positions/track → 1 h trail (LineString Feature)
|
|
GET <API_BASE>/webhook/fleet-dashboard → filter options
|
|
POST <API_BASE>/webhook/fleet-dashboard → trips {summary, geojson}
|
|
|
|
Mode model: land on LIVE (full fleet, polled). Pick a vehicle (map dot or
|
|
plate dropdown) or apply cost-centre + period → TRIPS for that selection.
|
|
The "● Live" pill returns to the live snapshot.
|
|
-->
|
|
|
|
<style>
|
|
:root {
|
|
/* Warm dark ops palette (ref screenshot 2026-06-05) */
|
|
--bg: #161a23;
|
|
--panel: #1e232e;
|
|
--panel-2: #232a36;
|
|
--border: #2c333f;
|
|
--text: #ECEFF4;
|
|
--muted: #93a0b4;
|
|
--accent: #E8954A; /* amber/orange — primary actions, Live pill, focus */
|
|
--accent-hover:#d97b2c;
|
|
--live: #2dd4a7; /* teal-green — online / moving / active */
|
|
--parked: #6b7280;
|
|
--offline: #b4791f; /* deep amber warning */
|
|
--warn: #f0a93b;
|
|
--danger: #ef5b5b;
|
|
--error-bg: #2a0a0a;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
html, body { margin: 0; height: 100%; }
|
|
body {
|
|
font: 14px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
background: var(--bg); color: var(--text); overflow: hidden;
|
|
}
|
|
.app { display: grid; grid-template-rows: auto 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) ─────────────────────────── */
|
|
/* 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); }
|
|
.veh-marker.parked .veh-pin { box-shadow: 0 2px 5px rgba(0,0,0,.4); opacity: .85; }
|
|
.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>
|
|
<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];
|
|
}
|
|
|
|
// ============================================================================
|
|
// State
|
|
// ============================================================================
|
|
let mode = 'live'; // 'live' | 'trips'
|
|
let map = null, popup = null;
|
|
let pollTimer = null, inFlight = null;
|
|
let lastLivePayload = null;
|
|
const liveMarkers = new Map(); // imei → maplibregl.Marker
|
|
const VEHICLE_META = new Map(); // plate → {cost_centre, assigned_city}
|
|
let openPopupImei = null, popupStuck = false, popupCloseTimer = null;
|
|
let trailedVehicle = null;
|
|
let animFrame = 0;
|
|
|
|
// ============================================================================
|
|
// EAT clock
|
|
// ============================================================================
|
|
const clockFmt = new Intl.DateTimeFormat('en-GB', {
|
|
timeZone: 'Africa/Nairobi', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
|
|
});
|
|
function tickClock() { document.getElementById('clock-time').textContent = clockFmt.format(new Date()); }
|
|
setInterval(tickClock, 1000); tickClock();
|
|
|
|
// ============================================================================
|
|
// Map
|
|
// ============================================================================
|
|
function ensureMap() {
|
|
if (map) return map;
|
|
const el = document.getElementById('map');
|
|
map = new maplibregl.Map({
|
|
container: el,
|
|
style: 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json',
|
|
center: EAST_AFRICA.center, zoom: EAST_AFRICA.zoom,
|
|
attributionControl: { compact: true },
|
|
});
|
|
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
|
|
popup = new maplibregl.Popup({ closeButton: true, closeOnClick: false, offset: 20 });
|
|
popup.on('close', () => { openPopupImei = null; popupStuck = false; });
|
|
map.on('load', () => { POIS.forEach(addPoiMarker); updateVehScale(); });
|
|
map.on('zoom', updateVehScale);
|
|
map.on('click', e => {
|
|
if (mode !== 'live') return;
|
|
if (!popupStuck) return;
|
|
// background click closes a pinned popup
|
|
popupStuck = false; openPopupImei = null; popup.remove();
|
|
});
|
|
return map;
|
|
}
|
|
function addPoiMarker(poi) {
|
|
const el = document.createElement('div');
|
|
el.className = 'hq-marker'; el.title = poi.name;
|
|
el.innerHTML = `<div class="hq-dot"></div><div class="hq-label">${escapeHtml(poi.name)}</div>`;
|
|
new maplibregl.Marker({ element: el, anchor: 'bottom' }).setLngLat([poi.lng, poi.lat]).addTo(map);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Vehicle state (tri-state)
|
|
// ============================================================================
|
|
function vehicleState(p) {
|
|
if (!p) return 'offline';
|
|
const ageMs = (typeof p.source_age_hours === 'number')
|
|
? p.source_age_hours * 3600 * 1000
|
|
: (p.gps_time_utc ? Date.now() - new Date(p.gps_time_utc).getTime() : 0);
|
|
if (ageMs >= OFFLINE_THRESHOLD_MS) return 'offline';
|
|
if (p.acc_status !== '1') return 'parked';
|
|
if (ageMs >= STALE_GPS_MS) return 'parked';
|
|
return 'active';
|
|
}
|
|
function isVehicleActive(p) { return vehicleState(p) === 'active'; }
|
|
function staleAgeText(p) {
|
|
const ageMs = (typeof p.source_age_hours === 'number')
|
|
? p.source_age_hours * 3600 * 1000
|
|
: (p.gps_time_utc ? Date.now() - new Date(p.gps_time_utc).getTime() : 0);
|
|
const days = ageMs / 86400000;
|
|
if (days >= 1) return `${days.toFixed(days >= 10 ? 0 : 1)}d`;
|
|
const hours = ageMs / 3600000;
|
|
if (hours >= 1) return `${hours.toFixed(0)}h`;
|
|
return `${Math.max(0, Math.round(ageMs / 60000))}m`;
|
|
}
|
|
function plateTail(plate) {
|
|
if (!plate) return '';
|
|
return String(plate).replace(/\s+/g, '').slice(-4);
|
|
}
|
|
|
|
// ============================================================================
|
|
// LIVE MODE — polling + DOM markers
|
|
// ============================================================================
|
|
async function fetchLive() {
|
|
if (inFlight) inFlight.abort();
|
|
inFlight = new AbortController();
|
|
try {
|
|
const resp = await fetch(EP_LIVE, { signal: inFlight.signal });
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
const data = await resp.json();
|
|
if (data.error) { showError(data.error.message || 'Live feed error'); return; }
|
|
hideError();
|
|
lastLivePayload = data;
|
|
if (mode === 'live') renderLive();
|
|
} catch (err) {
|
|
if (err.name === 'AbortError') return;
|
|
showError(`Couldn't load live positions: ${err.message}`);
|
|
} finally { inFlight = null; }
|
|
}
|
|
function startPolling() { stopPolling(); fetchLive(); pollTimer = setInterval(fetchLive, POLL_INTERVAL_MS); }
|
|
function stopPolling() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } }
|
|
|
|
let firstFit = false;
|
|
function renderLive() {
|
|
if (!lastLivePayload) return;
|
|
ensureMap();
|
|
const features = (lastLivePayload.geojson && lastLivePayload.geojson.features) || [];
|
|
|
|
// KPIs (recomputed client-side so they reflect what's drawn)
|
|
const active = features.filter(f => isVehicleActive(f.properties));
|
|
const speeds = active.map(f => Number(f.properties.speed || 0)).filter(s => s > 0).sort((a, b) => a - b);
|
|
const median = speeds.length ? speeds[Math.floor(speeds.length / 2)] : null;
|
|
const offline = features.filter(f => vehicleState(f.properties) === 'offline').length;
|
|
renderLiveKPIs({
|
|
total: features.length, moving: active.length,
|
|
parked: features.length - active.length - offline, offline,
|
|
median, last_batch_utc: lastLivePayload.summary?.last_batch_utc,
|
|
});
|
|
|
|
// Populate filter dropdowns from the full fleet (never shrink them)
|
|
populateFiltersFromLive(features);
|
|
|
|
const drawMarkers = () => {
|
|
const seen = new Set();
|
|
features.forEach(f => {
|
|
const p = f.properties || {};
|
|
const c = f.geometry && f.geometry.coordinates;
|
|
if (!Array.isArray(c)) return;
|
|
seen.add(p.imei);
|
|
upsertLiveMarker(p, c, f);
|
|
});
|
|
// Drop markers for vehicles no longer present
|
|
for (const [imei, m] of liveMarkers) { if (!seen.has(imei)) { m.remove(); liveMarkers.delete(imei); } }
|
|
|
|
if (!firstFit && features.length) {
|
|
const b = new maplibregl.LngLatBounds();
|
|
features.forEach(f => Array.isArray(f.geometry?.coordinates) && b.extend(f.geometry.coordinates));
|
|
if (!b.isEmpty()) map.fitBounds(b, { padding: 70, duration: 700, maxZoom: 11 });
|
|
firstFit = true;
|
|
}
|
|
// Re-attach a pinned popup to its (moved) vehicle
|
|
if (openPopupImei) {
|
|
const still = features.find(f => f.properties.imei === openPopupImei);
|
|
if (still) showLivePopup(still, true);
|
|
else { popup.remove(); openPopupImei = null; popupStuck = false; }
|
|
}
|
|
// Honour the current plate/cost-centre filter + size markers for this zoom.
|
|
applyLiveFilters();
|
|
updateVehScale();
|
|
};
|
|
if (map.isStyleLoaded()) drawMarkers(); else map.once('load', drawMarkers);
|
|
|
|
const ph = document.getElementById('placeholder'); if (ph) ph.style.display = 'none';
|
|
}
|
|
|
|
function upsertLiveMarker(p, coords, feature) {
|
|
const state = vehicleState(p);
|
|
const color = state === 'active' ? colorForCostCentre(p.cost_centre)
|
|
: state === 'offline' ? OFFLINE_COLOR : PARKED_COLOR;
|
|
const speed = Number(p.speed || 0);
|
|
const dir = Number(p.direction || 0);
|
|
let m = liveMarkers.get(p.imei);
|
|
if (!m) {
|
|
const el = document.createElement('div');
|
|
el.className = 'veh-marker';
|
|
el.innerHTML = `<div class="veh-inner"><div class="veh-pin"><span class="glyph"></span></div><div class="veh-plate"></div></div>`;
|
|
el.addEventListener('mouseenter', () => { cancelPopupClose(); openPopupImei = p.imei; const f = currentLiveFeature(p.imei); if (f) showLivePopup(f); });
|
|
el.addEventListener('mouseleave', () => { if (!popupStuck) schedulePopupClose(); });
|
|
el.addEventListener('click', e => {
|
|
e.stopPropagation();
|
|
cancelPopupClose(); popupStuck = true; openPopupImei = p.imei;
|
|
const f = currentLiveFeature(p.imei); if (f) showLivePopup(f);
|
|
});
|
|
m = new maplibregl.Marker({ element: el, anchor: 'center' }).setLngLat(coords).addTo(map);
|
|
liveMarkers.set(p.imei, m);
|
|
} else {
|
|
m.setLngLat(coords);
|
|
}
|
|
// Style the (possibly reused) element to current state. Use classList so we
|
|
// never wipe MapLibre's own `maplibregl-marker` class (doing so dropped
|
|
// position:absolute and made markers stack in document flow — FIX).
|
|
const el = m.getElement();
|
|
el.classList.add('veh-marker');
|
|
el.classList.remove('active', 'parked', 'offline');
|
|
el.classList.add(state);
|
|
const pin = el.querySelector('.veh-pin');
|
|
pin.style.setProperty('--c', color);
|
|
const glyph = el.querySelector('.glyph');
|
|
if (state === 'active' && speed > 0) {
|
|
glyph.className = 'glyph veh-arrow';
|
|
glyph.style.setProperty('--dir', dir + 'deg');
|
|
} 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 || []);
|
|
} 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);
|
|
});
|
|
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));
|
|
values.forEach(v => { if (have.has(v)) return; const o = document.createElement('option'); o.value = v; o.textContent = v; sel.appendChild(o); });
|
|
}
|
|
// Add any plate/cc seen in the live feed that the filter-options endpoint missed
|
|
function populateFiltersFromLive(features) {
|
|
const ccs = new Set(), vehSel = document.getElementById('f-vehicle');
|
|
const havePlates = new Set(Array.from(vehSel.options).map(o => o.value));
|
|
features.forEach(f => {
|
|
const p = f.properties;
|
|
if (p.cost_centre) ccs.add(p.cost_centre);
|
|
if (p.vehicle_number && !havePlates.has(p.vehicle_number)) {
|
|
havePlates.add(p.vehicle_number);
|
|
VEHICLE_META.set(p.vehicle_number, { cost_centre: p.cost_centre, assigned_city: p.assigned_city });
|
|
const o = document.createElement('option'); o.value = p.vehicle_number; o.textContent = p.vehicle_number; vehSel.appendChild(o);
|
|
}
|
|
});
|
|
fillSelect('f-cc', Array.from(ccs).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);
|
|
const sharedCC = collapse(metas.map(m => m.cost_centre));
|
|
setSelectValue('f-cc', sharedCC ?? '');
|
|
applyLiveFilters(); // reflect the plate selection on the live map immediately
|
|
}
|
|
function collapse(values) { if (!values.length) return null; const f = values[0]; return values.every(v => v === f) ? f : null; }
|
|
function setSelectValue(id, value) { const el = document.getElementById(id); const opt = Array.from(el.options).find(o => o.value === value); el.value = opt ? value : ''; }
|
|
|
|
// Scale the live markers with the zoom level so they don't bloat at country
|
|
// zoom or vanish when zoomed in. Linear from z5 → z14.
|
|
function updateVehScale() {
|
|
if (!map) return;
|
|
const t = Math.max(0, Math.min(1, (map.getZoom() - 5) / 9));
|
|
document.getElementById('map').style.setProperty('--veh-scale', (0.42 + t * 0.78).toFixed(3));
|
|
}
|
|
|
|
// Filter the LIVE markers by the selected plate(s) + cost centre, and recompute
|
|
// the header KPIs to match. Time period only applies to trips, not live.
|
|
function applyLiveFilters() {
|
|
if (mode !== 'live') return;
|
|
const plates = new Set(Array.from(document.getElementById('f-vehicle').selectedOptions).map(o => o.value).filter(Boolean));
|
|
const cc = document.getElementById('f-cc').value;
|
|
let total = 0, moving = 0, parked = 0, offline = 0; const speeds = [];
|
|
(lastLivePayload?.geojson?.features || []).forEach(f => {
|
|
const p = f.properties; const m = liveMarkers.get(p.imei); if (!m) return;
|
|
const pass = (plates.size === 0 || plates.has(p.vehicle_number)) && (!cc || p.cost_centre === cc);
|
|
m.getElement().style.display = pass ? '' : 'none';
|
|
if (!pass) return;
|
|
total++;
|
|
const st = vehicleState(p);
|
|
if (st === 'offline') offline++;
|
|
else if (st === 'active') { moving++; const sp = Number(p.speed || 0); if (sp > 0) speeds.push(sp); }
|
|
else parked++;
|
|
});
|
|
speeds.sort((a, b) => a - b);
|
|
renderLiveKPIs({ total, moving, parked, offline, median: speeds.length ? speeds[Math.floor(speeds.length / 2)] : null, last_batch_utc: lastLivePayload?.summary?.last_batch_utc });
|
|
}
|
|
|
|
document.getElementById('f-period').addEventListener('change', e => {
|
|
document.getElementById('custom').classList.toggle('show', e.target.value === 'custom');
|
|
});
|
|
// Selecting a cost centre filters the live map immediately.
|
|
document.getElementById('f-cc').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 || '',
|
|
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, 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: '', 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 || '',
|
|
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>
|