(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 showId = tl && tl.dataset.showId === "1";
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 + ")"; }
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 ? ' | ' : "";
var idCell = showId ? "#" + t.id + " | " : "";
row.innerHTML = cb + idCell + "" + 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; });
}());