feat(tickets map): fan out co-located pins so faded-closed is visible

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 <noreply@anthropic.com>
This commit is contained in:
david kiania 2026-06-18 17:04:06 +03:00
parent 62479c0a72
commit a32735bca3

View file

@ -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);