Render a session's track on the map
CI / check (push) Successful in 47s
CI / deploy (push) Successful in 19s

Clicking a LiveTrack session now fetches and draws its track (line +
latest position). Garmin's own API 403s any plain server-side request
(Cloudflare bot protection), so LiveTrackClient fetches the session
page instead and pulls the track out of the server-rendered data
embedded in it. The webhook now captures the session's token too,
since the id alone isn't enough to fetch anything.
This commit is contained in:
randy committed 2026-09-06 22:07:13 +02:00
1 parent 7083cec36c
commit f4b5237f01
7 files changed
+326 -29

No files matched your search

+25 -2
View File
@@ -440,12 +440,35 @@ html, body {
}
.livetrack-panel__item {
border-bottom: 1px solid #f0f0f0;
}
.livetrack-panel__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 0;
border-bottom: 1px solid #f0f0f0;
width: 100%;
padding: 6px 4px;
border: none;
border-radius: 4px;
background: none;
font: inherit;
text-align: left;
cursor: pointer;
}
.livetrack-panel__row:hover {
background: #f0f0f0;
}
.livetrack-panel__row.active {
background: #f1e6fb;
}
.livetrack-panel__row.loading {
opacity: 0.6;
cursor: wait;
}
.livetrack-panel__id {
+105 -4
View File
@@ -2,11 +2,27 @@ 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 session
// IDs received via the webhook (see docs/deployment.adoc) — read-only, just
// so they're visible somewhere instead of only in data/livetrack.json.
// 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";
@@ -56,6 +72,11 @@ export class LiveTrackPanelControl {
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);
const idSpan = document.createElement("span");
idSpan.className = "livetrack-panel__id";
idSpan.textContent = id;
@@ -64,8 +85,88 @@ export class LiveTrackPanelControl {
timeSpan.className = "livetrack-panel__time";
timeSpan.textContent = new Date(received_at).toLocaleString();
li.append(idSpan, timeSpan);
button.append(idSpan, 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");
} 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);
}
}
+1 -1
View File
@@ -128,7 +128,7 @@ layerToggle.setActive("online");
new GpxPanelControl(map).mount(sidebar);
new RadarControl(map).mount(sidebar);
new GnssControl(map).mount(sidebar);
new LiveTrackPanelControl().mount(sidebar);
new LiveTrackPanelControl(map).mount(sidebar);
function applyStyle(name) {
layerToggle.setActive(name);