Black topbar banner with live map-center coordinates, a black header (with NPS pictogram) on waypoint popups, offline basemap wood/park colors unified with the online Liberty green, and the sidebar grouped into the planned Explorer / Offline hub / Navigation menu sections.
1070 lines
42 KiB
JavaScript
1070 lines
42 KiB
JavaScript
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";
|
||
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._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));
|
||
});
|
||
}
|
||
|
||
// 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 });
|
||
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;
|
||
|
||
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("/")}`;
|
||
}
|
||
|
||
// 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, icon }) {
|
||
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));
|
||
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)}`;
|
||
}
|