Garmin's own LiveTrack page already shows both when the activity was
started from a saved course — the session payload carries the course's
geoPoints right alongside the track points (LiveTrackClient found it
nested under the same React Query cache the session/points come from).
Rendered as a second, dashed LineString distinct from the actual
track's own line ("kind": "actual" vs "planned" on each GeoJSON
feature) — muted gray rather than matching Garmin's own purple-for-
planned, since this app's actual track is already purple and reusing
it would make the two indistinguishable.
335 lines
13 KiB
JavaScript
335 lines
13 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";
|
|
// The planned route (only present when the activity was started from a
|
|
// saved Garmin course, see LiveTrackClient#fetch) — dashed and muted so it
|
|
// reads as a reference line, not more recorded data next to the actual
|
|
// track's own color.
|
|
const PLANNED_COLOR = "#888";
|
|
// 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, 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 });
|
|
// Added before the actual track's own line layer so it paints
|
|
// underneath — the recorded track is the thing that actually matters
|
|
// when the two overlap.
|
|
this._map.addLayer({
|
|
id: `${TRACK_SOURCE}-planned`,
|
|
type: "line",
|
|
source: TRACK_SOURCE,
|
|
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", ["get", "kind"], "planned"]],
|
|
layout: { "line-cap": "round", "line-join": "round" },
|
|
paint: { "line-color": PLANNED_COLOR, "line-width": 2, "line-dasharray": [2, 2] },
|
|
});
|
|
this._map.addLayer({
|
|
id: `${TRACK_SOURCE}-line`,
|
|
type: "line",
|
|
source: TRACK_SOURCE,
|
|
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", ["get", "kind"], "actual"]],
|
|
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`;
|
|
}
|