Files
maptool/public/js/gpx.js
T
randy 12635a48c3 Badge the exception (not yet cached) instead of the near-universal case
GpxStore#save converts eagerly, so "cached" is true for nearly every
file in the tree already — badging that was just noise. Flipped to
PENDING_ICON, shown only for the rare case: a .gpx/.fit dropped
straight into data/gpx/ on the filesystem, bypassing the app, and not
yet opened.

Also add an explicit download button next to delete (DOWNLOAD_ICON) —
the file name itself has been a download link since the last commit,
but isn't obviously clickable at a glance.
2026-09-07 22:59:02 +02:00

945 lines
37 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,
PENDING_ICON,
DOWNLOAD_ICON,
MAP_MARKER_ICON,
POI_ICONS,
PARK4NIGHT_TAGS,
} 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, sized to sit inside the marker badge below
const MAP_MARKER_SIZE = 0.55;
const BADGE_IMAGE_PREFIX = "badge-"; // one registered image per distinct badge color actually seen, see _registerBadgeColors
// 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 category glyphs before
// putting visible GPX layers back, so a symbol layer added right away
// doesn't reference a not-yet-existing image. Badge images are handled
// separately (_registerBadgeColors, called from _addLayers) since which
// colors are needed depends on which file/category is being shown.
this._iconsReady = this._registerCategoryGlyphs();
map.on("style.load", () => {
this._iconsReady = this._registerCategoryGlyphs();
this._visible.forEach((path) => this._addLayers(path));
});
}
// Registered once, always white, as plain (non-SDF) images: these used to
// be SDF images tinted white via icon-color, but MapLibre's SDF rendering
// assumes the source encodes an actual distance field (a smooth multi-pixel
// gradient at each edge) — feeding it a normally-antialiased raster (a
// sharp 1px edge transition) instead made every glyph noticeably softer
// than the base map's own icons, especially shrunk down to icon-size 0.3.
// Since the glyph color never varies (always white, unlike the badge
// below), there's no need for SDF's runtime-tintable alpha mask at all —
// baking the color into the raster and skipping SDF renders as sharp as
// any ordinary sprite.
async _registerCategoryGlyphs() {
await Promise.all(
POI_ICON_KEYS.map(async (key) => {
if (this._map.hasImage(key)) return;
const whiteSvg = POI_ICONS[key].replace(/currentColor/g, "#fff");
const imageData = await svgToImageData(whiteSvg, POI_ICON_IMAGE_SIZE);
if (!this._map.hasImage(key)) this._map.addImage(key, imageData);
}),
);
}
// The badge's color is per-category data (arbitrary hex from a GPX file),
// so unlike the glyphs it can't be a single fixed-color image — but a real
// POI export only ever has a handful of distinct category colors, so it's
// cheap to rasterize one sharp, non-SDF image per color actually seen
// (rather than one shared image SDF-tinted at render time, which is what
// made these blurry — see _registerCategoryGlyphs above) and cache them by
// color for reuse across files.
async _registerBadgeColors(colors) {
await Promise.all(
[...colors].map(async (color) => {
const id = badgeImageId(color);
if (this._map.hasImage(id)) return;
const coloredSvg = MAP_MARKER_ICON.replace(/currentColor/g, color);
const imageData = await svgToImageData(coloredSvg, POI_ICON_IMAGE_SIZE);
if (!this._map.hasImage(id)) this._map.addImage(id, imageData);
}),
);
}
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));
// Once a .gpx/.fit is cached, its converted GeoJSON is the thing that's
// actually shown on the map from here on (see GpxStore#geojson) — the
// name reflects that (matching the real "<path>.geojson" cache file on
// disk) rather than still reading as a plain, un-converted .gpx/.fit.
// A direct .geojson upload already ends in .geojson, so it's left
// alone. It's an <a>, not a <span>: clicking it downloads that GeoJSON
// (?download=1) — the only way to get it out of the app, since the
// original .gpx/.fit is never deleted but also never re-shown once
// converted. An <a> nested in a <label> doesn't also toggle the
// checkbox on click (unlike a plain element), so this doesn't fight
// the row's own toggle-on-map behavior.
const displayName = file.cached && !file.name.toLowerCase().endsWith(".geojson") ? `${file.name}.geojson` : file.name;
const name = document.createElement("a");
name.className = "gpx-tree__file-name";
name.href = `${gpxFileUrl(file.path)}?download=1`;
name.textContent = displayName;
name.title = `Download ${displayName}`;
label.appendChild(checkbox);
(file.kinds || []).forEach((kind) => label.appendChild(this._kindIcon(kind)));
if (!file.cached) label.appendChild(this._pendingIcon());
label.appendChild(name);
row.appendChild(label);
row.appendChild(this._downloadButton(file));
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;
}
_pendingIcon() {
const icon = document.createElement("span");
icon.className = "gpx-tree__kind-icon";
icon.innerHTML = PENDING_ICON;
icon.title = "Not yet converted — dropped straight into data/gpx/, not uploaded through the app";
return icon;
}
// Same destination as clicking the file's name (gpxFileUrl + ?download=1)
// — a second, more obviously clickable entry point for the same action.
_downloadButton(file) {
const link = document.createElement("a");
link.className = "gpx-tree__download";
link.href = `${gpxFileUrl(file.path)}?download=1`;
link.title = "Download GeoJSON";
link.setAttribute("aria-label", "Download GeoJSON");
link.innerHTML = DOWNLOAD_ICON;
return link;
}
_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);
}
// Normalized here (rather than a ["coalesce", ...] in the layer's
// icon-image expression) so every point's badge color is known upfront,
// before registering the per-color badge images below.
for (const feature of points.features) {
feature.properties.color ||= TRACK_COLOR;
}
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 });
const badgeColors = new Set(points.features.map((f) => f.properties.color));
await Promise.all([this._iconsReady, this._registerBadgeColors(badgeColors)]);
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 rounded-square badge (icons.js's MAP_MARKER_ICON), not a plain
// circle: a flat colored dot looked too much like a base map style's
// own small circular POI icons, so individual waypoints blended in
// instead of standing out as "your data". A rounded square rather than
// a pin/teardrop specifically to match how the NPS itself frames these
// exact category symbols (POI_ICONS below) on its own maps. Centered on
// the coordinate — a square has no natural "tip" the way a pin does, so
// default center anchoring is simplest and also means the glyph layer
// below needs no offset, just the same center anchor.
this._map.addLayer({
id: `${pointsId}-unclustered`,
type: "symbol",
source: pointsId,
filter: ["!", ["has", "point_count"]],
layout: {
"icon-image": ["concat", BADGE_IMAGE_PREFIX, ["get", "color"]],
"icon-size": MAP_MARKER_SIZE,
"icon-allow-overlap": true,
"icon-ignore-placement": true,
},
});
// 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 badge above with no glyph.
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-allow-overlap": true,
"icon-ignore-placement": true,
},
});
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/.geojson entries, so it's accepted here even though the
// tree never shows a .zip itself. .geojson needs no server-side
// conversion (GpxStore#geojson serves it as-is) — accepted here so a
// file already in this app's own shape (e.g. a previous /api/gpx/file
// response, saved and re-uploaded) can skip GPX/FIT entirely.
const files = [...fileList].filter((file) => /\.(gpx|fit|geojson|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 badgeImageId(color) {
return `${BADGE_IMAGE_PREFIX}${color}`;
}
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, photos }) {
const parts = [];
if (name) parts.push(`<strong>${escapeHtml(name)}</strong>`);
if (category) parts.push(`<p class="gpx-popup__category">${escapeHtml(category)}</p>`);
if (photos?.length > 0) parts.push(formatPhotos(photos));
if (description) parts.push(formatDescription(description));
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;
}
// park4night exports pack a free-text description and several lines of
// structured data (address, rating, services, activities, the listing URL)
// into one <desc> blob, separated only by newlines — rendered as one plain
// paragraph, it read as a wall of run-together text. Lines matching a
// recognizable "Label: value" or bare-URL shape get pulled out and rendered
// as their own visually distinct rows instead; anything else (the actual
// narrative, or a plain GPX file's <desc> with no such structure at all)
// stays exactly as before, one paragraph with line breaks — so this only
// ever adds structure, never loses any text.
const DESCRIPTION_META_LINE = /^(Rating|Services|Activities):\s*(.+)$/i;
const DESCRIPTION_LINK_LINE = /^https?:\/\/\S+$/;
// <link> photo URLs (a newer p4n2gpx export adds one per park4night listing
// photo — see lib/maptool/gpx.rb's waypoint_photos) as a row of thumbnails,
// each opening the full-size image in a new tab. Untrusted URLs from an
// uploaded file, same as everywhere else in this popup — escaped for use as
// both an href and an img src.
function formatPhotos(photos) {
const thumbs = photos
.map((url) => escapeHtml(url))
.map(
(url) =>
`<a href="${url}" target="_blank" rel="noopener noreferrer"><img class="gpx-popup__photo" src="${url}" loading="lazy" alt=""></a>`,
)
.join("");
return `<div class="gpx-popup__photos">${thumbs}</div>`;
}
function formatDescription(description) {
const body = [];
const meta = [];
const links = [];
for (const line of description.split("\n")) {
const trimmed = line.trim();
const metaMatch = trimmed.match(DESCRIPTION_META_LINE);
if (DESCRIPTION_LINK_LINE.test(trimmed)) {
links.push(trimmed);
} else if (metaMatch) {
meta.push(metaMatch);
} else {
body.push(line);
}
}
const parts = [];
const bodyText = body.join("\n").trim();
if (bodyText) parts.push(`<p class="gpx-popup__description">${linkify(bodyText).replace(/\n/g, "<br>")}</p>`);
if (meta.length > 0) {
const rows = meta.map(([, label, value]) => formatMetaRow(label, value)).join("");
parts.push(`<div class="gpx-popup__meta">${rows}</div>`);
}
links.forEach((url) => parts.push(`<p class="gpx-popup__link">${urlToLink(url)}</p>`));
return parts.join("");
}
// Services/Activities get rendered as icon+label tags (PARK4NIGHT_TAGS,
// icons.js) rather than their raw comma-separated park4night codes — "vtt,
// eaux_vives, peche" told a reader nothing on its own. Rating (or anything
// else matching DESCRIPTION_META_LINE) stays plain text; a park4night code
// this app hasn't catalogued yet just falls back to showing its raw text,
// same as before PARK4NIGHT_TAGS existed, so nothing is ever hidden.
function formatMetaRow(label, value) {
const isTagList = /^(services|activities)$/i.test(label);
const valueHtml = isTagList
? `<div class="gpx-popup__tags">${formatTags(value)}</div>`
: ` ${escapeHtml(value)}`;
return `<div class="gpx-popup__meta-row"><span class="gpx-popup__meta-label">${escapeHtml(label)}</span>${valueHtml}</div>`;
}
function formatTags(value) {
return value
.split(",")
.map((code) => code.trim())
.filter(Boolean)
.map((code) => {
const tag = PARK4NIGHT_TAGS[code];
const icon = tag?.icon ? `<span class="gpx-popup__tag-icon">${tag.icon}</span>` : "";
return `<span class="gpx-popup__tag">${icon}${escapeHtml(tag?.label ?? code)}</span>`;
})
.join("");
}
// 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)}`;
}