Render a session's track on the map
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:
1 parent
7083cec36c
commit
f4b5237f01
7 files changed
+326
-29
No files matched your search
+105
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user