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:
kianiadee 2026-06-06 23:44:09 +03:00
parent 33401415ad
commit cd627b4f9a
2 changed files with 122 additions and 39 deletions

View file

@ -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
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.
- **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*:
- **Number plate** — multi-select, sorted A→Z; picking a vehicle auto-fills its
cost centre + city.

View file

@ -240,6 +240,22 @@
.veh-marker.offline .veh-plate { color: var(--muted); }
/* ── 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-dot {
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" />
<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>
<body>
@ -416,8 +434,12 @@ let mode = 'live'; // 'live' | 'trips'
let map = null, popup = null;
let pollTimer = null, inFlight = 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 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 PLATE_KEYS = new Set(); // normalised plate keys already in the dropdown (tracker+camera collapse to one)
let openPopupImei = null, popupStuck = false, popupCloseTimer = null;
@ -450,6 +472,8 @@ function ensureMap() {
popup.on('close', () => { openPopupImei = null; popupStuck = false; });
map.on('load', () => { POIS.forEach(addPoiMarker); 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 => {
if (mode !== 'live') return;
if (!popupStuck) return;
@ -572,31 +596,22 @@ function renderLive() {
populateFiltersFromLive(features);
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) {
const b = new maplibregl.LngLatBounds();
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 });
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) {
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);
else { popup.remove(); openPopupImei = null; popupStuck = false; }
}
// Honour the current plate/cost-centre filter + size markers for this zoom.
applyLiveFilters();
updateVehScale();
};
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';
}
// 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) {
const state = vehicleState(p);
// 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));
}
// 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('custom').classList.toggle('show', e.target.value === 'custom');
});
@ -960,6 +1037,7 @@ function switchToTripsMode() {
cancelPopupClose(); popup.remove(); openPopupImei = null; popupStuck = false;
clearTrail(); trailedVehicle = null;
liveMarkers.forEach(m => m.getElement().style.display = 'none');
clusterMarkers.forEach(m => m.remove()); clusterMarkers.clear();
document.getElementById('triplist').classList.add('show');
document.getElementById('tripbar').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('stale-chip').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';
startPolling();
}