- placeMaxTick() clamps the label so it stays within the scrubber instead of overlapping the time/speed readouts when max speed is near an edge - give the scrubber row more bottom margin (mb-3) so the tick label clears the readout row below - move the trip location readout directly under the timestamp (left side), removing the separate bottom line
327 lines
18 KiB
JavaScript
327 lines
18 KiB
JavaScript
(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 = '<div class="spinner-border spinner-border-sm text-primary" role="status"><span class="visually-hidden">Loading…</span></div>';
|
|
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,
|
|
maxIndex: 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 = '<i class="fas fa-play" aria-hidden="true"></i>';
|
|
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; st.maxIndex = null;
|
|
var maxTick = document.getElementById("trip-max-tick");
|
|
if (maxTick) maxTick.hidden = true;
|
|
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 = {}, coords = [], 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;
|
|
coords.push({lat: pts[i].latitude, lon: pts[i].longitude});
|
|
}
|
|
if (!coords.length) return;
|
|
// Single batch POST instead of N individual GETs — one roundtrip.
|
|
fetch("/api/geocode/batch", {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({coords: coords}),
|
|
signal: sig
|
|
})
|
|
.then(function (r) { return r.ok ? r.json() : {}; })
|
|
.then(function (data) {
|
|
if (sig.aborted) return;
|
|
for (var key in data) { if (data.hasOwnProperty(key)) _cache[key] = data[key]; }
|
|
})
|
|
.catch(function () {});
|
|
}
|
|
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; }
|
|
// Fallback: individual fetch only if preload didn't cover this coord.
|
|
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); }, 200);
|
|
}
|
|
|
|
// ── 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);
|
|
var odoEl = document.getElementById("trip-replay-odometer");
|
|
if (odoEl) {
|
|
odoEl.textContent = (p.odometer != null)
|
|
? "Odometer: " + p.odometer.toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, " ") + " km"
|
|
: "";
|
|
}
|
|
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);
|
|
}
|
|
|
|
// ── Max-speed marker helpers ────────────────────────────────────────
|
|
function jumpToMax() {
|
|
if (st.maxIndex == null) return;
|
|
stopTm();
|
|
showPt(st.maxIndex);
|
|
}
|
|
function placeMaxTick() {
|
|
var tick = document.getElementById("trip-max-tick");
|
|
var scrubber = document.getElementById("trip-scrubber");
|
|
if (!tick || !scrubber) return;
|
|
if (st.maxIndex == null || !st.points || st.points.length < 2) {
|
|
tick.hidden = true;
|
|
return;
|
|
}
|
|
var ratio = st.maxIndex / (st.points.length - 1);
|
|
var thumb = 16; // approximate range thumb width
|
|
var w = scrubber.clientWidth;
|
|
var usable = Math.max(1, w - thumb);
|
|
var x = thumb / 2 + ratio * usable;
|
|
// Keep the centered label inside the scrubber so it doesn't overlap the
|
|
// time (left) or speed (right) readouts when max speed is at an edge.
|
|
var halfLabel = 24;
|
|
x = Math.max(halfLabel, Math.min(w - halfLabel, x));
|
|
tick.style.left = x + "px";
|
|
tick.hidden = false;
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
// Pin the fastest point on the route, remembering its index so
|
|
// clicking the marker scrubs the timeline to that point.
|
|
var maxPt = null; st.maxIndex = null;
|
|
for (var i = 0; i < d.points.length; i++) {
|
|
if (!maxPt || d.points[i].speed > maxPt.speed) {
|
|
maxPt = d.points[i];
|
|
st.maxIndex = i;
|
|
}
|
|
}
|
|
if (maxPt && maxPt.speed > 0) {
|
|
L.circleMarker([maxPt.latitude, maxPt.longitude], {
|
|
radius: 6,
|
|
color: "#fff",
|
|
weight: 2,
|
|
fillColor: "#f6c23e",
|
|
fillOpacity: 1,
|
|
})
|
|
.bindTooltip("Max speed: " + fS(maxPt.speed), {
|
|
permanent: true, direction: "top", offset: [0, -8],
|
|
})
|
|
.on("click", jumpToMax)
|
|
.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);
|
|
placeMaxTick();
|
|
} 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)); });
|
|
var maxTick = document.getElementById("trip-max-tick");
|
|
if (maxTick) maxTick.addEventListener("click", jumpToMax);
|
|
window.addEventListener("resize", placeMaxTick);
|
|
playButton.addEventListener("click", function () {
|
|
if (st.timer) { stopTm(); return; }
|
|
if (Number(scrubber.value) >= st.points.length - 1) showPt(0);
|
|
playButton.innerHTML = '<i class="fas fa-pause" aria-hidden="true"></i>';
|
|
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 ? '<button class="btn btn-sm btn-outline-danger ms-1" type="button" data-delete-trip-id="' + t.id + '" title="Delete"><i class="fas fa-trash fa-fw"></i></button>' : "";
|
|
var cbCell = bUrl ? '<td class="text-center"><input type="checkbox" class="trip-checkbox form-check-input" value="' + t.id + '" onclick="event.stopPropagation()"></td>' : '';
|
|
var idCell = showId ? '<td class="text-muted small text-end" style="width:1%">#' + t.id + '</td>' : '';
|
|
row.innerHTML = (bUrl ? '<td class="text-center"><input type="checkbox" class="trip-checkbox form-check-input" value="' + t.id + '" onclick="event.stopPropagation()"></td>' : '') + idCell + "<td>" + fT(t.start_datetime) + "</td><td>" + fK(t.distance_km) + "</td><td>" + fD(t.duration_seconds) + "</td><td>" + fS(t.max_speed_kmh) + "</td><td class=\"text-muted small\">" + (t.odo_start_km != null ? t.odo_start_km.toFixed(0) + " \u2192 " + t.odo_end_km.toFixed(0) + " km" : "\u2014") + "</td><td class=\"text-end text-nowrap d-none d-sm-table-cell\">" + db + "</td>";
|
|
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; });
|
|
}());
|