(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 map = L.map("history-map", { minZoom: 8, maxZoom: 15 }).setView([48.505, -0.09], 12); const tiles = L.tileLayer("/static/vendor/tiles/{z}/{x}/{y}.png", { minZoom: 8, maxZoom: 15, attribution: "© MapTiler © OpenStreetMap contributors", }); 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)}`; fetch(`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lon}`, { headers: { 'Accept-Language': 'en' }, signal: AbortSignal.timeout(5000), }) .then(r => r.ok ? r.json() : null) .then(data => { if (!data) { replayLocation.textContent = fallback; return; } const a = data.address || {}; const road = a.road || a.hamlet || a.village || a.suburb || ''; const city = a.city || a.town || a.village || a.municipality || ''; replayLocation.textContent = [road, city].filter(Boolean).join(', ') || data.display_name?.split(',').slice(0, 2).join(',') || fallback; }) .catch(() => { 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) { return `${kilometers.toFixed(kilometers < 1 ? 2 : 1)} km`; } function formatSpeed(kilometersPerHour) { 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); } 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"; row.innerHTML = `