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 // 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")); 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)); // Once a .gpx/.fit is cached, its converted GeoJSON is the thing that's // actually shown on the map from here on (see GpxStore#geojson) — the // name reflects that (matching the real ".geojson" cache file on // disk) rather than still reading as a plain, un-converted .gpx/.fit. // A direct .geojson upload already ends in .geojson, so it's left // alone. const displayName = file.cached && !file.name.toLowerCase().endsWith(".geojson") ? `${file.name}.geojson` : file.name; const loggedIn = isLoggedIn(); // Logged in: an , not a — clicking it downloads that GeoJSON, // the only way to get it out of the app, since the original .gpx/.fit // is never deleted but also never re-shown once converted. An // nested in a `, ) .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)}`; }