Trip panel: multi-vehicle overlay + aggregate KPIs (⌘-click to compare)
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:
parent
f47d3dc118
commit
c7369caf71
2 changed files with 275 additions and 50 deletions
|
|
@ -602,14 +602,39 @@ export function applyClientFilter(map, { costCentres = [], cities = [] } = {}) {
|
||||||
|
|
||||||
/* ---------- trip panel ---------- */
|
/* ---------- trip panel ---------- */
|
||||||
|
|
||||||
|
const TRIP_ANIM_MS = 10000;
|
||||||
|
// Single-trip animation overlays (used in single-vehicle mode only).
|
||||||
const TRIP_PATH_SOURCE = 'trip-path-source';
|
const TRIP_PATH_SOURCE = 'trip-path-source';
|
||||||
const TRIP_MARKER_SOURCE = 'trip-marker-source';
|
const TRIP_MARKER_SOURCE = 'trip-marker-source';
|
||||||
const TRIP_PATH_LAYER = 'trip-path-line';
|
const TRIP_PATH_LAYER = 'trip-path-line';
|
||||||
const TRIP_MARKER_LAYER = 'trip-marker-dot';
|
const TRIP_MARKER_LAYER = 'trip-marker-dot';
|
||||||
const TRIP_ANIM_MS = 10000;
|
|
||||||
|
// 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 _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) {
|
export function initTripPanel(map, panelRoot) {
|
||||||
const els = {
|
const els = {
|
||||||
|
|
@ -623,60 +648,159 @@ export function initTripPanel(map, panelRoot) {
|
||||||
};
|
};
|
||||||
|
|
||||||
els.close.addEventListener('click', () => _closeTripPanel(map, panelRoot, els));
|
els.close.addEventListener('click', () => _closeTripPanel(map, panelRoot, els));
|
||||||
els.date.addEventListener('change', () => {
|
els.date.addEventListener('change', async () => {
|
||||||
if (_tripState.vehicleId) {
|
_currentDate = els.date.value;
|
||||||
_loadTrips(map, panelRoot, els, _tripState.vehicleId, 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', () => {
|
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'];
|
const hoverLayers = ['vehicles-circle', 'vehicles-arrow', 'vehicles-label'];
|
||||||
map.on('click', (e) => {
|
map.on('click', async (e) => {
|
||||||
const features = map.queryRenderedFeatures(e.point, { layers });
|
const features = map.queryRenderedFeatures(e.point, { layers: hoverLayers });
|
||||||
if (!features || features.length === 0) return;
|
if (!features || features.length === 0) return;
|
||||||
const f = features[0];
|
const f = features[0];
|
||||||
const vid = f.properties.vehicle_id;
|
const vid = f.properties.vehicle_id;
|
||||||
if (!vid) return;
|
if (!vid) return;
|
||||||
els.plate.textContent = f.properties.plate || `Vehicle ${vid}`;
|
const plate = f.properties.plate || `Vehicle ${vid}`;
|
||||||
els.driver.textContent = f.properties.driver_name || '';
|
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();
|
if (!els.date.value) els.date.value = _todayEat();
|
||||||
|
_currentDate = els.date.value;
|
||||||
panelRoot.classList.add('open');
|
panelRoot.classList.add('open');
|
||||||
panelRoot.setAttribute('aria-hidden', 'false');
|
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) {
|
function _addVehicle(vid, plate, driver) {
|
||||||
_tripState.vehicleId = vehicleId;
|
_selection.set(vid, { plate, driver, color: _nextColor(), payload: null });
|
||||||
_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 _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 t = payload.totals || {};
|
||||||
const q = payload.data_quality || {};
|
const q = payload.data_quality || {};
|
||||||
const reportingTime = payload.reporting_time
|
const rep = payload.reporting_time ? _formatTimeOnly(payload.reporting_time) : '—';
|
||||||
? _formatTimeOnly(payload.reporting_time) : '—';
|
|
||||||
els.totals.innerHTML = `
|
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>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 || [];
|
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) {
|
function _reasonClass(r) {
|
||||||
if (r === 'work_stop') return 'work-stop';
|
if (r === 'work_stop') return 'work-stop';
|
||||||
if (r === 'nofix_stop') return 'nofix-stop';
|
if (r === 'nofix_stop') return 'nofix-stop';
|
||||||
|
|
@ -729,7 +948,7 @@ function _reasonLabel(r) {
|
||||||
|
|
||||||
function _showAndAnimateTrip(map, trip) {
|
function _showAndAnimateTrip(map, trip) {
|
||||||
_cancelTripAnim();
|
_cancelTripAnim();
|
||||||
_clearTripLayers(map);
|
_clearSingleTripLayers(map);
|
||||||
if (!trip.path || !trip.path.coordinates || trip.path.coordinates.length < 2) return;
|
if (!trip.path || !trip.path.coordinates || trip.path.coordinates.length < 2) return;
|
||||||
const coords = trip.path.coordinates;
|
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]) {
|
for (const id of [TRIP_MARKER_LAYER, TRIP_PATH_LAYER]) {
|
||||||
if (map.getLayer(id)) map.removeLayer(id);
|
if (map.getLayer(id)) map.removeLayer(id);
|
||||||
}
|
}
|
||||||
|
|
@ -830,13 +1049,13 @@ function _clearTripLayers(map) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function _closeTripPanel(map, panelRoot, els) {
|
function _closeTripPanel(map, panelRoot, els) {
|
||||||
_cancelTripAnim();
|
_clearSelection(map);
|
||||||
_clearTripLayers(map);
|
|
||||||
panelRoot.classList.remove('open');
|
panelRoot.classList.remove('open');
|
||||||
panelRoot.setAttribute('aria-hidden', 'true');
|
panelRoot.setAttribute('aria-hidden', 'true');
|
||||||
els.totals.innerHTML = 'Click a vehicle to see its trips.';
|
els.totals.innerHTML = 'Click a vehicle to see its trips.';
|
||||||
els.list.innerHTML = '';
|
els.list.innerHTML = '';
|
||||||
_tripState = { vehicleId: null, date: null, payload: null };
|
els.plate.textContent = '—';
|
||||||
|
els.driver.textContent = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function _todayEat() {
|
function _todayEat() {
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,12 @@
|
||||||
.trip-card-reason.work-stop { color: var(--accent); }
|
.trip-card-reason.work-stop { color: var(--accent); }
|
||||||
.trip-card-reason.nofix-stop { color: var(--warn); }
|
.trip-card-reason.nofix-stop { color: var(--warn); }
|
||||||
.trip-card-reason.long-gap { color: var(--bad); }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue