Compare commits

..

13 commits

Author SHA1 Message Date
david kiania
a6d5a19273 Merge: document map-overlay layers + recent features 2026-06-08 22:01:54 +03:00
david kiania
4eef23677b docs: document map-overlay layers + recent map features
README: add a "Map overlay layers" section (how the toggleable layer system
works + how to add a layer in ~2 min), document fleet segmentation / department
colours / legend / POIs, refresh the file tree (layers/), and correct the deploy
note (Coolify auto-deploys on push to main).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 22:01:52 +03:00
david kiania
58525ec73d Merge: overlay hover label (single popup) 2026-06-08 21:53:09 +03:00
david kiania
18501f00d3 feat(map): show overlay station label on hover, single popup
Switch the Shell layer from click→popup to hover: mousemove shows one reused
popup that follows whichever station is under the cursor, mouseleave clears it
— so only ever one label is visible. No click needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 21:53:07 +03:00
david kiania
1532ef6ae0 Merge: toggleable map layers + Shell stations overlay 2026-06-08 21:46:32 +03:00
david kiania
25f49a0f27 feat(map): toggleable overlay layers + Shell fuel stations (232)
Add a data-driven overlay system: an OVERLAYS registry + generic addOverlay()
that renders each layer as a MapLibre symbol layer (auto-declutter via
icon-allow-overlap:false, ~8->16px zoom-scaled icon), plus a collapsible
"Layers" control to toggle each on/off (all OFF by default). First layer:
Shell stations from layers/shell_stations.geojson (232 pts, OSM kenya-260605),
11px Shell-yellow pump icon. Dockerfile now copies layers/ into nginx. Adding
the next layer = drop a .geojson + one registry entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 21:46:30 +03:00
david kiania
09e12a7a58 Merge: add Safaricom HQ POI (Waiyaki Way) 2026-06-08 17:06:47 +03:00
david kiania
b25272cff3 feat(map): add Safaricom HQ (Waiyaki Way) as a POI
Second persistent POI marker alongside Fireside HQ, at Safaricom House on
Waiyaki Way (-1.2589726, 36.7854625, geocoded via OpenStreetMap).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 17:06:45 +03:00
david kiania
b678f1ecaa Merge: exempt specialist vehicles from clustering 2026-06-08 15:59:49 +03:00
david kiania
4f25fae6c8 feat(map): exempt specialist vehicles from clustering
Crane/motorbike/pick-up are held out of the supercluster index and always
rendered as individual markers at every zoom, so they never fold into a
cluster bubble and always stand out. The rest of the fleet clusters as before.
KPIs and legend are unaffected (still computed from the full filtered set).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 15:59:47 +03:00
david kiania
fc5a7ed31b Merge: coordinated cost-centre colours + collapsible legend 2026-06-08 15:10:14 +03:00
david kiania
e55cfadb1c feat(map): coordinated cost-centre colours + collapsible legend
colorForCostCentre() now maps each cost centre to a deliberate, distinct colour
(ISP/OSP/FDS/… via COST_CENTRE_COLORS, lowercase-normalised) instead of an
arbitrary hash, so same-centre vehicles share a colour and centres are easy to
tell apart. Unmapped centres still fall back to a stable hash. State intensity is
unchanged: full colour moving → pastel when stopped <24h → grey when offline >24h.

Adds a compact, collapsible "Key" legend (bottom-left, collapsed by default) that
lists only the cost centres currently on screen with live counts — rebuilt each
render in applyLiveFilters(). Self-contained (#legend block + .legend CSS +
renderLegend) so it can be removed cleanly if the screen needs to stay minimal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 15:10:12 +03:00
david kiania
b11d8131a9 Merge: distinct marker icons for specialist vehicles (crane/motorbike/pick-up) 2026-06-08 14:40:59 +03:00
4 changed files with 265 additions and 16 deletions

View file

@ -11,6 +11,9 @@ COPY nginx.conf /etc/nginx/conf.d/fleetnow.conf
# The whole app is one self-contained file (inline CSS/JS; MapLibre from a CDN). # The whole app is one self-contained file (inline CSS/JS; MapLibre from a CDN).
COPY index.html /usr/share/nginx/html/index.html COPY index.html /usr/share/nginx/html/index.html
# Static map-overlay data (toggleable layers: gas stations, etc.), served at /layers/.
COPY layers/ /usr/share/nginx/html/layers/
EXPOSE 80 EXPOSE 80
# Coolify reads this; also handy for `docker ps` health. # Coolify reads this; also handy for `docker ps` health.

View file

@ -3,13 +3,17 @@
A single-file map console that **merges live vehicle positions and historical A single-file map console that **merges live vehicle positions and historical
trips** into one view for the Fireside Communications / Tracksolid fleet. trips** into one view for the Fireside Communications / Tracksolid fleet.
> **Status:** v2 — **feature-frozen 2026-06-07** (two-tier bottom dock + clustering > **Status:** v2 — live at <https://fleetnow.rahamafresh.com>. Deployed from this
> + tracker/camera dedup). Live at <https://fleetnow.rahamafresh.com>. Deployed > repo via Coolify (Dockerfile → nginx). Reads the `dashboard_api` read-API at
> from this repo via Coolify (Dockerfile → nginx). Reads the `dashboard_api` > `fleetapi.rahamafresh.com`.
> read-API at `fleetapi.rahamafresh.com`.
> >
> Deploy is **manual** — push, then hit Redeploy in Coolify (no auto-deploy > **2026-06-08 additions:** fleet segmentation (specialist vehicle icons that
> webhook wired yet). > never cluster), per-department colour coordination + collapsible **Key** legend,
> persistent POIs (Fireside HQ, Safaricom HQ), and a **toggleable map-overlay
> layer system** (first layer: 232 Shell fuel stations). See *Map overlay layers*.
>
> **Deploy auto-fires on push to `main`** via Coolify (~23 min build); if it
> lags, hit Redeploy in Coolify.
## What it does ## What it does
@ -30,6 +34,15 @@ trips** into one view for the Fireside Communications / Tracksolid fleet.
Leaflet.MarkerCluster style, via `supercluster`); click a bubble to zoom and Leaflet.MarkerCluster style, via `supercluster`); click a bubble to zoom and
expand it. Clusters disband into individual pins at ~city zoom (z11). Clustering expand it. Clusters disband into individual pins at ~city zoom (z11). Clustering
honours the active filter and applies to the live view only (not trip routes). honours the active filter and applies to the live view only (not trip routes).
- **Fleet segmentation & department colours.** Specialist vehicles (crane /
motorbike / pick-up) get their own marker icons and are **never clustered**
(always individually visible); every cost centre has a fixed, distinct colour,
with a collapsible **Key** legend (bottom-left) listing only the centres on
screen. Non-operational vehicles (personal / management / Uganda-MTN) are
filtered out upstream in the read-API, so the live map shows the operational
fleet only.
- **Persistent POIs** — Fireside HQ and Safaricom HQ (Waiyaki Way) as labelled
reference markers.
- **Filters** (bottom-right card) apply to the live map *instantly*: - **Filters** (bottom-right card) apply to the live map *instantly*:
- **Number plate** — multi-select, sorted A→Z; picking a vehicle auto-fills its - **Number plate** — multi-select, sorted A→Z; picking a vehicle auto-fills its
cost centre + city. cost centre + city.
@ -64,9 +77,10 @@ GL JS loaded from a CDN). It has no build step and no local assets. It reads fro
the existing dashboard read-API — it does **not** talk to the database directly. the existing dashboard read-API — it does **not** talk to the database directly.
``` ```
index.html → the entire SPA index.html → the entire SPA
Dockerfile → bakes index.html into an nginx:alpine image (port 80) layers/*.geojson → static overlay data (gas stations, …), served at /layers/
nginx.conf → static serve + /healthz + no-cache on index.html Dockerfile → bakes index.html + layers/ into an nginx:alpine image (port 80)
nginx.conf → static serve + /healthz + no-cache on index.html
``` ```
### Backend it depends on ### Backend it depends on
@ -86,6 +100,38 @@ exposes:
(`DASHBOARD_CORS_ORIGINS`). It is in the code default; make sure the deployed (`DASHBOARD_CORS_ORIGINS`). It is in the code default; make sure the deployed
`dashboard_api` container's env includes it, then restart that container. `dashboard_api` container's env includes it, then restart that container.
## Map overlay layers
Toggleable reference overlays (gas stations, etc.) sit behind the **Layers**
control (top-right, collapsed, all **off** by default). Each is a static GeoJSON
in `layers/`, rendered as a MapLibre **symbol** layer that **auto-declutters**
(`icon-allow-overlap: false` — sparse when zoomed out, all points reveal as you
zoom in), with a zoom-scaled icon (~8→16 px) and a **hover** label (one reused
popup, so only ever one is visible). Overlays render *under* the vehicle markers.
Shipped layers:
| Layer | Data | Points |
|---|---|---|
| Shell stations | `layers/shell_stations.geojson` (OSM `kenya-260605`) | 232 |
**To add a layer (≈2 min):**
1. Drop its point GeoJSON in `layers/<name>.geojson`.
2. Add one entry to the `OVERLAYS` array near the top of the `<script>` in
`index.html`:
```js
{ id: '<name>', label: '<Label>', url: 'layers/<name>.geojson',
iconSvg: <40×40 SVG string>, nameKey: 'name', defaultOn: false }
```
`iconSvg` is registered as the marker image (reuse `SHELL_ICON_SVG` as a
template). Nothing else to wire — `addOverlay()` builds the source + symbol
layer, and the Layers control lists it automatically.
3. Commit + push. The `Dockerfile` already `COPY`s `layers/` into nginx.
> The Shell layer was extracted from a Kenya OSM `.pbf` — the reproducible
> workflow (filter `amenity=fuel`, `brand=Shell`) lives in the `tracksolid` repo:
> `scripts/export_osm_pois.py` + `docs/OSM_POI_EXPORT.md`.
## Deploy (Coolify, git-based) ## Deploy (Coolify, git-based)
1. In Coolify, create a new **Application** from this git repo 1. In Coolify, create a new **Application** from this git repo

View file

@ -278,6 +278,41 @@
.veh-marker.has-type.parked .veh-pin { transform: scale(1); border-radius: 50%; } .veh-marker.has-type.parked .veh-pin { transform: scale(1); border-radius: 50%; }
.veh-marker.has-type.offline .veh-type svg { opacity: .85; } .veh-marker.has-type.offline .veh-type svg { opacity: .85; }
/* ── Cost-centre colour key (collapsible, tidy) ────────────────────── */
#legend { position: absolute; left: 10px; bottom: 12px; z-index: 5;
font: 600 11px system-ui; color: #fff; user-select: none; }
.legend-toggle { cursor: pointer; border: 1px solid var(--border);
background: rgba(15,18,23,.92); color: #fff; font: 600 11px system-ui;
padding: 4px 10px; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,.45); }
.legend-toggle::before { content: '◑'; color: var(--accent); margin-right: 5px; }
.legend-body { margin-top: 6px; background: rgba(15,18,23,.92);
border: 1px solid var(--border); border-radius: 8px; padding: 7px 9px;
box-shadow: 0 4px 14px rgba(0,0,0,.5); max-height: 38vh; overflow-y: auto;
display: grid; gap: 4px; min-width: 124px; }
#legend.collapsed .legend-body { display: none; }
.legend-row { display: flex; align-items: center; gap: 7px; }
.legend-dot { width: 11px; height: 11px; border-radius: 3px; flex: 0 0 auto;
border: 1px solid rgba(255,255,255,.5); }
.legend-lbl { flex: 1; text-transform: uppercase; letter-spacing: .02em; }
.legend-n { color: var(--muted); font-weight: 700; }
/* ── Map layers control (toggleable overlays: gas stations, …) ──────── */
#layers { position: absolute; right: 10px; top: 10px; z-index: 5;
font: 600 11px system-ui; color: #fff; user-select: none; }
.layers-toggle { cursor: pointer; border: 1px solid var(--border);
background: rgba(15,18,23,.92); color: #fff; font: 600 11px system-ui;
padding: 4px 10px; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,.45); }
.layers-toggle::before { content: '▣'; color: var(--accent); margin-right: 5px; }
.layers-body { margin-top: 6px; background: rgba(15,18,23,.92);
border: 1px solid var(--border); border-radius: 8px; padding: 7px 9px;
box-shadow: 0 4px 14px rgba(0,0,0,.5); display: grid; gap: 5px; min-width: 150px; }
#layers.collapsed .layers-body { display: none; }
.layers-row { display: flex; align-items: center; gap: 7px; cursor: pointer; }
.layers-row input { accent-color: var(--accent); margin: 0; }
.layers-n { margin-left: auto; color: var(--muted); font-weight: 700; }
.ov-pop b { color: #fff; }
.ov-pop .ov-sub { color: var(--muted); font-weight: 600; font-size: 10px; margin-top: 2px; }
/* ── Persistent POI marker (Fireside HQ) ───────────────────────────── */ /* ── Persistent POI marker (Fireside HQ) ───────────────────────────── */
/* Cluster bubble (zoomed-out): amber circle + white count, tiered by size. /* Cluster bubble (zoomed-out): amber circle + white count, tiered by size.
Click zooms to expand into the individual pins. */ Click zooms to expand into the individual pins. */
@ -379,6 +414,18 @@
<div id="map"> <div id="map">
<div class="placeholder" id="placeholder">Loading live fleet…</div> <div class="placeholder" id="placeholder">Loading live fleet…</div>
<!-- Cost-centre colour key — collapsed by default to keep the map tidy.
Lists only centres currently on screen; safe to delete this block + its
CSS/JS (renderLegend) to remove the feature entirely. -->
<div id="legend" class="collapsed" aria-label="Cost-centre colour key">
<button type="button" class="legend-toggle" id="legend-toggle">Key</button>
<div class="legend-body" id="legend-body"></div>
</div>
<!-- Toggleable map overlays (gas stations, …); collapsed by default. -->
<div id="layers" class="collapsed" aria-label="Map layers">
<button type="button" class="layers-toggle" id="layers-toggle">Layers</button>
<div class="layers-body" id="layers-body"></div>
</div>
</div> </div>
<!-- Bottom dock: two tiers — filter tier (top) + trip-card tier (beneath) --> <!-- Bottom dock: two tiers — filter tier (top) + trip-card tier (beneath) -->
@ -453,12 +500,31 @@ const POLL_INTERVAL_MS = 15000;
const EAST_AFRICA = { center: [37.5, -3.0], zoom: 5.2 }; const EAST_AFRICA = { center: [37.5, -3.0], zoom: 5.2 };
const STALE_GPS_MS = 10 * 60 * 1000; const STALE_GPS_MS = 10 * 60 * 1000;
const OFFLINE_THRESHOLD_MS = 24 * 60 * 60 * 1000; const OFFLINE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
const POIS = [{ name: 'Fireside HQ', lng: 36.728785, lat: -1.2411485 }]; const POIS = [
{ name: 'Fireside HQ', lng: 36.728785, lat: -1.2411485 },
{ name: 'Safaricom HQ', lng: 36.7854625, lat: -1.2589726 }, // Safaricom House, Waiyaki Way (OSM)
];
// Cost-centre palette — categorical, brand colours lead. Stable per centre via hash. // Deliberate, distinct colour per cost centre so all vehicles in a centre share
// one colour and different centres are easy to tell apart at a glance. Keys are
// normalised (lowercase, trimmed). Anything not listed falls back to a stable
// hash of COST_CENTRE_PALETTE.
const COST_CENTRE_COLORS = {
'isp': '#3b82f6', // blue
'osp': '#E8954A', // brand amber
'osp patrol': '#f97316', // orange (OSP sibling)
'fds': '#22c55e', // green
'roll out': '#a855f7', // purple
'general': '#fbbf24', // gold
'regional': '#ec4899', // pink
'planning': '#06b6d4', // cyan
'deliveries': '#84cc16', // lime
'qehs': '#14b8a6', // teal
'airtel': '#ef4444', // brand red
};
// Fallback palette for any centre not in COST_CENTRE_COLORS (stable per name via hash).
const COST_CENTRE_PALETTE = [ const COST_CENTRE_PALETTE = [
'#E8954A', '#2dd4a7', '#3b82f6', '#a855f7', '#f43f5e', '#06b6d4', '#2dd4a7', '#8b5cf6', '#f43f5e', '#0ea5e9', '#eab308', '#d946ef', '#10b981',
'#84cc16', '#ec4899', '#fbbf24', '#14b8a6', '#8b5cf6', '#f97316', '#22c55e',
]; ];
const UNKNOWN_CC_COLOR = '#9ca3af'; const UNKNOWN_CC_COLOR = '#9ca3af';
const PARKED_COLOR = '#6b7280'; const PARKED_COLOR = '#6b7280';
@ -472,8 +538,10 @@ const seqColor = n => SEQ_PALETTE[((n || 1) - 1 + SEQ_PALETTE.length * 100) % SE
function colorForCostCentre(cc) { function colorForCostCentre(cc) {
if (!cc) return UNKNOWN_CC_COLOR; if (!cc) return UNKNOWN_CC_COLOR;
const key = String(cc).trim().toLowerCase();
if (COST_CENTRE_COLORS[key]) return COST_CENTRE_COLORS[key];
let h = 0; let h = 0;
for (let i = 0; i < cc.length; i++) h = (h * 31 + cc.charCodeAt(i)) | 0; for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) | 0;
return COST_CENTRE_PALETTE[Math.abs(h) % COST_CENTRE_PALETTE.length]; return COST_CENTRE_PALETTE[Math.abs(h) % COST_CENTRE_PALETTE.length];
} }
// Pastel tint of a #rrggbb colour — blended toward white. Used for recently- // Pastel tint of a #rrggbb colour — blended toward white. Used for recently-
@ -496,6 +564,7 @@ const liveMarkers = new Map(); // imei → maplibregl.Marker (individual vehicl
const clusterMarkers = new Map(); // cluster_id → maplibregl.Marker (count bubbles) const clusterMarkers = new Map(); // cluster_id → maplibregl.Marker (count bubbles)
let liveFeatures = []; // deduped (one device per vehicle) — see dedupeLiveFeatures let liveFeatures = []; // deduped (one device per vehicle) — see dedupeLiveFeatures
let cluster = null; // Supercluster index of the currently-filtered fleet let cluster = null; // Supercluster index of the currently-filtered fleet
let liveSpecialists = []; // crane/motorbike/pick-up — drawn individually, never clustered
const CLUSTER_RADIUS = 60; // px cluster radius const CLUSTER_RADIUS = 60; // px cluster radius
const CLUSTER_MAXZOOM = 11; // above this, clusters disband into individual pins (~city zoom) const CLUSTER_MAXZOOM = 11; // above this, clusters disband into individual pins (~city zoom)
const VEHICLE_META = new Map(); // plate → {cost_centre, assigned_city} const VEHICLE_META = new Map(); // plate → {cost_centre, assigned_city}
@ -528,7 +597,7 @@ function ensureMap() {
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right'); map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
popup = new maplibregl.Popup({ closeButton: true, closeOnClick: false, offset: 20 }); popup = new maplibregl.Popup({ closeButton: true, closeOnClick: false, offset: 20 });
popup.on('close', () => { openPopupImei = null; popupStuck = false; }); popup.on('close', () => { openPopupImei = null; popupStuck = false; });
map.on('load', () => { POIS.forEach(addPoiMarker); updateVehScale(); }); map.on('load', () => { POIS.forEach(addPoiMarker); OVERLAYS.forEach(addOverlay); buildLayersControl(); updateVehScale(); });
map.on('zoom', updateVehScale); map.on('zoom', updateVehScale);
// Re-cluster after any pan/zoom settles (live mode only). // Re-cluster after any pan/zoom settles (live mode only).
map.on('moveend', () => { if (mode === 'live') renderClusters(); }); map.on('moveend', () => { if (mode === 'live') renderClusters(); });
@ -547,6 +616,93 @@ function addPoiMarker(poi) {
new maplibregl.Marker({ element: el, anchor: 'bottom' }).setLngLat([poi.lng, poi.lat]).addTo(map); new maplibregl.Marker({ element: el, anchor: 'bottom' }).setLngLat([poi.lng, poi.lat]).addTo(map);
} }
// ============================================================================
// Toggleable map overlays (reference layers: gas stations, …)
// ----------------------------------------------------------------------------
// Each entry = a static GeoJSON in /layers/ rendered as a MapLibre SYMBOL layer
// that auto-declutters (icon-allow-overlap:false) and is OFF until toggled in
// the "Layers" control. To add another layer: drop its .geojson in layers/ and
// add one entry here (+ an iconSvg). Vehicle/cluster markers (DOM) stay on top.
// ============================================================================
const GAS_PUMP_PATH = 'M19.77 7.23l.01-.01-3.72-3.72L15 4.56l2.11 2.11c-.94.36-1.61 1.26-1.61 2.33 0 1.38 1.12 2.5 2.5 2.5.36 0 .69-.08 1-.21v7.21c0 .55-.45 1-1 1s-1-.45-1-1V14c0-1.1-.9-2-2-2h-1V5c0-1.1-.9-2-2-2H6c-1.1 0-2 .9-2 2v16h10v-7.5h1.5v5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V9c0-.69-.28-1.32-.73-1.77zM12 10H6V5h6v5z';
const SHELL_ICON_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">' +
'<circle cx="20" cy="20" r="18" fill="#FBCE07" stroke="#7a5f00" stroke-width="1.5"/>' +
'<g transform="translate(8,8)" fill="#b3121f"><path d="' + GAS_PUMP_PATH + '"/></g></svg>';
const OVERLAYS = [
{ id: 'shell', label: 'Shell stations', url: 'layers/shell_stations.geojson',
iconSvg: SHELL_ICON_SVG, nameKey: 'name', defaultOn: false },
// future layers: add { id, label, url, iconSvg, nameKey, defaultOn } here.
];
let overlayPopup = null; // single reused hover popup for overlay points (only one ever shown)
function registerOverlayIcon(def) {
return new Promise(resolve => {
const imgId = 'ov-icon-' + def.id;
if (map.hasImage(imgId)) return resolve(imgId);
const img = new Image(40, 40);
img.onload = () => { if (!map.hasImage(imgId)) map.addImage(imgId, img, { pixelRatio: 2 }); resolve(imgId); };
img.onerror = () => resolve(imgId);
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(def.iconSvg);
});
}
async function addOverlay(def) {
const srcId = 'ov-' + def.id, lyrId = 'ov-layer-' + def.id;
let data;
try { data = await (await fetch(def.url)).json(); }
catch (e) { console.warn('overlay load failed:', def.id, e); return; }
def._count = (data.features || []).length;
const imgId = await registerOverlayIcon(def);
if (!map.getSource(srcId)) map.addSource(srcId, { type: 'geojson', data });
if (!map.getLayer(lyrId)) {
map.addLayer({
id: lyrId, type: 'symbol', source: srcId,
layout: {
'icon-image': imgId,
// ~8px zoomed out → ~16px zoomed in (image is 20 CSS px at icon-size 1).
'icon-size': ['interpolate', ['linear'], ['zoom'], 6, 0.42, 11, 0.55, 16, 0.8],
'icon-allow-overlap': false, // auto-declutter: hide overlapping icons at low zoom
'icon-ignore-placement': false,
'visibility': def.defaultOn ? 'visible' : 'none',
},
});
// Hover (not click) shows a single label — one reused popup, so only ever
// one is visible; mousemove keeps it on whichever station is under the cursor.
map.on('mouseenter', lyrId, () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mousemove', lyrId, e => {
const f = e.features[0]; if (!f) return;
const p = f.properties || {};
if (!overlayPopup) overlayPopup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 12 });
overlayPopup.setLngLat(f.geometry.coordinates)
.setHTML(`<div class="ov-pop"><b>${escapeHtml(p[def.nameKey] || def.label)}</b><div class="ov-sub">${escapeHtml(p.brand || 'fuel station')}</div></div>`)
.addTo(map);
});
map.on('mouseleave', lyrId, () => { map.getCanvas().style.cursor = ''; if (overlayPopup) overlayPopup.remove(); });
}
buildLayersControl();
}
function buildLayersControl() {
const body = document.getElementById('layers-body');
const wrap = document.getElementById('layers');
if (!body || !wrap) return;
wrap.style.display = OVERLAYS.length ? '' : 'none';
body.innerHTML = OVERLAYS.map(d => {
const lyrId = 'ov-layer-' + d.id;
const on = map.getLayer(lyrId) && map.getLayoutProperty(lyrId, 'visibility') === 'visible';
const n = d._count != null ? ` <span class="layers-n">${d._count}</span>` : '';
return `<label class="layers-row"><input type="checkbox" data-lyr="${lyrId}"${on ? ' checked' : ''}><span>${escapeHtml(d.label)}</span>${n}</label>`;
}).join('');
body.querySelectorAll('input[type=checkbox]').forEach(cb => {
cb.addEventListener('change', () => {
const lyrId = cb.getAttribute('data-lyr');
if (map.getLayer(lyrId)) map.setLayoutProperty(lyrId, 'visibility', cb.checked ? 'visible' : 'none');
});
});
}
// ============================================================================ // ============================================================================
// Vehicle state (tri-state) // Vehicle state (tri-state)
// ============================================================================ // ============================================================================
@ -692,12 +848,22 @@ function filteredLiveFeatures() {
}); });
} }
// A vehicle is exempt from clustering exactly when it carries a specialist icon
// (crane / motorbike / pick-up) — keyed off the feed's vehicle_type / fleet_segment.
function isSpecialist(p) {
return !!(p && (SPECIALIST_ICONS[p.vehicle_type] || p.fleet_segment === 'specialist'));
}
// Load the filtered fleet into supercluster, redraw bubbles+pins, recompute KPIs. // Load the filtered fleet into supercluster, redraw bubbles+pins, recompute KPIs.
function applyLiveFilters() { function applyLiveFilters() {
if (mode !== 'live') return; if (mode !== 'live') return;
const filtered = filteredLiveFeatures(); const filtered = filteredLiveFeatures();
// Specialists are never clustered — they always render as individual icons so
// they stand out. Only the rest of the fleet feeds supercluster.
liveSpecialists = filtered.filter(f => isSpecialist(f.properties));
const clusterable = filtered.filter(f => !isSpecialist(f.properties));
cluster = new Supercluster({ radius: CLUSTER_RADIUS, maxZoom: CLUSTER_MAXZOOM }); cluster = new Supercluster({ radius: CLUSTER_RADIUS, maxZoom: CLUSTER_MAXZOOM });
cluster.load(filtered.map(f => ({ cluster.load(clusterable.map(f => ({
type: 'Feature', type: 'Feature',
properties: { ...f.properties }, properties: { ...f.properties },
geometry: { type: 'Point', coordinates: f.geometry.coordinates }, geometry: { type: 'Point', coordinates: f.geometry.coordinates },
@ -714,6 +880,27 @@ function applyLiveFilters() {
}); });
speeds.sort((a, b) => a - b); speeds.sort((a, b) => a - b);
renderLiveKPIs({ total: filtered.length, moving, parked, offline, median: speeds.length ? speeds[Math.floor(speeds.length / 2)] : null, last_batch_utc: lastLivePayload?.summary?.last_batch_utc }); renderLiveKPIs({ total: filtered.length, moving, parked, offline, median: speeds.length ? speeds[Math.floor(speeds.length / 2)] : null, last_batch_utc: lastLivePayload?.summary?.last_batch_utc });
renderLegend(filtered);
}
// Compact colour key — lists only the cost centres currently on the map (respects
// the active filter), sorted by count. Rebuilt on every live render so it stays
// in sync. Collapsed by default; toggled by the "Key" pill.
function renderLegend(features) {
const body = document.getElementById('legend-body');
const wrap = document.getElementById('legend');
if (!body || !wrap) return;
const counts = new Map();
(features || []).forEach(f => {
const key = ((f.properties && f.properties.cost_centre) || '').trim() || '(none)';
counts.set(key, (counts.get(key) || 0) + 1);
});
const rows = [...counts.entries()].sort((a, b) => b[1] - a[1]);
wrap.style.display = rows.length ? '' : 'none';
body.innerHTML = rows.map(([cc, n]) => {
const color = cc === '(none)' ? UNKNOWN_CC_COLOR : colorForCostCentre(cc);
return `<div class="legend-row"><span class="legend-dot" style="background:${color}"></span><span class="legend-lbl">${escapeHtml(cc)}</span><span class="legend-n">${n}</span></div>`;
}).join('');
} }
// Query the cluster index for the current viewport+zoom and draw either count // Query the cluster index for the current viewport+zoom and draw either count
@ -735,6 +922,12 @@ function renderClusters() {
upsertLiveMarker(it.properties, c, it); upsertLiveMarker(it.properties, c, it);
} }
}); });
// Specialists: always drawn individually, at every zoom (never clustered).
liveSpecialists.forEach(f => {
if (!Array.isArray(f.geometry && f.geometry.coordinates)) return;
seenVeh.add(f.properties.imei);
upsertLiveMarker(f.properties, f.geometry.coordinates, f);
});
for (const [imei, m] of liveMarkers) { if (!seenVeh.has(imei)) { m.remove(); liveMarkers.delete(imei); } } for (const [imei, m] of liveMarkers) { if (!seenVeh.has(imei)) { m.remove(); liveMarkers.delete(imei); } }
for (const [id, m] of clusterMarkers) { if (!seenClu.has(id)) { m.remove(); clusterMarkers.delete(id); } } for (const [id, m] of clusterMarkers) { if (!seenClu.has(id)) { m.remove(); clusterMarkers.delete(id); } }
// If the popped-open vehicle got absorbed into a cluster, close its popup. // If the popped-open vehicle got absorbed into a cluster, close its popup.
@ -1396,6 +1589,12 @@ function backToLive() {
startPolling(); startPolling();
} }
document.getElementById('live-pill').addEventListener('click', backToLive); document.getElementById('live-pill').addEventListener('click', backToLive);
document.getElementById('legend-toggle').addEventListener('click', () => {
document.getElementById('legend').classList.toggle('collapsed');
});
document.getElementById('layers-toggle').addEventListener('click', () => {
document.getElementById('layers').classList.toggle('collapsed');
});
// ============================================================================ // ============================================================================
// Reverse-geocoding (Nominatim) — queued, 1 req/sec, in-memory cache // Reverse-geocoding (Nominatim) — queued, 1 req/sec, in-memory cache

File diff suppressed because one or more lines are too long