Files
maptool/public/js/gpx.js
T
randy 7c664578af Fix category filter not hiding polygon/line notes, only points
TODO.adoc had this flagged: toggling a category off in the legend only
re-sliced the points source (_applyCategoryFilter), so a polygon or
line note (Measure's "Save as note") sharing that category stayed
visible. Added _linesByPath alongside the existing _pointsByPath,
tracked/cleaned up the same way, and reused for both the legend's
category scan and the filter's re-slice.
2026-09-09 18:21:39 +02:00

1301 lines
54 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, onGpxStoreChanged, notifyGpxStoreChanged } from "/js/panels.js";
import { formatActivityType } from "/js/track_profile.js";
import { isLoggedIn } from "/js/auth.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
this._linesByPath = new Map(); // path -> its lines/polygons FeatureCollection, same reason (a polygon/line note also carries a category)
this._hiddenCategories = new Set(); // category names toggled off in the legend
this._openDownloadMenu = null; // the one gpx-tree__download-menu currently open, if any
// 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));
});
// notes.js writes directly into the same GPX store (a hand-drawn note
// saved into a .geojson layer) — refresh so a newly created/grown file
// shows up in the tree without a manual reopen, and auto-show it (check
// + render) since a note the user just placed being invisible until
// they find and check its layer by hand defeats the point of dropping
// a pin right there on the map.
onGpxStoreChanged((path) => {
if (path) {
this._visible.add(path);
this._reloadFile(path);
}
this._loadTree();
});
}
// 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;
if (!isLoggedIn()) {
this._panel.querySelector('[data-action="new-folder"]').hidden = true;
this._panel.querySelector('[data-action="upload"]').hidden = true;
}
this._panel
.querySelector(".gpx-panel__close")
.addEventListener("click", () => this._togglePanel(false));
onlyOneSidePanelOpen(this._panel, () => this._icon.classList.remove("active"));
// Closes whichever per-file download menu is open on any click that
// isn't the toggle button that opened it (that one calls
// stopPropagation so this never fights it) — same "outside click
// closes it" behavior sidebar-flyout dropdowns already have, just not
// wired through onlyOneSidePanelOpen since these aren't full side
// panels.
document.addEventListener("click", () => this._closeDownloadMenu());
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);
if (isLoggedIn()) 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));
// Just the real, as-uploaded name — it used to grow a ".geojson" suffix
// once cached (matching the real "<path>.geojson" cache file on disk),
// back when the name doubled as a GeoJSON-only download link and the
// suffix explained what clicking it would get you. Now that downloading
// is the download button's job (see below), that suffix was just
// confusing: the file is still named "ride.gpx", not "ride.gpx.geojson".
const loggedIn = isLoggedIn();
const name = document.createElement("span");
name.className = "gpx-tree__file-name";
name.textContent = file.name;
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);
// Delete/download require login (app.rb); shown only once logged in
// rather than shown-but-failing-on-click.
if (loggedIn) {
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;
}
// One button, one obvious place for both download kinds — clicking it
// opens a small menu (Original / GeoJSON) instead of splitting them
// across two differently-shaped click targets (the file name used to be
// a GeoJSON-only download link; easy to miss it was a link at all, and
// it only ever covered half the story). Costs a click for the common
// case of downloading several files in a row, in exchange for both
// formats actually being reachable from one, discoverable place.
_downloadButton(file) {
const wrapper = document.createElement("span");
wrapper.className = "gpx-tree__download-wrapper";
const button = document.createElement("button");
button.type = "button";
button.className = "gpx-tree__download";
button.title = "Download";
button.setAttribute("aria-label", "Download");
button.innerHTML = DOWNLOAD_ICON;
const menu = document.createElement("div");
menu.className = "gpx-tree__download-menu";
menu.hidden = true;
const geojsonName = file.cached && !file.name.toLowerCase().endsWith(".geojson") ? `${file.name}.geojson` : file.name;
menu.appendChild(this._downloadMenuItem("Original file", `${gpxFileUrl(file.path)}?download=original`, file.name));
menu.appendChild(this._downloadMenuItem("GeoJSON", `${gpxFileUrl(file.path)}?download=geojson`, geojsonName));
// stopPropagation so this click doesn't immediately reach the
// document-level listener (mount()) that closes whichever menu is
// open on an outside click — otherwise it would close right back up
// the instant it opened.
button.addEventListener("click", (event) => {
event.stopPropagation();
this._toggleDownloadMenu(menu);
});
wrapper.appendChild(button);
wrapper.appendChild(menu);
return wrapper;
}
_downloadMenuItem(label, url, filename) {
const item = document.createElement("a");
item.className = "gpx-tree__download-menu-item";
item.href = url;
item.textContent = label;
item.addEventListener("click", (event) => {
event.preventDefault();
this._closeDownloadMenu();
this._download(url, filename);
});
return item;
}
// Only one menu open at a time — opening a second one closes whichever
// was already open, same as onlyOneSidePanelOpen does for the sidebar's
// own panels (panels.js), just scoped to this one control instead of
// shared across the page.
_toggleDownloadMenu(menu) {
if (this._openDownloadMenu === menu) {
this._closeDownloadMenu();
return;
}
this._closeDownloadMenu();
menu.hidden = false;
this._openDownloadMenu = menu;
}
_closeDownloadMenu() {
if (this._openDownloadMenu) this._openDownloadMenu.hidden = true;
this._openDownloadMenu = null;
}
// fetch-based rather than a plain navigation so a 401 (not logged in —
// both download kinds require login now, see app.rb) redirects to
// /login like every other write action in this file
// (redirectIfUnauthorized), instead of navigating to a raw JSON error
// page. The filename is already known client-side, so there's no need
// to parse it back out of a Content-Disposition header.
async _download(url, filename) {
const response = await fetch(url);
if (redirectIfUnauthorized(response)) return;
if (!response.ok) {
window.alert("Could not download the file.");
return;
}
const blobUrl = URL.createObjectURL(await response.blob());
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(blobUrl);
}
_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 });
// Excludes category:"Note" — a saved Measure-tool distance line gets
// its own layer below (popup-on-click, not this hover-summary/profile-
// panel track behavior), not this one.
this._map.addLayer({
id: `${linesId}-line`,
type: "line",
source: linesId,
filter: ["any", ["!has", "category"], ["!=", ["get", "category"], "Note"]],
layout: { "line-cap": "round", "line-join": "round" },
paint: { "line-color": TRACK_COLOR, "line-width": 3 },
});
this._registerLineInteractivity(`${linesId}-line`);
// Polygon/line notes (see notes.js and measure.js's "Save as note") —
// the same "lines" source already only renders LineString geometry
// through the line layer above, and Polygon geometry isn't drawn by a
// "line"-type layer at all (MapLibre only draws LineString for that
// type), so these are just more layers reading the same source, not a
// new bucket. Popup-on-click like a waypoint (_registerPointInteractivity),
// not the track line's hover-summary/profile-panel behavior — a note
// has no elevation profile to open.
this._map.addLayer({
id: `${linesId}-polygon-fill`,
type: "fill",
source: linesId,
filter: ["==", ["geometry-type"], "Polygon"],
paint: { "fill-color": TRACK_COLOR, "fill-opacity": 0.2 },
});
this._map.addLayer({
id: `${linesId}-polygon-outline`,
type: "line",
source: linesId,
filter: ["==", ["geometry-type"], "Polygon"],
paint: { "line-color": TRACK_COLOR, "line-width": 2 },
});
this._registerPointInteractivity(`${linesId}-polygon-fill`, path);
this._map.addLayer({
id: `${linesId}-note-line`,
type: "line",
source: linesId,
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", ["get", "category"], "Note"]],
layout: { "line-cap": "round", "line-join": "round" },
paint: { "line-color": TRACK_COLOR, "line-width": 3, "line-dasharray": [2, 1] },
});
this._registerPointInteractivity(`${linesId}-note-line`, path);
// 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, path);
if (points.features.length > 0) this._pointsByPath.set(path, points);
if (lines.features.length > 0) this._linesByPath.set(path, lines);
this._updateLegend();
this._applyCategoryFilter();
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, path) {
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`, path);
}
// 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.
// `path` (which file this layer's features came from) is only used for
// a "Note" feature's Edit/Delete actions — plain GPX/FIT/park4night
// waypoints don't get those, so it's otherwise unused.
_registerPointInteractivity(layerId, path) {
if (this._interactiveLayers.has(layerId)) return;
this._interactiveLayers.add(layerId);
this._map.on("click", layerId, (event) => {
const feature = event.features[0];
if (!feature) return;
// properties.id is what update/delete target by — a note saved before
// that existed (see `rake gpx:backfill_note_ids`) doesn't have one
// yet, and offering Edit/Delete for it would just 404.
const editable = feature.properties.category === "Note" && !!feature.properties.id && isLoggedIn();
const popup = new Popup({ closeButton: true, maxWidth: "260px" })
.setLngLat(event.lngLat)
.setHTML(popupHtml(feature.properties, { editable, lngLat: event.lngLat }))
.addTo(this._map);
if (editable) this._wireNoteActions(popup, path, feature);
});
this._map.on("mouseenter", layerId, () => {
this._map.getCanvas().style.cursor = "pointer";
});
this._map.on("mouseleave", layerId, () => {
this._map.getCanvas().style.cursor = "";
});
}
// Edit swaps the popup's content for a plain textarea + Save/Cancel (the
// note's location doesn't change, just its text, so there's no need for
// NoteSaveForm's fuller target-file picker here). Delete just confirms
// and removes. Both end by reloading the file's layers from the server —
// simplest way to keep the map, the category legend, and this popup's
// own feature data all in sync with what the edit/delete actually wrote,
// rather than hand-patching three different client-side copies of it.
_wireNoteActions(popup, path, feature) {
const element = popup.getElement();
const id = feature.properties.id;
element.querySelector(".gpx-popup__delete")?.addEventListener("click", async () => {
if (!window.confirm("Delete this note?")) return;
try {
const response = await fetch(`/api/gpx/notes?${new URLSearchParams({ path, id })}`, { method: "DELETE" });
if (!response.ok) throw new Error(`delete failed: ${response.status}`);
popup.remove();
notifyGpxStoreChanged();
await this._reloadFile(path);
} catch (err) {
window.alert("Could not delete the note — check your connection and try again.");
}
});
element.querySelector(".gpx-popup__edit")?.addEventListener("click", () => {
const content = popup.getElement().querySelector(".maplibregl-popup-content");
content.innerHTML = `
<div class="gpx-popup__edit-form">
<input type="text" class="gpx-popup__edit-title" value="${escapeHtml(feature.properties.name || "")}">
<textarea class="gpx-popup__edit-body" rows="3" placeholder="Details (optional) — **bold**, *italic*, links">${escapeHtml(feature.properties.description || "")}</textarea>
<div class="gpx-popup__edit-actions">
<button type="button" class="gpx-popup__edit-save">Save</button>
<button type="button" class="gpx-popup__edit-cancel">Cancel</button>
</div>
</div>
`;
const titleInput = content.querySelector(".gpx-popup__edit-title");
const bodyInput = content.querySelector(".gpx-popup__edit-body");
titleInput.focus();
content.querySelector(".gpx-popup__edit-cancel").addEventListener("click", () => {
content.innerHTML = popupHtml(feature.properties, { editable: true, lngLat: popup.getLngLat() });
this._wireNoteActions(popup, path, feature);
});
content.querySelector(".gpx-popup__edit-save").addEventListener("click", async () => {
const title = titleInput.value.trim();
if (!title) return;
const details = bodyInput.value.trim();
const properties = { ...feature.properties, name: title };
if (details) properties.description = details;
else delete properties.description;
const updated = { ...feature, properties };
try {
const body = new URLSearchParams({ path, id, feature: JSON.stringify(updated) });
const response = await fetch("/api/gpx/notes", { method: "PUT", body });
if (!response.ok) throw new Error(`save failed: ${response.status}`);
popup.remove();
notifyGpxStoreChanged();
await this._reloadFile(path);
} catch (err) {
window.alert("Could not save the note — check your connection and try again.");
}
});
});
}
async _reloadFile(path) {
this._removeLayers(path);
await this._addLayers(path);
}
// 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`,
`${linesId}-polygon-fill`,
`${linesId}-polygon-outline`,
`${linesId}-note-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._linesByPath.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. Doubles as a filter: each item is a
// toggle button (Unigrid-style icon grid) that hides/shows that category
// on the map, not just a read-only color key.
_updateLegend() {
const categories = new Map(); // category name -> { color, icon }
for (const collection of [...this._pointsByPath.values(), ...this._linesByPath.values()]) {
for (const feature of collection.features) {
const category = feature.properties.category;
if (category && !categories.has(category)) {
categories.set(category, { color: feature.properties.color || TRACK_COLOR, icon: feature.properties.icon });
}
}
}
this._legendContainer.innerHTML = "";
this._legendContainer.hidden = categories.size === 0;
[...categories].sort(([a], [b]) => a.localeCompare(b)).forEach(([category, { color, icon }]) => {
const item = document.createElement("button");
item.type = "button";
item.className = "gpx-legend__item";
item.classList.toggle("gpx-legend__item--hidden", this._hiddenCategories.has(category));
item.title = category;
item.setAttribute("aria-pressed", String(!this._hiddenCategories.has(category)));
const glyph = document.createElement("span");
if (icon && POI_ICONS[icon]) {
glyph.className = "gpx-legend__icon";
glyph.innerHTML = POI_ICONS[icon];
} else {
glyph.className = "gpx-legend__swatch";
glyph.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.className = "gpx-legend__label";
label.textContent = category;
item.appendChild(glyph);
item.appendChild(label);
item.addEventListener("click", () => {
if (!this._hiddenCategories.delete(category)) this._hiddenCategories.add(category);
this._updateLegend();
this._applyCategoryFilter();
});
this._legendContainer.appendChild(item);
});
}
// Re-clusters each visible file's points around only the categories still
// toggled on — a plain layer `filter` can't do this: MapLibre clusters a
// GeoJSON source's *entire* data set regardless of what a layer filter
// hides, so a cluster's count/expansion would stay wrong. Cheap to redo
// in full: _pointsByPath already holds each file's complete features in
// memory, this is just re-slicing it, no re-parsing or network involved.
_applyCategoryFilter() {
for (const [path, points] of this._pointsByPath) {
const source = this._map.getSource(pointsSourceId(path));
if (!source) continue;
const visible = points.features.filter((f) => !this._hiddenCategories.has(f.properties.category));
source.setData({ type: "FeatureCollection", features: visible });
}
// Same re-slicing for polygon/line notes sharing the "lines" source with
// plain tracks/routes — those have no `category` of their own, so
// `_hiddenCategories.has(undefined)` is always false and they're never
// affected by this, only a hidden Note (or other categorized) shape is.
for (const [path, lines] of this._linesByPath) {
const source = this._map.getSource(linesSourceId(path));
if (!source) continue;
const visible = lines.features.filter((f) => !this._hiddenCategories.has(f.properties.category));
source.setData({ type: "FeatureCollection", features: visible });
}
}
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;
let tree = null;
let failed = 0;
for (const [index, file] of files.entries()) {
this._setStatus(files.length === 1 ? "Uploading…" : `Uploading ${index + 1} of ${files.length}…`);
const formData = new FormData();
formData.append("file", file);
formData.append("folder", folder);
try {
// fetch() has no cross-browser upload progress event, so this one
// upload uses XMLHttpRequest instead — its upload.progress firing
// with loaded === total is the only client-observable signal that
// the server has (or is about to start) converting rather than
// still receiving bytes, worth surfacing now that a several-
// thousand-waypoint POI export's conversion can itself take
// several more seconds on top of the transfer (GpxStore#save).
const xhr = await uploadFile(formData, () => this._setStatus("Converting…"));
if (xhr.status === 401) {
window.location.href = "/login";
return;
}
if (xhr.status < 200 || xhr.status >= 300) throw new Error("upload failed");
({ tree } = JSON.parse(xhr.responseText));
} 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;
}
// XMLHttpRequest rather than fetch() — only so onUploadComplete (the
// upload.progress event) is available at all; every other write action in
// this file stays on fetch()/redirectIfUnauthorized. Resolves with the xhr
// itself (not just responseText) so the caller can still read .status the
// same way a fetch Response would let it.
function uploadFile(formData, onUploadComplete) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", UPLOAD_URL);
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable && event.loaded === event.total) onUploadComplete();
});
xhr.addEventListener("load", () => resolve(xhr));
xhr.addEventListener("error", () => reject(new Error("network error")));
xhr.send(formData);
});
}
// 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("/")}`;
}
// Set once from map.js after its one-time GET /api/kiwix check (same
// fetch-once-at-load story as the offline-tiles/terrain-tiles checks) —
// module-level rather than threaded through every popupHtml call, since
// it's the same answer for the whole page's lifetime.
let kiwixConfig = { available: false, url: "" };
export function setKiwixConfig(config) {
kiwixConfig = config;
}
// properties come straight from an uploaded GPX file — untrusted input —
// so every value is escaped before landing in the popup's HTML.
// editable (only ever true for a "Note" feature the viewer is logged in
// for — see _registerPointInteractivity) adds Edit/Delete buttons; the
// click handling for them lives in _wireNoteActions, not here, since this
// only builds markup, it has no map/store access of its own. lngLat (the
// popup's own anchor point) drives the "Nearby on Wikipedia" link — kiwix-
// serve's own geo search, linked to directly rather than proxied/embedded
// (it already has a full search UI of its own, see docs/development.adoc).
function popupHtml({ name, description, time, category, photos, icon }, { editable = false, lngLat = null } = {}) {
const parts = [];
if (name) {
const glyph = icon && POI_ICONS[icon] ? `<span class="gpx-popup__header-icon">${POI_ICONS[icon]}</span>` : "";
parts.push(`<div class="gpx-popup__header"><strong>${escapeHtml(name)}</strong>${glyph}</div>`);
}
if (category) parts.push(`<p class="gpx-popup__category">${escapeHtml(category)}</p>`);
if (photos?.length > 0) parts.push(formatPhotos(photos));
// A Note's description is self-authored by the (logged-in) viewer, not
// uploaded/untrusted like every other description here — formatDescription
// is deliberately left alone for that untrusted case (see
// docs/nps-resources.adoc's note on why markdown rendering wasn't adopted
// for it); formatNoteBody is the separate, narrower path that's actually
// safe to add light markdown to.
if (description) parts.push(category === "Note" ? formatNoteBody(description) : 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>`);
}
}
if (kiwixConfig.available && lngLat) {
const wikiUrl = `${kiwixConfig.url}/search?pattern=&latitude=${lngLat.lat}&longitude=${lngLat.lng}&distance=2000`;
parts.push(`<p class="gpx-popup__wiki"><a href="${escapeHtml(wikiUrl)}" target="_blank" rel="noopener noreferrer">Nearby on Wikipedia →</a></p>`);
}
if (editable) {
parts.push(
`<div class="gpx-popup__note-actions"><button type="button" class="gpx-popup__edit">Edit</button><button type="button" class="gpx-popup__delete">Delete</button></div>`,
);
}
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)}`;
}
// A Note's body only — never an uploaded file's description (see the
// comment where this is called). linkify() runs first and does its own
// escaping, so what it returns is already safe HTML; **bold**/*italic* are
// then matched against that (already-escaped) text and wrapped in real
// tags — order matters: running it before linkify would have that same
// escaping re-encode the tags this just added.
function formatNoteBody(text) {
let html = linkify(text)
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.+?)\*/g, "<em>$1</em>")
.replace(/\n/g, "<br>");
return `<p class="gpx-popup__description">${html}</p>`;
}