diff --git a/index.html b/index.html
index 89f4a17..d5992b2 100644
--- a/index.html
+++ b/index.html
@@ -278,6 +278,24 @@
.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; }
+
/* ── 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 +397,13 @@
@@ -455,10 +480,26 @@ const STALE_GPS_MS = 10 * 60 * 1000;
const OFFLINE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
const POIS = [{ name: 'Fireside HQ', lng: 36.728785, lat: -1.2411485 }];
-// Cost-centre palette — categorical, brand colours lead. Stable per centre via hash.
+// 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 +513,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-
@@ -714,6 +757,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 `${escapeHtml(cc)}${n}
`;
+ }).join('');
}
// Query the cluster index for the current viewport+zoom and draw either count
@@ -1396,6 +1460,9 @@ function backToLive() {
startPolling();
}
document.getElementById('live-pill').addEventListener('click', backToLive);
+document.getElementById('legend-toggle').addEventListener('click', () => {
+ document.getElementById('legend').classList.toggle('collapsed');
+});
// ============================================================================
// Reverse-geocoding (Nominatim) — queued, 1 req/sec, in-memory cache