feat(tickets map): closed tickets render as faded same SLA colour

Previously closed INC tickets all used one flat slate pin, losing the SLA
outcome and reading as inactive/uninformative. Now a closed ticket keeps its
SLA colour but as a light ('pastel') version — Breached → light red, Compliant
→ light green — so active tickets stay vivid and closed ones are a washed-out
same-hue. Makes active-vs-closed and SLA outcome apparent across both layers.

- Faded closed pin images keyed on sla_status (Compliant|Breached, + fallback).
- inc-closed layer matches sla_status → faded pin; popup badge + legend updated
  (new "Closed SLA" key); header subtitle reworded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
david kiania 2026-06-18 16:01:56 +03:00
parent 737f8c3d31
commit 62479c0a72

View file

@ -424,7 +424,7 @@
</div> </div>
<div class="card span12"> <div class="card span12">
<h2>Live INC map <span class="count">open (SLA-coloured) · closed overlay · ISP vehicles</span></h2> <h2>Live INC map <span class="count">open (SLA-coloured) · closed (faded same colour) · ISP vehicles</span></h2>
<div class="map-wrap"> <div class="map-wrap">
<div id="tk-map"></div> <div id="tk-map"></div>
<div id="tk-layers" class="map-ctl collapsed"> <div id="tk-layers" class="map-ctl collapsed">
@ -903,7 +903,11 @@ const EMPTY_FC = { type: 'FeatureCollection', features: [] };
// SLA state → colour (open layer + legend); mirrors the warm-dark palette. // SLA state → colour (open layer + legend); mirrors the warm-dark palette.
const SLA_COLORS = { breached: '#ef5b5b', at_risk: '#f0a93b', ok: '#2dd4a7', unknown: '#6b7280' }; const SLA_COLORS = { breached: '#ef5b5b', at_risk: '#f0a93b', ok: '#2dd4a7', unknown: '#6b7280' };
const SLA_LABELS = { breached: 'Breached', at_risk: 'At risk', ok: 'OK', unknown: 'Unknown' }; const SLA_LABELS = { breached: 'Breached', at_risk: 'At risk', ok: 'OK', unknown: 'Unknown' };
const CLOSED_COLOR = '#94a3b8'; // muted slate — closed tickets (status irrelevant) const CLOSED_COLOR = '#94a3b8'; // fallback slate — closed tickets with no SLA outcome
// Closed tickets keep their SLA colour but as a light ('pastel') version, so active
// tickets show vivid and closed ones a washed-out same-colour — status stays apparent
// across both. sla_status (closed) is 'Compliant' | 'Breached' (capitalised).
const CLOSED_SLA_BASE = { Breached: SLA_COLORS.breached, Compliant: SLA_COLORS.ok };
// Coast (Mombasa / Voi) cluster classifier — splits the cluster breakdown by name // Coast (Mombasa / Voi) cluster classifier — splits the cluster breakdown by name
// (the feed's region field is noisy; cluster names classify cleanly). // (the feed's region field is noisy; cluster names classify cleanly).
const COAST_HINTS = ['coast', 'mombasa', 'voi', 'nyali', 'mtwapa', 'kiembeni', 'vipingo', const COAST_HINTS = ['coast', 'mombasa', 'voi', 'nyali', 'mtwapa', 'kiembeni', 'vipingo',
@ -932,6 +936,8 @@ function pastel(hex, mix = 0.58) {
const t = (c) => Math.round(c + (255 - c) * mix); const t = (c) => Math.round(c + (255 - c) * mix);
return `rgb(${t(r)}, ${t(g)}, ${t(b)})`; return `rgb(${t(r)}, ${t(g)}, ${t(b)})`;
} }
// Faded ('light') colour for a closed ticket, from its SLA outcome (see CLOSED_SLA_BASE).
const closedColor = (slaStatus) => pastel(CLOSED_SLA_BASE[slaStatus] || CLOSED_COLOR);
function plateTail(v) { const s = String(v || '').replace(/\s+/g, ''); return s ? s.slice(-4) : '—'; } function plateTail(v) { const s = String(v || '').replace(/\s+/g, ''); return s ? s.slice(-4) : '—'; }
function vehState(p) { function vehState(p) {
if (!p) return 'offline'; if (!p) return 'offline';
@ -991,7 +997,11 @@ function pinImageData(fill) {
function addPinImages() { function addPinImages() {
const pins = { const pins = {
'pin-breached': SLA_COLORS.breached, 'pin-at_risk': SLA_COLORS.at_risk, 'pin-breached': SLA_COLORS.breached, 'pin-at_risk': SLA_COLORS.at_risk,
'pin-ok': SLA_COLORS.ok, 'pin-unknown': SLA_COLORS.unknown, 'pin-closed': CLOSED_COLOR, 'pin-ok': SLA_COLORS.ok, 'pin-unknown': SLA_COLORS.unknown,
// closed = faded ('light') version of the same SLA colour (see CLOSED_SLA_BASE)
'pin-closed-breached': closedColor('Breached'),
'pin-closed-ok': closedColor('Compliant'),
'pin-closed-unknown': pastel(CLOSED_COLOR),
}; };
for (const [id, fill] of Object.entries(pins)) for (const [id, fill] of Object.entries(pins))
if (!tkMap.hasImage(id)) tkMap.addImage(id, pinImageData(fill), { pixelRatio: 2 }); if (!tkMap.hasImage(id)) tkMap.addImage(id, pinImageData(fill), { pixelRatio: 2 });
@ -1018,19 +1028,21 @@ function initIncMap() {
tkPopup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 14 }); tkPopup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 14 });
tkMap.on('load', () => { tkMap.on('load', () => {
addPinImages(); // teardrop pin icons (open by SLA, closed one slate colour) addPinImages(); // teardrop pin icons (open by SLA; closed = faded same SLA colour)
// Closed overlay (windowed) — drawn UNDER the live open layer; status irrelevant // Closed overlay (windowed) — drawn UNDER the live open layer. Each closed ticket
// so every closed ticket uses the single muted 'pin-closed', slightly smaller. // uses a faded ('light') version of its SLA colour (Breached→light red, Compliant→
// light green), slightly smaller, so it reads as the same status but inactive.
tkMap.addSource('inc-closed', { type: 'geojson', data: EMPTY_FC }); tkMap.addSource('inc-closed', { type: 'geojson', data: EMPTY_FC });
tkMap.addLayer({ tkMap.addLayer({
id: 'inc-closed', type: 'symbol', source: 'inc-closed', id: 'inc-closed', type: 'symbol', source: 'inc-closed',
layout: { layout: {
visibility: tkLayerState.closed ? 'visible' : 'none', visibility: tkLayerState.closed ? 'visible' : 'none',
'icon-image': 'pin-closed', 'icon-image': ['match', ['get', 'sla_status'],
'Breached', 'pin-closed-breached', 'Compliant', 'pin-closed-ok', 'pin-closed-unknown'],
'icon-size': ['interpolate', ['linear'], ['zoom'], 6, 0.42, 11, 0.6, 16, 0.78], 'icon-size': ['interpolate', ['linear'], ['zoom'], 6, 0.42, 11, 0.6, 16, 0.78],
'icon-anchor': 'bottom', 'icon-allow-overlap': true, 'icon-ignore-placement': true, 'icon-anchor': 'bottom', 'icon-allow-overlap': true, 'icon-ignore-placement': true,
}, },
paint: { 'icon-opacity': 0.85 }, paint: { 'icon-opacity': 0.9 },
}); });
// Open layer (live) — pin colour = derived SLA state; larger for hierarchy. // Open layer (live) — pin colour = derived SLA state; larger for hierarchy.
tkMap.addSource('inc-open', { type: 'geojson', data: EMPTY_FC }); tkMap.addSource('inc-open', { type: 'geojson', data: EMPTY_FC });
@ -1229,22 +1241,25 @@ function showIncPopup(f, closed) {
} }
if (p.geo_source === 'cluster') lines.push('<div class="row muted" style="font-size:10px">approx — cluster location</div>'); if (p.geo_source === 'cluster') lines.push('<div class="row muted" style="font-size:10px">approx — cluster location</div>');
tkPopup.setLngLat(f.geometry.coordinates).setHTML(`<div class="pop"> tkPopup.setLngLat(f.geometry.coordinates).setHTML(`<div class="pop">
<b>${escapeHtml(p.ticket_id || '—')} <span class="badge" style="color:${closed ? CLOSED_COLOR : (SLA_COLORS[p.sla_state] || '#fff')}">${closed ? 'CLOSED' : 'OPEN'}</span></b> <b>${escapeHtml(p.ticket_id || '—')} <span class="badge" style="color:${closed ? closedColor(p.sla_status) : (SLA_COLORS[p.sla_state] || '#fff')}">${closed ? 'CLOSED' : 'OPEN'}</span></b>
${lines.join('')}</div>`).addTo(tkMap); ${lines.join('')}</div>`).addTo(tkMap);
} }
function buildIncLayers() { function buildIncLayers() {
const m = (incData && incData.metrics) || {}; const m = (incData && incData.metrics) || {};
const rows = [ const rows = [
{ id: 'open', label: 'Open INC', color: SLA_COLORS.breached, n: m.open_now ?? 0 }, { id: 'open', label: 'Open INC', color: SLA_COLORS.breached, n: m.open_now ?? 0 },
{ id: 'closed', label: 'Closed INC', color: CLOSED_COLOR, n: m.closed_in_window ?? 0 }, { id: 'closed', label: 'Closed INC', color: pastel(CLOSED_COLOR), n: m.closed_in_window ?? 0 },
{ id: 'vehicles', label: 'ISP vehicles', color: '#E8954A', n: vehCount }, { id: 'vehicles', label: 'ISP vehicles', color: '#E8954A', n: vehCount },
]; ];
let html = rows.map((r) => let html = rows.map((r) =>
`<label class="layers-row"><input type="checkbox" data-lyr="${r.id}"${tkLayerState[r.id] ? ' checked' : ''}> `<label class="layers-row"><input type="checkbox" data-lyr="${r.id}"${tkLayerState[r.id] ? ' checked' : ''}>
<span class="legend-dot" style="background:${r.color}"></span><span>${r.label}</span><span class="layers-n">${intg(r.n)}</span></label>`).join(''); <span class="legend-dot" style="background:${r.color}"></span><span>${r.label}</span><span class="layers-n">${intg(r.n)}</span></label>`).join('');
html += '<div class="legend-sep">Open SLA</div>' + ['breached', 'at_risk', 'ok', 'unknown'].map((s) => html += '<div class="legend-sep">Open SLA</div>' + ['breached', 'at_risk', 'ok', 'unknown'].map((s) =>
`<div class="layers-row"><span class="legend-dot" style="background:${SLA_COLORS[s]}"></span><span>${SLA_LABELS[s]}</span></div>`).join(''); `<div class="layers-row"><span class="legend-dot" style="background:${SLA_COLORS[s]}"></span><span>${SLA_LABELS[s]}</span></div>`).join('');
// Closed = faded ('light') version of the same SLA colour (see CLOSED_SLA_BASE).
html += '<div class="legend-sep">Closed SLA</div>' + [['Breached', closedColor('Breached')], ['Compliant', closedColor('Compliant')]].map(([lbl, col]) =>
`<div class="layers-row"><span class="legend-dot" style="background:${col}"></span><span>${lbl}</span></div>`).join('');
$('tk-layers-body').innerHTML = html; $('tk-layers-body').innerHTML = html;
$('tk-layers-body').querySelectorAll('input[type=checkbox]').forEach((cb) => $('tk-layers-body').querySelectorAll('input[type=checkbox]').forEach((cb) =>
cb.addEventListener('change', () => { cb.addEventListener('change', () => {