2026-05-22 21:53:42 +00:00
|
|
|
// fleet-core.js
|
|
|
|
|
// Shared client primitives for the fleet-platform dashboards.
|
|
|
|
|
//
|
2026-05-23 06:29:04 +00:00
|
|
|
// Server-driven rendering per PRD §8: the API attaches `marker_color`,
|
|
|
|
|
// `show_arrow`, `style_class`, etc. The JS just paints what it's told.
|
|
|
|
|
//
|
|
|
|
|
// Markers (three MapLibre layers stacked on one source):
|
|
|
|
|
// 1. circle — `marker_color` (cost-centre colour when moving, grey otherwise)
|
|
|
|
|
// 2. arrow — white SVG icon, rotated by `heading_deg`, visible only when
|
|
|
|
|
// `show_arrow == true`
|
|
|
|
|
// 3. label — `plate_short` (last 4 chars of plate), below the circle
|
|
|
|
|
|
|
|
|
|
const STORAGE_ACCESS = 'fleet.accessToken';
|
2026-05-22 21:53:42 +00:00
|
|
|
const STORAGE_REFRESH = 'fleet.refreshToken';
|
|
|
|
|
const STORAGE_EXPIRES = 'fleet.expiresAt';
|
|
|
|
|
|
2026-05-23 06:29:04 +00:00
|
|
|
const VEHICLE_SOURCE = 'vehicles';
|
|
|
|
|
|
2026-05-22 21:53:42 +00:00
|
|
|
/* ---------- authClient ---------- */
|
|
|
|
|
|
|
|
|
|
export const authClient = {
|
|
|
|
|
isAuthenticated() {
|
|
|
|
|
const expiresAt = Number(localStorage.getItem(STORAGE_EXPIRES) || 0);
|
|
|
|
|
return localStorage.getItem(STORAGE_ACCESS) !== null && Date.now() < expiresAt * 1000;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async login(username, password) {
|
|
|
|
|
const body = new URLSearchParams({ username, password });
|
2026-05-23 06:29:04 +00:00
|
|
|
const res = await fetch('/api/auth/token', {
|
2026-05-22 21:53:42 +00:00
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
|
|
|
body,
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const detail = await res.json().catch(() => ({ detail: 'login failed' }));
|
|
|
|
|
throw new Error(detail.detail || 'login failed');
|
|
|
|
|
}
|
|
|
|
|
const payload = await res.json();
|
|
|
|
|
const expiresAt = Math.floor(Date.now() / 1000) + Number(payload.expires_in || 900);
|
|
|
|
|
localStorage.setItem(STORAGE_ACCESS, payload.access_token);
|
|
|
|
|
localStorage.setItem(STORAGE_REFRESH, payload.refresh_token);
|
|
|
|
|
localStorage.setItem(STORAGE_EXPIRES, String(expiresAt));
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
logout() {
|
|
|
|
|
localStorage.removeItem(STORAGE_ACCESS);
|
|
|
|
|
localStorage.removeItem(STORAGE_REFRESH);
|
|
|
|
|
localStorage.removeItem(STORAGE_EXPIRES);
|
|
|
|
|
},
|
|
|
|
|
|
2026-05-27 11:14:06 +00:00
|
|
|
getToken() {
|
|
|
|
|
return localStorage.getItem(STORAGE_ACCESS);
|
|
|
|
|
},
|
|
|
|
|
|
2026-05-22 21:53:42 +00:00
|
|
|
requireSession({ loginPath = '/login.html' } = {}) {
|
|
|
|
|
if (!this.isAuthenticated()) {
|
|
|
|
|
window.location.href = loginPath;
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export async function apiFetch(path, { params, ...opts } = {}) {
|
|
|
|
|
const url = new URL(path, window.location.origin);
|
|
|
|
|
if (params) {
|
|
|
|
|
for (const [k, v] of Object.entries(params)) {
|
|
|
|
|
if (v !== undefined && v !== null && v !== '') {
|
|
|
|
|
url.searchParams.set(k, typeof v === 'string' ? v : JSON.stringify(v));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const token = localStorage.getItem(STORAGE_ACCESS);
|
|
|
|
|
const res = await fetch(url.toString(), {
|
|
|
|
|
...opts,
|
|
|
|
|
headers: {
|
|
|
|
|
...(opts.headers || {}),
|
|
|
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
|
|
|
Accept: 'application/json',
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
if (res.status === 401) {
|
|
|
|
|
authClient.logout();
|
|
|
|
|
window.location.href = '/login.html';
|
|
|
|
|
throw new Error('unauthorized');
|
|
|
|
|
}
|
2026-05-23 06:29:04 +00:00
|
|
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
2026-05-22 21:53:42 +00:00
|
|
|
return res.json();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 06:29:04 +00:00
|
|
|
/* ---------- map setup ---------- */
|
2026-05-22 21:53:42 +00:00
|
|
|
|
2026-05-23 06:29:04 +00:00
|
|
|
function _addArrowImage(map) {
|
|
|
|
|
if (map.hasImage('arrow-white')) return;
|
|
|
|
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
|
|
|
|
<path d="M12 2 L19.5 19.5 L12 15.5 L4.5 19.5 Z"
|
|
|
|
|
fill="white" stroke="rgba(0,0,0,0.55)" stroke-width="1.2" stroke-linejoin="round"/>
|
|
|
|
|
</svg>`;
|
|
|
|
|
const img = new Image(24, 24);
|
|
|
|
|
img.onload = () => { if (!map.hasImage('arrow-white')) map.addImage('arrow-white', img); };
|
|
|
|
|
img.src = 'data:image/svg+xml;utf8,' + encodeURIComponent(svg);
|
|
|
|
|
}
|
2026-05-22 21:53:42 +00:00
|
|
|
|
|
|
|
|
export function initMap(elementId, opts = {}) {
|
2026-05-23 06:29:04 +00:00
|
|
|
const center = opts.center || [36.8172, -1.2864]; // Nairobi
|
2026-05-22 21:53:42 +00:00
|
|
|
const zoom = opts.zoom ?? 7;
|
2026-05-23 06:29:04 +00:00
|
|
|
const styleUrl = opts.styleUrl
|
2026-05-27 19:26:52 +00:00
|
|
|
|| 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json';
|
2026-05-22 21:53:42 +00:00
|
|
|
|
|
|
|
|
// eslint-disable-next-line no-undef
|
|
|
|
|
const map = new maplibregl.Map({
|
|
|
|
|
container: elementId,
|
|
|
|
|
style: styleUrl,
|
|
|
|
|
center,
|
|
|
|
|
zoom,
|
|
|
|
|
attributionControl: true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
map.on('load', () => {
|
2026-05-23 06:29:04 +00:00
|
|
|
_addArrowImage(map);
|
|
|
|
|
|
2026-05-22 21:53:42 +00:00
|
|
|
map.addSource(VEHICLE_SOURCE, {
|
|
|
|
|
type: 'geojson',
|
|
|
|
|
data: { type: 'FeatureCollection', features: [] },
|
|
|
|
|
});
|
2026-05-23 06:29:04 +00:00
|
|
|
|
2026-05-22 21:53:42 +00:00
|
|
|
map.addLayer({
|
2026-05-23 06:29:04 +00:00
|
|
|
id: 'vehicles-circle',
|
2026-05-22 21:53:42 +00:00
|
|
|
type: 'circle',
|
|
|
|
|
source: VEHICLE_SOURCE,
|
|
|
|
|
paint: {
|
2026-05-27 09:07:50 +00:00
|
|
|
// Scale dot size with zoom — small at country/city overview to keep
|
|
|
|
|
// the Nairobi cluster legible, full size once you're zoomed into a
|
|
|
|
|
// neighbourhood. text-offset on the label layer is in ems so it
|
|
|
|
|
// scales naturally with text-size; no extra interpolation needed.
|
|
|
|
|
'circle-radius': [
|
|
|
|
|
'interpolate', ['linear'], ['zoom'],
|
|
|
|
|
5, 2,
|
|
|
|
|
9, 4,
|
|
|
|
|
12, 7,
|
|
|
|
|
15, 13,
|
|
|
|
|
18, 20,
|
|
|
|
|
],
|
2026-05-27 19:07:03 +00:00
|
|
|
// Cost-centre tint for moving + parked; offline goes solid grey
|
|
|
|
|
// (no cost-centre signal worth showing when we haven't heard from
|
|
|
|
|
// the device). Opacity differentiates moving vs parked.
|
|
|
|
|
'circle-color': [
|
|
|
|
|
'case',
|
|
|
|
|
['==', ['get', 'operational_state'], 'offline'], '#9ca3af',
|
|
|
|
|
['coalesce', ['get', 'cost_centre_color'], '#94a3b8'],
|
|
|
|
|
],
|
2026-05-27 18:56:53 +00:00
|
|
|
'circle-opacity': [
|
|
|
|
|
'case',
|
|
|
|
|
['==', ['get', 'operational_state'], 'moving'], 1.0,
|
2026-05-27 19:07:03 +00:00
|
|
|
['==', ['get', 'operational_state'], 'parked'], 0.75,
|
|
|
|
|
0.55,
|
2026-05-27 18:56:53 +00:00
|
|
|
],
|
2026-05-23 06:29:04 +00:00
|
|
|
'circle-stroke-color': '#0b1220',
|
2026-05-27 09:07:50 +00:00
|
|
|
'circle-stroke-width': [
|
|
|
|
|
'interpolate', ['linear'], ['zoom'],
|
|
|
|
|
5, 0.5,
|
|
|
|
|
12, 1.5,
|
|
|
|
|
16, 2,
|
|
|
|
|
],
|
2026-05-22 21:53:42 +00:00
|
|
|
},
|
|
|
|
|
});
|
2026-05-23 06:29:04 +00:00
|
|
|
|
|
|
|
|
map.addLayer({
|
|
|
|
|
id: 'vehicles-arrow',
|
|
|
|
|
type: 'symbol',
|
|
|
|
|
source: VEHICLE_SOURCE,
|
|
|
|
|
filter: ['==', ['get', 'show_arrow'], true],
|
|
|
|
|
layout: {
|
|
|
|
|
'icon-image': 'arrow-white',
|
|
|
|
|
'icon-rotate': ['coalesce', ['get', 'heading_deg'], 0],
|
|
|
|
|
'icon-rotation-alignment': 'map',
|
|
|
|
|
'icon-allow-overlap': true,
|
|
|
|
|
'icon-ignore-placement': true,
|
2026-05-27 09:07:50 +00:00
|
|
|
'icon-size': [
|
|
|
|
|
'interpolate', ['linear'], ['zoom'],
|
|
|
|
|
5, 0.2,
|
|
|
|
|
12, 0.45,
|
|
|
|
|
15, 0.7,
|
|
|
|
|
18, 1.0,
|
|
|
|
|
],
|
2026-05-23 06:29:04 +00:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-27 19:26:52 +00:00
|
|
|
_addHqPoi(map);
|
|
|
|
|
|
2026-05-23 06:29:04 +00:00
|
|
|
map.addLayer({
|
|
|
|
|
id: 'vehicles-label',
|
|
|
|
|
type: 'symbol',
|
|
|
|
|
source: VEHICLE_SOURCE,
|
2026-05-27 09:07:50 +00:00
|
|
|
// Plate-tail labels add nothing but clutter at city-overview zoom —
|
|
|
|
|
// skip them until you're zoomed in enough that they don't overlap.
|
|
|
|
|
minzoom: 11,
|
2026-05-23 06:29:04 +00:00
|
|
|
layout: {
|
|
|
|
|
'text-field': ['get', 'plate_short'],
|
|
|
|
|
'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'],
|
2026-05-27 09:07:50 +00:00
|
|
|
'text-size': [
|
|
|
|
|
'interpolate', ['linear'], ['zoom'],
|
|
|
|
|
11, 8,
|
|
|
|
|
14, 11,
|
|
|
|
|
17, 14,
|
|
|
|
|
],
|
2026-05-23 06:29:04 +00:00
|
|
|
'text-offset': [0, 1.7],
|
|
|
|
|
'text-anchor': 'top',
|
|
|
|
|
'text-allow-overlap': true,
|
|
|
|
|
'text-ignore-placement': true,
|
|
|
|
|
'text-letter-spacing': 0.04,
|
|
|
|
|
},
|
|
|
|
|
paint: {
|
2026-05-27 19:26:52 +00:00
|
|
|
// Dark text + white halo reads cleanly on the Positron light basemap.
|
|
|
|
|
'text-color': '#0f172a',
|
|
|
|
|
'text-halo-color': '#ffffff',
|
2026-05-23 06:29:04 +00:00
|
|
|
'text-halo-width': 2,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
_wireHoverPopup(map);
|
2026-05-22 21:53:42 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-23 06:29:04 +00:00
|
|
|
return map;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 19:26:52 +00:00
|
|
|
/* ---------- POIs ---------- */
|
|
|
|
|
|
|
|
|
|
const HQ_POI = {
|
|
|
|
|
name: 'Fireside Group HQ',
|
|
|
|
|
lng: 36.728803986483136,
|
|
|
|
|
lat: -1.2408926683427806,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function _addHqPoi(map) {
|
|
|
|
|
map.addSource('poi-source', {
|
|
|
|
|
type: 'geojson',
|
|
|
|
|
data: {
|
|
|
|
|
type: 'FeatureCollection',
|
|
|
|
|
features: [{
|
|
|
|
|
type: 'Feature',
|
|
|
|
|
properties: { name: HQ_POI.name, kind: 'hq' },
|
|
|
|
|
geometry: { type: 'Point', coordinates: [HQ_POI.lng, HQ_POI.lat] },
|
|
|
|
|
}],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
map.addLayer({
|
|
|
|
|
id: 'poi-hq-halo',
|
|
|
|
|
type: 'circle',
|
|
|
|
|
source: 'poi-source',
|
|
|
|
|
paint: {
|
|
|
|
|
'circle-radius': ['interpolate', ['linear'], ['zoom'], 5, 5, 9, 9, 13, 16, 18, 26],
|
|
|
|
|
'circle-color': '#dc2626',
|
|
|
|
|
'circle-opacity': 0.20,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
map.addLayer({
|
|
|
|
|
id: 'poi-hq-dot',
|
|
|
|
|
type: 'circle',
|
|
|
|
|
source: 'poi-source',
|
|
|
|
|
paint: {
|
|
|
|
|
'circle-radius': ['interpolate', ['linear'], ['zoom'], 5, 3, 9, 5, 13, 8, 18, 14],
|
|
|
|
|
'circle-color': '#dc2626',
|
|
|
|
|
'circle-stroke-color': '#ffffff',
|
|
|
|
|
'circle-stroke-width': 2,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
map.addLayer({
|
|
|
|
|
id: 'poi-hq-label',
|
|
|
|
|
type: 'symbol',
|
|
|
|
|
source: 'poi-source',
|
|
|
|
|
minzoom: 9,
|
|
|
|
|
layout: {
|
|
|
|
|
'text-field': ['get', 'name'],
|
|
|
|
|
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
|
|
|
|
|
'text-size': ['interpolate', ['linear'], ['zoom'], 9, 10, 13, 12, 17, 15],
|
|
|
|
|
'text-offset': [0, 1.4],
|
|
|
|
|
'text-anchor': 'top',
|
|
|
|
|
'text-allow-overlap': false,
|
|
|
|
|
'text-letter-spacing': 0.04,
|
|
|
|
|
},
|
|
|
|
|
paint: {
|
|
|
|
|
'text-color': '#7f1d1d',
|
|
|
|
|
'text-halo-color': '#ffffff',
|
|
|
|
|
'text-halo-width': 2,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 06:29:04 +00:00
|
|
|
/* ---------- hover popup ---------- */
|
|
|
|
|
|
|
|
|
|
function _wireHoverPopup(map) {
|
|
|
|
|
// eslint-disable-next-line no-undef
|
|
|
|
|
const popup = new maplibregl.Popup({
|
|
|
|
|
closeButton: false,
|
|
|
|
|
closeOnClick: false,
|
|
|
|
|
offset: 16,
|
|
|
|
|
className: 'fleet-popup',
|
2026-05-22 21:53:42 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-23 06:29:04 +00:00
|
|
|
const show = (e) => {
|
|
|
|
|
const f = e.features && e.features[0];
|
|
|
|
|
if (!f) return;
|
|
|
|
|
map.getCanvas().style.cursor = 'pointer';
|
|
|
|
|
popup.setLngLat(f.geometry.coordinates).setHTML(_popupHtml(f.properties)).addTo(map);
|
|
|
|
|
};
|
|
|
|
|
const hide = () => { map.getCanvas().style.cursor = ''; popup.remove(); };
|
2026-05-22 21:53:42 +00:00
|
|
|
|
2026-05-23 06:29:04 +00:00
|
|
|
for (const layer of ['vehicles-circle', 'vehicles-arrow', 'vehicles-label']) {
|
|
|
|
|
map.on('mouseenter', layer, show);
|
|
|
|
|
map.on('mousemove', layer, show);
|
|
|
|
|
map.on('mouseleave', layer, hide);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _esc(s) {
|
|
|
|
|
return String(s ?? '').replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _formatAge(sec) {
|
|
|
|
|
if (sec == null) return '—';
|
|
|
|
|
const s = Math.max(0, Math.round(Number(sec)));
|
|
|
|
|
if (s < 60) return `${s}s ago`;
|
|
|
|
|
if (s < 3600) return `${Math.round(s / 60)}m ago`;
|
|
|
|
|
if (s < 86400) return `${Math.round(s / 3600)}h ago`;
|
|
|
|
|
return `${Math.round(s / 86400)}d ago`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _formatLocal(iso) {
|
|
|
|
|
if (!iso) return '—';
|
|
|
|
|
const d = new Date(iso);
|
|
|
|
|
if (isNaN(d.getTime())) return iso;
|
|
|
|
|
return d.toLocaleString('en-GB', {
|
|
|
|
|
timeZone: 'Africa/Nairobi',
|
|
|
|
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
|
|
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
|
|
|
hour12: false,
|
|
|
|
|
}).replace(',', '');
|
2026-05-22 21:53:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _popupHtml(props) {
|
2026-05-23 06:29:04 +00:00
|
|
|
const state = String(props.operational_state || 'unknown').toLowerCase();
|
|
|
|
|
const speed = Math.round(Number(props.speed_kmh || 0));
|
|
|
|
|
const pillText = state === 'moving'
|
|
|
|
|
? `MOVING · ${speed} KMH`
|
|
|
|
|
: (state === 'parked' ? 'PARKED' : state.toUpperCase());
|
|
|
|
|
|
|
|
|
|
const tagLine = [props.cost_centre, props.assigned_city]
|
|
|
|
|
.filter(Boolean).join(' · ').toLowerCase();
|
2026-05-23 20:06:25 +00:00
|
|
|
const addressLine = props.address_short || props.address || null;
|
2026-05-23 06:29:04 +00:00
|
|
|
|
|
|
|
|
const headingPart = (props.heading_deg != null)
|
|
|
|
|
? `heading ${Math.round(Number(props.heading_deg))}°` : null;
|
|
|
|
|
const sigPart = (props.gps_signal != null) ? `gps signal ${props.gps_signal}` : null;
|
|
|
|
|
const headingLine = [headingPart, sigPart].filter(Boolean).join(' · ');
|
|
|
|
|
|
|
|
|
|
let mileageLine = null;
|
|
|
|
|
if (props.current_mileage_km != null) {
|
|
|
|
|
const km = Number(props.current_mileage_km);
|
|
|
|
|
if (!Number.isNaN(km)) {
|
|
|
|
|
mileageLine = `${km.toLocaleString('en-US', { maximumFractionDigits: 2 })} km on the clock`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sourceLine = [props.mc_type, props.device_type].filter(Boolean).join(' · ');
|
|
|
|
|
const ageLine = `last fix ${_formatAge(props.age_sec)} · ${_formatLocal(props.occurred_at)}`;
|
|
|
|
|
|
2026-05-22 21:53:42 +00:00
|
|
|
return `
|
2026-05-23 06:29:04 +00:00
|
|
|
<div class="popup-card">
|
|
|
|
|
<div class="popup-header">
|
|
|
|
|
<strong class="popup-plate">${_esc(props.plate)}</strong>
|
|
|
|
|
<span class="popup-pill pill-${_esc(state)}">${_esc(pillText)}</span>
|
|
|
|
|
</div>
|
2026-05-23 20:13:46 +00:00
|
|
|
${props.driver_name ? `<div class="popup-driver">${_esc(props.driver_name)}</div>` : ''}
|
2026-05-23 06:29:04 +00:00
|
|
|
${tagLine ? `<div class="popup-meta">${_esc(tagLine)}</div>` : ''}
|
2026-05-23 20:06:25 +00:00
|
|
|
${addressLine ? `<div class="popup-address">${_esc(addressLine)}</div>` : ''}
|
2026-05-23 06:29:04 +00:00
|
|
|
${headingLine ? `<div class="popup-row">${_esc(headingLine)}</div>` : ''}
|
|
|
|
|
${mileageLine ? `<div class="popup-row">${_esc(mileageLine)}</div>` : ''}
|
|
|
|
|
<div class="popup-row">${_esc(ageLine)}</div>
|
|
|
|
|
${sourceLine ? `<div class="popup-row">source ${_esc(sourceLine)}</div>` : ''}
|
2026-05-22 21:53:42 +00:00
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------- render ---------- */
|
|
|
|
|
|
2026-05-27 19:14:24 +00:00
|
|
|
export function renderView(map, payload, { summaryRoot } = {}) {
|
2026-05-22 21:53:42 +00:00
|
|
|
if (!payload || !payload.geojson) return;
|
|
|
|
|
const src = map.getSource(VEHICLE_SOURCE);
|
|
|
|
|
if (src) src.setData(payload.geojson);
|
|
|
|
|
if (summaryRoot) _renderSummary(summaryRoot, payload.summary || {});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _renderSummary(root, summary) {
|
|
|
|
|
const tiles = [
|
2026-05-27 18:56:53 +00:00
|
|
|
{ label: 'Active', value: summary.total_active ?? '—' },
|
|
|
|
|
{ label: 'Moving', value: summary.moving ?? '—' },
|
|
|
|
|
{ label: 'Parked', value: summary.parked ?? '—' },
|
2026-05-22 21:53:42 +00:00
|
|
|
{ label: 'Offline', value: summary.offline ?? '—' },
|
|
|
|
|
];
|
|
|
|
|
root.innerHTML = tiles
|
2026-05-23 06:29:04 +00:00
|
|
|
.map(t => `
|
|
|
|
|
<div class="tile">
|
|
|
|
|
<div class="tile-label">${t.label}</div>
|
|
|
|
|
<div class="tile-value">${t.value}</div>
|
|
|
|
|
</div>`).join('');
|
2026-05-22 21:53:42 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-27 18:56:53 +00:00
|
|
|
/* ---------- filters (multi-select dropdowns) ---------- */
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Wire the cost-centre + assigned-city filter widgets.
|
|
|
|
|
*
|
|
|
|
|
* Returns an `updateOptions(features)` callback that the caller invokes
|
|
|
|
|
* after each /api/views/live refresh — the widgets repopulate from the
|
|
|
|
|
* loaded feature properties, so we don't need a separate enum endpoint.
|
|
|
|
|
*
|
|
|
|
|
* The widget itself is a button that toggles a popover. The popover has
|
|
|
|
|
* an "All …" checkbox at the top and a checkbox per distinct value.
|
|
|
|
|
* `cost_centre_color` from the feature is rendered as a swatch beside the
|
|
|
|
|
* cost-centre options — turning the filter into a live colour legend.
|
|
|
|
|
*
|
|
|
|
|
* onChange receives a flat filter object compatible with serve.fn_live_view:
|
|
|
|
|
* { cost_centre: 'isp', assigned_city: 'Nairobi' } ← single select OR
|
|
|
|
|
* no key ← when "All" picked
|
|
|
|
|
*
|
|
|
|
|
* Multiple selections within one widget collapse to the first value (the
|
|
|
|
|
* SQL function takes one) — we treat additional selections client-side
|
|
|
|
|
* as an OR by sending no filter and letting the renderer hide the rest.
|
|
|
|
|
* That keeps the SQL contract unchanged for P1.
|
|
|
|
|
*/
|
|
|
|
|
export function initFilters(root, onChange) {
|
|
|
|
|
const ccWidget = _buildMultiSelect(
|
|
|
|
|
root.querySelector('#flt-cost-centre'),
|
|
|
|
|
{ label: 'cost centre', plural: 'cost centres', showSwatch: true },
|
|
|
|
|
);
|
|
|
|
|
const cityWidget = _buildMultiSelect(
|
|
|
|
|
root.querySelector('#flt-assigned-city'),
|
|
|
|
|
{ label: 'assigned city', plural: 'cities', showSwatch: false },
|
|
|
|
|
);
|
2026-05-22 21:53:42 +00:00
|
|
|
|
2026-05-27 18:56:53 +00:00
|
|
|
const emit = () => {
|
2026-05-22 21:53:42 +00:00
|
|
|
const filters = {};
|
2026-05-27 18:56:53 +00:00
|
|
|
const cc = ccWidget.getValues();
|
|
|
|
|
const city = cityWidget.getValues();
|
|
|
|
|
if (cc.length === 1) filters.cost_centre = cc[0];
|
|
|
|
|
if (city.length === 1) filters.assigned_city = city[0];
|
|
|
|
|
// For multi-selection client-side narrowing, stash so renderer can filter
|
|
|
|
|
onChange(filters, { costCentres: cc, cities: city });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
ccWidget.onChange(emit);
|
|
|
|
|
cityWidget.onChange(emit);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
updateOptions(features) {
|
|
|
|
|
const cc = new Map();
|
|
|
|
|
const city = new Set();
|
|
|
|
|
for (const f of features) {
|
|
|
|
|
const p = f.properties || {};
|
|
|
|
|
if (p.cost_centre) cc.set(p.cost_centre, p.cost_centre_color || '#94a3b8');
|
|
|
|
|
if (p.assigned_city) city.add(p.assigned_city);
|
|
|
|
|
}
|
|
|
|
|
ccWidget.setOptions([...cc.entries()].sort()
|
|
|
|
|
.map(([value, color]) => ({ value, color })));
|
|
|
|
|
cityWidget.setOptions([...city].sort()
|
|
|
|
|
.map(value => ({ value })));
|
|
|
|
|
},
|
|
|
|
|
getActive() {
|
|
|
|
|
return { costCentres: ccWidget.getValues(), cities: cityWidget.getValues() };
|
|
|
|
|
},
|
2026-05-22 21:53:42 +00:00
|
|
|
};
|
2026-05-27 18:56:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _buildMultiSelect(root, { label, plural, showSwatch }) {
|
|
|
|
|
// root is a <div> we own. Render: a button + a hidden popover.
|
|
|
|
|
root.classList.add('ms');
|
|
|
|
|
root.innerHTML = `
|
|
|
|
|
<button type="button" class="ms-btn" aria-haspopup="listbox" aria-expanded="false">
|
|
|
|
|
<span class="ms-btn-label">All ${plural}</span>
|
|
|
|
|
<span class="ms-caret">▾</span>
|
|
|
|
|
</button>
|
|
|
|
|
<div class="ms-pop" role="listbox" hidden>
|
|
|
|
|
<label class="ms-row ms-row-all">
|
|
|
|
|
<input type="checkbox" class="ms-all" checked />
|
|
|
|
|
<span>All ${plural}</span>
|
|
|
|
|
</label>
|
|
|
|
|
<div class="ms-options"></div>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
const btn = root.querySelector('.ms-btn');
|
|
|
|
|
const btnLabel = root.querySelector('.ms-btn-label');
|
|
|
|
|
const pop = root.querySelector('.ms-pop');
|
|
|
|
|
const allBox = root.querySelector('.ms-all');
|
|
|
|
|
const optsRoot = root.querySelector('.ms-options');
|
|
|
|
|
|
|
|
|
|
const listeners = [];
|
|
|
|
|
let options = []; // [{value, color?}]
|
|
|
|
|
|
|
|
|
|
const updateLabel = () => {
|
|
|
|
|
const checked = [...optsRoot.querySelectorAll('input:checked')];
|
|
|
|
|
if (checked.length === 0 || checked.length === options.length) {
|
|
|
|
|
btnLabel.textContent = `All ${plural}`;
|
|
|
|
|
allBox.checked = true;
|
|
|
|
|
} else if (checked.length === 1) {
|
|
|
|
|
btnLabel.textContent = checked[0].value;
|
|
|
|
|
allBox.checked = false;
|
|
|
|
|
} else {
|
|
|
|
|
btnLabel.textContent = `${checked.length} ${plural}`;
|
|
|
|
|
allBox.checked = false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
const fire = () => listeners.forEach(fn => fn());
|
|
|
|
|
|
|
|
|
|
btn.addEventListener('click', (e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
const open = pop.hasAttribute('hidden');
|
|
|
|
|
if (open) {
|
|
|
|
|
pop.removeAttribute('hidden');
|
|
|
|
|
btn.setAttribute('aria-expanded', 'true');
|
|
|
|
|
} else {
|
|
|
|
|
pop.setAttribute('hidden', '');
|
|
|
|
|
btn.setAttribute('aria-expanded', 'false');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
document.addEventListener('click', (e) => {
|
|
|
|
|
if (!root.contains(e.target) && !pop.hasAttribute('hidden')) {
|
|
|
|
|
pop.setAttribute('hidden', '');
|
|
|
|
|
btn.setAttribute('aria-expanded', 'false');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
allBox.addEventListener('change', () => {
|
|
|
|
|
const checked = allBox.checked;
|
|
|
|
|
optsRoot.querySelectorAll('input').forEach(cb => { cb.checked = checked; });
|
|
|
|
|
if (!checked) allBox.checked = false; // "All" un-check = clear
|
|
|
|
|
updateLabel();
|
|
|
|
|
fire();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
setOptions(opts) {
|
|
|
|
|
options = opts;
|
|
|
|
|
// Preserve current selections by value when re-rendering
|
|
|
|
|
const prevChecked = new Set(
|
|
|
|
|
[...optsRoot.querySelectorAll('input:checked')].map(cb => cb.value),
|
|
|
|
|
);
|
|
|
|
|
const wasAll = prevChecked.size === 0 || allBox.checked;
|
|
|
|
|
optsRoot.innerHTML = opts.map(({ value, color }) => `
|
|
|
|
|
<label class="ms-row">
|
|
|
|
|
<input type="checkbox" value="${_esc(value)}" ${wasAll || prevChecked.has(value) ? 'checked' : ''} />
|
|
|
|
|
${showSwatch && color ? `<span class="ms-swatch" style="background:${_esc(color)}"></span>` : ''}
|
|
|
|
|
<span class="ms-row-label">${_esc(value)}</span>
|
|
|
|
|
</label>
|
|
|
|
|
`).join('');
|
|
|
|
|
optsRoot.querySelectorAll('input').forEach(cb => {
|
|
|
|
|
cb.addEventListener('change', () => { updateLabel(); fire(); });
|
|
|
|
|
});
|
|
|
|
|
updateLabel();
|
|
|
|
|
},
|
|
|
|
|
getValues() {
|
|
|
|
|
const checked = [...optsRoot.querySelectorAll('input:checked')];
|
|
|
|
|
if (checked.length === 0 || checked.length === options.length) return [];
|
|
|
|
|
return checked.map(cb => cb.value);
|
|
|
|
|
},
|
|
|
|
|
onChange(fn) { listeners.push(fn); },
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Client-side narrowing: after rendering, hide markers whose cost_centre
|
|
|
|
|
* or assigned_city isn't in the multi-select set. Used when the user picks
|
|
|
|
|
* a subset (the SQL accepts only single-value filters in P1).
|
|
|
|
|
*
|
|
|
|
|
* Note: this hides via MapLibre `setFilter` so the source data is intact —
|
|
|
|
|
* counts in the FLEET NOW tiles still reflect everything fetched.
|
|
|
|
|
*/
|
|
|
|
|
export function applyClientFilter(map, { costCentres = [], cities = [] } = {}) {
|
|
|
|
|
const layers = ['vehicles-circle', 'vehicles-arrow', 'vehicles-label'];
|
|
|
|
|
const conds = [];
|
|
|
|
|
if (costCentres.length > 0 && costCentres.length > 1) {
|
|
|
|
|
conds.push(['in', ['get', 'cost_centre'], ['literal', costCentres]]);
|
|
|
|
|
}
|
|
|
|
|
if (cities.length > 0 && cities.length > 1) {
|
|
|
|
|
conds.push(['in', ['get', 'assigned_city'], ['literal', cities]]);
|
|
|
|
|
}
|
|
|
|
|
const filter = conds.length === 0 ? null
|
|
|
|
|
: conds.length === 1 ? conds[0]
|
|
|
|
|
: ['all', ...conds];
|
|
|
|
|
// Preserve the show_arrow filter on the arrow layer
|
|
|
|
|
for (const id of layers) {
|
|
|
|
|
if (!map.getLayer(id)) continue;
|
|
|
|
|
if (id === 'vehicles-arrow') {
|
|
|
|
|
const base = ['==', ['get', 'show_arrow'], true];
|
|
|
|
|
map.setFilter(id, filter ? ['all', base, filter] : base);
|
|
|
|
|
} else {
|
|
|
|
|
map.setFilter(id, filter);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-22 21:53:42 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-27 11:14:06 +00:00
|
|
|
/* ---------- trip panel ---------- */
|
|
|
|
|
|
|
|
|
|
const TRIP_ANIM_MS = 10000;
|
2026-05-27 20:24:12 +00:00
|
|
|
// Single-trip animation overlays (used in single-vehicle mode only).
|
|
|
|
|
const TRIP_PATH_SOURCE = 'trip-path-source';
|
|
|
|
|
const TRIP_MARKER_SOURCE = 'trip-marker-source';
|
|
|
|
|
const TRIP_PATH_LAYER = 'trip-path-line';
|
|
|
|
|
const TRIP_MARKER_LAYER = 'trip-marker-dot';
|
|
|
|
|
|
|
|
|
|
// Distinct palette for selected vehicles in multi-mode (kept separate from
|
|
|
|
|
// cost-centre tints so a multi-comparison reads cleanly even when picked
|
|
|
|
|
// vehicles share a cost centre).
|
|
|
|
|
const SELECTION_PALETTE = [
|
|
|
|
|
'#10b981', // emerald (single-mode default)
|
|
|
|
|
'#3b82f6', // blue
|
|
|
|
|
'#ef4444', // red
|
|
|
|
|
'#f59e0b', // amber
|
|
|
|
|
'#a855f7', // purple
|
|
|
|
|
'#06b6d4', // cyan
|
|
|
|
|
'#ec4899', // pink
|
|
|
|
|
'#84cc16', // lime
|
|
|
|
|
];
|
2026-05-27 11:14:06 +00:00
|
|
|
|
2026-05-27 20:31:57 +00:00
|
|
|
// 12-colour palette for per-trip colouring inside a single vehicle's day.
|
|
|
|
|
// Cycles past 12 (rare).
|
|
|
|
|
const TRIP_PALETTE = [
|
|
|
|
|
'#ef4444', // red
|
|
|
|
|
'#f97316', // orange
|
|
|
|
|
'#f59e0b', // amber
|
|
|
|
|
'#84cc16', // lime
|
|
|
|
|
'#10b981', // emerald
|
|
|
|
|
'#06b6d4', // cyan
|
|
|
|
|
'#3b82f6', // blue
|
|
|
|
|
'#8b5cf6', // violet
|
|
|
|
|
'#ec4899', // pink
|
|
|
|
|
'#facc15', // yellow
|
|
|
|
|
'#14b8a6', // teal
|
|
|
|
|
'#a855f7', // purple
|
|
|
|
|
];
|
|
|
|
|
function _tripColor(tripId) {
|
|
|
|
|
return TRIP_PALETTE[((tripId - 1) % TRIP_PALETTE.length + TRIP_PALETTE.length) % TRIP_PALETTE.length];
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 11:14:06 +00:00
|
|
|
let _tripAnimRAF = null;
|
2026-05-27 20:24:12 +00:00
|
|
|
// vehicleId → { plate, driver, color, payload | null }
|
|
|
|
|
const _selection = new Map();
|
|
|
|
|
let _currentDate = null;
|
|
|
|
|
|
|
|
|
|
function _nextColor() {
|
|
|
|
|
const used = new Set([..._selection.values()].map(v => v.color));
|
|
|
|
|
for (const c of SELECTION_PALETTE) {
|
|
|
|
|
if (!used.has(c)) return c;
|
|
|
|
|
}
|
|
|
|
|
return SELECTION_PALETTE[_selection.size % SELECTION_PALETTE.length];
|
|
|
|
|
}
|
2026-05-27 11:14:06 +00:00
|
|
|
|
|
|
|
|
export function initTripPanel(map, panelRoot) {
|
|
|
|
|
const els = {
|
2026-05-27 20:24:12 +00:00
|
|
|
plate: panelRoot.querySelector('#trip-plate'),
|
|
|
|
|
driver: panelRoot.querySelector('#trip-driver'),
|
|
|
|
|
date: panelRoot.querySelector('#trip-date'),
|
|
|
|
|
csv: panelRoot.querySelector('#trip-csv'),
|
|
|
|
|
totals: panelRoot.querySelector('#trip-totals'),
|
|
|
|
|
list: panelRoot.querySelector('#trip-list'),
|
|
|
|
|
close: panelRoot.querySelector('#trip-close'),
|
2026-05-27 11:14:06 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
els.close.addEventListener('click', () => _closeTripPanel(map, panelRoot, els));
|
2026-05-27 20:24:12 +00:00
|
|
|
els.date.addEventListener('change', async () => {
|
|
|
|
|
_currentDate = els.date.value;
|
|
|
|
|
// Re-fetch every currently-selected vehicle for the new date.
|
|
|
|
|
for (const vid of [..._selection.keys()]) {
|
|
|
|
|
await _fetchAndDraw(map, vid);
|
2026-05-27 11:14:06 +00:00
|
|
|
}
|
2026-05-27 20:24:12 +00:00
|
|
|
_renderDock(map, els);
|
2026-05-27 11:14:06 +00:00
|
|
|
});
|
|
|
|
|
els.csv.addEventListener('click', () => {
|
2026-05-27 20:24:12 +00:00
|
|
|
for (const vid of _selection.keys()) {
|
|
|
|
|
_downloadTripsCsv(vid, _currentDate);
|
|
|
|
|
}
|
2026-05-27 11:14:06 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-27 20:24:12 +00:00
|
|
|
const hoverLayers = ['vehicles-circle', 'vehicles-arrow', 'vehicles-label'];
|
|
|
|
|
map.on('click', async (e) => {
|
|
|
|
|
const features = map.queryRenderedFeatures(e.point, { layers: hoverLayers });
|
2026-05-27 11:14:06 +00:00
|
|
|
if (!features || features.length === 0) return;
|
|
|
|
|
const f = features[0];
|
|
|
|
|
const vid = f.properties.vehicle_id;
|
|
|
|
|
if (!vid) return;
|
2026-05-27 20:24:12 +00:00
|
|
|
const plate = f.properties.plate || `Vehicle ${vid}`;
|
|
|
|
|
const driver = f.properties.driver_name || '';
|
|
|
|
|
const multi = e.originalEvent.metaKey || e.originalEvent.ctrlKey || e.originalEvent.shiftKey;
|
|
|
|
|
|
2026-05-27 11:14:06 +00:00
|
|
|
if (!els.date.value) els.date.value = _todayEat();
|
2026-05-27 20:24:12 +00:00
|
|
|
_currentDate = els.date.value;
|
2026-05-27 11:14:06 +00:00
|
|
|
panelRoot.classList.add('open');
|
|
|
|
|
panelRoot.setAttribute('aria-hidden', 'false');
|
2026-05-27 20:24:12 +00:00
|
|
|
|
|
|
|
|
if (multi) {
|
|
|
|
|
if (_selection.has(vid)) {
|
|
|
|
|
_removeVehicle(map, vid);
|
|
|
|
|
_renderDock(map, els);
|
|
|
|
|
} else {
|
|
|
|
|
_addVehicle(vid, plate, driver);
|
|
|
|
|
await _fetchAndDraw(map, vid);
|
|
|
|
|
_renderDock(map, els);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Plain click → reset to this single vehicle
|
|
|
|
|
_clearSelection(map);
|
|
|
|
|
_addVehicle(vid, plate, driver);
|
|
|
|
|
await _fetchAndDraw(map, vid);
|
|
|
|
|
_renderDock(map, els);
|
|
|
|
|
}
|
2026-05-27 11:14:06 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 20:24:12 +00:00
|
|
|
function _addVehicle(vid, plate, driver) {
|
|
|
|
|
_selection.set(vid, { plate, driver, color: _nextColor(), payload: null });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _removeVehicle(map, vid) {
|
|
|
|
|
_clearVehicleLayers(map, vid);
|
|
|
|
|
_selection.delete(vid);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _clearSelection(map) {
|
|
|
|
|
for (const vid of [..._selection.keys()]) _clearVehicleLayers(map, vid);
|
|
|
|
|
_selection.clear();
|
2026-05-27 11:14:06 +00:00
|
|
|
_cancelTripAnim();
|
2026-05-27 20:24:12 +00:00
|
|
|
_clearSingleTripLayers(map);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function _fetchAndDraw(map, vid) {
|
|
|
|
|
const entry = _selection.get(vid);
|
|
|
|
|
if (!entry) return;
|
2026-05-27 11:14:06 +00:00
|
|
|
try {
|
2026-05-27 20:24:12 +00:00
|
|
|
entry.payload = await apiFetch(
|
|
|
|
|
`/api/views/vehicle/${vid}/trips`,
|
|
|
|
|
{ params: { date: _currentDate } },
|
2026-05-27 11:14:06 +00:00
|
|
|
);
|
|
|
|
|
} catch (err) {
|
2026-05-27 20:24:12 +00:00
|
|
|
entry.payload = { error: err.message || String(err), trips: [] };
|
|
|
|
|
}
|
2026-05-27 20:31:57 +00:00
|
|
|
// Drawing decision is mode-dependent and happens in _renderDock,
|
|
|
|
|
// so just stash the payload here.
|
2026-05-27 20:24:12 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-27 20:31:57 +00:00
|
|
|
function _drawVehicleDayPaths(map, vid, payload, fixedColor) {
|
|
|
|
|
// If `fixedColor` is set (multi-mode), every trip uses that colour and the
|
|
|
|
|
// vehicle reads as one route. If null (single-mode), each trip is coloured
|
|
|
|
|
// independently from TRIP_PALETTE so the day's segments are visually
|
|
|
|
|
// distinguishable.
|
2026-05-27 20:24:12 +00:00
|
|
|
_clearVehicleLayers(map, vid);
|
|
|
|
|
const trips = (payload.trips || []).filter(t => t.path && t.path.coordinates);
|
|
|
|
|
if (trips.length === 0) return;
|
|
|
|
|
const features = trips.map(t => ({
|
|
|
|
|
type: 'Feature',
|
|
|
|
|
geometry: t.path,
|
2026-05-27 20:31:57 +00:00
|
|
|
properties: {
|
|
|
|
|
trip_id: t.trip_id,
|
|
|
|
|
vehicle_id: vid,
|
|
|
|
|
color: fixedColor || _tripColor(t.trip_id),
|
|
|
|
|
},
|
2026-05-27 20:24:12 +00:00
|
|
|
}));
|
|
|
|
|
const srcId = `vroute-${vid}`;
|
|
|
|
|
const layerId = `vroute-line-${vid}`;
|
|
|
|
|
map.addSource(srcId, {
|
|
|
|
|
type: 'geojson',
|
|
|
|
|
data: { type: 'FeatureCollection', features },
|
|
|
|
|
});
|
|
|
|
|
map.addLayer({
|
|
|
|
|
id: layerId,
|
|
|
|
|
type: 'line',
|
|
|
|
|
source: srcId,
|
|
|
|
|
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
|
|
|
|
paint: {
|
2026-05-27 20:31:57 +00:00
|
|
|
'line-color': ['get', 'color'],
|
2026-05-27 20:24:12 +00:00
|
|
|
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 1.5, 14, 3, 17, 5],
|
|
|
|
|
'line-opacity': 0.85,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _clearVehicleLayers(map, vid) {
|
|
|
|
|
const layerId = `vroute-line-${vid}`;
|
|
|
|
|
const srcId = `vroute-${vid}`;
|
|
|
|
|
if (map.getLayer(layerId)) map.removeLayer(layerId);
|
|
|
|
|
if (map.getSource(srcId)) map.removeSource(srcId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _renderDock(map, els) {
|
|
|
|
|
if (_selection.size === 0) {
|
|
|
|
|
els.plate.textContent = '—';
|
|
|
|
|
els.driver.textContent = '';
|
|
|
|
|
els.totals.innerHTML = 'Click a vehicle to see its trips.';
|
|
|
|
|
els.list.innerHTML = '';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 20:31:57 +00:00
|
|
|
// Redraw all polylines from scratch so we transition cleanly between
|
|
|
|
|
// single-mode (per-trip colours) and multi-mode (per-vehicle colour).
|
|
|
|
|
for (const [vid] of _selection) _clearVehicleLayers(map, vid);
|
2026-05-27 20:24:12 +00:00
|
|
|
if (_selection.size === 1) {
|
2026-05-27 20:31:57 +00:00
|
|
|
const [[vid, entry]] = _selection;
|
|
|
|
|
if (entry.payload && !entry.payload.error) {
|
|
|
|
|
_drawVehicleDayPaths(map, vid, entry.payload, null); // per-trip palette
|
|
|
|
|
}
|
2026-05-27 20:24:12 +00:00
|
|
|
_renderSingle(map, els);
|
|
|
|
|
} else {
|
2026-05-27 20:31:57 +00:00
|
|
|
for (const [vid, entry] of _selection) {
|
|
|
|
|
if (entry.payload && !entry.payload.error) {
|
|
|
|
|
_drawVehicleDayPaths(map, vid, entry.payload, entry.color);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-27 20:24:12 +00:00
|
|
|
_renderMulti(map, els);
|
2026-05-27 11:14:06 +00:00
|
|
|
}
|
2026-05-27 20:24:12 +00:00
|
|
|
_fitSelectionBounds(map);
|
2026-05-27 11:14:06 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-27 20:24:12 +00:00
|
|
|
function _renderSingle(map, els) {
|
|
|
|
|
const [[vid, entry]] = _selection;
|
|
|
|
|
els.plate.textContent = entry.plate;
|
|
|
|
|
els.driver.textContent = entry.driver;
|
|
|
|
|
if (!entry.payload || entry.payload.error) {
|
|
|
|
|
els.totals.innerHTML = entry.payload?.error
|
|
|
|
|
? `<span style="color:var(--bad)">${_esc(entry.payload.error)}</span>`
|
|
|
|
|
: 'Loading…';
|
|
|
|
|
els.list.innerHTML = '';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const payload = entry.payload;
|
2026-05-27 11:14:06 +00:00
|
|
|
const t = payload.totals || {};
|
|
|
|
|
const q = payload.data_quality || {};
|
2026-05-27 20:24:12 +00:00
|
|
|
const rep = payload.reporting_time ? _formatTimeOnly(payload.reporting_time) : '—';
|
2026-05-27 11:14:06 +00:00
|
|
|
els.totals.innerHTML = `
|
2026-05-27 20:24:12 +00:00
|
|
|
<div>Reporting <strong>${_esc(rep)}</strong> · <strong>${t.trip_count ?? 0}</strong> trips · <strong>${_fmtNum(t.distance_km, 1)}</strong> km</div>
|
2026-05-27 18:56:53 +00:00
|
|
|
<div>drive ${_fmtNum(t.driving_min, 0)}m · idle ${_fmtNum(t.idling_min, 0)}m · stop ${_fmtNum(t.stopped_min, 0)}m</div>
|
2026-05-27 20:24:12 +00:00
|
|
|
<div class="trip-quality">${q.fix_count ?? 0} fixes · ACC ${q.has_acc_data ? 'on' : 'off'} · ⌘-click to compare</div>
|
2026-05-27 11:14:06 +00:00
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const trips = payload.trips || [];
|
|
|
|
|
if (trips.length === 0) {
|
|
|
|
|
els.list.innerHTML = '<div class="trip-list-empty">No trips on this day.</div>';
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-27 20:31:57 +00:00
|
|
|
els.list.innerHTML = trips.map(trip => {
|
|
|
|
|
const color = _tripColor(trip.trip_id);
|
|
|
|
|
return `
|
|
|
|
|
<div class="trip-card" data-trip-id="${trip.trip_id}" style="border-left:3px solid ${color}">
|
2026-05-27 18:56:53 +00:00
|
|
|
<div class="trip-card-top">
|
2026-05-27 11:14:06 +00:00
|
|
|
<span>Trip ${trip.trip_id}</span>
|
|
|
|
|
<span>${_fmtNum(trip.distance_km, 1)} km</span>
|
|
|
|
|
</div>
|
2026-05-27 18:56:53 +00:00
|
|
|
<div class="trip-card-times">${_formatTimeOnly(trip.started_at)} → ${_formatTimeOnly(trip.ended_at)}</div>
|
|
|
|
|
<div class="trip-card-meta">
|
2026-05-27 11:14:06 +00:00
|
|
|
<span>${_fmtNum(trip.duration_min, 0)} min</span>
|
|
|
|
|
${trip.idling_min > 0 ? `<span>idle ${_fmtNum(trip.idling_min, 0)}m</span>` : ''}
|
|
|
|
|
</div>
|
2026-05-27 18:56:53 +00:00
|
|
|
<div class="trip-card-reason ${_reasonClass(trip.end_reason)}">
|
2026-05-27 11:14:06 +00:00
|
|
|
${_reasonLabel(trip.end_reason)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-05-27 20:31:57 +00:00
|
|
|
`;
|
|
|
|
|
}).join('');
|
2026-05-27 18:56:53 +00:00
|
|
|
for (const card of els.list.querySelectorAll('.trip-card')) {
|
|
|
|
|
card.addEventListener('click', () => {
|
|
|
|
|
els.list.querySelectorAll('.trip-card.selected')
|
|
|
|
|
.forEach(c => c.classList.remove('selected'));
|
|
|
|
|
card.classList.add('selected');
|
|
|
|
|
const tid = Number(card.dataset.tripId);
|
2026-05-27 11:14:06 +00:00
|
|
|
const trip = trips.find(x => x.trip_id === tid);
|
|
|
|
|
if (trip) _showAndAnimateTrip(map, trip);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 20:24:12 +00:00
|
|
|
function _renderMulti(map, els) {
|
|
|
|
|
// Aggregate totals across all loaded payloads.
|
|
|
|
|
let tripCount = 0, distance = 0, drive = 0, idle = 0, stop = 0;
|
|
|
|
|
for (const { payload } of _selection.values()) {
|
|
|
|
|
if (!payload || payload.error) continue;
|
|
|
|
|
const t = payload.totals || {};
|
|
|
|
|
tripCount += t.trip_count ?? 0;
|
|
|
|
|
distance += Number(t.distance_km ?? 0);
|
|
|
|
|
drive += Number(t.driving_min ?? 0);
|
|
|
|
|
idle += Number(t.idling_min ?? 0);
|
|
|
|
|
stop += Number(t.stopped_min ?? 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
els.plate.textContent = `${_selection.size} vehicles`;
|
|
|
|
|
els.driver.textContent = '⌘-click another vehicle to add / remove';
|
|
|
|
|
els.totals.innerHTML = `
|
|
|
|
|
<div><strong>${tripCount}</strong> trips · <strong>${_fmtNum(distance, 1)}</strong> km</div>
|
|
|
|
|
<div>drive ${_fmtNum(drive, 0)}m · idle ${_fmtNum(idle, 0)}m · stop ${_fmtNum(stop, 0)}m</div>
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
// One compact row per vehicle, in selection order.
|
|
|
|
|
const rows = [..._selection.entries()].map(([vid, entry]) => {
|
|
|
|
|
const t = entry.payload?.totals || {};
|
|
|
|
|
return `
|
|
|
|
|
<div class="trip-card trip-vehicle-row" data-vehicle-id="${vid}" style="border-left-color:${_esc(entry.color)};border-left-width:4px;border-left-style:solid">
|
|
|
|
|
<div class="trip-card-top">
|
|
|
|
|
<span>${_esc(entry.plate)}</span>
|
|
|
|
|
<button class="trip-vehicle-remove" data-remove="${vid}" aria-label="Remove">✕</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="trip-card-times">${_esc(entry.driver || '—')}</div>
|
|
|
|
|
<div class="trip-card-meta">
|
|
|
|
|
<span>${t.trip_count ?? 0} trips</span>
|
|
|
|
|
<span>${_fmtNum(t.distance_km, 1)} km</span>
|
|
|
|
|
<span>${_fmtNum(t.driving_min, 0)}m drive</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
}).join('');
|
|
|
|
|
els.list.innerHTML = rows;
|
|
|
|
|
|
|
|
|
|
for (const btn of els.list.querySelectorAll('.trip-vehicle-remove')) {
|
|
|
|
|
btn.addEventListener('click', (ev) => {
|
|
|
|
|
ev.stopPropagation();
|
|
|
|
|
const vid = Number(btn.dataset.remove);
|
|
|
|
|
_removeVehicle(map, vid);
|
|
|
|
|
_renderDock(map, els);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
for (const row of els.list.querySelectorAll('.trip-vehicle-row')) {
|
|
|
|
|
row.addEventListener('click', () => {
|
|
|
|
|
const vid = Number(row.dataset.vehicleId);
|
|
|
|
|
_fitVehicleBounds(map, vid);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _fitSelectionBounds(map) {
|
|
|
|
|
// eslint-disable-next-line no-undef
|
|
|
|
|
let bounds = null;
|
|
|
|
|
for (const { payload } of _selection.values()) {
|
|
|
|
|
if (!payload || !payload.trips) continue;
|
|
|
|
|
for (const trip of payload.trips) {
|
|
|
|
|
const coords = trip.path?.coordinates;
|
|
|
|
|
if (!coords || coords.length < 1) continue;
|
|
|
|
|
for (const c of coords) {
|
|
|
|
|
// eslint-disable-next-line no-undef
|
|
|
|
|
if (!bounds) bounds = new maplibregl.LngLatBounds(c, c);
|
|
|
|
|
else bounds.extend(c);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (bounds) {
|
|
|
|
|
map.fitBounds(bounds, { padding: { top: 60, right: 60, bottom: 360, left: 60 }, duration: 600, maxZoom: 14 });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _fitVehicleBounds(map, vid) {
|
|
|
|
|
const entry = _selection.get(vid);
|
|
|
|
|
if (!entry?.payload?.trips) return;
|
|
|
|
|
// eslint-disable-next-line no-undef
|
|
|
|
|
let bounds = null;
|
|
|
|
|
for (const trip of entry.payload.trips) {
|
|
|
|
|
const coords = trip.path?.coordinates;
|
|
|
|
|
if (!coords) continue;
|
|
|
|
|
for (const c of coords) {
|
|
|
|
|
// eslint-disable-next-line no-undef
|
|
|
|
|
if (!bounds) bounds = new maplibregl.LngLatBounds(c, c);
|
|
|
|
|
else bounds.extend(c);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (bounds) {
|
|
|
|
|
map.fitBounds(bounds, { padding: { top: 60, right: 60, bottom: 360, left: 60 }, duration: 600, maxZoom: 14 });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 11:14:06 +00:00
|
|
|
function _reasonClass(r) {
|
|
|
|
|
if (r === 'work_stop') return 'work-stop';
|
|
|
|
|
if (r === 'nofix_stop') return 'nofix-stop';
|
|
|
|
|
if (r === 'long_gap') return 'long-gap';
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
function _reasonLabel(r) {
|
|
|
|
|
return ({
|
|
|
|
|
work_stop: 'Stopped for work',
|
|
|
|
|
nofix_stop: 'Reporting silence',
|
|
|
|
|
long_gap: 'Long gap',
|
|
|
|
|
day_end: 'Day end',
|
|
|
|
|
})[r] || (r || '—');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _showAndAnimateTrip(map, trip) {
|
|
|
|
|
_cancelTripAnim();
|
2026-05-27 20:24:12 +00:00
|
|
|
_clearSingleTripLayers(map);
|
2026-05-27 11:14:06 +00:00
|
|
|
if (!trip.path || !trip.path.coordinates || trip.path.coordinates.length < 2) return;
|
|
|
|
|
const coords = trip.path.coordinates;
|
|
|
|
|
|
|
|
|
|
map.addSource(TRIP_PATH_SOURCE, { type: 'geojson', data: trip.path });
|
|
|
|
|
map.addLayer({
|
|
|
|
|
id: TRIP_PATH_LAYER,
|
|
|
|
|
type: 'line',
|
|
|
|
|
source: TRIP_PATH_SOURCE,
|
|
|
|
|
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
|
|
|
|
paint: {
|
|
|
|
|
'line-color': '#10b981',
|
|
|
|
|
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 2, 14, 4, 17, 6],
|
|
|
|
|
'line-opacity': 0.85,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
map.addSource(TRIP_MARKER_SOURCE, {
|
|
|
|
|
type: 'geojson',
|
|
|
|
|
data: { type: 'Feature', geometry: { type: 'Point', coordinates: coords[0] }, properties: {} },
|
|
|
|
|
});
|
|
|
|
|
map.addLayer({
|
|
|
|
|
id: TRIP_MARKER_LAYER,
|
|
|
|
|
type: 'circle',
|
|
|
|
|
source: TRIP_MARKER_SOURCE,
|
|
|
|
|
paint: {
|
|
|
|
|
'circle-radius': ['interpolate', ['linear'], ['zoom'], 8, 4, 14, 8, 17, 12],
|
|
|
|
|
'circle-color': '#ffffff',
|
|
|
|
|
'circle-stroke-color': '#10b981',
|
|
|
|
|
'circle-stroke-width': 3,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// eslint-disable-next-line no-undef
|
|
|
|
|
const bounds = coords.reduce(
|
|
|
|
|
(b, c) => b.extend(c),
|
|
|
|
|
// eslint-disable-next-line no-undef
|
|
|
|
|
new maplibregl.LngLatBounds(coords[0], coords[0]),
|
|
|
|
|
);
|
2026-05-27 18:56:53 +00:00
|
|
|
// Bottom panel takes ~60% height when open; pad the south more so the
|
|
|
|
|
// polyline isn't hidden under the trip cards.
|
|
|
|
|
map.fitBounds(bounds, { padding: { top: 60, right: 60, bottom: 360, left: 60 }, duration: 600 });
|
2026-05-27 11:14:06 +00:00
|
|
|
|
|
|
|
|
_animatePathMarker(map, coords, TRIP_ANIM_MS);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _animatePathMarker(map, coords, durationMs) {
|
|
|
|
|
// Pre-compute cumulative segment lengths (planar — fine for animation interpolation).
|
|
|
|
|
let total = 0;
|
|
|
|
|
const cum = [0];
|
|
|
|
|
for (let i = 1; i < coords.length; i++) {
|
|
|
|
|
const dx = coords[i][0] - coords[i - 1][0];
|
|
|
|
|
const dy = coords[i][1] - coords[i - 1][1];
|
|
|
|
|
total += Math.sqrt(dx * dx + dy * dy);
|
|
|
|
|
cum.push(total);
|
|
|
|
|
}
|
|
|
|
|
if (total === 0) return;
|
|
|
|
|
const startMs = performance.now();
|
|
|
|
|
const src = map.getSource(TRIP_MARKER_SOURCE);
|
|
|
|
|
const frame = (now) => {
|
|
|
|
|
if (!map.getSource(TRIP_MARKER_SOURCE)) return; // panel closed
|
|
|
|
|
const t = Math.min(1, (now - startMs) / durationMs);
|
|
|
|
|
const target = total * t;
|
|
|
|
|
// Binary search the segment containing `target`
|
|
|
|
|
let lo = 1, hi = cum.length - 1;
|
|
|
|
|
while (lo < hi) {
|
|
|
|
|
const mid = (lo + hi) >> 1;
|
|
|
|
|
if (cum[mid] < target) lo = mid + 1; else hi = mid;
|
|
|
|
|
}
|
|
|
|
|
const i = lo;
|
|
|
|
|
const segStart = cum[i - 1], segEnd = cum[i];
|
|
|
|
|
const segT = segEnd > segStart ? (target - segStart) / (segEnd - segStart) : 0;
|
|
|
|
|
const a = coords[i - 1], b = coords[i];
|
|
|
|
|
const pos = [a[0] + (b[0] - a[0]) * segT, a[1] + (b[1] - a[1]) * segT];
|
|
|
|
|
src.setData({ type: 'Feature', geometry: { type: 'Point', coordinates: pos }, properties: {} });
|
|
|
|
|
if (t < 1) {
|
|
|
|
|
_tripAnimRAF = requestAnimationFrame(frame);
|
|
|
|
|
} else {
|
|
|
|
|
_tripAnimRAF = null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
_tripAnimRAF = requestAnimationFrame(frame);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _cancelTripAnim() {
|
|
|
|
|
if (_tripAnimRAF !== null) {
|
|
|
|
|
cancelAnimationFrame(_tripAnimRAF);
|
|
|
|
|
_tripAnimRAF = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 20:24:12 +00:00
|
|
|
function _clearSingleTripLayers(map) {
|
2026-05-27 11:14:06 +00:00
|
|
|
for (const id of [TRIP_MARKER_LAYER, TRIP_PATH_LAYER]) {
|
|
|
|
|
if (map.getLayer(id)) map.removeLayer(id);
|
|
|
|
|
}
|
|
|
|
|
for (const id of [TRIP_MARKER_SOURCE, TRIP_PATH_SOURCE]) {
|
|
|
|
|
if (map.getSource(id)) map.removeSource(id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _closeTripPanel(map, panelRoot, els) {
|
2026-05-27 20:24:12 +00:00
|
|
|
_clearSelection(map);
|
2026-05-27 11:14:06 +00:00
|
|
|
panelRoot.classList.remove('open');
|
|
|
|
|
panelRoot.setAttribute('aria-hidden', 'true');
|
|
|
|
|
els.totals.innerHTML = 'Click a vehicle to see its trips.';
|
|
|
|
|
els.list.innerHTML = '';
|
2026-05-27 20:24:12 +00:00
|
|
|
els.plate.textContent = '—';
|
|
|
|
|
els.driver.textContent = '';
|
2026-05-27 11:14:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _todayEat() {
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const eat = new Date(now.getTime() + 3 * 3600 * 1000);
|
|
|
|
|
return eat.toISOString().slice(0, 10);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _formatTimeOnly(iso) {
|
|
|
|
|
if (!iso) return '—';
|
|
|
|
|
const d = new Date(iso);
|
|
|
|
|
if (isNaN(d.getTime())) return iso;
|
|
|
|
|
return d.toLocaleTimeString('en-GB', {
|
|
|
|
|
timeZone: 'Africa/Nairobi', hour: '2-digit', minute: '2-digit', hour12: false,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _fmtNum(v, digits) {
|
|
|
|
|
if (v == null || isNaN(Number(v))) return '—';
|
|
|
|
|
return Number(v).toFixed(digits);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function _downloadTripsCsv(vehicleId, dateStr) {
|
|
|
|
|
const url = `/api/views/vehicle/${vehicleId}/trips.csv?date=${encodeURIComponent(dateStr)}`;
|
|
|
|
|
try {
|
|
|
|
|
const r = await fetch(url, {
|
|
|
|
|
headers: { Authorization: `Bearer ${authClient.getToken()}` },
|
|
|
|
|
});
|
|
|
|
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
|
|
|
const blob = await r.blob();
|
|
|
|
|
const cd = r.headers.get('Content-Disposition') || '';
|
|
|
|
|
const m = cd.match(/filename="([^"]+)"/);
|
|
|
|
|
const filename = (m && m[1]) || `trips_${vehicleId}_${dateStr}.csv`;
|
|
|
|
|
const a = document.createElement('a');
|
|
|
|
|
a.href = URL.createObjectURL(blob);
|
|
|
|
|
a.download = filename;
|
|
|
|
|
document.body.appendChild(a);
|
|
|
|
|
a.click();
|
|
|
|
|
a.remove();
|
|
|
|
|
URL.revokeObjectURL(a.href);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
alert(`CSV download failed: ${err.message || err}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 21:53:42 +00:00
|
|
|
/* ---------- clockEAT ---------- */
|
|
|
|
|
|
|
|
|
|
export function clockEAT(elementId) {
|
|
|
|
|
const el = document.getElementById(elementId);
|
|
|
|
|
if (!el) return;
|
|
|
|
|
const tick = () => {
|
|
|
|
|
const fmt = new Intl.DateTimeFormat('en-GB', {
|
|
|
|
|
timeZone: 'Africa/Nairobi',
|
2026-05-23 06:29:04 +00:00
|
|
|
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
2026-05-22 21:53:42 +00:00
|
|
|
});
|
2026-05-23 06:29:04 +00:00
|
|
|
el.textContent = `${fmt.format(new Date())} EAT`;
|
2026-05-22 21:53:42 +00:00
|
|
|
};
|
|
|
|
|
tick();
|
|
|
|
|
setInterval(tick, 1000);
|
|
|
|
|
}
|