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:
parent
62479c0a72
commit
a32735bca3
1 changed files with 48 additions and 7 deletions
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue