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 = ``; 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, map_available }) => { const li = document.createElement("li"); li.className = "livetrack-panel__item"; const unavailable = map_available === false; const button = document.createElement("button"); button.type = "button"; button.className = "livetrack-panel__row"; button.classList.toggle("active", id === this._activeSessionId); button.disabled = unavailable; button.title = unavailable ? "Garmin no longer serves this session's map" : 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); } if (!unavailable) 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 ? `Live` : `Ended`; const parts = [`${escapeHtml(session_name || "LiveTrack session")} ${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(`

${stats.join(" · ")}

`); 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(`

${latest.join(" · ")}

`); if (time) { const parsed = new Date(time); if (!Number.isNaN(parsed.getTime())) { parts.push(`

${escapeHtml(parsed.toLocaleString())}

`); } } 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`; }