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
|
||||
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.
|
||||
|
|
|
|||
157
index.html
157
index.html
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue