From 93934918692645ae9f276802f1a1f5d5058bff22 Mon Sep 17 00:00:00 2001 From: kianiadee Date: Wed, 27 May 2026 14:14:06 +0300 Subject: [PATCH] =?UTF-8?q?Trip=20panel=20UI:=20click=20vehicle=20?= =?UTF-8?q?=E2=86=92=20side=20panel,=20trip=20list,=20animated=20playback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- web/fleet-core.js | 290 ++++++++++++++++++++++++++++++++++++++++++++ web/index-live.html | 105 +++++++++++++++- 2 files changed, 393 insertions(+), 2 deletions(-) diff --git a/web/fleet-core.js b/web/fleet-core.js index 50178e7..9cdbdd9 100644 --- a/web/fleet-core.js +++ b/web/fleet-core.js @@ -48,6 +48,10 @@ export const authClient = { localStorage.removeItem(STORAGE_EXPIRES); }, + getToken() { + return localStorage.getItem(STORAGE_ACCESS); + }, + requireSession({ loginPath = '/login.html' } = {}) { if (!this.isAuthenticated()) { window.location.href = loginPath; @@ -357,6 +361,292 @@ export function initFilters(formEl, onChange) { 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 = `${_esc(err.message || err)}`; + } +} + +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 = ` +
Reporting time: ${_esc(reportingTime)}
+
+ ${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'}
+ `; + + const trips = payload.trips || []; + if (trips.length === 0) { + els.list.innerHTML = '
No trips on this day.
'; + return; + } + els.list.innerHTML = trips.map(trip => ` +
+
+ Trip ${trip.trip_id} + ${_fmtNum(trip.distance_km, 1)} km +
+
+ ${_formatTimeOnly(trip.started_at)} → ${_formatTimeOnly(trip.ended_at)} + ${_fmtNum(trip.duration_min, 0)} min + ${trip.idling_min > 0 ? `idle ${_fmtNum(trip.idling_min, 0)}m` : ''} +
+
+ ${_reasonLabel(trip.end_reason)} +
+
+ `).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 ---------- */ export function clockEAT(elementId) { diff --git a/web/index-live.html b/web/index-live.html index a922a15..313d518 100644 --- a/web/index-live.html +++ b/web/index-live.html @@ -72,6 +72,88 @@ 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: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); } @@ -98,12 +180,29 @@ -
+
+
+ +