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).
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
# 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
trips** into one view for the Fireside Communications / Tracksolid fleet.
> **Status:** v2 — **feature-frozen 2026-06-07** (two-tier bottom dock + clustering
> + tracker/camera dedup). Live at <https://fleetnow.rahamafresh.com>. Deployed
> from this repo via Coolify (Dockerfile → nginx). Reads the `dashboard_api`
> read-API at `fleetapi.rahamafresh.com`.
> **Status:** v2 — live at <https://fleetnow.rahamafresh.com>. Deployed from this
> repo via Coolify (Dockerfile → nginx). Reads the `dashboard_api` read-API at
> `fleetapi.rahamafresh.com`.
>
> Deploy is **manual** — push, then hit Redeploy in Coolify (no auto-deploy
> webhook wired yet).
> **2026-06-08 additions:** fleet segmentation (specialist vehicle icons that
> 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
@ -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
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).
- **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*:
- **Number plate** — multi-select, sorted A→Z; picking a vehicle auto-fills its
cost centre + city.
@ -65,7 +78,8 @@ the existing dashboard read-API — it does **not** talk to the database directl
```
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/
Dockerfile → bakes index.html + layers/ into an nginx:alpine image (port 80)
nginx.conf → static serve + /healthz + no-cache on index.html
```
@ -86,6 +100,38 @@ exposes:
(`DASHBOARD_CORS_ORIGINS`). It is in the code default; make sure the deployed
`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)
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.offline .veh-type svg { opacity: .85; }
/* ── Cost-centre colour key (collapsible, tidy) ────────────────────── */
#legend { position: absolute; left: 10px; bottom: 12px; z-index: 5;
font: 600 11px system-ui; color: #fff; user-select: none; }
.legend-toggle { cursor: pointer; border: 1px solid var(--border);
background: rgba(15,18,23,.92); color: #fff; font: 600 11px system-ui;
padding: 4px 10px; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,.45); }
.legend-toggle::before { content: '◑'; color: var(--accent); margin-right: 5px; }
.legend-body { margin-top: 6px; background: rgba(15,18,23,.92);
border: 1px solid var(--border); border-radius: 8px; padding: 7px 9px;
box-shadow: 0 4px 14px rgba(0,0,0,.5); max-height: 38vh; overflow-y: auto;
display: grid; gap: 4px; min-width: 124px; }
#legend.collapsed .legend-body { display: none; }
.legend-row { display: flex; align-items: center; gap: 7px; }
.legend-dot { width: 11px; height: 11px; border-radius: 3px; flex: 0 0 auto;
border: 1px solid rgba(255,255,255,.5); }
.legend-lbl { flex: 1; text-transform: uppercase; letter-spacing: .02em; }
.legend-n { color: var(--muted); font-weight: 700; }
/* ── Map layers control (toggleable overlays: gas stations, …) ──────── */
#layers { position: absolute; right: 10px; top: 10px; z-index: 5;
font: 600 11px system-ui; color: #fff; user-select: none; }
.layers-toggle { cursor: pointer; border: 1px solid var(--border);
background: rgba(15,18,23,.92); color: #fff; font: 600 11px system-ui;
padding: 4px 10px; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,.45); }
.layers-toggle::before { content: '▣'; color: var(--accent); margin-right: 5px; }
.layers-body { margin-top: 6px; background: rgba(15,18,23,.92);
border: 1px solid var(--border); border-radius: 8px; padding: 7px 9px;
box-shadow: 0 4px 14px rgba(0,0,0,.5); display: grid; gap: 5px; min-width: 150px; }
#layers.collapsed .layers-body { display: none; }
.layers-row { display: flex; align-items: center; gap: 7px; cursor: pointer; }
.layers-row input { accent-color: var(--accent); margin: 0; }
.layers-n { margin-left: auto; color: var(--muted); font-weight: 700; }
.ov-pop b { color: #fff; }
.ov-pop .ov-sub { color: var(--muted); font-weight: 600; font-size: 10px; margin-top: 2px; }
/* ── Persistent POI marker (Fireside HQ) ───────────────────────────── */
/* Cluster bubble (zoomed-out): amber circle + white count, tiered by size.
Click zooms to expand into the individual pins. */
@ -379,6 +414,18 @@
<div id="map">
<div class="placeholder" id="placeholder">Loading live fleet…</div>
<!-- Cost-centre colour key — collapsed by default to keep the map tidy.
Lists only centres currently on screen; safe to delete this block + its
CSS/JS (renderLegend) to remove the feature entirely. -->
<div id="legend" class="collapsed" aria-label="Cost-centre colour key">
<button type="button" class="legend-toggle" id="legend-toggle">Key</button>
<div class="legend-body" id="legend-body"></div>
</div>
<!-- Toggleable map overlays (gas stations, …); collapsed by default. -->
<div id="layers" class="collapsed" aria-label="Map layers">
<button type="button" class="layers-toggle" id="layers-toggle">Layers</button>
<div class="layers-body" id="layers-body"></div>
</div>
</div>
<!-- Bottom dock: two tiers — filter tier (top) + trip-card tier (beneath) -->
@ -453,12 +500,31 @@ 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 }];
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 = [
'#E8954A', '#2dd4a7', '#3b82f6', '#a855f7', '#f43f5e', '#06b6d4',
'#84cc16', '#ec4899', '#fbbf24', '#14b8a6', '#8b5cf6', '#f97316', '#22c55e',
'#2dd4a7', '#8b5cf6', '#f43f5e', '#0ea5e9', '#eab308', '#d946ef', '#10b981',
];
const UNKNOWN_CC_COLOR = '#9ca3af';
const PARKED_COLOR = '#6b7280';
@ -472,8 +538,10 @@ const seqColor = n => SEQ_PALETTE[((n || 1) - 1 + SEQ_PALETTE.length * 100) % SE
function colorForCostCentre(cc) {
if (!cc) return UNKNOWN_CC_COLOR;
const key = String(cc).trim().toLowerCase();
if (COST_CENTRE_COLORS[key]) return COST_CENTRE_COLORS[key];
let h = 0;
for (let i = 0; i < 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];
}
// 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)
let liveFeatures = []; // deduped (one device per vehicle) — see dedupeLiveFeatures
let cluster = null; // Supercluster index of the currently-filtered fleet
let liveSpecialists = []; // crane/motorbike/pick-up — drawn individually, never clustered
const CLUSTER_RADIUS = 60; // px cluster radius
const CLUSTER_MAXZOOM = 11; // above this, clusters disband into individual pins (~city zoom)
const VEHICLE_META = new Map(); // plate → {cost_centre, assigned_city}
@ -528,7 +597,7 @@ function ensureMap() {
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('load', () => { POIS.forEach(addPoiMarker); OVERLAYS.forEach(addOverlay); buildLayersControl(); updateVehScale(); });
map.on('zoom', updateVehScale);
// Re-cluster after any pan/zoom settles (live mode only).
map.on('moveend', () => { if (mode === 'live') renderClusters(); });
@ -547,6 +616,93 @@ function addPoiMarker(poi) {
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)
// ============================================================================
@ -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.
function applyLiveFilters() {
if (mode !== 'live') return;
const filtered = filteredLiveFeatures();
// Specialists are never clustered — they always render as individual icons so
// they stand out. Only the rest of the fleet feeds supercluster.
liveSpecialists = filtered.filter(f => isSpecialist(f.properties));
const clusterable = filtered.filter(f => !isSpecialist(f.properties));
cluster = new Supercluster({ radius: CLUSTER_RADIUS, maxZoom: CLUSTER_MAXZOOM });
cluster.load(filtered.map(f => ({
cluster.load(clusterable.map(f => ({
type: 'Feature',
properties: { ...f.properties },
geometry: { type: 'Point', coordinates: f.geometry.coordinates },
@ -714,6 +880,27 @@ function applyLiveFilters() {
});
speeds.sort((a, b) => a - b);
renderLiveKPIs({ total: filtered.length, moving, parked, offline, median: speeds.length ? speeds[Math.floor(speeds.length / 2)] : null, last_batch_utc: lastLivePayload?.summary?.last_batch_utc });
renderLegend(filtered);
}
// Compact colour key — lists only the cost centres currently on the map (respects
// the active filter), sorted by count. Rebuilt on every live render so it stays
// in sync. Collapsed by default; toggled by the "Key" pill.
function renderLegend(features) {
const body = document.getElementById('legend-body');
const wrap = document.getElementById('legend');
if (!body || !wrap) return;
const counts = new Map();
(features || []).forEach(f => {
const key = ((f.properties && f.properties.cost_centre) || '').trim() || '(none)';
counts.set(key, (counts.get(key) || 0) + 1);
});
const rows = [...counts.entries()].sort((a, b) => b[1] - a[1]);
wrap.style.display = rows.length ? '' : 'none';
body.innerHTML = rows.map(([cc, n]) => {
const color = cc === '(none)' ? UNKNOWN_CC_COLOR : colorForCostCentre(cc);
return `<div class="legend-row"><span class="legend-dot" style="background:${color}"></span><span class="legend-lbl">${escapeHtml(cc)}</span><span class="legend-n">${n}</span></div>`;
}).join('');
}
// Query the cluster index for the current viewport+zoom and draw either count
@ -735,6 +922,12 @@ function renderClusters() {
upsertLiveMarker(it.properties, c, it);
}
});
// Specialists: always drawn individually, at every zoom (never clustered).
liveSpecialists.forEach(f => {
if (!Array.isArray(f.geometry && f.geometry.coordinates)) return;
seenVeh.add(f.properties.imei);
upsertLiveMarker(f.properties, f.geometry.coordinates, f);
});
for (const [imei, m] of liveMarkers) { if (!seenVeh.has(imei)) { m.remove(); liveMarkers.delete(imei); } }
for (const [id, m] of clusterMarkers) { if (!seenClu.has(id)) { m.remove(); clusterMarkers.delete(id); } }
// If the popped-open vehicle got absorbed into a cluster, close its popup.
@ -1396,6 +1589,12 @@ function backToLive() {
startPolling();
}
document.getElementById('live-pill').addEventListener('click', backToLive);
document.getElementById('legend-toggle').addEventListener('click', () => {
document.getElementById('legend').classList.toggle('collapsed');
});
document.getElementById('layers-toggle').addEventListener('click', () => {
document.getElementById('layers').classList.toggle('collapsed');
});
// ============================================================================
// Reverse-geocoding (Nominatim) — queued, 1 req/sec, in-memory cache

File diff suppressed because one or more lines are too long