feat(tickets): teardrop pin icons; closed = one slate colour, status ignored

Render INC tickets as canvas-drawn teardrop map pins via MapLibre symbol layers
(scales to thousands of closed features, unlike DOM markers):

- Open pins coloured by SLA state; larger than the old circles for hierarchy.
- Closed pins use a single muted slate colour irrespective of status (the only
  distinction that matters once closed), slightly smaller + under the open layer.
- Legend/popup closed swatch aligned to the new closed colour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
david kiania 2026-06-16 12:05:03 +03:00
parent 6504328e58
commit 0ed9b6a252

View file

@ -884,6 +884,7 @@ 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 COST_CENTRE_COLORS = { const COST_CENTRE_COLORS = {
'isp': '#3b82f6', 'osp': '#E8954A', 'osp patrol': '#f97316', 'fds': '#22c55e', 'isp': '#3b82f6', 'osp': '#E8954A', 'osp patrol': '#f97316', 'fds': '#22c55e',
@ -945,6 +946,32 @@ function incQs() {
return p.toString(); return p.toString();
} }
// Draw a teardrop map-pin (head circle + point) at 2x for crispness; returns
// ImageData added to the map style. Recoloured per SLA state / closed.
function pinImageData(fill) {
const r = 22, sw = 4, cx = r + sw, cy = r + sw, tip = cy + r * 2.55;
const w = 2 * (r + sw), h = Math.ceil(tip + sw);
const cv = document.createElement('canvas'); cv.width = w; cv.height = h;
const ctx = cv.getContext('2d');
ctx.beginPath();
ctx.arc(cx, cy, r, Math.PI * 0.75, Math.PI * 0.25, false); // top ~270° of the head
ctx.lineTo(cx, tip); // taper to the tip
ctx.closePath();
ctx.fillStyle = fill; ctx.fill();
ctx.lineWidth = sw; ctx.strokeStyle = 'rgba(255,255,255,.95)'; ctx.lineJoin = 'round'; ctx.stroke();
ctx.beginPath(); ctx.arc(cx, cy, r * 0.4, 0, 2 * Math.PI); // inner white hole
ctx.fillStyle = 'rgba(255,255,255,.95)'; ctx.fill();
return ctx.getImageData(0, 0, w, h);
}
function addPinImages() {
const pins = {
'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,
};
for (const [id, fill] of Object.entries(pins))
if (!tkMap.hasImage(id)) tkMap.addImage(id, pinImageData(fill), { pixelRatio: 2 });
}
function initIncMap() { function initIncMap() {
if (tkMap) { tkMap.resize(); return; } // already built — just fix sizing if (tkMap) { tkMap.resize(); return; } // already built — just fix sizing
@ -966,28 +993,30 @@ 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', () => {
// Closed overlay (windowed) under the live open layer. addPinImages(); // teardrop pin icons (open by SLA, closed one slate colour)
// Closed overlay (windowed) — drawn UNDER the live open layer; status irrelevant
// so every closed ticket uses the single muted 'pin-closed', slightly smaller.
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: 'circle', source: 'inc-closed', id: 'inc-closed', type: 'symbol', source: 'inc-closed',
layout: { visibility: tkLayerState.closed ? 'visible' : 'none' }, layout: {
paint: { visibility: tkLayerState.closed ? 'visible' : 'none',
'circle-radius': ['interpolate', ['linear'], ['zoom'], 6, 3, 11, 5, 16, 7], 'icon-image': 'pin-closed',
'circle-color': '#6b7280', 'circle-opacity': 0.45, 'icon-size': ['interpolate', ['linear'], ['zoom'], 6, 0.42, 11, 0.6, 16, 0.78],
'circle-stroke-color': '#9ca3af', 'circle-stroke-width': 1, 'icon-anchor': 'bottom', 'icon-allow-overlap': true, 'icon-ignore-placement': true,
}, },
paint: { 'icon-opacity': 0.85 },
}); });
// Open layer (live) — coloured by derived SLA state. // 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 });
tkMap.addLayer({ tkMap.addLayer({
id: 'inc-open', type: 'circle', source: 'inc-open', id: 'inc-open', type: 'symbol', source: 'inc-open',
layout: { visibility: tkLayerState.open ? 'visible' : 'none' }, layout: {
paint: { visibility: tkLayerState.open ? 'visible' : 'none',
'circle-radius': ['interpolate', ['linear'], ['zoom'], 6, 4, 11, 6, 16, 9], 'icon-image': ['match', ['get', 'sla_state'],
'circle-color': ['match', ['get', 'sla_state'], 'breached', 'pin-breached', 'at_risk', 'pin-at_risk', 'ok', 'pin-ok', 'pin-unknown'],
'breached', SLA_COLORS.breached, 'at_risk', SLA_COLORS.at_risk, 'ok', SLA_COLORS.ok, 'icon-size': ['interpolate', ['linear'], ['zoom'], 6, 0.52, 11, 0.78, 16, 1],
SLA_COLORS.unknown], 'icon-anchor': 'bottom', 'icon-allow-overlap': true, 'icon-ignore-placement': true,
'circle-stroke-color': '#fff', 'circle-stroke-width': 1.5, 'circle-opacity': 0.92,
}, },
}); });
for (const id of ['inc-open', 'inc-closed']) { for (const id of ['inc-open', 'inc-closed']) {
@ -1183,7 +1212,7 @@ 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 ? '#9ca3af' : (SLA_COLORS[p.sla_state] || '#fff')}">${closed ? 'CLOSED' : 'OPEN'}</span></b> <b>${escapeHtml(p.ticket_id || '—')} <span class="badge" style="color:${closed ? CLOSED_COLOR : (SLA_COLORS[p.sla_state] || '#fff')}">${closed ? 'CLOSED' : 'OPEN'}</span></b>
${lines.join('')}</div>`).addTo(tkMap); ${lines.join('')}</div>`).addTo(tkMap);
} }
@ -1191,7 +1220,7 @@ 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: '#9ca3af', n: m.closed_in_window ?? 0 }, { id: 'closed', label: 'Closed INC', color: CLOSED_COLOR, n: m.closed_in_window ?? 0 },
{ id: 'vehicles', label: 'Vehicles', color: '#E8954A', n: vehCount }, { id: 'vehicles', label: 'Vehicles', color: '#E8954A', n: vehCount },
]; ];
let html = rows.map((r) => let html = rows.map((r) =>