Files
maptool/public/js/gpx.js
T
randy 2a83bb10ca
CI / check (push) Successful in 42s
CI / deploy (push) Successful in 16s
Turn POI markers into pins and use a real icon set
Colored circles for waypoints looked too similar to a base map style's own
small circular POI icons, so uploaded POI data blended into the map instead
of standing out. Switched the marker shape to a pin/teardrop (reusing the
existing WAYPOINT_ICON path, filled instead of stroked) — no base map style
uses that shape for its own points, so it reads immediately as "your data".
Anchored at its tip, the standard map-pin convention, rather than centered
on the coordinate.

Also swapped the hand-drawn category glyphs for the National Park Service's
own map symbols (github.com/nationalparkservice/symbol-library, BSD
licensed, forked from Mapbox's Maki) for the six categories it covers —
professional, purpose-built pictograms beat ad-hoc "fuel pump"/"picnic
table" line art. `forest` has no NPS equivalent and stays hand-drawn.

The glyph now sits in the pin's head rather than centered on a flat dot,
which needed real geometry (both layers anchor to the same point, so the
offset is the gap between where the pin's head-center ends up and where the
glyph's own visual center already sits) rather than the old anchor="center"
zero-offset placement.
2026-09-07 16:37:31 +02:00

799 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs";
import {
GPX_ICON,
NEW_FOLDER_ICON,
UPLOAD_ICON,
WAYPOINT_ICON,
TRACK_ICON,
ROUTE_ICON,
MAP_PIN_ICON,
POI_ICONS,
} from "/js/icons.js";
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
import { formatActivityType } from "/js/track_profile.js";
const TREE_URL = "/api/gpx/tree";
const UPLOAD_URL = "/api/gpx";
const FOLDER_URL = "/api/gpx/folder";
const MOVE_URL = "/api/gpx/move";
const TRACK_COLOR = "#e63946";
const POI_ICON_KEYS = Object.keys(POI_ICONS);
const POI_ICON_IMAGE_SIZE = 48; // rasterized once at this size, rendered much smaller by icon-size below
const POI_ICON_SIZE = 0.3; // 48 * 0.3 = ~14px, matching the pin head's diameter below
// The pin glyph (icons.js's MAP_PIN_ICON) is a 24x24 viewBox with its head
// circle centered at y=9, rasterized onto the same POI_ICON_IMAGE_SIZE
// canvas as every other icon. icon-anchor: "bottom" anchors both this layer
// and the category-glyph layer below to the *raster canvas's* bottom edge
// (not the pin's visual tip, which sits a little above that edge) — so both
// layers share one screen anchor point, and placing the glyph in the pin's
// head is just "how far above that shared anchor is each icon's own visual
// center, and what's the gap between them":
const MAP_PIN_ID = "map-pin";
const MAP_PIN_SIZE = 0.55;
const MAP_PIN_HEAD_CENTER_SVG_Y = 9;
const MAP_PIN_VIEWBOX = 24;
const pinHeadCenterAboveAnchorPx =
(MAP_PIN_VIEWBOX - MAP_PIN_HEAD_CENTER_SVG_Y) * (POI_ICON_IMAGE_SIZE / MAP_PIN_VIEWBOX) * MAP_PIN_SIZE;
const glyphOwnCenterAboveAnchorPx = (POI_ICON_IMAGE_SIZE * POI_ICON_SIZE) / 2; // glyph content roughly fills its own canvas
const POI_ICON_OFFSET_Y = -((pinHeadCenterAboveAnchorPx - glyphOwnCenterAboveAnchorPx) / POI_ICON_SIZE);
// Custom drag payload MIME type for moving an already-uploaded file between
// folders, as opposed to an OS file drag (type "Files") for uploading a new
// one — both land on the same drop targets, told apart by this.
const MOVE_MIME = "application/x-gpx-path";
// A sidebar icon that opens a side panel for uploading GPX files (via
// drag & drop, stored server-side in a folder hierarchy the user can add to
// and reorganize by dragging), and toggling their tracks/waypoints on and
// off the map.
export class GpxPanelControl {
constructor(map, profilePanel) {
this._map = map;
this._profilePanel = profilePanel;
this._visible = new Set(); // file paths currently shown on the map
this._interactiveLayers = new Set(); // point layer ids with click/hover wired up
this._pointsByPath = new Map(); // path -> its waypoints FeatureCollection, for the category legend
// setStyle() (switching the Online/Offline base map) tears down all
// custom sources/layers/images — re-register the POI icon sprites
// before putting visible GPX layers back, so a symbol layer added right
// away doesn't reference a not-yet-existing image.
this._iconsReady = this._registerPoiIcons();
map.on("style.load", () => {
this._iconsReady = this._registerPoiIcons();
this._visible.forEach((path) => this._addLayers(path));
});
}
// SDF images (a single alpha shape, tinted at render time via icon-color)
// rather than plain color images: the source SVGs are silhouettes with no
// color information worth preserving, and SDF lets the same sprite be
// reused if a future category needs a different tint.
async _registerPoiIcons() {
const sources = { ...POI_ICONS, [MAP_PIN_ID]: MAP_PIN_ICON };
await Promise.all(
Object.keys(sources).map(async (key) => {
if (this._map.hasImage(key)) return;
const imageData = await svgToImageData(sources[key], POI_ICON_IMAGE_SIZE);
if (!this._map.hasImage(key)) this._map.addImage(key, imageData, { sdf: true });
}),
);
}
mount(sidebar) {
this._container = document.createElement("div");
this._container.className = "sidebar-item";
this._container.innerHTML = `<button type="button" class="sidebar-icon" title="Tracks" aria-label="Tracks">${GPX_ICON}</button>`;
this._icon = this._container.querySelector(".sidebar-icon");
this._icon.addEventListener("click", () => this._togglePanel());
this._panel = document.getElementById("gpx-panel");
this._treeContainer = this._panel.querySelector(".gpx-tree");
this._legendContainer = this._panel.querySelector(".gpx-legend");
this._uploadStatus = this._panel.querySelector(".gpx-upload__status");
this._panel.querySelector('[data-action="new-folder"]').innerHTML = NEW_FOLDER_ICON;
this._panel.querySelector('[data-action="upload"]').innerHTML = UPLOAD_ICON;
this._panel
.querySelector(".gpx-panel__close")
.addEventListener("click", () => this._togglePanel(false));
onlyOneSidePanelOpen(this._panel, () => this._icon.classList.remove("active"));
this._setupToolbarActions();
this._setupRootDropTarget();
this._setupMapDropTarget();
this._loadTree();
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);
}
// Both toolbar actions are icon buttons now (matching OsmAnd's tracks
// panel) rather than an always-visible dropzone/form: upload triggers the
// native file picker, new folder prompts for a path.
_setupToolbarActions() {
const fileInput = this._panel.querySelector(".gpx-file-input");
fileInput.addEventListener("change", () => {
this._uploadFiles(fileInput.files, "");
fileInput.value = "";
});
this._panel.querySelector('[data-action="upload"]').addEventListener("click", () => fileInput.click());
this._panel.querySelector('[data-action="new-folder"]').addEventListener("click", async () => {
const path = window.prompt("New folder name (e.g. Hiking/2026):");
if (!path || !path.trim()) return;
const response = await fetch(FOLDER_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `path=${encodeURIComponent(path.trim())}`,
});
if (redirectIfUnauthorized(response)) return;
if (response.ok) {
const { tree } = await response.json();
this._renderTree(tree);
}
});
}
// The tree itself is the drop target for both an OS file drag (upload to
// the top level) and, for anywhere that isn't a folder (blank space, or
// onto another file — folder drop targets call stopPropagation() so this
// only fires then), an internal file drag (move back to the top level).
_setupRootDropTarget() {
this._treeContainer.addEventListener("dragover", (event) => {
if (!event.dataTransfer.types.includes("Files") && !event.dataTransfer.types.includes(MOVE_MIME)) return;
event.preventDefault();
this._treeContainer.classList.add("gpx-drop-hover");
});
this._treeContainer.addEventListener("dragleave", (event) => {
if (event.target === this._treeContainer) this._treeContainer.classList.remove("gpx-drop-hover");
});
this._treeContainer.addEventListener("drop", (event) => {
event.preventDefault();
this._treeContainer.classList.remove("gpx-drop-hover");
if (event.dataTransfer.files?.length > 0) {
this._uploadFiles(event.dataTransfer.files, "");
return;
}
const from = event.dataTransfer.getData(MOVE_MIME);
if (from) this._moveFile(from, "");
});
}
// Dropping a GPX file straight onto the map (not just the panel) uploads
// it to the top level too.
_setupMapDropTarget() {
const mapContainer = this._map.getContainer();
mapContainer.addEventListener("dragover", (event) => {
if (!event.dataTransfer.types.includes("Files")) return;
event.preventDefault();
mapContainer.classList.add("gpx-drop-hover");
});
mapContainer.addEventListener("dragleave", (event) => {
if (event.target === mapContainer) mapContainer.classList.remove("gpx-drop-hover");
});
mapContainer.addEventListener("drop", (event) => {
mapContainer.classList.remove("gpx-drop-hover");
if (event.dataTransfer.files?.length > 0) {
event.preventDefault();
this._uploadFiles(event.dataTransfer.files, "");
}
});
}
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") {
li.appendChild(this._renderFolder(child));
} else {
li.appendChild(this._renderFile(child));
}
ul.appendChild(li);
});
return ul;
}
_renderFolder(folder) {
const details = document.createElement("details");
details.open = true;
const summary = document.createElement("summary");
const row = document.createElement("span");
row.className = "gpx-tree__folder-row";
const name = document.createElement("span");
name.textContent = folder.name;
row.appendChild(name);
row.appendChild(this._deleteButton(() => this._deleteFolder(folder.path)));
summary.appendChild(row);
this._wireFolderDropTarget(summary, folder.path);
details.appendChild(summary);
details.appendChild(this._renderChildren(folder.children));
return details;
}
_renderFile(file) {
const row = document.createElement("div");
row.className = "gpx-tree__file";
row.draggable = true;
row.addEventListener("dragstart", (event) => {
event.dataTransfer.setData(MOVE_MIME, file.path);
event.dataTransfer.effectAllowed = "move";
});
const label = document.createElement("label");
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = this._visible.has(file.path);
checkbox.addEventListener("change", () => this._toggleFile(file.path, checkbox.checked, checkbox));
const name = document.createElement("span");
name.className = "gpx-tree__file-name";
name.textContent = file.name;
name.title = file.name;
label.appendChild(checkbox);
(file.kinds || []).forEach((kind) => label.appendChild(this._kindIcon(kind)));
label.appendChild(name);
row.appendChild(label);
row.appendChild(this._deleteButton(() => this._deleteFile(file.path)));
return row;
}
_kindIcon(kind) {
const icon = document.createElement("span");
icon.className = "gpx-tree__kind-icon";
if (kind === "waypoint") {
icon.innerHTML = WAYPOINT_ICON;
icon.title = "Has waypoints";
} else if (kind === "route") {
icon.innerHTML = ROUTE_ICON;
icon.title = "Has a route";
} else {
icon.innerHTML = TRACK_ICON;
icon.title = "Has a track";
}
return icon;
}
_deleteButton(onClick) {
const button = document.createElement("button");
button.type = "button";
button.className = "gpx-tree__delete";
button.title = "Delete";
button.textContent = "×";
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
onClick();
});
return button;
}
// A folder accepts both an OS file drag (upload straight into it) and an
// internal file drag (move into it).
_wireFolderDropTarget(summary, folderPath) {
summary.addEventListener("dragover", (event) => {
if (!event.dataTransfer.types.includes("Files") && !event.dataTransfer.types.includes(MOVE_MIME)) return;
event.preventDefault();
summary.classList.add("gpx-drop-hover");
});
summary.addEventListener("dragleave", () => summary.classList.remove("gpx-drop-hover"));
summary.addEventListener("drop", (event) => {
event.preventDefault();
event.stopPropagation();
summary.classList.remove("gpx-drop-hover");
if (event.dataTransfer.files?.length > 0) {
this._uploadFiles(event.dataTransfer.files, folderPath);
return;
}
const from = event.dataTransfer.getData(MOVE_MIME);
if (from) this._moveFile(from, folderPath);
});
}
async _toggleFile(path, visible, checkbox) {
if (visible) {
this._visible.add(path);
const ok = await this._addLayers(path);
if (!ok) {
// Loading failed (see _addLayers) — don't leave the box checked
// for a file that isn't actually showing anything.
this._visible.delete(path);
if (checkbox) checkbox.checked = false;
}
} else {
this._visible.delete(path);
this._removeLayers(path);
}
}
// Fetches the file's GeoJSON itself (rather than handing maplibre a URL
// to fetch internally) so a parse failure — a real GPX a device/app
// wrote in some way REXML doesn't like, say — surfaces as an alert
// instead of the layer just silently never appearing. Returns whether it
// succeeded.
async _addLayers(path) {
const linesId = linesSourceId(path);
const pointsId = pointsSourceId(path);
if (this._map.getSource(linesId) || this._map.getSource(pointsId)) return true;
let geojson;
try {
const response = await fetch(gpxFileUrl(path));
if (!response.ok) {
const { error } = await response.json().catch(() => ({}));
window.alert(error || `Could not load "${path}".`);
return false;
}
geojson = await response.json();
} catch (err) {
window.alert(`Could not load "${path}": ${err.message}`);
return false;
}
if (this._map.getSource(linesId) || this._map.getSource(pointsId)) return true; // added concurrently (e.g. a style.load race) while this was fetching
// Split into two sources: MapLibre's clustering only understands a
// source made entirely of Point/MultiPoint features, and a file can mix
// a track/route line with waypoints (or, like a park4night POI export,
// be thousands of waypoints and nothing else).
const lines = { type: "FeatureCollection", features: [] };
const points = { type: "FeatureCollection", features: [] };
for (const feature of geojson.features) {
(feature.geometry.type === "Point" ? points : lines).features.push(feature);
}
this._map.addSource(linesId, { type: "geojson", data: lines });
this._map.addLayer({
id: `${linesId}-line`,
type: "line",
source: linesId,
layout: { "line-cap": "round", "line-join": "round" },
paint: { "line-color": TRACK_COLOR, "line-width": 3 },
});
this._registerLineInteractivity(`${linesId}-line`);
// clusterRadius/clusterMaxZoom are harmless when there's nothing dense
// enough to cluster (a handful of waypoints just render individually).
this._map.addSource(pointsId, { type: "geojson", data: points, cluster: true, clusterRadius: 50, clusterMaxZoom: 14 });
await this._iconsReady;
if (!this._map.getSource(pointsId)) return true; // a style.load (basemap switch) tore it down while icons were loading
this._addClusterLayers(pointsId);
if (points.features.length > 0) this._pointsByPath.set(path, points);
this._updateLegend();
return true;
}
// A single-hue, light->dark ramp keyed on point_count (magnitude, not
// category) for the clustered circles; individual points below that use
// each waypoint's own category color (see Maptool::Gpx#waypoint_color),
// falling back to the plain track color for waypoints with no category
// (e.g. a hand-placed GNSS pin, not from a POI export).
_addClusterLayers(pointsId) {
this._map.addLayer({
id: `${pointsId}-clusters`,
type: "circle",
source: pointsId,
filter: ["has", "point_count"],
paint: {
"circle-radius": ["step", ["get", "point_count"], 16, 25, 20, 100, 26],
"circle-color": ["step", ["get", "point_count"], "#a8c8ea", 25, "#5b9bd5", 100, "#2e6da4"],
"circle-stroke-color": "#fff",
"circle-stroke-width": 1.5,
},
});
this._map.addLayer({
id: `${pointsId}-cluster-count`,
type: "symbol",
source: pointsId,
filter: ["has", "point_count"],
layout: { "text-field": ["get", "point_count_abbreviated"], "text-font": ["Noto Sans Regular"], "text-size": 11 },
paint: { "text-color": "#fff" },
});
// A pin (icons.js's MAP_PIN_ICON), not a plain circle: a flat colored
// dot looks too much like a base map style's own small circular POI
// icons, so individual waypoints blended in and were hard to spot as
// "your data" versus the map's own points. Anchored at its tip (the
// standard map-pin convention), not centered on the coordinate.
this._map.addLayer({
id: `${pointsId}-unclustered`,
type: "symbol",
source: pointsId,
filter: ["!", ["has", "point_count"]],
layout: {
"icon-image": MAP_PIN_ID,
"icon-size": MAP_PIN_SIZE,
"icon-anchor": "bottom",
"icon-allow-overlap": true,
"icon-ignore-placement": true,
},
paint: { "icon-color": ["coalesce", ["get", "color"], TRACK_COLOR] },
});
// Only for the curated POI_ICON_KEYS (see icons.js) — anything else
// (a plain hand-placed waypoint, or a category p4n2gpx/OsmAnd hasn't
// been taught here) just keeps the plain pin above with no glyph.
// icon-offset moves it up from the anchor (the pin's tip) into the
// pin's round head — see POI_ICON_OFFSET_Y's comment above.
this._map.addLayer({
id: `${pointsId}-unclustered-icon`,
type: "symbol",
source: pointsId,
filter: ["all", ["!", ["has", "point_count"]], ["in", ["get", "icon"], ["literal", POI_ICON_KEYS]]],
layout: {
"icon-image": ["get", "icon"],
"icon-size": POI_ICON_SIZE,
"icon-anchor": "bottom",
"icon-offset": [0, POI_ICON_OFFSET_Y],
"icon-allow-overlap": true,
"icon-ignore-placement": true,
},
paint: { "icon-color": "#fff" },
});
this._registerClusterInteractivity(pointsId);
this._registerPointInteractivity(`${pointsId}-unclustered`);
}
// Clicking a cluster zooms in just far enough to break it apart, the
// standard MapLibre cluster-expansion pattern.
_registerClusterInteractivity(pointsId) {
const clusterId = `${pointsId}-clusters`;
if (this._interactiveLayers.has(clusterId)) return;
this._interactiveLayers.add(clusterId);
this._map.on("click", clusterId, async (event) => {
const feature = this._map.queryRenderedFeatures(event.point, { layers: [clusterId] })[0];
if (!feature) return;
const zoom = await this._map.getSource(pointsId).getClusterExpansionZoom(feature.properties.cluster_id);
this._map.easeTo({ center: feature.geometry.coordinates, zoom });
});
this._map.on("mouseenter", clusterId, () => {
this._map.getCanvas().style.cursor = "pointer";
});
this._map.on("mouseleave", clusterId, () => {
this._map.getCanvas().style.cursor = "";
});
}
// 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 = "";
});
}
// Hover shows a quick summary (unlike a waypoint popup, which is
// click-triggered — a track's summary describes the whole line, not
// whatever point along it happens to get hovered, so there's no single
// right anchor point worth waiting for a click on). Click opens the full
// profile panel, which does need the click gesture to avoid flashing
// open on every casual hover.
_registerLineInteractivity(layerId) {
if (this._interactiveLayers.has(layerId)) return;
this._interactiveLayers.add(layerId);
let popup = null;
this._map.on("mouseenter", layerId, (event) => {
this._map.getCanvas().style.cursor = "pointer";
const feature = event.features[0];
if (!feature) return;
popup = new Popup({ closeButton: false, closeOnClick: false, maxWidth: "260px" })
.setLngLat(event.lngLat)
.setHTML(trackPopupHtml(feature.properties))
.addTo(this._map);
});
this._map.on("mouseleave", layerId, () => {
this._map.getCanvas().style.cursor = "";
popup?.remove();
popup = null;
});
this._map.on("click", layerId, (event) => {
const feature = event.features[0];
if (feature) this._profilePanel?.show(feature.properties);
});
}
_removeLayers(path) {
const linesId = linesSourceId(path);
const pointsId = pointsSourceId(path);
[
`${linesId}-line`,
`${pointsId}-clusters`,
`${pointsId}-cluster-count`,
`${pointsId}-unclustered`,
`${pointsId}-unclustered-icon`,
].forEach((layerId) => {
if (this._map.getLayer(layerId)) this._map.removeLayer(layerId);
});
if (this._map.getSource(linesId)) this._map.removeSource(linesId);
if (this._map.getSource(pointsId)) this._map.removeSource(pointsId);
this._pointsByPath.delete(path);
this._updateLegend();
}
// Rebuilt from whichever visible files actually carry categorized
// waypoints (a park4night/OsmAnd POI export) — a plain GPS track has none
// and the legend just stays hidden.
_updateLegend() {
const categories = new Map(); // category name -> color
for (const points of this._pointsByPath.values()) {
for (const feature of points.features) {
const category = feature.properties.category;
if (category && !categories.has(category)) categories.set(category, feature.properties.color || TRACK_COLOR);
}
}
this._legendContainer.innerHTML = "";
this._legendContainer.hidden = categories.size === 0;
[...categories].sort(([a], [b]) => a.localeCompare(b)).forEach(([category, color]) => {
const item = document.createElement("div");
item.className = "gpx-legend__item";
const swatch = document.createElement("span");
swatch.className = "gpx-legend__swatch";
swatch.style.background = color; // a malformed/malicious value from an uploaded file is simply ignored by the CSSOM, never parsed as markup
const label = document.createElement("span");
label.textContent = category;
item.appendChild(swatch);
item.appendChild(label);
this._legendContainer.appendChild(item);
});
}
async _uploadFiles(fileList, folder) {
// .zip: Garmin's bulk/FIT export format — the server expands it into
// its .gpx/.fit entries, so it's accepted here even though the tree
// never shows a .zip itself.
const files = [...fileList].filter((file) => /\.(gpx|fit|zip)$/i.test(file.name));
if (files.length === 0) return;
this._setStatus(files.length === 1 ? "Uploading…" : `Uploading ${files.length} files…`);
let tree = null;
let failed = 0;
for (const file of files) {
const formData = new FormData();
formData.append("file", file);
formData.append("folder", folder);
try {
const response = await fetch(UPLOAD_URL, { method: "POST", body: formData });
if (redirectIfUnauthorized(response)) return;
if (!response.ok) throw new Error("upload failed");
({ tree } = await response.json());
} catch (err) {
failed += 1;
}
}
if (tree) this._renderTree(tree);
this._setStatus(failed > 0 ? `${failed} of ${files.length} uploads failed.` : "Uploaded.");
}
async _deleteFile(path) {
if (!window.confirm(`Delete "${path}"?`)) return;
const response = await fetch(gpxFileUrl(path), { method: "DELETE" });
if (redirectIfUnauthorized(response)) return;
if (!response.ok) return;
if (this._visible.has(path)) {
this._visible.delete(path);
this._removeLayers(path);
}
const { tree } = await response.json();
this._renderTree(tree);
}
async _deleteFolder(path) {
if (!window.confirm(`Delete "${path}" and everything in it?`)) return;
const response = await fetch(`/api/gpx/folder/${path.split("/").map(encodeURIComponent).join("/")}`, {
method: "DELETE",
});
if (redirectIfUnauthorized(response)) return;
if (!response.ok) return;
const prefix = `${path}/`;
[...this._visible].forEach((visiblePath) => {
if (visiblePath === path || visiblePath.startsWith(prefix)) {
this._visible.delete(visiblePath);
this._removeLayers(visiblePath);
}
});
const { tree } = await response.json();
this._renderTree(tree);
}
async _moveFile(from, toFolder) {
if (from === toFolder) return;
const response = await fetch(MOVE_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `from=${encodeURIComponent(from)}&to_folder=${encodeURIComponent(toFolder)}`,
});
if (redirectIfUnauthorized(response)) return;
if (!response.ok) return;
const { path: newPath, tree } = await response.json();
if (this._visible.has(from)) {
this._visible.delete(from);
this._removeLayers(from);
this._visible.add(newPath);
this._addLayers(newPath);
}
this._renderTree(tree);
}
_setStatus(text) {
this._uploadStatus.hidden = false;
this._uploadStatus.textContent = text;
}
}
// Write endpoints require login (see app.rb); a 401 means the session
// expired or was never established — send the user to log in rather than
// silently failing.
function redirectIfUnauthorized(response) {
if (response.status !== 401) return false;
window.location.href = "/login";
return true;
}
// Rasterizes a hand-authored SVG icon to ImageData for map.addImage(), at a
// fixed square size (the source SVGs have no explicit width/height, just a
// viewBox, so one is injected — otherwise an <img> falls back to the CSS
// default replaced-element size instead of the viewBox's aspect).
async function svgToImageData(svgMarkup, size) {
// xmlns is required here even though the other hand-drawn icons in this
// app get away without it — those are inlined via innerHTML, where the
// HTML parser assigns the SVG namespace from context, but a standalone
// <img src="blob:..."> loads it as its own XML document, which needs an
// explicit namespace or every browser refuses to decode it.
const sized = svgMarkup.replace("<svg ", `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" `);
const url = URL.createObjectURL(new Blob([sized], { type: "image/svg+xml" }));
try {
const img = new Image();
img.src = url;
await img.decode();
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, size, size);
return ctx.getImageData(0, 0, size, size);
} finally {
URL.revokeObjectURL(url);
}
}
function linesSourceId(path) {
return `gpx-${path}-lines`;
}
function pointsSourceId(path) {
return `gpx-${path}-points`;
}
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, category }) {
const parts = [];
if (name) parts.push(`<strong>${escapeHtml(name)}</strong>`);
if (category) parts.push(`<p class="gpx-popup__category">${escapeHtml(category)}</p>`);
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>";
}
// properties come straight from an uploaded GPX/FIT file — untrusted input.
function trackPopupHtml({ name, activity_type, distance_meters, elevation_gain_meters, duration_secs }) {
const parts = [];
if (name) parts.push(`<strong>${escapeHtml(name)}</strong>`);
const stats = [];
if (activity_type) stats.push(escapeHtml(formatActivityType(activity_type)));
if (typeof distance_meters === "number") stats.push(`${(distance_meters / 1000).toFixed(1)} km`);
if (typeof elevation_gain_meters === "number") stats.push(`↑ ${Math.round(elevation_gain_meters)} m`);
if (typeof duration_secs === "number") stats.push(formatDuration(duration_secs));
if (stats.length > 0) parts.push(`<p>${stats.join(" · ")}</p>`);
parts.push(`<p class="gpx-popup__time">Click for the full profile</p>`);
return parts.length > 0 ? parts.join("") : "<em>No details</em>";
}
function formatDuration(totalSeconds) {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
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)}`;
}