diff --git a/src/index.html b/src/index.html index fd0c63c..5bdf733 100644 --- a/src/index.html +++ b/src/index.html @@ -884,6 +884,7 @@ const EMPTY_FC = { type: 'FeatureCollection', features: [] }; // 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_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 = { 'isp': '#3b82f6', 'osp': '#E8954A', 'osp patrol': '#f97316', 'fds': '#22c55e', @@ -945,6 +946,32 @@ function incQs() { 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() { 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 }); 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.addLayer({ - id: 'inc-closed', type: 'circle', source: 'inc-closed', - layout: { visibility: tkLayerState.closed ? 'visible' : 'none' }, - paint: { - 'circle-radius': ['interpolate', ['linear'], ['zoom'], 6, 3, 11, 5, 16, 7], - 'circle-color': '#6b7280', 'circle-opacity': 0.45, - 'circle-stroke-color': '#9ca3af', 'circle-stroke-width': 1, + id: 'inc-closed', type: 'symbol', source: 'inc-closed', + layout: { + visibility: tkLayerState.closed ? 'visible' : 'none', + 'icon-image': 'pin-closed', + '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, }, + 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.addLayer({ - id: 'inc-open', type: 'circle', source: 'inc-open', - layout: { visibility: tkLayerState.open ? 'visible' : 'none' }, - paint: { - 'circle-radius': ['interpolate', ['linear'], ['zoom'], 6, 4, 11, 6, 16, 9], - 'circle-color': ['match', ['get', 'sla_state'], - 'breached', SLA_COLORS.breached, 'at_risk', SLA_COLORS.at_risk, 'ok', SLA_COLORS.ok, - SLA_COLORS.unknown], - 'circle-stroke-color': '#fff', 'circle-stroke-width': 1.5, 'circle-opacity': 0.92, + id: 'inc-open', type: 'symbol', source: 'inc-open', + layout: { + visibility: tkLayerState.open ? 'visible' : 'none', + 'icon-image': ['match', ['get', 'sla_state'], + 'breached', 'pin-breached', 'at_risk', 'pin-at_risk', 'ok', 'pin-ok', 'pin-unknown'], + 'icon-size': ['interpolate', ['linear'], ['zoom'], 6, 0.52, 11, 0.78, 16, 1], + 'icon-anchor': 'bottom', 'icon-allow-overlap': true, 'icon-ignore-placement': true, }, }); for (const id of ['inc-open', 'inc-closed']) { @@ -1183,7 +1212,7 @@ function showIncPopup(f, closed) { } if (p.geo_source === 'cluster') lines.push('
approx — cluster location
'); tkPopup.setLngLat(f.geometry.coordinates).setHTML(`
- ${escapeHtml(p.ticket_id || '—')} ${closed ? 'CLOSED' : 'OPEN'} + ${escapeHtml(p.ticket_id || '—')} ${closed ? 'CLOSED' : 'OPEN'} ${lines.join('')}
`).addTo(tkMap); } @@ -1191,7 +1220,7 @@ function buildIncLayers() { const m = (incData && incData.metrics) || {}; const rows = [ { 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 }, ]; let html = rows.map((r) =>