Trip panel: multi-vehicle overlay + aggregate KPIs (⌘-click to compare)
Some checks are pending
build / lint-test (push) Waiting to run
build / build-push (push) Blocked by required conditions

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.
This commit is contained in:
kianiadee 2026-05-27 23:24:12 +03:00
parent f47d3dc118
commit c7369caf71
2 changed files with 275 additions and 50 deletions

View file

@ -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 = `<span style="color:var(--bad)">${_esc(err.message || err)}</span>`;
}
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
? `<span style="color:var(--bad)">${_esc(entry.payload.error)}</span>`
: '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 = `
<div>Reporting <strong>${_esc(reportingTime)}</strong> · <strong>${t.trip_count ?? 0}</strong> trips · <strong>${_fmtNum(t.distance_km, 1)}</strong> km</div>
<div>Reporting <strong>${_esc(rep)}</strong> · <strong>${t.trip_count ?? 0}</strong> trips · <strong>${_fmtNum(t.distance_km, 1)}</strong> km</div>
<div>drive ${_fmtNum(t.driving_min, 0)}m · idle ${_fmtNum(t.idling_min, 0)}m · stop ${_fmtNum(t.stopped_min, 0)}m</div>
<div class="trip-quality">${q.fix_count ?? 0} fixes · ACC ${q.has_acc_data ? 'on' : 'off'}</div>
<div class="trip-quality">${q.fix_count ?? 0} fixes · ACC ${q.has_acc_data ? 'on' : 'off'} · -click to compare</div>
`;
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 = `
<div><strong>${tripCount}</strong> trips · <strong>${_fmtNum(distance, 1)}</strong> km</div>
<div>drive ${_fmtNum(drive, 0)}m · idle ${_fmtNum(idle, 0)}m · stop ${_fmtNum(stop, 0)}m</div>
`;
// One compact row per vehicle, in selection order.
const rows = [..._selection.entries()].map(([vid, entry]) => {
const t = entry.payload?.totals || {};
return `
<div class="trip-card trip-vehicle-row" data-vehicle-id="${vid}" style="border-left-color:${_esc(entry.color)};border-left-width:4px;border-left-style:solid">
<div class="trip-card-top">
<span>${_esc(entry.plate)}</span>
<button class="trip-vehicle-remove" data-remove="${vid}" aria-label="Remove"></button>
</div>
<div class="trip-card-times">${_esc(entry.driver || '—')}</div>
<div class="trip-card-meta">
<span>${t.trip_count ?? 0} trips</span>
<span>${_fmtNum(t.distance_km, 1)} km</span>
<span>${_fmtNum(t.driving_min, 0)}m drive</span>
</div>
</div>
`;
}).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() {

View file

@ -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); }
</style>
</head>
<body>