Files
maptool/public/js/livetrack.js
T
randy 0ee98d8f07
CI / check (push) Successful in 48s
CI / deploy (push) Successful in 23s
Summarize LiveTrack sessions and prune stale ones
Fetching a session's track now also grabs its summary (name, activity
type, distance, duration) from the same page and stores it — sessions
the panel has actually shown replace their raw UUID with that summary,
and are kept indefinitely regardless of age. A session nobody ever
opened (no summary) is more likely test/dead data than something worth
keeping, so it's dropped automatically after 48h.
2026-09-06 22:18:25 +02:00

199 lines
6.8 KiB
JavaScript

import { LIVETRACK_ICON } from "/js/icons.js";
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
const SESSIONS_URL = "/api/livetrack/sessions";
const TRACK_SOURCE = "livetrack-track";
const TRACK_COLOR = "#7209b7";
// A sidebar icon that opens a side panel listing Garmin LiveTrack sessions
// received via the webhook (see docs/deployment.adoc). Clicking one fetches
// and draws its track on the map (only one shown at a time — clicking the
// active one again hides it).
export class LiveTrackPanelControl {
constructor(map) {
this._map = map;
this._activeSessionId = null;
this._geojson = null;
// setStyle() (switching the Online/Offline base map) tears down all
// custom sources and layers — put the track back afterwards, same as
// GpxPanelControl does for visible GPX files.
map.on("style.load", () => {
if (this._geojson) this._showTrack(this._geojson, { fit: false });
});
}
mount(sidebar) {
this._container = document.createElement("div");
this._container.className = "sidebar-item";
this._container.innerHTML = `<button type="button" class="sidebar-icon" title="LiveTrack" aria-label="LiveTrack">${LIVETRACK_ICON}</button>`;
this._icon = this._container.querySelector(".sidebar-icon");
this._icon.addEventListener("click", () => this._togglePanel());
this._panel = document.getElementById("livetrack-panel");
this._list = this._panel.querySelector(".livetrack-panel__list");
this._panel
.querySelector(".basemap-panel__close")
.addEventListener("click", () => this._togglePanel(false));
onlyOneSidePanelOpen(this._panel, () => this._icon.classList.remove("active"));
sidebar.appendChild(this._container);
}
_togglePanel(force) {
const open = force === undefined ? this._panel.hidden : force;
this._panel.hidden = !open;
this._icon.classList.toggle("active", open);
if (open) {
notifySidePanelOpen(this._panel);
this._loadSessions();
}
}
async _loadSessions() {
const response = await fetch(SESSIONS_URL);
const { sessions } = await response.json();
this._renderSessions(sessions);
}
_renderSessions(sessions) {
this._list.innerHTML = "";
if (sessions.length === 0) {
const li = document.createElement("li");
li.className = "gpx-tree__empty";
li.textContent = "No sessions yet";
this._list.appendChild(li);
return;
}
sessions.forEach(({ id, received_at, summary }) => {
const li = document.createElement("li");
li.className = "livetrack-panel__item";
const button = document.createElement("button");
button.type = "button";
button.className = "livetrack-panel__row";
button.classList.toggle("active", id === this._activeSessionId);
button.title = id;
const labelSpan = document.createElement("span");
labelSpan.className = "livetrack-panel__label";
labelSpan.classList.toggle("livetrack-panel__label--id", !summary);
labelSpan.textContent = summary ? summaryLabel(summary) : id;
const timeSpan = document.createElement("span");
timeSpan.className = "livetrack-panel__time";
timeSpan.textContent = new Date(received_at).toLocaleString();
button.append(labelSpan, timeSpan);
button.addEventListener("click", () => this._toggleTrack(id, button));
li.appendChild(button);
this._list.appendChild(li);
});
}
async _toggleTrack(id, button) {
const wasActive = id === this._activeSessionId;
this._removeTrack();
this._list.querySelectorAll(".livetrack-panel__row.active").forEach((el) => el.classList.remove("active"));
if (wasActive) return;
button.classList.add("loading");
try {
const response = await fetch(`/api/livetrack/sessions/${encodeURIComponent(id)}/track`);
if (!response.ok) {
const { error } = await response.json().catch(() => ({}));
window.alert(error || "Could not load track.");
return;
}
this._geojson = await response.json();
this._showTrack(this._geojson);
this._activeSessionId = id;
button.classList.add("active");
// The fetch above also computed and stored a summary server-side
// (see LiveTrackClient.summarize) — reload so this row picks it up
// instead of showing the raw id from here on.
this._loadSessions();
} finally {
button.classList.remove("loading");
}
}
_showTrack(geojson, { fit = true } = {}) {
if (this._map.getSource(TRACK_SOURCE)) return;
this._map.addSource(TRACK_SOURCE, { type: "geojson", data: geojson });
this._map.addLayer({
id: `${TRACK_SOURCE}-line`,
type: "line",
source: TRACK_SOURCE,
filter: ["==", ["geometry-type"], "LineString"],
layout: { "line-cap": "round", "line-join": "round" },
paint: { "line-color": TRACK_COLOR, "line-width": 3 },
});
this._map.addLayer({
id: `${TRACK_SOURCE}-point`,
type: "circle",
source: TRACK_SOURCE,
filter: ["==", ["geometry-type"], "Point"],
paint: {
"circle-radius": 6,
"circle-color": TRACK_COLOR,
"circle-stroke-color": "#fff",
"circle-stroke-width": 1.5,
},
});
if (!fit) return;
const coordinates = geojson.features[0].geometry.coordinates;
const bounds = coordinates.reduce(
([minLon, minLat, maxLon, maxLat], [lon, lat]) => [
Math.min(minLon, lon),
Math.min(minLat, lat),
Math.max(maxLon, lon),
Math.max(maxLat, lat),
],
[Infinity, Infinity, -Infinity, -Infinity],
);
this._map.fitBounds(
[
[bounds[0], bounds[1]],
[bounds[2], bounds[3]],
],
{ padding: 40 },
);
}
_removeTrack() {
this._activeSessionId = null;
this._geojson = null;
[`${TRACK_SOURCE}-line`, `${TRACK_SOURCE}-point`].forEach((layerId) => {
if (this._map.getLayer(layerId)) this._map.removeLayer(layerId);
});
if (this._map.getSource(TRACK_SOURCE)) this._map.removeSource(TRACK_SOURCE);
}
}
function summaryLabel({ session_name, activity_type, distance_meters, duration_secs }) {
const parts = [];
if (activity_type) parts.push(titleCase(activity_type));
if (typeof distance_meters === "number") parts.push(`${(distance_meters / 1000).toFixed(1)} km`);
if (typeof duration_secs === "number") parts.push(formatDuration(duration_secs));
const stats = parts.join(" · ");
if (session_name && stats) return `${session_name} — ${stats}`;
return session_name || stats || "Untitled session";
}
function titleCase(text) {
return text.charAt(0) + text.slice(1).toLowerCase();
}
function formatDuration(totalSeconds) {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}