(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 tileSubdomains = mapEl.dataset.tileSubdomains || "abc"; const minZoom = parseInt(mapEl.dataset.minZoom, 10) || 3; const maxZoom = parseInt(mapEl.dataset.maxZoom, 10) || 19; const map = L.map("history-map", { minZoom: minZoom, maxZoom: maxZoom }).setView([48.505, -0.09], 12); const tiles = L.tileLayer(tileUrl, { minZoom: minZoom, maxZoom: maxZoom, subdomains: tileSubdomains, attribution: tileAttrib, }).addTo(map); 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; let _geoCache = {}; let _preloadController = null; function _geoKey(lat, lon) { const la = typeof lat === 'number' ? lat : parseFloat(lat); const lo = typeof lon === 'number' ? lon : parseFloat(lon); if (isNaN(la) || isNaN(lo)) return null; return `${la.toFixed(3)},${lo.toFixed(3)}`; } function preloadGeocodes(points) { // Abort any in-flight preload from a previous trip selection. if (_preloadController) _preloadController.abort(); _preloadController = new AbortController(); const signal = _preloadController.signal; _geoCache = {}; const seen = new Set(); const queue = []; for (const p of points) { const key = _geoKey(p.latitude, p.longitude); if (!key || seen.has(key)) continue; seen.add(key); queue.push({ key, lat: p.latitude, lon: p.longitude }); } if (!queue.length) return; const CONCURRENCY = 2; let idx = 0; function next() { if (idx >= queue.length || signal.aborted) return; const item = queue[idx++]; const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), 3000); signal.addEventListener('abort', () => ctrl.abort()); fetch(`/api/geocode?lat=${item.lat}&lon=${item.lon}`, { signal: ctrl.signal }) .then(r => { clearTimeout(t); return r.ok ? r.json() : null; }) .then(data => { if (!signal.aborted) _geoCache[item.key] = (data && data.label) || null; }) .catch(() => { clearTimeout(t); }) .finally(() => { if (!signal.aborted) next(); }); } for (let i = 0; i < CONCURRENCY && i < queue.length; i++) next(); } function geocodePoint(lat, lon) { const key = _geoKey(lat, lon); if (!key || key === _lastGeoKey) return; _lastGeoKey = key; const fallback = `${lat.toFixed(5)}, ${lon.toFixed(5)}`; // Use cache if available if (_geoCache.hasOwnProperty(key)) { replayLocation.textContent = _geoCache[key] || fallback; return; } const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), 4000); fetch(`/api/geocode?lat=${lat}&lon=${lon}`, { signal: ctrl.signal }) .then(r => { clearTimeout(t); return r.ok ? r.json() : null; }) .then(data => { const label = (data && data.label) || fallback; _geoCache[key] = (data && data.label) || null; replayLocation.textContent = label; }) .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(); } 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`; preloadGeocodes(result.points); initializeTimeline(result.points); } const tripList = document.getElementById("trip-list"); const deletable = tripList && tripList.dataset.deletable === "1"; const deleteUrl = (tripList && tripList.dataset.deleteUrl) || "/api/trip/"; const batchDeleteUrl = tripList && tripList.dataset.batchDeleteUrl; const deleteSelectedBtn = document.getElementById("delete-selected-btn"); function getCheckedIds() { return Array.from(body.querySelectorAll("input.trip-checkbox:checked")) .map(cb => parseInt(cb.value)); } function updateDeleteSelectedBtn() { if (!deleteSelectedBtn) return; const count = getCheckedIds().length; deleteSelectedBtn.hidden = count === 0; deleteSelectedBtn.textContent = `Delete selected (${count})`; } function selectAllTrips() { const cbs = body.querySelectorAll("input.trip-checkbox"); const allChecked = cbs.length > 0 && Array.from(cbs).every(cb => cb.checked); cbs.forEach(cb => { cb.checked = !allChecked; }); updateDeleteSelectedBtn(); } async function deleteSelected() { const ids = getCheckedIds(); if (!ids.length || !batchDeleteUrl) return; const confirmed = await window.confirmDelete( `Delete ${ids.length} selected trip(s)?\n\nRaw GPS data is kept — only computed trip records are removed.` ); if (!confirmed) return; try { const res = await fetch(batchDeleteUrl, { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ids: ids}), }); if (!res.ok) throw new Error("Delete failed"); ids.forEach(id => { const row = body.querySelector(`tr[data-trip-id="${id}"]`); if (row) row.remove(); }); updateDeleteSelectedBtn(); } catch(e) { showError(e.message); } } if (deleteSelectedBtn) { deleteSelectedBtn.addEventListener("click", deleteSelected); } 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 ? `` : ""; const checkboxCol = batchDeleteUrl ? `