Trip panel UI: click vehicle → side panel, trip list, animated playback
Some checks are pending
build / lint-test (push) Waiting to run
build / build-push (push) Blocked by required conditions

Click any vehicle on the map to open a 360px slide-in panel showing:
  - reporting time (first ACC_ON of the day)
  - day totals: trip count, distance, drive/idle/stop minutes
  - per-trip rows with start/end/duration/distance/idling, click to
    select; selected trip renders its polyline + animates a marker
    along it over 10 seconds
  - end-reason badge per trip (work stop, reporting silence, long gap,
    day end) with colour-coded accent
  - date picker (defaults to today EAT)
  - CSV download button → /trips.csv?date=...

Map clicks query rendered features across circle/arrow/label layers and
take the topmost — single click handler, no per-layer duplicates. The
existing hover popup remains untouched.

Wraps #map in #map-container so the panel can absolute-position over
the right side without disturbing the existing left-aside grid layout.
authClient gets a getToken() helper so the CSV download path can attach
the Authorization header for a plain fetch (apiFetch returns JSON only).
This commit is contained in:
kianiadee 2026-05-27 14:14:06 +03:00
parent 7d63c03191
commit 9393491869
2 changed files with 393 additions and 2 deletions

View file

@ -48,6 +48,10 @@ export const authClient = {
localStorage.removeItem(STORAGE_EXPIRES); localStorage.removeItem(STORAGE_EXPIRES);
}, },
getToken() {
return localStorage.getItem(STORAGE_ACCESS);
},
requireSession({ loginPath = '/login.html' } = {}) { requireSession({ loginPath = '/login.html' } = {}) {
if (!this.isAuthenticated()) { if (!this.isAuthenticated()) {
window.location.href = loginPath; window.location.href = loginPath;
@ -357,6 +361,292 @@ export function initFilters(formEl, onChange) {
formEl.addEventListener('submit', (e) => { e.preventDefault(); handler(); }); formEl.addEventListener('submit', (e) => { e.preventDefault(); handler(); });
} }
/* ---------- 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;
let _tripAnimRAF = null;
let _tripState = { vehicleId: null, date: null, payload: null };
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'),
};
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.csv.addEventListener('click', () => {
if (_tripState.vehicleId) _downloadTripsCsv(_tripState.vehicleId, els.date.value);
});
const layers = ['vehicles-circle', 'vehicles-arrow', 'vehicles-label'];
map.on('click', (e) => {
const features = map.queryRenderedFeatures(e.point, { layers });
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 || '';
if (!els.date.value) els.date.value = _todayEat();
panelRoot.classList.add('open');
panelRoot.setAttribute('aria-hidden', 'false');
_loadTrips(map, panelRoot, els, vid, els.date.value);
});
}
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 _renderTripPanel(map, els, payload) {
const t = payload.totals || {};
const q = payload.data_quality || {};
const reportingTime = payload.reporting_time
? _formatTimeOnly(payload.reporting_time) : '—';
els.totals.innerHTML = `
<div>Reporting time: <strong>${_esc(reportingTime)}</strong></div>
<div style="margin-top:6px">
<strong>${t.trip_count ?? 0}</strong> trips ·
<strong>${_fmtNum(t.distance_km, 1)}</strong> km
</div>
<div style="margin-top:4px">
drive ${_fmtNum(t.driving_min, 0)}m · idle ${_fmtNum(t.idling_min, 0)}m · stop ${_fmtNum(t.stopped_min, 0)}m
</div>
<div class="quality">${q.fix_count ?? 0} fixes · ACC ${q.has_acc_data ? 'on' : 'off'}</div>
`;
const trips = payload.trips || [];
if (trips.length === 0) {
els.list.innerHTML = '<div class="trip-list-empty">No trips on this day.</div>';
return;
}
els.list.innerHTML = trips.map(trip => `
<div class="trip-row" data-trip-id="${trip.trip_id}">
<div class="trip-row-top">
<span>Trip ${trip.trip_id}</span>
<span>${_fmtNum(trip.distance_km, 1)} km</span>
</div>
<div class="trip-row-meta">
<span>${_formatTimeOnly(trip.started_at)} ${_formatTimeOnly(trip.ended_at)}</span>
<span>${_fmtNum(trip.duration_min, 0)} min</span>
${trip.idling_min > 0 ? `<span>idle ${_fmtNum(trip.idling_min, 0)}m</span>` : ''}
</div>
<div class="trip-row-reason ${_reasonClass(trip.end_reason)}">
${_reasonLabel(trip.end_reason)}
</div>
</div>
`).join('');
for (const row of els.list.querySelectorAll('.trip-row')) {
row.addEventListener('click', () => {
els.list.querySelectorAll('.trip-row.selected')
.forEach(r => r.classList.remove('selected'));
row.classList.add('selected');
const tid = Number(row.dataset.tripId);
const trip = trips.find(x => x.trip_id === tid);
if (trip) _showAndAnimateTrip(map, trip);
});
}
}
function _reasonClass(r) {
if (r === 'work_stop') return 'work-stop';
if (r === 'nofix_stop') return 'nofix-stop';
if (r === 'long_gap') return 'long-gap';
return '';
}
function _reasonLabel(r) {
return ({
work_stop: 'Stopped for work',
nofix_stop: 'Reporting silence',
long_gap: 'Long gap',
day_end: 'Day end',
})[r] || (r || '—');
}
function _showAndAnimateTrip(map, trip) {
_cancelTripAnim();
_clearTripLayers(map);
if (!trip.path || !trip.path.coordinates || trip.path.coordinates.length < 2) return;
const coords = trip.path.coordinates;
map.addSource(TRIP_PATH_SOURCE, { type: 'geojson', data: trip.path });
map.addLayer({
id: TRIP_PATH_LAYER,
type: 'line',
source: TRIP_PATH_SOURCE,
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: {
'line-color': '#10b981',
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 2, 14, 4, 17, 6],
'line-opacity': 0.85,
},
});
map.addSource(TRIP_MARKER_SOURCE, {
type: 'geojson',
data: { type: 'Feature', geometry: { type: 'Point', coordinates: coords[0] }, properties: {} },
});
map.addLayer({
id: TRIP_MARKER_LAYER,
type: 'circle',
source: TRIP_MARKER_SOURCE,
paint: {
'circle-radius': ['interpolate', ['linear'], ['zoom'], 8, 4, 14, 8, 17, 12],
'circle-color': '#ffffff',
'circle-stroke-color': '#10b981',
'circle-stroke-width': 3,
},
});
// eslint-disable-next-line no-undef
const bounds = coords.reduce(
(b, c) => b.extend(c),
// eslint-disable-next-line no-undef
new maplibregl.LngLatBounds(coords[0], coords[0]),
);
map.fitBounds(bounds, { padding: { top: 80, right: 380, bottom: 60, left: 60 }, duration: 600 });
_animatePathMarker(map, coords, TRIP_ANIM_MS);
}
function _animatePathMarker(map, coords, durationMs) {
// Pre-compute cumulative segment lengths (planar — fine for animation interpolation).
let total = 0;
const cum = [0];
for (let i = 1; i < coords.length; i++) {
const dx = coords[i][0] - coords[i - 1][0];
const dy = coords[i][1] - coords[i - 1][1];
total += Math.sqrt(dx * dx + dy * dy);
cum.push(total);
}
if (total === 0) return;
const startMs = performance.now();
const src = map.getSource(TRIP_MARKER_SOURCE);
const frame = (now) => {
if (!map.getSource(TRIP_MARKER_SOURCE)) return; // panel closed
const t = Math.min(1, (now - startMs) / durationMs);
const target = total * t;
// Binary search the segment containing `target`
let lo = 1, hi = cum.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (cum[mid] < target) lo = mid + 1; else hi = mid;
}
const i = lo;
const segStart = cum[i - 1], segEnd = cum[i];
const segT = segEnd > segStart ? (target - segStart) / (segEnd - segStart) : 0;
const a = coords[i - 1], b = coords[i];
const pos = [a[0] + (b[0] - a[0]) * segT, a[1] + (b[1] - a[1]) * segT];
src.setData({ type: 'Feature', geometry: { type: 'Point', coordinates: pos }, properties: {} });
if (t < 1) {
_tripAnimRAF = requestAnimationFrame(frame);
} else {
_tripAnimRAF = null;
}
};
_tripAnimRAF = requestAnimationFrame(frame);
}
function _cancelTripAnim() {
if (_tripAnimRAF !== null) {
cancelAnimationFrame(_tripAnimRAF);
_tripAnimRAF = null;
}
}
function _clearTripLayers(map) {
for (const id of [TRIP_MARKER_LAYER, TRIP_PATH_LAYER]) {
if (map.getLayer(id)) map.removeLayer(id);
}
for (const id of [TRIP_MARKER_SOURCE, TRIP_PATH_SOURCE]) {
if (map.getSource(id)) map.removeSource(id);
}
}
function _closeTripPanel(map, panelRoot, els) {
_cancelTripAnim();
_clearTripLayers(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 };
}
function _todayEat() {
const now = new Date();
const eat = new Date(now.getTime() + 3 * 3600 * 1000);
return eat.toISOString().slice(0, 10);
}
function _formatTimeOnly(iso) {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleTimeString('en-GB', {
timeZone: 'Africa/Nairobi', hour: '2-digit', minute: '2-digit', hour12: false,
});
}
function _fmtNum(v, digits) {
if (v == null || isNaN(Number(v))) return '—';
return Number(v).toFixed(digits);
}
async function _downloadTripsCsv(vehicleId, dateStr) {
const url = `/api/views/vehicle/${vehicleId}/trips.csv?date=${encodeURIComponent(dateStr)}`;
try {
const r = await fetch(url, {
headers: { Authorization: `Bearer ${authClient.getToken()}` },
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const blob = await r.blob();
const cd = r.headers.get('Content-Disposition') || '';
const m = cd.match(/filename="([^"]+)"/);
const filename = (m && m[1]) || `trips_${vehicleId}_${dateStr}.csv`;
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(a.href);
} catch (err) {
alert(`CSV download failed: ${err.message || err}`);
}
}
/* ---------- clockEAT ---------- */ /* ---------- clockEAT ---------- */
export function clockEAT(elementId) { export function clockEAT(elementId) {

View file

@ -72,6 +72,88 @@
form.filters label { font-size: 11px; text-transform: uppercase; color: var(--muted); letter-spacing: 0.06em; } form.filters label { font-size: 11px; text-transform: uppercase; color: var(--muted); letter-spacing: 0.06em; }
button.logout { background: transparent; color: var(--muted); border: 1px solid var(--muted); padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px; } button.logout { background: transparent; color: var(--muted); border: 1px solid var(--muted); padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px; }
button.logout:hover { color: var(--text); border-color: var(--text); } button.logout:hover { color: var(--text); border-color: var(--text); }
/* trip panel */
#map-container { position: relative; height: 100%; }
#map { position: absolute; inset: 0; }
.trip-panel {
position: absolute; top: 0; right: 0; bottom: 0;
width: 360px;
background: var(--panel);
border-left: 1px solid #0b1220;
color: var(--text);
display: flex; flex-direction: column;
transform: translateX(100%);
transition: transform 0.22s ease;
z-index: 2;
overflow: hidden;
box-shadow: -8px 0 24px rgba(0,0,0,0.35);
}
.trip-panel.open { transform: translateX(0); }
.trip-panel-header {
display: flex; justify-content: space-between; align-items: flex-start;
padding: 14px 16px 10px; border-bottom: 1px solid #0b1220;
}
.trip-plate { font-size: 18px; font-weight: 700; letter-spacing: 0.01em; }
.trip-driver { font-size: 12px; color: var(--muted); margin-top: 2px; }
.trip-close {
background: transparent; color: var(--muted); border: 0;
font-size: 24px; cursor: pointer; line-height: 1; padding: 0 4px;
}
.trip-close:hover { color: var(--text); }
.trip-controls {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 16px; gap: 8px; border-bottom: 1px solid #0b1220;
}
.trip-controls label { font-size: 11px; text-transform: uppercase; color: var(--muted); letter-spacing: 0.06em; display: flex; align-items: center; gap: 8px; }
.trip-controls input[type="date"] {
background: #0b1220; color: var(--text);
border: 1px solid #0b1220; border-radius: 4px;
padding: 4px 6px; font-size: 12px; font-family: inherit;
color-scheme: dark;
}
.trip-csv {
background: transparent; color: var(--muted);
border: 1px solid var(--muted); border-radius: 4px;
padding: 4px 10px; cursor: pointer;
font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase;
}
.trip-csv:hover { color: var(--text); border-color: var(--text); }
.trip-totals {
padding: 12px 16px; font-size: 12.5px; color: var(--muted);
border-bottom: 1px solid #0b1220; line-height: 1.5;
}
.trip-totals strong { color: var(--text); font-weight: 600; }
.trip-totals .quality { font-size: 11px; margin-top: 6px; }
.trip-list { overflow: auto; flex: 1; }
.trip-list-empty { padding: 16px; color: var(--muted); font-size: 12.5px; }
.trip-row {
padding: 10px 16px;
border-bottom: 1px solid #0b1220;
cursor: pointer;
}
.trip-row:hover { background: #0b1220; }
.trip-row.selected {
background: #0b1220;
border-left: 3px solid var(--accent);
padding-left: 13px;
}
.trip-row-top {
display: flex; justify-content: space-between;
font-size: 13px; font-weight: 600;
}
.trip-row-meta {
display: flex; gap: 10px; flex-wrap: wrap;
font-size: 11.5px; color: var(--muted); margin-top: 4px;
}
.trip-row-reason {
display: inline-block; margin-top: 6px;
font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em;
color: var(--muted);
}
.trip-row-reason.work-stop { color: var(--accent); }
.trip-row-reason.nofix-stop { color: var(--warn); }
.trip-row-reason.long-gap { color: var(--bad); }
</style> </style>
</head> </head>
<body> <body>
@ -98,12 +180,29 @@
<input id="f-city" name="assigned_city" placeholder="e.g. Nairobi" /> <input id="f-city" name="assigned_city" placeholder="e.g. Nairobi" />
</form> </form>
</aside> </aside>
<div id="map-container">
<div id="map"></div> <div id="map"></div>
<aside id="trip-panel" class="trip-panel" aria-hidden="true">
<div class="trip-panel-header">
<div>
<div class="trip-plate" id="trip-plate"></div>
<div class="trip-driver" id="trip-driver"></div>
</div>
<button class="trip-close" id="trip-close" aria-label="Close trip panel">×</button>
</div>
<div class="trip-controls">
<label>Date <input type="date" id="trip-date" /></label>
<button class="trip-csv" id="trip-csv" type="button">CSV</button>
</div>
<div class="trip-totals" id="trip-totals">Click a vehicle to see its trips.</div>
<div class="trip-list" id="trip-list"></div>
</aside>
</div>
</main> </main>
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script> <script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
<script type="module"> <script type="module">
import { authClient, apiFetch, initMap, renderView, initFilters, clockEAT } from '/fleet-core.js'; import { authClient, apiFetch, initMap, renderView, initFilters, clockEAT, initTripPanel } from '/fleet-core.js';
if (!authClient.requireSession()) { /* redirected */ } if (!authClient.requireSession()) { /* redirected */ }
@ -131,6 +230,8 @@
refresh(); refresh();
}); });
initTripPanel(map, document.getElementById('trip-panel'));
refresh(); refresh();
setInterval(refresh, 15000); setInterval(refresh, 15000);
</script> </script>