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>
This commit is contained in:
parent
b11d8131a9
commit
e55cfadb1c
1 changed files with 71 additions and 4 deletions
75
index.html
75
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 @@
|
|||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Bottom dock: two tiers — filter tier (top) + trip-card tier (beneath) -->
|
||||
|
|
@ -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 `<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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue