import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs"; const TREE_URL = "/api/gpx/tree"; const UPLOAD_URL = "/api/gpx"; const TRACK_COLOR = "#e63946"; // A MapLibre IControl: a "GPX" button that opens a side panel for // uploading GPX files (stored server-side in a folder hierarchy) and // toggling their tracks/waypoints on and off the map. export class GpxPanelControl { constructor(map) { this._map = map; this._visible = new Set(); // file paths currently shown on the map this._interactiveLayers = new Set(); // point layer ids with click/hover wired up // setStyle() (switching the Online/Offline base map) tears down all // custom sources and layers — put visible GPX layers back afterwards. map.on("style.load", () => { this._visible.forEach((path) => this._addLayers(path)); }); } onAdd() { this._button = document.createElement("div"); this._button.className = "maplibregl-ctrl maplibregl-ctrl-group gpx-toggle"; this._button.innerHTML = ``; this._button .querySelector("button") .addEventListener("click", () => this._togglePanel()); this._panel = document.getElementById("gpx-panel"); this._treeContainer = this._panel.querySelector(".gpx-tree"); this._uploadForm = this._panel.querySelector(".gpx-upload"); this._uploadStatus = this._panel.querySelector(".gpx-upload__status"); this._panel .querySelector(".gpx-panel__close") .addEventListener("click", () => this._togglePanel(false)); this._uploadForm.addEventListener("submit", (event) => this._handleUpload(event)); this._loadTree(); return this._button; } onRemove() { this._button.remove(); } _togglePanel(force) { this._panel.hidden = force === undefined ? !this._panel.hidden : !force; } async _loadTree() { const response = await fetch(TREE_URL); const tree = await response.json(); this._renderTree(tree); } _renderTree(node) { this._treeContainer.innerHTML = ""; this._treeContainer.appendChild(this._renderChildren(node.children)); } _renderChildren(children) { const ul = document.createElement("ul"); if (children.length === 0) { const li = document.createElement("li"); li.className = "gpx-tree__empty"; li.textContent = "No files yet"; ul.appendChild(li); return ul; } children.forEach((child) => { const li = document.createElement("li"); if (child.type === "folder") { const details = document.createElement("details"); details.open = true; const summary = document.createElement("summary"); summary.textContent = child.name; details.appendChild(summary); details.appendChild(this._renderChildren(child.children)); li.appendChild(details); } else { const label = document.createElement("label"); label.className = "gpx-tree__file"; const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.checked = this._visible.has(child.path); checkbox.addEventListener("change", () => this._toggleFile(child.path, checkbox.checked)); label.appendChild(checkbox); label.append(` ${child.name}`); li.appendChild(label); } ul.appendChild(li); }); return ul; } _toggleFile(path, visible) { if (visible) { this._visible.add(path); this._addLayers(path); } else { this._visible.delete(path); this._removeLayers(path); } } _addLayers(path) { const id = sourceId(path); if (this._map.getSource(id)) return; this._map.addSource(id, { type: "geojson", data: gpxFileUrl(path) }); this._map.addLayer({ id: `${id}-line`, type: "line", source: id, filter: ["==", ["geometry-type"], "LineString"], layout: { "line-cap": "round", "line-join": "round" }, paint: { "line-color": TRACK_COLOR, "line-width": 3 }, }); this._map.addLayer({ id: `${id}-points`, type: "circle", source: id, filter: ["==", ["geometry-type"], "Point"], paint: { "circle-radius": 5, "circle-color": TRACK_COLOR, "circle-stroke-color": "#fff", "circle-stroke-width": 1.5, }, }); this._registerPointInteractivity(`${id}-points`); } // Registered once per layer id, 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 _addLayers) re-adds // it, so re-registering on every re-add would stack duplicate popups. _registerPointInteractivity(layerId) { if (this._interactiveLayers.has(layerId)) return; this._interactiveLayers.add(layerId); this._map.on("click", layerId, (event) => { const feature = event.features[0]; if (!feature) return; new Popup({ closeButton: true, maxWidth: "260px" }) .setLngLat(event.lngLat) .setHTML(popupHtml(feature.properties)) .addTo(this._map); }); this._map.on("mouseenter", layerId, () => { this._map.getCanvas().style.cursor = "pointer"; }); this._map.on("mouseleave", layerId, () => { this._map.getCanvas().style.cursor = ""; }); } _removeLayers(path) { const id = sourceId(path); [`${id}-line`, `${id}-points`].forEach((layerId) => { if (this._map.getLayer(layerId)) this._map.removeLayer(layerId); }); if (this._map.getSource(id)) this._map.removeSource(id); } async _handleUpload(event) { event.preventDefault(); const formData = new FormData(this._uploadForm); this._setStatus("Uploading…"); try { const response = await fetch(UPLOAD_URL, { method: "POST", body: formData }); if (!response.ok) throw new Error("upload failed"); const { tree } = await response.json(); this._renderTree(tree); this._uploadForm.reset(); this._setStatus("Uploaded."); } catch (err) { this._setStatus("Upload failed."); } } _setStatus(text) { this._uploadStatus.hidden = false; this._uploadStatus.textContent = text; } } function sourceId(path) { return `gpx-${path}`; } function gpxFileUrl(path) { return `/api/gpx/file/${path.split("/").map(encodeURIComponent).join("/")}`; } // properties come straight from an uploaded GPX file — untrusted input — // so every value is escaped before landing in the popup's HTML. function popupHtml({ name, description, time }) { const parts = []; if (name) parts.push(`${escapeHtml(name)}`); if (description) parts.push(`
${linkify(description).replace(/\n/g, "
")}
${escapeHtml(parsed.toLocaleString())}
`); } } return parts.length > 0 ? parts.join("") : "No details"; } function escapeHtml(text) { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } // Turns bare URLs in untrusted text into safe links: the string is // split on the URL pattern and every piece is escaped — including the URL // itself, both as href and as link text — before any HTML is assembled, so // nothing bypasses escaping. The URL pattern excludes quote characters, so // a matched URL can never break out of the href="..." attribute. const URL_PATTERN = /(https?:\/\/[^\s<>"']+)/g; function linkify(text) { return text .split(URL_PATTERN) .map((part, i) => (i % 2 === 1 ? urlToLink(part) : escapeHtml(part))) .join(""); } function urlToLink(url) { // Keep trailing sentence punctuation (e.g. "...place/1234.") out of the // link itself. const [, core, trailing] = url.match(/^(.*?)([.,;:!?]*)$/); const href = escapeHtml(core); return `${href}${escapeHtml(trailing)}`; }