feat(live): Folium-style marker clustering (supercluster + DOM pins)
Zoomed out, vehicles group into amber count-bubbles (tiered by size); click to zoom-expand; clusters disband into individual pins at ~z11. Implemented with supercluster while KEEPING the DOM pin/square/arrow look — the filtered+deduped fleet is loaded into a cluster index, re-queried on moveend per viewport/zoom. Clustering is live-mode only; honours plate/cost-centre/city filters; KPIs track the filtered set. Verified: z6 → 4 bubbles summing to fleet total, z13 → pins, cluster click zooms in. No console errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
33401415ad
commit
cd627b4f9a
2 changed files with 122 additions and 39 deletions
|
|
@ -23,6 +23,10 @@ trips** into one view for the Fireside Communications / Tracksolid fleet.
|
||||||
the **tracker is primary**; if the tracker isn't reporting (>24h), it **falls
|
the **tracker is primary**; if the tracker isn't reporting (>24h), it **falls
|
||||||
back to the camera**; if both report, the tracker wins. Pairing is by
|
back to the camera**; if both report, the tracker wins. Pairing is by
|
||||||
normalised plate, so a stray space (`KDS 453 Y` vs `KDS 453Y`) still merges.
|
normalised plate, so a stray space (`KDS 453 Y` vs `KDS 453Y`) still merges.
|
||||||
|
- **Clustering (zoomed out).** Vehicles group into amber count-bubbles (Folium /
|
||||||
|
Leaflet.MarkerCluster style, via `supercluster`); click a bubble to zoom and
|
||||||
|
expand it. Clusters disband into individual pins at ~city zoom (z11). Clustering
|
||||||
|
honours the active filter and applies to the live view only (not trip routes).
|
||||||
- **Filters** (bottom-right card) apply to the live map *instantly*:
|
- **Filters** (bottom-right card) apply to the live map *instantly*:
|
||||||
- **Number plate** — multi-select, sorted A→Z; picking a vehicle auto-fills its
|
- **Number plate** — multi-select, sorted A→Z; picking a vehicle auto-fills its
|
||||||
cost centre + city.
|
cost centre + city.
|
||||||
|
|
|
||||||
157
index.html
157
index.html
|
|
@ -240,6 +240,22 @@
|
||||||
.veh-marker.offline .veh-plate { color: var(--muted); }
|
.veh-marker.offline .veh-plate { color: var(--muted); }
|
||||||
|
|
||||||
/* ── Persistent POI marker (Fireside HQ) ───────────────────────────── */
|
/* ── Persistent POI marker (Fireside HQ) ───────────────────────────── */
|
||||||
|
/* Cluster bubble (zoomed-out): amber circle + white count, tiered by size.
|
||||||
|
Click zooms to expand into the individual pins. */
|
||||||
|
.cluster-bubble {
|
||||||
|
display: grid; place-items: center; cursor: pointer;
|
||||||
|
border-radius: 50%; color: #1a1009; font-weight: 800;
|
||||||
|
background: var(--accent);
|
||||||
|
border: 2px solid rgba(255,255,255,.92);
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,.5);
|
||||||
|
transition: transform .08s ease;
|
||||||
|
}
|
||||||
|
.cluster-bubble:hover { transform: scale(1.08); }
|
||||||
|
.cluster-bubble.t1 { width: 34px; height: 34px; font-size: 13px; }
|
||||||
|
.cluster-bubble.t2 { width: 42px; height: 42px; font-size: 14px; }
|
||||||
|
.cluster-bubble.t3 { width: 52px; height: 52px; font-size: 15px; }
|
||||||
|
.cluster-bubble.t4 { width: 62px; height: 62px; font-size: 16px; }
|
||||||
|
|
||||||
.hq-marker { display: flex; flex-direction: column; align-items: center; cursor: default; }
|
.hq-marker { display: flex; flex-direction: column; align-items: center; cursor: default; }
|
||||||
.hq-dot {
|
.hq-dot {
|
||||||
width: 13px; height: 13px; border-radius: 50%; background: var(--accent);
|
width: 13px; height: 13px; border-radius: 50%; background: var(--accent);
|
||||||
|
|
@ -288,6 +304,8 @@
|
||||||
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css" />
|
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css" />
|
||||||
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
|
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
|
||||||
|
<!-- Supercluster: Folium-style marker clustering while keeping our DOM pins. -->
|
||||||
|
<script src="https://unpkg.com/supercluster@8.0.1/dist/supercluster.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|
@ -416,8 +434,12 @@ let mode = 'live'; // 'live' | 'trips'
|
||||||
let map = null, popup = null;
|
let map = null, popup = null;
|
||||||
let pollTimer = null, inFlight = null;
|
let pollTimer = null, inFlight = null;
|
||||||
let lastLivePayload = null;
|
let lastLivePayload = null;
|
||||||
const liveMarkers = new Map(); // imei → maplibregl.Marker
|
const liveMarkers = new Map(); // imei → maplibregl.Marker (individual vehicles)
|
||||||
|
const clusterMarkers = new Map(); // cluster_id → maplibregl.Marker (count bubbles)
|
||||||
let liveFeatures = []; // deduped (one device per vehicle) — see dedupeLiveFeatures
|
let liveFeatures = []; // deduped (one device per vehicle) — see dedupeLiveFeatures
|
||||||
|
let cluster = null; // Supercluster index of the currently-filtered fleet
|
||||||
|
const CLUSTER_RADIUS = 60; // px cluster radius
|
||||||
|
const CLUSTER_MAXZOOM = 11; // above this, clusters disband into individual pins (~city zoom)
|
||||||
const VEHICLE_META = new Map(); // plate → {cost_centre, assigned_city}
|
const VEHICLE_META = new Map(); // plate → {cost_centre, assigned_city}
|
||||||
const PLATE_KEYS = new Set(); // normalised plate keys already in the dropdown (tracker+camera collapse to one)
|
const PLATE_KEYS = new Set(); // normalised plate keys already in the dropdown (tracker+camera collapse to one)
|
||||||
let openPopupImei = null, popupStuck = false, popupCloseTimer = null;
|
let openPopupImei = null, popupStuck = false, popupCloseTimer = null;
|
||||||
|
|
@ -450,6 +472,8 @@ function ensureMap() {
|
||||||
popup.on('close', () => { openPopupImei = null; popupStuck = false; });
|
popup.on('close', () => { openPopupImei = null; popupStuck = false; });
|
||||||
map.on('load', () => { POIS.forEach(addPoiMarker); updateVehScale(); });
|
map.on('load', () => { POIS.forEach(addPoiMarker); updateVehScale(); });
|
||||||
map.on('zoom', updateVehScale);
|
map.on('zoom', updateVehScale);
|
||||||
|
// Re-cluster after any pan/zoom settles (live mode only).
|
||||||
|
map.on('moveend', () => { if (mode === 'live') renderClusters(); });
|
||||||
map.on('click', e => {
|
map.on('click', e => {
|
||||||
if (mode !== 'live') return;
|
if (mode !== 'live') return;
|
||||||
if (!popupStuck) return;
|
if (!popupStuck) return;
|
||||||
|
|
@ -572,31 +596,22 @@ function renderLive() {
|
||||||
populateFiltersFromLive(features);
|
populateFiltersFromLive(features);
|
||||||
|
|
||||||
const drawMarkers = () => {
|
const drawMarkers = () => {
|
||||||
const seen = new Set();
|
|
||||||
features.forEach(f => {
|
|
||||||
const p = f.properties || {};
|
|
||||||
const c = f.geometry && f.geometry.coordinates;
|
|
||||||
if (!Array.isArray(c)) return;
|
|
||||||
seen.add(p.imei);
|
|
||||||
upsertLiveMarker(p, c, f);
|
|
||||||
});
|
|
||||||
// Drop markers for vehicles no longer present (incl. the now-deduped twins)
|
|
||||||
for (const [imei, m] of liveMarkers) { if (!seen.has(imei)) { m.remove(); liveMarkers.delete(imei); } }
|
|
||||||
|
|
||||||
if (!firstFit && features.length) {
|
if (!firstFit && features.length) {
|
||||||
const b = new maplibregl.LngLatBounds();
|
const b = new maplibregl.LngLatBounds();
|
||||||
features.forEach(f => Array.isArray(f.geometry?.coordinates) && b.extend(f.geometry.coordinates));
|
features.forEach(f => Array.isArray(f.geometry?.coordinates) && b.extend(f.geometry.coordinates));
|
||||||
if (!b.isEmpty()) map.fitBounds(b, { padding: 70, duration: 700, maxZoom: 11 });
|
if (!b.isEmpty()) map.fitBounds(b, { padding: 70, duration: 700, maxZoom: 11 });
|
||||||
firstFit = true;
|
firstFit = true;
|
||||||
}
|
}
|
||||||
// Re-attach a pinned popup to its (moved) vehicle
|
// Build the cluster index from the filtered fleet + draw bubbles/pins.
|
||||||
|
applyLiveFilters();
|
||||||
|
// Re-attach a pinned popup to its (moved) vehicle, if it's still an
|
||||||
|
// individual pin on the map (not absorbed into a cluster).
|
||||||
if (openPopupImei) {
|
if (openPopupImei) {
|
||||||
const still = features.find(f => f.properties.imei === openPopupImei);
|
const m = liveMarkers.get(openPopupImei);
|
||||||
|
const still = m && m.getElement().style.display !== 'none' && features.find(f => f.properties.imei === openPopupImei);
|
||||||
if (still) showLivePopup(still, true);
|
if (still) showLivePopup(still, true);
|
||||||
else { popup.remove(); openPopupImei = null; popupStuck = false; }
|
else { popup.remove(); openPopupImei = null; popupStuck = false; }
|
||||||
}
|
}
|
||||||
// Honour the current plate/cost-centre filter + size markers for this zoom.
|
|
||||||
applyLiveFilters();
|
|
||||||
updateVehScale();
|
updateVehScale();
|
||||||
};
|
};
|
||||||
if (map.isStyleLoaded()) drawMarkers(); else map.once('load', drawMarkers);
|
if (map.isStyleLoaded()) drawMarkers(); else map.once('load', drawMarkers);
|
||||||
|
|
@ -604,6 +619,91 @@ function renderLive() {
|
||||||
const ph = document.getElementById('placeholder'); if (ph) ph.style.display = 'none';
|
const ph = document.getElementById('placeholder'); if (ph) ph.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Features matching the current plate/cost-centre/city filter (live mode).
|
||||||
|
function filteredLiveFeatures() {
|
||||||
|
const plates = new Set(Array.from(document.getElementById('f-vehicle').selectedOptions).map(o => normPlate(o.value)).filter(Boolean));
|
||||||
|
const cc = document.getElementById('f-cc').value;
|
||||||
|
const city = document.getElementById('f-city').value;
|
||||||
|
return liveFeatures.filter(f => {
|
||||||
|
const p = f.properties;
|
||||||
|
if (!Array.isArray(f.geometry?.coordinates)) return false;
|
||||||
|
if (plates.size && !plates.has(normPlate(p.vehicle_number))) return false;
|
||||||
|
if (cc && p.cost_centre !== cc) return false;
|
||||||
|
if (city && p.assigned_city !== city) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the filtered fleet into supercluster, redraw bubbles+pins, recompute KPIs.
|
||||||
|
function applyLiveFilters() {
|
||||||
|
if (mode !== 'live') return;
|
||||||
|
const filtered = filteredLiveFeatures();
|
||||||
|
cluster = new Supercluster({ radius: CLUSTER_RADIUS, maxZoom: CLUSTER_MAXZOOM });
|
||||||
|
cluster.load(filtered.map(f => ({
|
||||||
|
type: 'Feature',
|
||||||
|
properties: { ...f.properties },
|
||||||
|
geometry: { type: 'Point', coordinates: f.geometry.coordinates },
|
||||||
|
})));
|
||||||
|
renderClusters();
|
||||||
|
|
||||||
|
// KPIs from the filtered set.
|
||||||
|
let moving = 0, parked = 0, offline = 0; const speeds = [];
|
||||||
|
filtered.forEach(f => {
|
||||||
|
const st = vehicleState(f.properties);
|
||||||
|
if (st === 'offline') offline++;
|
||||||
|
else if (st === 'active') { moving++; const sp = Number(f.properties.speed || 0); if (sp > 0) speeds.push(sp); }
|
||||||
|
else parked++;
|
||||||
|
});
|
||||||
|
speeds.sort((a, b) => a - b);
|
||||||
|
renderLiveKPIs({ total: filtered.length, moving, parked, offline, median: speeds.length ? speeds[Math.floor(speeds.length / 2)] : null, last_batch_utc: lastLivePayload?.summary?.last_batch_utc });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query the cluster index for the current viewport+zoom and draw either count
|
||||||
|
// bubbles (clusters) or individual vehicle pins (leaves).
|
||||||
|
function renderClusters() {
|
||||||
|
if (!cluster || mode !== 'live' || !map) return;
|
||||||
|
const b = map.getBounds();
|
||||||
|
const bbox = [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()];
|
||||||
|
const z = Math.round(map.getZoom());
|
||||||
|
const items = cluster.getClusters(bbox, z);
|
||||||
|
const seenVeh = new Set(), seenClu = new Set();
|
||||||
|
items.forEach(it => {
|
||||||
|
const c = it.geometry.coordinates;
|
||||||
|
if (it.properties.cluster) {
|
||||||
|
seenClu.add(it.properties.cluster_id);
|
||||||
|
upsertClusterMarker(it.properties.cluster_id, it.properties.point_count, c);
|
||||||
|
} else {
|
||||||
|
seenVeh.add(it.properties.imei);
|
||||||
|
upsertLiveMarker(it.properties, c, it);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const [imei, m] of liveMarkers) { if (!seenVeh.has(imei)) { m.remove(); liveMarkers.delete(imei); } }
|
||||||
|
for (const [id, m] of clusterMarkers) { if (!seenClu.has(id)) { m.remove(); clusterMarkers.delete(id); } }
|
||||||
|
// If the popped-open vehicle got absorbed into a cluster, close its popup.
|
||||||
|
if (openPopupImei && !liveMarkers.has(openPopupImei)) { popup.remove(); openPopupImei = null; popupStuck = false; }
|
||||||
|
updateVehScale();
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertClusterMarker(id, count, coords) {
|
||||||
|
let m = clusterMarkers.get(id);
|
||||||
|
const tier = count < 10 ? 't1' : count < 25 ? 't2' : count < 60 ? 't3' : 't4';
|
||||||
|
if (!m) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'cluster-bubble ' + tier;
|
||||||
|
el.textContent = count;
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
const expZoom = Math.min(cluster.getClusterExpansionZoom(id), 18);
|
||||||
|
map.easeTo({ center: coords, zoom: expZoom, duration: 500 });
|
||||||
|
});
|
||||||
|
m = new maplibregl.Marker({ element: el, anchor: 'center' }).setLngLat(coords).addTo(map);
|
||||||
|
clusterMarkers.set(id, m);
|
||||||
|
} else {
|
||||||
|
m.setLngLat(coords);
|
||||||
|
m.getElement().className = 'cluster-bubble ' + tier;
|
||||||
|
m.getElement().textContent = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function upsertLiveMarker(p, coords, feature) {
|
function upsertLiveMarker(p, coords, feature) {
|
||||||
const state = vehicleState(p);
|
const state = vehicleState(p);
|
||||||
// Active (moving now) = full department colour. Parked (reported within 24h)
|
// Active (moving now) = full department colour. Parked (reported within 24h)
|
||||||
|
|
@ -861,29 +961,6 @@ function updateVehScale() {
|
||||||
document.getElementById('map').style.setProperty('--veh-scale', (0.42 + t * 0.78).toFixed(3));
|
document.getElementById('map').style.setProperty('--veh-scale', (0.42 + t * 0.78).toFixed(3));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter the LIVE markers by the selected plate(s) + cost centre, and recompute
|
|
||||||
// the header KPIs to match. Time period only applies to trips, not live.
|
|
||||||
function applyLiveFilters() {
|
|
||||||
if (mode !== 'live') return;
|
|
||||||
const plates = new Set(Array.from(document.getElementById('f-vehicle').selectedOptions).map(o => normPlate(o.value)).filter(Boolean));
|
|
||||||
const cc = document.getElementById('f-cc').value;
|
|
||||||
const city = document.getElementById('f-city').value;
|
|
||||||
let total = 0, moving = 0, parked = 0, offline = 0; const speeds = [];
|
|
||||||
liveFeatures.forEach(f => {
|
|
||||||
const p = f.properties; const m = liveMarkers.get(p.imei); if (!m) return;
|
|
||||||
const pass = (plates.size === 0 || plates.has(normPlate(p.vehicle_number))) && (!cc || p.cost_centre === cc) && (!city || p.assigned_city === city);
|
|
||||||
m.getElement().style.display = pass ? '' : 'none';
|
|
||||||
if (!pass) return;
|
|
||||||
total++;
|
|
||||||
const st = vehicleState(p);
|
|
||||||
if (st === 'offline') offline++;
|
|
||||||
else if (st === 'active') { moving++; const sp = Number(p.speed || 0); if (sp > 0) speeds.push(sp); }
|
|
||||||
else parked++;
|
|
||||||
});
|
|
||||||
speeds.sort((a, b) => a - b);
|
|
||||||
renderLiveKPIs({ total, moving, parked, offline, median: speeds.length ? speeds[Math.floor(speeds.length / 2)] : null, last_batch_utc: lastLivePayload?.summary?.last_batch_utc });
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('f-period').addEventListener('change', e => {
|
document.getElementById('f-period').addEventListener('change', e => {
|
||||||
document.getElementById('custom').classList.toggle('show', e.target.value === 'custom');
|
document.getElementById('custom').classList.toggle('show', e.target.value === 'custom');
|
||||||
});
|
});
|
||||||
|
|
@ -960,6 +1037,7 @@ function switchToTripsMode() {
|
||||||
cancelPopupClose(); popup.remove(); openPopupImei = null; popupStuck = false;
|
cancelPopupClose(); popup.remove(); openPopupImei = null; popupStuck = false;
|
||||||
clearTrail(); trailedVehicle = null;
|
clearTrail(); trailedVehicle = null;
|
||||||
liveMarkers.forEach(m => m.getElement().style.display = 'none');
|
liveMarkers.forEach(m => m.getElement().style.display = 'none');
|
||||||
|
clusterMarkers.forEach(m => m.remove()); clusterMarkers.clear();
|
||||||
document.getElementById('triplist').classList.add('show');
|
document.getElementById('triplist').classList.add('show');
|
||||||
document.getElementById('tripbar').classList.add('show');
|
document.getElementById('tripbar').classList.add('show');
|
||||||
document.getElementById('live-pill').classList.add('show');
|
document.getElementById('live-pill').classList.add('show');
|
||||||
|
|
@ -1163,6 +1241,7 @@ function backToLive() {
|
||||||
document.getElementById('live-pill').classList.remove('show');
|
document.getElementById('live-pill').classList.remove('show');
|
||||||
document.getElementById('stale-chip').style.display = '';
|
document.getElementById('stale-chip').style.display = '';
|
||||||
liveMarkers.forEach(m => m.getElement().style.display = '');
|
liveMarkers.forEach(m => m.getElement().style.display = '');
|
||||||
|
if (liveFeatures.length) applyLiveFilters(); // rebuild cluster bubbles/pins
|
||||||
const ph = document.getElementById('placeholder'); if (ph) ph.style.display = 'none';
|
const ph = document.getElementById('placeholder'); if (ph) ph.style.display = 'none';
|
||||||
startPolling();
|
startPolling();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue