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