Pi offline tiles only cover zoom 8-15; OSM live tiles support 0-19. history.js now reads data-min-zoom/data-max-zoom from the #history-map div instead of hardcoding 3/19. Pi template caps to 8-15 (prevents blank tiles at extreme zooms), server template allows full 3-19 OSM range. Co-authored-by: Copilot <[email protected]>
296 lines
11 KiB
JavaScript
296 lines
11 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 minZoom = parseInt(mapEl.dataset.minZoom || "3", 10);
|
|
const maxZoom = parseInt(mapEl.dataset.maxZoom || "19", 10);
|
|
const map = L.map("history-map", { minZoom, maxZoom }).setView([48.505, -0.09], 12);
|
|
const tiles = L.tileLayer(tileUrl, {
|
|
minZoom,
|
|
maxZoom,
|
|
attribution: tileAttrib,
|
|
});
|
|
let routeLayer = null;
|
|
let vehicleMarker = null;
|
|
let replayPoints = [];
|
|
let replayTimer = null;
|
|
let replayController = null;
|
|
const recenterBtn = document.getElementById("btn-recenter-history");
|
|
const replayLocation = document.getElementById("trip-replay-location");
|
|
|
|
let _geoTimer = null;
|
|
let _lastGeoKey = null;
|
|
|
|
function geocodePoint(lat, lon) {
|
|
const key = `${lat.toFixed(3)},${lon.toFixed(3)}`;
|
|
if (key === _lastGeoKey) return;
|
|
_lastGeoKey = key;
|
|
const fallback = `${lat.toFixed(5)}, ${lon.toFixed(5)}`;
|
|
const ctrl = new AbortController();
|
|
const t = setTimeout(() => ctrl.abort(), 6000);
|
|
fetch(`/api/geocode?lat=${lat}&lon=${lon}`, { signal: ctrl.signal })
|
|
.then(r => { clearTimeout(t); return r.ok ? r.json() : null; })
|
|
.then(data => { replayLocation.textContent = (data && data.label) ? data.label : fallback; })
|
|
.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 response = await fetch(
|
|
`/api/trip/${tripId}`,
|
|
{ signal: replayController.signal }
|
|
);
|
|
const result = await response.json();
|
|
if (!response.ok) {
|
|
throw new Error(result.error || "Could not load trip route.");
|
|
}
|
|
|
|
if (routeLayer) {
|
|
routeLayer.remove();
|
|
}
|
|
if (!map.hasLayer(tiles)) {
|
|
tiles.addTo(map);
|
|
}
|
|
const coordinates = result.points.map(function (point) {
|
|
return [point.latitude, point.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 (coordinates.length > 1) {
|
|
map.fitBounds(routeLayer.getBounds(), { padding: [24, 24] });
|
|
} else if (coordinates.length === 1) {
|
|
map.setView(coordinates[0], 15);
|
|
}
|
|
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`;
|
|
initializeTimeline(result.points);
|
|
}
|
|
|
|
const tripList = document.getElementById("trip-list");
|
|
const deletable = tripList && tripList.dataset.deletable === "1";
|
|
|
|
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>`
|
|
: "";
|
|
row.innerHTML = `
|
|
<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">
|
|
<button class="btn btn-sm btn-primary" type="button" data-trip-id="${trip.id}">
|
|
Replay
|
|
</button>${deleteBtn}
|
|
</td>`;
|
|
body.appendChild(row);
|
|
});
|
|
}
|
|
|
|
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(`/api/v1/portal/trip/${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;
|
|
}
|
|
}
|
|
|
|
// Replay button click (desktop) or row click (mobile)
|
|
const button = event.target.closest("[data-trip-id]");
|
|
const row = event.target.closest("tr[data-trip-id]");
|
|
const tripId = button ? button.dataset.tripId : (row ? row.dataset.tripId : null);
|
|
if (!tripId) {
|
|
return;
|
|
}
|
|
replayTrip(tripId).catch(function (replayError) {
|
|
if (replayError.name !== "AbortError") {
|
|
showError(replayError.message);
|
|
}
|
|
});
|
|
});
|
|
|
|
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;
|
|
});
|
|
}());
|