From c7369caf71c9e3c7907f232583ad54ac7ac25c23 Mon Sep 17 00:00:00 2001 From: kianiadee Date: Wed, 27 May 2026 23:24:12 +0300 Subject: [PATCH] =?UTF-8?q?Trip=20panel:=20multi-vehicle=20overlay=20+=20a?= =?UTF-8?q?ggregate=20KPIs=20(=E2=8C=98-click=20to=20compare)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plain-click on a vehicle marker: single-vehicle mode, full trip-card list, click a card → animated playback (unchanged behaviour). ⌘/Ctrl/Shift-click: add/remove the vehicle from the selection. Each selected vehicle's day routes are drawn on the map as a polyline in a distinct colour from an 8-colour selection palette. Trip dock switches to a compact per-vehicle row layout with ✕ remove buttons; the header shows aggregate trip count + distance + drive / idle / stop minutes summed across the selection. Date change re-fetches every selected vehicle; CSV button downloads one file per selected vehicle. Map auto-fits to the union of bounds. Click a vehicle row in multi-mode → map flies to just that vehicle's trips. Removing the last vehicle empties the dock; the X button closes it entirely. Internals: replaced singular `_tripState` with a `_selection` Map keyed by vehicle_id. Single-trip animation layers still exist for the single-mode trip-card playback; multi-mode uses per-vehicle line-only layers (vroute-line-{id}) with no marker animation. --- web/fleet-core.js | 319 +++++++++++++++++++++++++++++++++++++------- web/index-live.html | 6 + 2 files changed, 275 insertions(+), 50 deletions(-) diff --git a/web/fleet-core.js b/web/fleet-core.js index d949e4a..3dce904 100644 --- a/web/fleet-core.js +++ b/web/fleet-core.js @@ -602,81 +602,205 @@ export function applyClientFilter(map, { costCentres = [], cities = [] } = {}) { /* ---------- trip panel ---------- */ -const TRIP_PATH_SOURCE = 'trip-path-source'; -const TRIP_MARKER_SOURCE = 'trip-marker-source'; -const TRIP_PATH_LAYER = 'trip-path-line'; -const TRIP_MARKER_LAYER = 'trip-marker-dot'; const TRIP_ANIM_MS = 10000; +// Single-trip animation overlays (used in single-vehicle mode only). +const TRIP_PATH_SOURCE = 'trip-path-source'; +const TRIP_MARKER_SOURCE = 'trip-marker-source'; +const TRIP_PATH_LAYER = 'trip-path-line'; +const TRIP_MARKER_LAYER = 'trip-marker-dot'; + +// Distinct palette for selected vehicles in multi-mode (kept separate from +// cost-centre tints so a multi-comparison reads cleanly even when picked +// vehicles share a cost centre). +const SELECTION_PALETTE = [ + '#10b981', // emerald (single-mode default) + '#3b82f6', // blue + '#ef4444', // red + '#f59e0b', // amber + '#a855f7', // purple + '#06b6d4', // cyan + '#ec4899', // pink + '#84cc16', // lime +]; let _tripAnimRAF = null; -let _tripState = { vehicleId: null, date: null, payload: null }; +// vehicleId → { plate, driver, color, payload | null } +const _selection = new Map(); +let _currentDate = null; + +function _nextColor() { + const used = new Set([..._selection.values()].map(v => v.color)); + for (const c of SELECTION_PALETTE) { + if (!used.has(c)) return c; + } + return SELECTION_PALETTE[_selection.size % SELECTION_PALETTE.length]; +} export function initTripPanel(map, panelRoot) { const els = { - plate: panelRoot.querySelector('#trip-plate'), - driver: panelRoot.querySelector('#trip-driver'), - date: panelRoot.querySelector('#trip-date'), - csv: panelRoot.querySelector('#trip-csv'), - totals: panelRoot.querySelector('#trip-totals'), - list: panelRoot.querySelector('#trip-list'), - close: panelRoot.querySelector('#trip-close'), + plate: panelRoot.querySelector('#trip-plate'), + driver: panelRoot.querySelector('#trip-driver'), + date: panelRoot.querySelector('#trip-date'), + csv: panelRoot.querySelector('#trip-csv'), + totals: panelRoot.querySelector('#trip-totals'), + list: panelRoot.querySelector('#trip-list'), + close: panelRoot.querySelector('#trip-close'), }; els.close.addEventListener('click', () => _closeTripPanel(map, panelRoot, els)); - els.date.addEventListener('change', () => { - if (_tripState.vehicleId) { - _loadTrips(map, panelRoot, els, _tripState.vehicleId, els.date.value); + els.date.addEventListener('change', async () => { + _currentDate = els.date.value; + // Re-fetch every currently-selected vehicle for the new date. + for (const vid of [..._selection.keys()]) { + await _fetchAndDraw(map, vid); } + _renderDock(map, els); }); els.csv.addEventListener('click', () => { - if (_tripState.vehicleId) _downloadTripsCsv(_tripState.vehicleId, els.date.value); + for (const vid of _selection.keys()) { + _downloadTripsCsv(vid, _currentDate); + } }); - const layers = ['vehicles-circle', 'vehicles-arrow', 'vehicles-label']; - map.on('click', (e) => { - const features = map.queryRenderedFeatures(e.point, { layers }); + const hoverLayers = ['vehicles-circle', 'vehicles-arrow', 'vehicles-label']; + map.on('click', async (e) => { + const features = map.queryRenderedFeatures(e.point, { layers: hoverLayers }); if (!features || features.length === 0) return; const f = features[0]; const vid = f.properties.vehicle_id; if (!vid) return; - els.plate.textContent = f.properties.plate || `Vehicle ${vid}`; - els.driver.textContent = f.properties.driver_name || ''; + const plate = f.properties.plate || `Vehicle ${vid}`; + const driver = f.properties.driver_name || ''; + const multi = e.originalEvent.metaKey || e.originalEvent.ctrlKey || e.originalEvent.shiftKey; + if (!els.date.value) els.date.value = _todayEat(); + _currentDate = els.date.value; panelRoot.classList.add('open'); panelRoot.setAttribute('aria-hidden', 'false'); - _loadTrips(map, panelRoot, els, vid, els.date.value); + + if (multi) { + if (_selection.has(vid)) { + _removeVehicle(map, vid); + _renderDock(map, els); + } else { + _addVehicle(vid, plate, driver); + await _fetchAndDraw(map, vid); + _renderDock(map, els); + } + } else { + // Plain click → reset to this single vehicle + _clearSelection(map); + _addVehicle(vid, plate, driver); + await _fetchAndDraw(map, vid); + _renderDock(map, els); + } }); } -async function _loadTrips(map, panelRoot, els, vehicleId, dateStr) { - _tripState.vehicleId = vehicleId; - _tripState.date = dateStr; - _tripState.payload = null; - _cancelTripAnim(); - _clearTripLayers(map); - els.totals.innerHTML = 'Loading…'; - els.list.innerHTML = ''; - try { - const payload = await apiFetch( - `/api/views/vehicle/${vehicleId}/trips`, - { params: { date: dateStr } }, - ); - _tripState.payload = payload; - _renderTripPanel(map, els, payload); - } catch (err) { - els.totals.innerHTML = `${_esc(err.message || err)}`; - } +function _addVehicle(vid, plate, driver) { + _selection.set(vid, { plate, driver, color: _nextColor(), payload: null }); } -function _renderTripPanel(map, els, payload) { +function _removeVehicle(map, vid) { + _clearVehicleLayers(map, vid); + _selection.delete(vid); +} + +function _clearSelection(map) { + for (const vid of [..._selection.keys()]) _clearVehicleLayers(map, vid); + _selection.clear(); + _cancelTripAnim(); + _clearSingleTripLayers(map); +} + +async function _fetchAndDraw(map, vid) { + const entry = _selection.get(vid); + if (!entry) return; + try { + entry.payload = await apiFetch( + `/api/views/vehicle/${vid}/trips`, + { params: { date: _currentDate } }, + ); + } catch (err) { + entry.payload = { error: err.message || String(err), trips: [] }; + return; + } + // Draw this vehicle's all-day routes as a static overlay. + _drawVehicleDayPaths(map, vid, entry.payload, entry.color); +} + +function _drawVehicleDayPaths(map, vid, payload, color) { + _clearVehicleLayers(map, vid); + const trips = (payload.trips || []).filter(t => t.path && t.path.coordinates); + if (trips.length === 0) return; + const features = trips.map(t => ({ + type: 'Feature', + geometry: t.path, + properties: { trip_id: t.trip_id, vehicle_id: vid }, + })); + const srcId = `vroute-${vid}`; + const layerId = `vroute-line-${vid}`; + map.addSource(srcId, { + type: 'geojson', + data: { type: 'FeatureCollection', features }, + }); + map.addLayer({ + id: layerId, + type: 'line', + source: srcId, + layout: { 'line-join': 'round', 'line-cap': 'round' }, + paint: { + 'line-color': color, + 'line-width': ['interpolate', ['linear'], ['zoom'], 8, 1.5, 14, 3, 17, 5], + 'line-opacity': 0.85, + }, + }); +} + +function _clearVehicleLayers(map, vid) { + const layerId = `vroute-line-${vid}`; + const srcId = `vroute-${vid}`; + if (map.getLayer(layerId)) map.removeLayer(layerId); + if (map.getSource(srcId)) map.removeSource(srcId); +} + +function _renderDock(map, els) { + if (_selection.size === 0) { + els.plate.textContent = '—'; + els.driver.textContent = ''; + els.totals.innerHTML = 'Click a vehicle to see its trips.'; + els.list.innerHTML = ''; + return; + } + + if (_selection.size === 1) { + _renderSingle(map, els); + } else { + _renderMulti(map, els); + } + // Fit map to union of all selected vehicles' route bounds + _fitSelectionBounds(map); +} + +function _renderSingle(map, els) { + const [[vid, entry]] = _selection; + els.plate.textContent = entry.plate; + els.driver.textContent = entry.driver; + if (!entry.payload || entry.payload.error) { + els.totals.innerHTML = entry.payload?.error + ? `${_esc(entry.payload.error)}` + : 'Loading…'; + els.list.innerHTML = ''; + return; + } + const payload = entry.payload; const t = payload.totals || {}; const q = payload.data_quality || {}; - const reportingTime = payload.reporting_time - ? _formatTimeOnly(payload.reporting_time) : '—'; + const rep = payload.reporting_time ? _formatTimeOnly(payload.reporting_time) : '—'; els.totals.innerHTML = ` -
Reporting ${_esc(reportingTime)} · ${t.trip_count ?? 0} trips · ${_fmtNum(t.distance_km, 1)} km
+
Reporting ${_esc(rep)} · ${t.trip_count ?? 0} trips · ${_fmtNum(t.distance_km, 1)} km
drive ${_fmtNum(t.driving_min, 0)}m · idle ${_fmtNum(t.idling_min, 0)}m · stop ${_fmtNum(t.stopped_min, 0)}m
-
${q.fix_count ?? 0} fixes · ACC ${q.has_acc_data ? 'on' : 'off'}
+
${q.fix_count ?? 0} fixes · ACC ${q.has_acc_data ? 'on' : 'off'} · ⌘-click to compare
`; const trips = payload.trips || []; @@ -712,6 +836,101 @@ function _renderTripPanel(map, els, payload) { } } +function _renderMulti(map, els) { + // Aggregate totals across all loaded payloads. + let tripCount = 0, distance = 0, drive = 0, idle = 0, stop = 0; + for (const { payload } of _selection.values()) { + if (!payload || payload.error) continue; + const t = payload.totals || {}; + tripCount += t.trip_count ?? 0; + distance += Number(t.distance_km ?? 0); + drive += Number(t.driving_min ?? 0); + idle += Number(t.idling_min ?? 0); + stop += Number(t.stopped_min ?? 0); + } + + els.plate.textContent = `${_selection.size} vehicles`; + els.driver.textContent = '⌘-click another vehicle to add / remove'; + els.totals.innerHTML = ` +
${tripCount} trips · ${_fmtNum(distance, 1)} km
+
drive ${_fmtNum(drive, 0)}m · idle ${_fmtNum(idle, 0)}m · stop ${_fmtNum(stop, 0)}m
+ `; + + // One compact row per vehicle, in selection order. + const rows = [..._selection.entries()].map(([vid, entry]) => { + const t = entry.payload?.totals || {}; + return ` +
+
+ ${_esc(entry.plate)} + +
+
${_esc(entry.driver || '—')}
+
+ ${t.trip_count ?? 0} trips + ${_fmtNum(t.distance_km, 1)} km + ${_fmtNum(t.driving_min, 0)}m drive +
+
+ `; + }).join(''); + els.list.innerHTML = rows; + + for (const btn of els.list.querySelectorAll('.trip-vehicle-remove')) { + btn.addEventListener('click', (ev) => { + ev.stopPropagation(); + const vid = Number(btn.dataset.remove); + _removeVehicle(map, vid); + _renderDock(map, els); + }); + } + for (const row of els.list.querySelectorAll('.trip-vehicle-row')) { + row.addEventListener('click', () => { + const vid = Number(row.dataset.vehicleId); + _fitVehicleBounds(map, vid); + }); + } +} + +function _fitSelectionBounds(map) { + // eslint-disable-next-line no-undef + let bounds = null; + for (const { payload } of _selection.values()) { + if (!payload || !payload.trips) continue; + for (const trip of payload.trips) { + const coords = trip.path?.coordinates; + if (!coords || coords.length < 1) continue; + for (const c of coords) { + // eslint-disable-next-line no-undef + if (!bounds) bounds = new maplibregl.LngLatBounds(c, c); + else bounds.extend(c); + } + } + } + if (bounds) { + map.fitBounds(bounds, { padding: { top: 60, right: 60, bottom: 360, left: 60 }, duration: 600, maxZoom: 14 }); + } +} + +function _fitVehicleBounds(map, vid) { + const entry = _selection.get(vid); + if (!entry?.payload?.trips) return; + // eslint-disable-next-line no-undef + let bounds = null; + for (const trip of entry.payload.trips) { + const coords = trip.path?.coordinates; + if (!coords) continue; + for (const c of coords) { + // eslint-disable-next-line no-undef + if (!bounds) bounds = new maplibregl.LngLatBounds(c, c); + else bounds.extend(c); + } + } + if (bounds) { + map.fitBounds(bounds, { padding: { top: 60, right: 60, bottom: 360, left: 60 }, duration: 600, maxZoom: 14 }); + } +} + function _reasonClass(r) { if (r === 'work_stop') return 'work-stop'; if (r === 'nofix_stop') return 'nofix-stop'; @@ -729,7 +948,7 @@ function _reasonLabel(r) { function _showAndAnimateTrip(map, trip) { _cancelTripAnim(); - _clearTripLayers(map); + _clearSingleTripLayers(map); if (!trip.path || !trip.path.coordinates || trip.path.coordinates.length < 2) return; const coords = trip.path.coordinates; @@ -820,7 +1039,7 @@ function _cancelTripAnim() { } } -function _clearTripLayers(map) { +function _clearSingleTripLayers(map) { for (const id of [TRIP_MARKER_LAYER, TRIP_PATH_LAYER]) { if (map.getLayer(id)) map.removeLayer(id); } @@ -830,13 +1049,13 @@ function _clearTripLayers(map) { } function _closeTripPanel(map, panelRoot, els) { - _cancelTripAnim(); - _clearTripLayers(map); + _clearSelection(map); panelRoot.classList.remove('open'); panelRoot.setAttribute('aria-hidden', 'true'); els.totals.innerHTML = 'Click a vehicle to see its trips.'; els.list.innerHTML = ''; - _tripState = { vehicleId: null, date: null, payload: null }; + els.plate.textContent = '—'; + els.driver.textContent = ''; } function _todayEat() { diff --git a/web/index-live.html b/web/index-live.html index 07a383f..c790495 100644 --- a/web/index-live.html +++ b/web/index-live.html @@ -182,6 +182,12 @@ .trip-card-reason.work-stop { color: var(--accent); } .trip-card-reason.nofix-stop { color: var(--warn); } .trip-card-reason.long-gap { color: var(--bad); } + .trip-vehicle-row { min-width: 200px; } + .trip-vehicle-remove { + background: transparent; color: var(--muted); border: 0; + font-size: 14px; cursor: pointer; padding: 0 4px; line-height: 1; + } + .trip-vehicle-remove:hover { color: var(--bad); }