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 @@
Loading live fleet…
+ +
@@ -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