diff --git a/README.md b/README.md index 8f8f832..e048cfa 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/index.html b/index.html index 5f1e1c3..58c9f57 100644 --- a/index.html +++ b/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 @@ + + @@ -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(); }