import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs"; import { GPX_ICON, NEW_FOLDER_ICON, UPLOAD_ICON, WAYPOINT_ICON, TRACK_ICON, ROUTE_ICON, PENDING_ICON, DOWNLOAD_ICON, MAP_MARKER_ICON, POI_ICONS, PARK4NIGHT_TAGS, } from "/js/icons.js"; import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js"; import { formatActivityType } from "/js/track_profile.js"; const TREE_URL = "/api/gpx/tree"; const UPLOAD_URL = "/api/gpx"; const FOLDER_URL = "/api/gpx/folder"; const MOVE_URL = "/api/gpx/move"; const TRACK_COLOR = "#e63946"; const POI_ICON_KEYS = Object.keys(POI_ICONS); const POI_ICON_IMAGE_SIZE = 48; // rasterized once at this size, rendered much smaller by icon-size below const POI_ICON_SIZE = 0.3; // 48 * 0.3 = ~14px, sized to sit inside the marker badge below const MAP_MARKER_SIZE = 0.55; const BADGE_IMAGE_PREFIX = "badge-"; // one registered image per distinct badge color actually seen, see _registerBadgeColors // Custom drag payload MIME type for moving an already-uploaded file between // folders, as opposed to an OS file drag (type "Files") for uploading a new // one — both land on the same drop targets, told apart by this. const MOVE_MIME = "application/x-gpx-path"; // No server-rendered flag for this exists yet, and adding one would be more // plumbing than reusing what's already on the page: the topbar's own // logged_in check (views/index.erb) already renders a "Log out" link only // when logged in. Write-action controls (delete/download/upload/new // folder) check this to hide themselves entirely rather than being shown // and failing on click — login state never changes without a full page // reload, so this only needs checking once per render, not reactively. function isLoggedIn() { return !!document.querySelector('a[href="/logout"]'); } // 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 = ``; 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 ".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 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 // loads it as its own XML document, which needs an // explicit namespace or every browser refuses to decode it. const sized = svgMarkup.replace("${escapeHtml(name)}`); if (category) parts.push(`

${escapeHtml(category)}

`); 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(`

${escapeHtml(parsed.toLocaleString())}

`); } } return parts.length > 0 ? parts.join("") : "No details"; } // 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(`${escapeHtml(name)}`); 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(`

${stats.join(" · ")}

`); parts.push(`

Click for the full profile

`); return parts.length > 0 ? parts.join("") : "No details"; } 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 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 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+$/; // 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) => ``, ) .join(""); return `
${thumbs}
`; } 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(`

${linkify(bodyText).replace(/\n/g, "
")}

`); if (meta.length > 0) { const rows = meta.map(([, label, value]) => formatMetaRow(label, value)).join(""); parts.push(`
${rows}
`); } links.forEach((url) => parts.push(``)); 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 ? `
${formatTags(value)}
` : ` ${escapeHtml(value)}`; return `
${escapeHtml(label)}${valueHtml}
`; } function formatTags(value) { return value .split(",") .map((code) => code.trim()) .filter(Boolean) .map((code) => { const tag = PARK4NIGHT_TAGS[code]; const icon = tag?.icon ? `${tag.icon}` : ""; return `${icon}${escapeHtml(tag?.label ?? code)}`; }) .join(""); } // Turns bare URLs in untrusted text into safe 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 `${href}${escapeHtml(trailing)}`; }