(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); // ── Loading spinner on map ────────────────────────────────────────── const spinner = L.control({position: "topright"}); spinner.onAdd = function () { var d = L.DomUtil.create("div", "history-spinner"); d.innerHTML = '
Loading…
'; d.style.cssText = "background:rgba(255,255,255,.85);padding:6px 8px;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.1);display:none"; return d; }; spinner.addTo(map); function setLoading(on) { spinner.getContainer().style.display = on ? "block" : "none"; } // ── Core state ────────────────────────────────────────────────────── let st = { route: null, marker: null, points: [], timer: null, ctrl: null, activeRow: null }; let _cache = {}; let _geoTimer = null, _geoCtrl = null, _lastKey = null; const recenterBtn = document.getElementById("btn-recenter-history"); const locEl = document.getElementById("trip-replay-location"); // ── Helpers ────────────────────────────────────────────────────────── function gk(lat, lon) { var a = typeof lat === "number" ? lat : parseFloat(lat); var b = typeof lon === "number" ? lon : parseFloat(lon); if (isNaN(a) || isNaN(b)) return null; return a.toFixed(3) + "," + b.toFixed(3); } function showErr(m) { error.textContent = m; error.hidden = false; } function stopTm() { if (st.timer) { clearInterval(st.timer); st.timer = null; } playButton.innerHTML = ''; playButton.setAttribute("aria-label", "Play"); } function cleanup() { stopTm(); if (st.route) { map.removeLayer(st.route); st.route = null; } if (st.marker) { map.removeLayer(st.marker); st.marker = null; } st.points = []; _cache = {}; _lastKey = null; if (_geoCtrl) { _geoCtrl.abort(); _geoCtrl = null; } clearTimeout(_geoTimer); timeline.hidden = true; summary.textContent = "Select a trip"; locEl.textContent = ""; recenterBtn.hidden = true; } function hlRow(r) { if (st.activeRow) st.activeRow.classList.remove("table-active"); if (r) r.classList.add("table-active"); st.activeRow = r; } // ── Geocode ────────────────────────────────────────────────────────── function preload(pts) { if (_geoCtrl) _geoCtrl.abort(); _geoCtrl = new AbortController(); var sig = _geoCtrl.signal; _cache = {}; var seen = {}, q = [], i; for (i = 0; i < pts.length; i++) { var k = gk(pts[i].latitude, pts[i].longitude); if (!k || seen[k]) continue; seen[k] = 1; q.push({k: k, lat: pts[i].latitude, lon: pts[i].longitude}); } if (!q.length) return; var C = 2, idx = 0; function nx() { if (idx >= q.length || sig.aborted) return; var it = q[idx++], c = new AbortController(); var t = setTimeout(function () { c.abort(); }, 3000); sig.addEventListener("abort", function () { c.abort(); }); fetch("/api/geocode?lat=" + it.lat + "&lon=" + it.lon, {signal: c.signal}) .then(function (r) { clearTimeout(t); return r.ok ? r.json() : null; }) .then(function (d) { if (!sig.aborted) _cache[it.k] = (d && d.label) || null; }) .catch(function () { clearTimeout(t); }) .finally(function () { if (!sig.aborted) nx(); }); } for (var j = 0; j < C && j < q.length; j++) nx(); } function showLoc(lat, lon) { var k = gk(lat, lon); if (!k || k === _lastKey) return; _lastKey = k; var fb = lat.toFixed(5) + ", " + lon.toFixed(5); if (_cache.hasOwnProperty(k)) { locEl.textContent = _cache[k] || fb; return; } var c = new AbortController(); var t = setTimeout(function () { c.abort(); }, 3000); fetch("/api/geocode?lat=" + lat + "&lon=" + lon, {signal: c.signal}) .then(function (r) { clearTimeout(t); return r.ok ? r.json() : null; }) .then(function (d) { var lb = (d && d.label) || fb; _cache[k] = (d && d.label) || null; locEl.textContent = lb; }) .catch(function () { clearTimeout(t); locEl.textContent = fb; }); } function schedLoc(lat, lon) { clearTimeout(_geoTimer); _geoTimer = setTimeout(function () { showLoc(lat, lon); }, 600); } // ── Format ─────────────────────────────────────────────────────────── function fD(s) { var h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60); return h ? h + "h " + m + "m" : m ? m + "m" : s + "s"; } function fT(v) { return new Date(v.replace(" ", "T")).toLocaleString(); } function fK(km) { return km == null ? "\u2014" : km.toFixed(km < 1 ? 2 : 1) + " km"; } function fS(kmh) { return kmh == null ? "\u2014" : kmh.toFixed(1) + " km/h"; } // ── Timeline ───────────────────────────────────────────────────────── function showPt(i) { var p = st.points[i]; if (!p) return; st.marker.setLatLng([p.latitude, p.longitude]); scrubber.value = i; replayTime.textContent = fT(p.datetime); replaySpeed.textContent = (p.speed_source === "obd" ? "OBD" : "GPS") + ": " + fS(p.speed); schedLoc(p.latitude, p.longitude); } function initTL(pts) { stopTm(); st.points = pts; scrubber.max = Math.max(0, pts.length - 1); scrubber.value = 0; timeline.hidden = !pts.length; locEl.textContent = ""; _lastKey = null; if (st.marker) { map.removeLayer(st.marker); st.marker = null; } if (!pts.length) return; st.marker = L.circleMarker( [pts[0].latitude, pts[0].longitude], {radius: 8, color: "#fff", weight: 3, fillColor: "#e74a3b", fillOpacity: 1} ).bindTooltip("Vehicle position").addTo(map); showPt(0); } // ── Trip loader ───────────────────────────────────────────────────── var _deb; function loadTrip(id) { clearTimeout(_deb); _deb = setTimeout(function () { _load(id); }, 300); } async function _load(id) { error.hidden = true; cleanup(); setLoading(true); if (st.ctrl) st.ctrl.abort(); st.ctrl = new AbortController(); var sig = st.ctrl.signal; try { var r = await fetch("/api/trip/" + id, {signal: sig}); var d = await r.json(); if (!r.ok) throw new Error(d.error || "Cannot load trip route."); if (sig.aborted) return; preload(d.points); var coords = d.points.map(function (p) { return [p.latitude, p.longitude]; }); st.route = L.featureGroup(); L.polyline(coords, {color: "#4e73df", weight: 5}).addTo(st.route); if (coords.length) { L.marker(coords[0]).bindPopup("Start").addTo(st.route); L.marker(coords[coords.length - 1]).bindPopup("End").addTo(st.route); } st.route.addTo(map); if (sig.aborted) { cleanup(); return; } if (coords.length > 1) map.fitBounds(st.route.getBounds(), {padding: [24, 24], maxZoom: 15}); else if (coords.length === 1) map.setView(coords[0], 15); if (sig.aborted) { cleanup(); return; } recenterBtn.hidden = false; recenterBtn.onclick = function () { if (coords.length > 1) map.fitBounds(st.route.getBounds(), {padding: [24, 24], maxZoom: 15}); else if (coords.length === 1) map.setView(coords[0], 15); }; summary.textContent = fK(d.trip.distance_km) + " \u00b7 " + fD(d.trip.duration_seconds) + " \u00b7 avg " + fS(d.trip.average_speed_kmh) + " \u00b7 max " + fS(d.trip.max_speed_kmh) + " \u00b7 " + d.points.length + " pts"; initTL(d.points); } catch (e) { if (e.name !== "AbortError") showErr(e.message); } finally { if (!sig.aborted) setLoading(false); } } // ── Scrubber & play ───────────────────────────────────────────────── scrubber.addEventListener("input", function () { stopTm(); showPt(Number(scrubber.value)); }); playButton.addEventListener("click", function () { if (st.timer) { stopTm(); return; } if (Number(scrubber.value) >= st.points.length - 1) showPt(0); playButton.innerHTML = ''; playButton.setAttribute("aria-label", "Pause"); st.timer = setInterval(function () { var nx = Number(scrubber.value) + 1; if (nx >= st.points.length) { stopTm(); return; } showPt(nx); }, 500); }); // ── Trip list ──────────────────────────────────────────────────────── var tl = document.getElementById("trip-list"); var del = tl && tl.dataset.deletable === "1"; var dUrl = (tl && tl.dataset.deleteUrl) || "/api/trip/"; var bUrl = tl && tl.dataset.batchDeleteUrl; var btD = document.getElementById("delete-selected-btn"); function cIds() { return Array.from(body.querySelectorAll("input.trip-checkbox:checked")).map(function (c) { return parseInt(c.value); }); } function upBt() { if (!btD) return; var n = cIds().length; btD.hidden = !n; btD.textContent = "Delete selected (" + n + ")"; } function slAll() { var cbs = body.querySelectorAll("input.trip-checkbox"); var all = cbs.length && Array.from(cbs).every(function (c) { return c.checked; }); cbs.forEach(function (c) { c.checked = !all; }); upBt(); } async function delSel() { var ids = cIds(); if (!ids.length || !bUrl) return; if (!(await window.confirmDelete("Delete " + ids.length + " trip(s)? Raw GPS data is kept."))) return; try { var r = await fetch(bUrl, {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ids: ids})}); if (!r.ok) throw new Error("Delete failed"); ids.forEach(function (id) { var row = body.querySelector('tr[data-trip-id="' + id + '"]'); if (row) row.remove(); }); upBt(); } catch (e) { showErr(e.message); } } if (btD) btD.addEventListener("click", delSel); function render(tt) { body.replaceChildren(); tt.forEach(function (t) { var row = document.createElement("tr"); row.dataset.started = t.start_datetime; row.dataset.distance = t.distance_km; row.dataset.duration = t.duration_seconds; row.dataset.maxSpeed = t.max_speed_kmh; row.dataset.tripId = t.id; row.style.cursor = "pointer"; var db = del ? '' : ""; var cb = bUrl ? '' : ""; row.innerHTML = cb + "" + fT(t.start_datetime) + "" + fK(t.distance_km) + "" + fD(t.duration_seconds) + "" + fS(t.max_speed_kmh) + "" + (t.odo_start_km != null ? t.odo_start_km.toFixed(0) + " \u2192 " + t.odo_end_km.toFixed(0) + " km" : "\u2014") + "" + db + ""; body.appendChild(row); }); body.querySelectorAll("input.trip-checkbox").forEach(function (c) { c.addEventListener("change", upBt); }); upBt(); } body.addEventListener("click", function (e) { if (del) { var db = e.target.closest("[data-delete-trip-id]"); if (db) { e.stopPropagation(); e.preventDefault(); var dt = db.closest("tr").querySelector("td").textContent.trim(); window.confirmDelete("Delete trip from " + dt + "?\n\nRaw GPS data is kept.") .then(function (ok) { if (!ok) return; return fetch(dUrl + db.dataset.deleteTripId, {method: "DELETE"}) .then(function (r) { if (!r.ok) throw new Error("Delete failed"); db.closest("tr").remove(); }) .catch(function (err) { showErr(err.message); }); }); return; } } var row = e.target.closest("tr[data-trip-id]"); if (!row) return; hlRow(row); loadTrip(row.dataset.tripId); }); fetch("/api/trips") .then(function (r) { if (!r.ok) throw new Error("Cannot load trips."); return r.json(); }) .then(function (d) { render(d.trips); }) .catch(function (e) { showErr(e.message); }) .finally(function () { loading.hidden = true; }); }());