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.
173 lines
5.6 KiB
JavaScript
173 lines
5.6 KiB
JavaScript
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 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";
|
|
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 }) => {
|
|
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;
|
|
|
|
const timeSpan = document.createElement("span");
|
|
timeSpan.className = "livetrack-panel__time";
|
|
timeSpan.textContent = new Date(received_at).toLocaleString();
|
|
|
|
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);
|
|
}
|
|
}
|