When a route loads, add an amber circle + permanent 'Max speed: X km/h' tooltip at the point with the highest speed. Shared history.js, so it applies to both the Pi and the server portal.
288 lines
16 KiB
JavaScript
288 lines
16 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
|
|
};
|
|
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;
|
|
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);
|
|
}
|
|
|
|
// ── 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.
|
|
var maxPt = null;
|
|
for (var i = 0; i < d.points.length; i++) {
|
|
if (!maxPt || d.points[i].speed > maxPt.speed) maxPt = d.points[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],
|
|
})
|
|
.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 = '<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; });
|
|
}());
|