From a32735bca3b240eebc24eb3d72dffca2d8b4caac Mon Sep 17 00:00:00 2001 From: david kiania Date: Thu, 18 Jun 2026 17:04:06 +0300 Subject: [PATCH] feat(tickets map): fan out co-located pins so faded-closed is visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cluster-centroid tickets share an exact coordinate, so the open layer sat on top of the faded closed pins (23 of 25 closed points were occluded) — the new closed colouring was correct but invisible. Now pins sharing a coordinate are fanned into a small deterministic sunflower spiral around it, computed across BOTH layers so an open and a closed ticket at the same centroid separate. Active (vivid) and closed (faded) now both show. Also default the Closed layer on. Offsets are cosmetic (cluster centroids are already approximate). Co-Authored-By: Claude Opus 4.8 --- src/index.html | 55 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/src/index.html b/src/index.html index 72927aa..f610721 100644 --- a/src/index.html +++ b/src/index.html @@ -951,7 +951,7 @@ function vehState(p) { let tkMap = null, tkPopup = null, tkLivePoll = null, tkClosureChart = null; const tkMarkers = new Map(); // imei → maplibregl.Marker -const tkLayerState = { open: true, closed: false, vehicles: true }; +const tkLayerState = { open: true, closed: true, vehicles: true }; let incData = null, incDropdownsInit = false, vehCount = 0; // ── INC helpers ─────────────────────────────────────────────────────────── @@ -1007,6 +1007,51 @@ function addPinImages() { if (!tkMap.hasImage(id)) tkMap.addImage(id, pinImageData(fill), { pixelRatio: 2 }); } +// Co-located declutter ("fan-out"). Cluster-centroid tickets share an EXACT coordinate, +// so the open layer would sit on top of (hide) the faded closed pins at the same point. +// Pins that share a coordinate are fanned into a small deterministic sunflower spiral +// around it — computed across BOTH layers so an open and a closed ticket separate — so +// active (vivid) and closed (faded) are both visible. Offsets are cosmetic (a few tens +// to ~hundreds of m around the cluster centroid; popups already flag 'approx'). +const JITTER_BASE_DEG = 0.00035; // innermost step (~38 m); grows ~sqrt(i) +const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); +function fanOutColocated(openFC, closedFC) { + const groups = new Map(); + const add = (f, layer) => { + const c = f && f.geometry && f.geometry.coordinates; if (!c) return; + const key = c[0].toFixed(6) + ',' + c[1].toFixed(6); + let arr = groups.get(key); if (!arr) { arr = []; groups.set(key, arr); } + arr.push({ f, layer }); + }; + ((openFC && openFC.features) || []).forEach((f) => add(f, 'open')); + ((closedFC && closedFC.features) || []).forEach((f) => add(f, 'closed')); + const outOpen = [], outClosed = []; + for (const items of groups.values()) { + if (items.length > 1) // stable order so pins don't jump between refreshes + items.sort((a, b) => String((a.f.properties || {}).ticket_id || '') + .localeCompare(String((b.f.properties || {}).ticket_id || ''))); + items.forEach((it, i) => { + const c = it.f.geometry.coordinates; + let coords = c; + if (items.length > 1 && i > 0) { // i=0 stays on the true centroid + const r = JITTER_BASE_DEG * Math.sqrt(i), a = i * GOLDEN_ANGLE; + const latRad = c[1] * Math.PI / 180; + coords = [c[0] + (r * Math.cos(a)) / Math.max(Math.cos(latRad), 0.2), c[1] + r * Math.sin(a)]; + } + (it.layer === 'open' ? outOpen : outClosed).push( + { type: 'Feature', properties: it.f.properties, geometry: { type: 'Point', coordinates: coords } }); + }); + } + return { open: { type: 'FeatureCollection', features: outOpen }, + closed: { type: 'FeatureCollection', features: outClosed } }; +} +function renderIncMap() { + if (!tkMap || !incData) return; + const { open, closed } = fanOutColocated(incData.open, incData.closed); + if (tkMap.getSource('inc-open')) tkMap.getSource('inc-open').setData(open); + if (tkMap.getSource('inc-closed')) tkMap.getSource('inc-closed').setData(closed); +} + function initIncMap() { if (tkMap) { tkMap.resize(); return; } // already built — just fix sizing @@ -1062,10 +1107,7 @@ function initIncMap() { tkMap.on('mousemove', id, (e) => showIncPopup(e.features[0], id === 'inc-closed')); } tkMap.on('zoom', updateVehScale); updateVehScale(); - if (incData) { // INC data may have arrived before the basemap finished loading - tkMap.getSource('inc-open').setData(incData.open || EMPTY_FC); - tkMap.getSource('inc-closed').setData(incData.closed || EMPTY_FC); - } + if (incData) renderIncMap(); // INC data may have arrived before the basemap loaded loadLive(); tkLivePoll = setInterval(loadLive, LIVE_POLL_MS); }); @@ -1154,8 +1196,7 @@ async function loadInc() { renderIncMetrics(j.metrics, j.freshness); renderIncTables(j.metrics); renderClosureChart(j.metrics && j.metrics.closure_rate); - if (tkMap && tkMap.getSource('inc-open')) tkMap.getSource('inc-open').setData(j.open || EMPTY_FC); - if (tkMap && tkMap.getSource('inc-closed')) tkMap.getSource('inc-closed').setData(j.closed || EMPTY_FC); + renderIncMap(); // fan out co-located pins so open (vivid) + closed (faded) both show buildIncLayers(); } catch (e) { console.error(e);