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