Add GPX upload with hierarchical folders and map display

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.
This commit is contained in:
randy committed 2026-08-25 14:05:39 +02:00
1 parent 16e06b2ffe
commit 4e0d2abbd0
13 files changed
+629 -2

No files matched your search

+117
View File
@@ -76,3 +76,120 @@ html, body {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.gpx-toggle button {
/* Override maplibregl-ctrl-group's 29x29px square icon-button sizing —
this is a text button. */
width: auto;
height: auto;
border: none;
background: #fff;
padding: 6px 10px;
font: inherit;
font-size: 13px;
white-space: nowrap;
cursor: pointer;
}
.gpx-panel {
position: fixed;
top: 0;
right: 0;
width: 300px;
max-width: 90vw;
height: 100%;
display: flex;
flex-direction: column;
background: #fff;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15);
font-size: 13px;
z-index: 10;
}
.gpx-panel[hidden] {
display: none;
}
.gpx-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid #eee;
}
.gpx-panel__close {
border: none;
background: none;
font-size: 18px;
line-height: 1;
padding: 0 4px;
cursor: pointer;
}
.gpx-upload {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border-bottom: 1px solid #eee;
}
.gpx-upload input {
font: inherit;
font-size: 13px;
}
.gpx-upload button {
align-self: flex-start;
font: inherit;
font-size: 13px;
cursor: pointer;
}
.gpx-upload__status {
margin: 0;
color: #666;
}
.gpx-tree {
overflow-y: auto;
padding: 8px 12px;
}
.gpx-tree ul {
list-style: none;
margin: 0;
padding-left: 16px;
}
.gpx-tree > ul {
padding-left: 0;
}
.gpx-tree summary {
cursor: pointer;
font-weight: 600;
}
.gpx-tree__file {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 0;
cursor: pointer;
}
.gpx-tree__empty {
color: #888;
font-style: italic;
}
.maplibregl-popup-content p {
margin: 4px 0 0;
}
.gpx-popup__time {
color: #888;
font-size: 12px;
}
+244
View File
@@ -0,0 +1,244 @@
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)}`;
}
+2
View File
@@ -4,6 +4,7 @@ import {
addProtocol,
} from "/vendor/maplibre-gl/maplibre-gl.mjs";
import { RadarControl } from "/js/radar.js";
import { GpxPanelControl } from "/js/gpx.js";
const CENTER = [17.1077, 48.1486];
const ZOOM = 13;
@@ -74,6 +75,7 @@ map.addControl(new RadarControl(map), "top-right");
const layerToggle = new LayerToggleControl(applyStyle);
map.addControl(layerToggle, "top-left");
layerToggle.setActive("online");
map.addControl(new GpxPanelControl(map), "top-left");
function applyStyle(name) {
layerToggle.setActive(name);