Upload GPX files into a folder hierarchy (traversal-safe path resolution against the store root), browse them in a tree panel, and toggle each one's waypoints/tracks on the map. Waypoints render as clickable points showing name, description and time; bare URLs in the description are turned into safe links. GPX layers survive switching between the Online/Offline base map, same as the radar overlay. Adds rexml as an explicit dependency — it moved to a bundled gem in this Ruby version and isn't picked up by Bundler otherwise.
245 lines
7.9 KiB
JavaScript
245 lines
7.9 KiB
JavaScript
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 = `<button type="button">GPX</button>`;
|
|
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(`<strong>${escapeHtml(name)}</strong>`);
|
|
if (description) parts.push(`<p>${linkify(description).replace(/\n/g, "<br>")}</p>`);
|
|
if (time) {
|
|
const parsed = new Date(time);
|
|
if (!Number.isNaN(parsed.getTime())) {
|
|
parts.push(`<p class="gpx-popup__time">${escapeHtml(parsed.toLocaleString())}</p>`);
|
|
}
|
|
}
|
|
return parts.length > 0 ? parts.join("") : "<em>No details</em>";
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement("div");
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// Turns bare URLs in untrusted text into safe <a> 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 `<a href="${href}" target="_blank" rel="noopener noreferrer">${href}</a>${escapeHtml(trailing)}`;
|
|
}
|