Files
yaris-obd/static/js/history.js
T

401 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);
let routeLayer = null;
let vehicleMarker = null;
let replayPoints = [];
let replayTimer = null;
let replayController = null;
let _replayDebounce = null;
const recenterBtn = document.getElementById("btn-recenter-history");
const replayLocation = document.getElementById("trip-replay-location");
let _geoTimer = null;
let _lastGeoKey = null;
let _geoCache = {};
let _preloadController = null;
function _geoKey(lat, lon) {
const la = typeof lat === 'number' ? lat : parseFloat(lat);
const lo = typeof lon === 'number' ? lon : parseFloat(lon);
if (isNaN(la) || isNaN(lo)) return null;
return `${la.toFixed(3)},${lo.toFixed(3)}`;
}
function preloadGeocodes(points) {
// Abort any in-flight preload from a previous trip selection.
if (_preloadController) _preloadController.abort();
_preloadController = new AbortController();
const signal = _preloadController.signal;
_geoCache = {};
const seen = new Set();
const queue = [];
for (const p of points) {
const key = _geoKey(p.latitude, p.longitude);
if (!key || seen.has(key)) continue;
seen.add(key);
queue.push({ key, lat: p.latitude, lon: p.longitude });
}
if (!queue.length) return;
const CONCURRENCY = 2;
let idx = 0;
function next() {
if (idx >= queue.length || signal.aborted) return;
const item = queue[idx++];
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 3000);
signal.addEventListener('abort', () => ctrl.abort());
fetch(`/api/geocode?lat=${item.lat}&lon=${item.lon}`, { signal: ctrl.signal })
.then(r => { clearTimeout(t); return r.ok ? r.json() : null; })
.then(data => {
if (!signal.aborted) _geoCache[item.key] = (data && data.label) || null;
})
.catch(() => { clearTimeout(t); })
.finally(() => { if (!signal.aborted) next(); });
}
for (let i = 0; i < CONCURRENCY && i < queue.length; i++) next();
}
function geocodePoint(lat, lon) {
const key = _geoKey(lat, lon);
if (!key || key === _lastGeoKey) return;
_lastGeoKey = key;
const fallback = `${lat.toFixed(5)}, ${lon.toFixed(5)}`;
// Use cache if available
if (_geoCache.hasOwnProperty(key)) {
replayLocation.textContent = _geoCache[key] || fallback;
return;
}
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 4000);
fetch(`/api/geocode?lat=${lat}&lon=${lon}`, { signal: ctrl.signal })
.then(r => { clearTimeout(t); return r.ok ? r.json() : null; })
.then(data => {
const label = (data && data.label) || fallback;
_geoCache[key] = (data && data.label) || null;
replayLocation.textContent = label;
})
.catch(() => { clearTimeout(t); replayLocation.textContent = fallback; });
}
function scheduleGeocode(lat, lon) {
clearTimeout(_geoTimer);
_geoTimer = setTimeout(() => geocodePoint(lat, lon), 600);
}
function formatDuration(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours) {
return `${hours}h ${minutes}m`;
}
return minutes ? `${minutes}m` : `${seconds}s`;
}
function formatDate(value) {
return new Date(value.replace(" ", "T")).toLocaleString();
}
function formatDistance(kilometers) {
if (kilometers == null) return '—';
return `${kilometers.toFixed(kilometers < 1 ? 2 : 1)} km`;
}
function formatSpeed(kilometersPerHour) {
if (kilometersPerHour == null) return '—';
return `${kilometersPerHour.toFixed(1)} km/h`;
}
function showError(message) {
error.textContent = message;
error.hidden = false;
}
function stopReplay() {
if (replayTimer) {
window.clearInterval(replayTimer);
replayTimer = null;
}
playButton.innerHTML = '<i class="fas fa-play" aria-hidden="true"></i>';
playButton.setAttribute("aria-label", "Play trip replay");
}
function showReplayPoint(index) {
const point = replayPoints[index];
if (!point) {
return;
}
const coordinate = [point.latitude, point.longitude];
vehicleMarker.setLatLng(coordinate);
scrubber.value = index;
replayTime.textContent = formatDate(point.datetime);
const source = point.speed_source === "obd" ? "OBD wheel" : "GPS";
replaySpeed.textContent = `${source} speed: ${formatSpeed(point.speed)}`;
scheduleGeocode(point.latitude, point.longitude);
}
function initializeTimeline(points) {
stopReplay();
replayPoints = points;
scrubber.max = Math.max(0, points.length - 1);
scrubber.value = 0;
timeline.hidden = points.length === 0;
replayLocation.textContent = '';
_lastGeoKey = null;
if (vehicleMarker) {
vehicleMarker.remove();
}
if (!points.length) {
vehicleMarker = null;
return;
}
vehicleMarker = L.circleMarker(
[points[0].latitude, points[0].longitude],
{
radius: 8,
color: "#fff",
weight: 3,
fillColor: "#e74a3b",
fillOpacity: 1,
}
).bindTooltip("Vehicle position").addTo(map);
showReplayPoint(0);
}
async function replayTrip(tripId) {
error.hidden = true;
if (replayController) {
replayController.abort();
}
replayController = new AbortController();
const signal = replayController.signal;
try {
const response = await fetch(`/api/trip/${tripId}`, { signal });
const result = await response.json();
if (!response.ok) throw new Error(result.error || "Could not load trip route.");
if (signal.aborted) return;
if (routeLayer) routeLayer.remove();
const coordinates = result.points.map(p => [p.latitude, p.longitude]);
routeLayer = L.featureGroup();
L.polyline(coordinates, { color: "#4e73df", weight: 5 }).addTo(routeLayer);
if (coordinates.length) {
L.marker(coordinates[0]).bindPopup("Trip start").addTo(routeLayer);
L.marker(coordinates[coordinates.length - 1]).bindPopup("Trip end").addTo(routeLayer);
}
routeLayer.addTo(map);
if (signal.aborted) { routeLayer.remove(); return; }
if (coordinates.length > 1) {
map.fitBounds(routeLayer.getBounds(), { padding: [24, 24] });
} else if (coordinates.length === 1) {
map.setView(coordinates[0], 15);
}
if (signal.aborted) { routeLayer.remove(); return; }
recenterBtn.hidden = false;
recenterBtn.onclick = function () {
if (coordinates.length > 1) {
map.fitBounds(routeLayer.getBounds(), { padding: [24, 24] });
} else if (coordinates.length === 1) {
map.setView(coordinates[0], 15);
}
};
summary.textContent = `${formatDistance(result.trip.distance_km)} · `
+ `${formatDuration(result.trip.duration_seconds)} · `
+ `avg ${formatSpeed(result.trip.average_speed_kmh)} · `
+ `max ${formatSpeed(result.trip.max_speed_kmh)} · `
+ `${result.points.length} route points`;
if (signal.aborted) { routeLayer.remove(); return; }
preloadGeocodes(result.points);
initializeTimeline(result.points);
} catch (e) {
if (e.name !== "AbortError") showError(e.message);
}
}
const tripList = document.getElementById("trip-list");
const deletable = tripList && tripList.dataset.deletable === "1";
const deleteUrl = (tripList && tripList.dataset.deleteUrl) || "/api/trip/";
const batchDeleteUrl = tripList && tripList.dataset.batchDeleteUrl;
const deleteSelectedBtn = document.getElementById("delete-selected-btn");
function getCheckedIds() {
return Array.from(body.querySelectorAll("input.trip-checkbox:checked"))
.map(cb => parseInt(cb.value));
}
function updateDeleteSelectedBtn() {
if (!deleteSelectedBtn) return;
const count = getCheckedIds().length;
deleteSelectedBtn.hidden = count === 0;
deleteSelectedBtn.textContent = `Delete selected (${count})`;
}
function selectAllTrips() {
const cbs = body.querySelectorAll("input.trip-checkbox");
const allChecked = cbs.length > 0 && Array.from(cbs).every(cb => cb.checked);
cbs.forEach(cb => { cb.checked = !allChecked; });
updateDeleteSelectedBtn();
}
async function deleteSelected() {
const ids = getCheckedIds();
if (!ids.length || !batchDeleteUrl) return;
const confirmed = await window.confirmDelete(
`Delete ${ids.length} selected trip(s)?\n\nRaw GPS data is kept — only computed trip records are removed.`
);
if (!confirmed) return;
try {
const res = await fetch(batchDeleteUrl, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ids: ids}),
});
if (!res.ok) throw new Error("Delete failed");
ids.forEach(id => {
const row = body.querySelector(`tr[data-trip-id="${id}"]`);
if (row) row.remove();
});
updateDeleteSelectedBtn();
} catch(e) { showError(e.message); }
}
if (deleteSelectedBtn) {
deleteSelectedBtn.addEventListener("click", deleteSelected);
}
function renderTrips(trips) {
body.replaceChildren();
trips.forEach(function (trip) {
const row = document.createElement("tr");
row.dataset.started = trip.start_datetime;
row.dataset.distance = trip.distance_km;
row.dataset.duration = trip.duration_seconds;
row.dataset.maxSpeed = trip.max_speed_kmh;
row.dataset.tripId = trip.id;
row.style.cursor = "pointer";
const deleteBtn = deletable
? `<button class="btn btn-sm btn-outline-danger ms-1" type="button" data-delete-trip-id="${trip.id}" title="Delete trip">
<i class="fas fa-trash fa-fw" aria-hidden="true"></i>
</button>`
: "";
const checkboxCol = batchDeleteUrl
? `<td class="text-center" onclick="event.stopPropagation()"><input type="checkbox" class="trip-checkbox form-check-input" value="${trip.id}"></td>`
: "";
row.innerHTML = `
${checkboxCol}
<td>${formatDate(trip.start_datetime)}</td>
<td>${formatDistance(trip.distance_km)}</td>
<td>${formatDuration(trip.duration_seconds)}</td>
<td>${formatSpeed(trip.max_speed_kmh)}</td>
<td class="text-muted small">${trip.odo_start_km != null ? trip.odo_start_km.toFixed(0) + ' → ' + trip.odo_end_km.toFixed(0) + ' km' : '—'}</td>
<td class="text-end text-nowrap d-none d-sm-table-cell">${deleteBtn}</td>`;
body.appendChild(row);
});
// Wire checkbox listeners
body.querySelectorAll("input.trip-checkbox").forEach(cb => {
cb.addEventListener("change", updateDeleteSelectedBtn);
});
updateDeleteSelectedBtn();
}
body.addEventListener("click", function (event) {
// Delete button — must check before replay to prevent row click triggering replay
if (deletable) {
const deleteBtn = event.target.closest("[data-delete-trip-id]");
if (deleteBtn) {
event.stopPropagation();
event.preventDefault();
const tripId = deleteBtn.dataset.deleteTripId;
const date = deleteBtn.closest("tr").querySelector("td").textContent.trim();
window.confirmDelete(`Delete trip from ${date}?\n\nThe raw GPS data is kept — only this computed trip record is removed.`)
.then(function(confirmed) {
if (!confirmed) return;
return fetch(deleteUrl + tripId, { method: "DELETE" })
.then(function(res) {
if (!res.ok) throw new Error("Delete failed");
deleteBtn.closest("tr").remove();
})
.catch(function(e) { showError(e.message); });
});
return;
}
}
// Row click triggers replay — debounced
const row = event.target.closest("tr[data-trip-id]");
if (!row) return;
const tripId = row.dataset.tripId;
clearTimeout(_replayDebounce);
_replayDebounce = setTimeout(() => replayTrip(tripId), 100);
});
scrubber.addEventListener("input", function () {
stopReplay();
showReplayPoint(Number(scrubber.value));
});
playButton.addEventListener("click", function () {
if (replayTimer) {
stopReplay();
return;
}
if (Number(scrubber.value) >= replayPoints.length - 1) {
showReplayPoint(0);
}
playButton.innerHTML = '<i class="fas fa-pause" aria-hidden="true"></i>';
playButton.setAttribute("aria-label", "Pause trip replay");
replayTimer = window.setInterval(function () {
const nextIndex = Number(scrubber.value) + 1;
if (nextIndex >= replayPoints.length) {
stopReplay();
return;
}
showReplayPoint(nextIndex);
}, 500);
});
fetch("/api/trips")
.then(function (response) {
if (!response.ok) {
throw new Error("Could not load trip history.");
}
return response.json();
})
.then(function (result) {
renderTrips(result.trips);
})
.catch(function (loadError) {
showError(loadError.message);
})
.finally(function () {
loading.hidden = true;
});
}());