Files
maptool/public/js/livetrack.js
T
randy 55607aece9
CI / check (push) Successful in 55s
CI / deploy (push) Successful in 23s
Show session summary on hover over its LiveTrack marker
The track endpoint now merges the session summary into the marker's
GeoJSON properties (already computed for storage, just wasn't passed
through), and the marker gets the same hover-popup treatment GPX
points already have.
2026-09-06 22:32:44 +02:00

265 lines
9.6 KiB
JavaScript

import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs";
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;
this._hoverPopup = null;
this._pointInteractivityRegistered = false;
// 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,
},
});
this._registerPointInteractivity();
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 },
);
}
// Registered once, ever — map.on(type, layerId, fn) is a delegated
// listener that lives on the map itself and keeps matching that layer id
// after setStyle() removes and (via _showTrack) re-adds it, so
// re-registering on every re-add would stack duplicate popups (same
// reasoning as GpxPanelControl's own point interactivity).
_registerPointInteractivity() {
if (this._pointInteractivityRegistered) return;
this._pointInteractivityRegistered = true;
const layerId = `${TRACK_SOURCE}-point`;
this._map.on("mouseenter", layerId, (event) => {
this._map.getCanvas().style.cursor = "pointer";
const feature = event.features[0];
if (!feature) return;
this._hoverPopup = new Popup({ closeButton: false, closeOnClick: false, offset: 10 })
.setLngLat(event.lngLat)
.setHTML(popupHtml(feature.properties))
.addTo(this._map);
});
this._map.on("mouseleave", layerId, () => {
this._map.getCanvas().style.cursor = "";
this._hoverPopup?.remove();
this._hoverPopup = null;
});
}
_removeTrack() {
this._activeSessionId = null;
this._geojson = null;
this._hoverPopup?.remove();
this._hoverPopup = 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);
}
}
// Properties come straight off the GeoJSON the server built from Garmin
// data — session_name in particular is user-supplied (whatever the LiveTrack
// session was named on the watch/app) — so escape before it lands in HTML,
// same as GpxPanelControl's popups do for GPX file contents.
function popupHtml({ session_name, activity_type, distance_meters, duration_secs, time, speed_meters_per_sec, heart_rate_beats_per_min }) {
const parts = [`<strong>${escapeHtml(session_name || "LiveTrack session")}</strong>`];
const stats = [];
if (activity_type) stats.push(escapeHtml(titleCase(activity_type)));
if (typeof distance_meters === "number") stats.push(`${(distance_meters / 1000).toFixed(1)} km`);
if (typeof duration_secs === "number") stats.push(formatDuration(duration_secs));
if (stats.length > 0) parts.push(`<p>${stats.join(" · ")}</p>`);
const latest = [];
if (typeof speed_meters_per_sec === "number") latest.push(`${(speed_meters_per_sec * 3.6).toFixed(1)} km/h`);
if (typeof heart_rate_beats_per_min === "number") latest.push(`${heart_rate_beats_per_min} bpm`);
if (latest.length > 0) parts.push(`<p>${latest.join(" · ")}</p>`);
if (time) {
const parsed = new Date(time);
if (!Number.isNaN(parsed.getTime())) {
parts.push(`<p class="gpx-popup__time">${escapeHtml(parsed.toLocaleString())}</p>`);
}
}
return parts.join("");
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
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`;
}