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 = ` -