(function () { "use strict"; const body = document.querySelector("#trip-list [data-data-list-body]"); const loading = document.getElementById("trip-loading"); const error = document.getElementById("trip-error"); const summary = document.getElementById("trip-summary"); const timeline = document.getElementById("trip-timeline"); const scrubber = document.getElementById("trip-scrubber"); const playButton = document.getElementById("trip-play"); const replayTime = document.getElementById("trip-replay-time"); const replaySpeed = document.getElementById("trip-replay-speed"); const mapEl = document.getElementById("history-map"); const tileUrl = mapEl.dataset.tileUrl || "/static/vendor/tiles/{z}/{x}/{y}.png"; const tileAttrib = mapEl.dataset.tileAttrib || "© MapTiler © OpenStreetMap contributors"; const minZoom = parseInt(mapEl.dataset.minZoom || "3", 10); const maxZoom = parseInt(mapEl.dataset.maxZoom || "19", 10); const map = L.map("history-map", { minZoom, maxZoom }).setView([48.505, -0.09], 12); const tiles = L.tileLayer(tileUrl, { minZoom, maxZoom, attribution: tileAttrib, }); let routeLayer = null; let vehicleMarker = null; let replayPoints = []; let replayTimer = null; let replayController = null; const recenterBtn = document.getElementById("btn-recenter-history"); const replayLocation = document.getElementById("trip-replay-location"); let _geoTimer = null; let _lastGeoKey = null; function geocodePoint(lat, lon) { const key = `${lat.toFixed(3)},${lon.toFixed(3)}`; if (key === _lastGeoKey) return; _lastGeoKey = key; const fallback = `${lat.toFixed(5)}, ${lon.toFixed(5)}`; const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), 6000); fetch(`/api/geocode?lat=${lat}&lon=${lon}`, { signal: ctrl.signal }) .then(r => { clearTimeout(t); return r.ok ? r.json() : null; }) .then(data => { replayLocation.textContent = (data && data.label) ? data.label : fallback; }) .catch(() => { clearTimeout(t); replayLocation.textContent = fallback; }); } function scheduleGeocode(lat, lon) { clearTimeout(_geoTimer); _geoTimer = setTimeout(() => geocodePoint(lat, lon), 600); } function formatDuration(seconds) { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); if (hours) { return `${hours}h ${minutes}m`; } return minutes ? `${minutes}m` : `${seconds}s`; } function formatDate(value) { return new Date(value.replace(" ", "T")).toLocaleString(); } function formatDistance(kilometers) { if (kilometers == null) return '—'; return `${kilometers.toFixed(kilometers < 1 ? 2 : 1)} km`; } function formatSpeed(kilometersPerHour) { if (kilometersPerHour == null) return '—'; return `${kilometersPerHour.toFixed(1)} km/h`; } function showError(message) { error.textContent = message; error.hidden = false; } function stopReplay() { if (replayTimer) { window.clearInterval(replayTimer); replayTimer = null; } playButton.innerHTML = ''; playButton.setAttribute("aria-label", "Play trip replay"); } function showReplayPoint(index) { const point = replayPoints[index]; if (!point) { return; } const coordinate = [point.latitude, point.longitude]; vehicleMarker.setLatLng(coordinate); scrubber.value = index; replayTime.textContent = formatDate(point.datetime); const source = point.speed_source === "obd" ? "OBD wheel" : "GPS"; replaySpeed.textContent = `${source} speed: ${formatSpeed(point.speed)}`; scheduleGeocode(point.latitude, point.longitude); } function initializeTimeline(points) { stopReplay(); replayPoints = points; scrubber.max = Math.max(0, points.length - 1); scrubber.value = 0; timeline.hidden = points.length === 0; replayLocation.textContent = ''; _lastGeoKey = null; if (vehicleMarker) { vehicleMarker.remove(); } if (!points.length) { vehicleMarker = null; return; } vehicleMarker = L.circleMarker( [points[0].latitude, points[0].longitude], { radius: 8, color: "#fff", weight: 3, fillColor: "#e74a3b", fillOpacity: 1, } ).bindTooltip("Vehicle position").addTo(map); showReplayPoint(0); } async function replayTrip(tripId) { error.hidden = true; if (replayController) { replayController.abort(); } replayController = new AbortController(); const response = await fetch( `/api/trip/${tripId}`, { signal: replayController.signal } ); const result = await response.json(); if (!response.ok) { throw new Error(result.error || "Could not load trip route."); } if (routeLayer) { routeLayer.remove(); } if (!map.hasLayer(tiles)) { tiles.addTo(map); } const coordinates = result.points.map(function (point) { return [point.latitude, point.longitude]; }); routeLayer = L.featureGroup(); L.polyline(coordinates, { color: "#4e73df", weight: 5 }).addTo(routeLayer); if (coordinates.length) { L.marker(coordinates[0]).bindPopup("Trip start").addTo(routeLayer); L.marker(coordinates[coordinates.length - 1]).bindPopup("Trip end").addTo(routeLayer); } routeLayer.addTo(map); if (coordinates.length > 1) { map.fitBounds(routeLayer.getBounds(), { padding: [24, 24] }); } else if (coordinates.length === 1) { map.setView(coordinates[0], 15); } recenterBtn.hidden = false; recenterBtn.onclick = function () { if (coordinates.length > 1) { map.fitBounds(routeLayer.getBounds(), { padding: [24, 24] }); } else if (coordinates.length === 1) { map.setView(coordinates[0], 15); } }; summary.textContent = `${formatDistance(result.trip.distance_km)} · ` + `${formatDuration(result.trip.duration_seconds)} · ` + `avg ${formatSpeed(result.trip.average_speed_kmh)} · ` + `max ${formatSpeed(result.trip.max_speed_kmh)} · ` + `${result.points.length} route points`; initializeTimeline(result.points); } const tripList = document.getElementById("trip-list"); const deletable = tripList && tripList.dataset.deletable === "1"; function renderTrips(trips) { body.replaceChildren(); trips.forEach(function (trip) { const row = document.createElement("tr"); row.dataset.started = trip.start_datetime; row.dataset.distance = trip.distance_km; row.dataset.duration = trip.duration_seconds; row.dataset.maxSpeed = trip.max_speed_kmh; row.dataset.tripId = trip.id; row.style.cursor = "pointer"; const deleteBtn = deletable ? `` : ""; row.innerHTML = `