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;
|
let tkMap = null, tkPopup = null, tkLivePoll = null, tkClosureChart = null;
|
||||||
const tkMarkers = new Map(); // imei → maplibregl.Marker
|
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;
|
let incData = null, incDropdownsInit = false, vehCount = 0;
|
||||||
|
|
||||||
// ── INC helpers ───────────────────────────────────────────────────────────
|
// ── INC helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
@ -1007,6 +1007,51 @@ function addPinImages() {
|
||||||
if (!tkMap.hasImage(id)) tkMap.addImage(id, pinImageData(fill), { pixelRatio: 2 });
|
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() {
|
function initIncMap() {
|
||||||
if (tkMap) { tkMap.resize(); return; } // already built — just fix sizing
|
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('mousemove', id, (e) => showIncPopup(e.features[0], id === 'inc-closed'));
|
||||||
}
|
}
|
||||||
tkMap.on('zoom', updateVehScale); updateVehScale();
|
tkMap.on('zoom', updateVehScale); updateVehScale();
|
||||||
if (incData) { // INC data may have arrived before the basemap finished loading
|
if (incData) renderIncMap(); // INC data may have arrived before the basemap loaded
|
||||||
tkMap.getSource('inc-open').setData(incData.open || EMPTY_FC);
|
|
||||||
tkMap.getSource('inc-closed').setData(incData.closed || EMPTY_FC);
|
|
||||||
}
|
|
||||||
loadLive();
|
loadLive();
|
||||||
tkLivePoll = setInterval(loadLive, LIVE_POLL_MS);
|
tkLivePoll = setInterval(loadLive, LIVE_POLL_MS);
|
||||||
});
|
});
|
||||||
|
|
@ -1154,8 +1196,7 @@ async function loadInc() {
|
||||||
renderIncMetrics(j.metrics, j.freshness);
|
renderIncMetrics(j.metrics, j.freshness);
|
||||||
renderIncTables(j.metrics);
|
renderIncTables(j.metrics);
|
||||||
renderClosureChart(j.metrics && j.metrics.closure_rate);
|
renderClosureChart(j.metrics && j.metrics.closure_rate);
|
||||||
if (tkMap && tkMap.getSource('inc-open')) tkMap.getSource('inc-open').setData(j.open || EMPTY_FC);
|
renderIncMap(); // fan out co-located pins so open (vivid) + closed (faded) both show
|
||||||
if (tkMap && tkMap.getSource('inc-closed')) tkMap.getSource('inc-closed').setData(j.closed || EMPTY_FC);
|
|
||||||
buildIncLayers();
|
buildIncLayers();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue