Files
yaris-obd/static/js/history.js
T
Mathieu Moran 87d63e8f32 feat: pin the fastest point on the trip replay map (FEAT-16)
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.
2026-08-15 17:54:49 -04:00

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; });
}());