import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs";
import {
GPX_ICON,
NEW_FOLDER_ICON,
UPLOAD_ICON,
WAYPOINT_ICON,
TRACK_ICON,
ROUTE_ICON,
MAP_MARKER_ICON,
POI_ICONS,
} 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";
// 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;
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);
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));
const name = document.createElement("span");
name.className = "gpx-tree__file-name";
name.textContent = file.name;
name.title = file.name;
label.appendChild(checkbox);
(file.kinds || []).forEach((kind) => label.appendChild(this._kindIcon(kind)));
label.appendChild(name);
row.appendChild(label);
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;
}
_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 entries, so it's accepted here even though the tree
// never shows a .zip itself.
const files = [...fileList].filter((file) => /\.(gpx|fit|zip)$/i.test(file.name));
if (files.length === 0) return;
this._setStatus(files.length === 1 ? "Uploading…" : `Uploading ${files.length} files…`);
let tree = null;
let failed = 0;
for (const file of files) {
const formData = new FormData();
formData.append("file", file);
formData.append("folder", folder);
try {
const response = await fetch(UPLOAD_URL, { method: "POST", body: formData });
if (redirectIfUnauthorized(response)) return;
if (!response.ok) throw new Error("upload failed");
({ tree } = await response.json());
} 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;
}
// 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("