Opening a session that's still recording (active in its summary) now re-fetches every 30s and updates the map in place, instead of freezing at whatever the track looked like when first opened. Stops on its own once the session ends, or immediately if switched away from.
316 lines
12 KiB
JavaScript
316 lines
12 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";
|
|
// Garmin devices post a new point roughly every 15s (postTrackPointFrequency
|
|
// in the session data) — polling twice that often would just re-fetch the
|
|
// same 400+KB page for nothing most of the time.
|
|
const REFRESH_INTERVAL_MS = 30000;
|
|
|
|
// 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;
|
|
this._refreshTimer = 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();
|
|
|
|
if (summary) {
|
|
button.append(statusBadge(summary.active), labelSpan, timeSpan);
|
|
} else {
|
|
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 {
|
|
this._geojson = await this._fetchTrack(id);
|
|
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();
|
|
this._scheduleRefresh(id);
|
|
} catch (err) {
|
|
window.alert(err.message);
|
|
} finally {
|
|
button.classList.remove("loading");
|
|
}
|
|
}
|
|
|
|
async _fetchTrack(id) {
|
|
const response = await fetch(`/api/livetrack/sessions/${encodeURIComponent(id)}/track`);
|
|
if (!response.ok) {
|
|
const { error } = await response.json().catch(() => ({}));
|
|
throw new Error(error || "Could not load track.");
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
// Re-fetches on a timer for as long as the session is still recording
|
|
// (properties.active on the Point feature — see LiveTrackClient), so an
|
|
// in-progress activity's line keeps growing instead of staying frozen at
|
|
// whatever it looked like when first opened. Stops on its own once the
|
|
// session ends, or immediately if the session is switched away from or
|
|
// toggled off in the meantime.
|
|
_scheduleRefresh(id) {
|
|
if (!this._geojson.features[1].properties.active) return;
|
|
|
|
this._refreshTimer = setTimeout(async () => {
|
|
if (this._activeSessionId !== id) return;
|
|
try {
|
|
const geojson = await this._fetchTrack(id);
|
|
if (this._activeSessionId !== id) return;
|
|
this._geojson = geojson;
|
|
this._map.getSource(TRACK_SOURCE)?.setData(geojson);
|
|
this._loadSessions();
|
|
} catch {
|
|
// Transient fetch failure — leave the track as-is and try again
|
|
// next tick rather than interrupting with an alert.
|
|
}
|
|
this._scheduleRefresh(id);
|
|
}, REFRESH_INTERVAL_MS);
|
|
}
|
|
|
|
_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() {
|
|
clearTimeout(this._refreshTimer);
|
|
this._refreshTimer = null;
|
|
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, active, time, speed_meters_per_sec, heart_rate_beats_per_min }) {
|
|
const status = active ? `<span class="livetrack-badge livetrack-badge--live">Live</span>` : `<span class="livetrack-badge">Ended</span>`;
|
|
const parts = [`<strong>${escapeHtml(session_name || "LiveTrack session")}</strong> ${status}`];
|
|
|
|
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 statusBadge(active) {
|
|
const badge = document.createElement("span");
|
|
badge.className = "livetrack-badge";
|
|
badge.classList.toggle("livetrack-badge--live", !!active);
|
|
badge.textContent = active ? "Live" : "Ended";
|
|
return badge;
|
|
}
|
|
|
|
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`;
|
|
}
|