Turn the GPX category legend into a clickable Unigrid filter matrix
CI / check (push) Successful in 2m5s
CI / deploy (push) Successful in 13s

Legend items are now toggle buttons (icon grid, not a color-swatch
list) that hide/show that category's waypoints on the map. Filtering
re-slices each file's source data rather than using a layer filter,
since MapLibre clusters a GeoJSON source's full data regardless of
what a layer filter hides.
This commit is contained in:
randy committed 2026-09-09 09:09:07 +02:00
1 parent bc9ea58614
commit 53b0e8961b
3 files changed
+90 -13

No files matched your search

+48 -9
View File
@@ -43,6 +43,7 @@ export class GpxPanelControl {
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._hiddenCategories = new Set(); // category names toggled off in the legend
this._openDownloadMenu = null; // the one gpx-tree__download-menu currently open, if any
// setStyle() (switching the Online/Offline base map) tears down all
@@ -545,6 +546,7 @@ export class GpxPanelControl {
if (points.features.length > 0) this._pointsByPath.set(path, points);
this._updateLegend();
this._applyCategoryFilter();
return true;
}
@@ -713,32 +715,69 @@ export class GpxPanelControl {
// 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.
// and the legend just stays hidden. Doubles as a filter: each item is a
// toggle button (Unigrid-style icon grid) that hides/shows that category
// on the map, not just a read-only color key.
_updateLegend() {
const categories = new Map(); // category name -> color
const categories = new Map(); // category name -> { color, icon }
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);
if (category && !categories.has(category)) {
categories.set(category, { color: feature.properties.color || TRACK_COLOR, icon: feature.properties.icon });
}
}
}
this._legendContainer.innerHTML = "";
this._legendContainer.hidden = categories.size === 0;
[...categories].sort(([a], [b]) => a.localeCompare(b)).forEach(([category, color]) => {
const item = document.createElement("div");
[...categories].sort(([a], [b]) => a.localeCompare(b)).forEach(([category, { color, icon }]) => {
const item = document.createElement("button");
item.type = "button";
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
item.classList.toggle("gpx-legend__item--hidden", this._hiddenCategories.has(category));
item.title = category;
item.setAttribute("aria-pressed", String(!this._hiddenCategories.has(category)));
const glyph = document.createElement("span");
if (icon && POI_ICONS[icon]) {
glyph.className = "gpx-legend__icon";
glyph.innerHTML = POI_ICONS[icon];
} else {
glyph.className = "gpx-legend__swatch";
glyph.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.className = "gpx-legend__label";
label.textContent = category;
item.appendChild(swatch);
item.appendChild(glyph);
item.appendChild(label);
item.addEventListener("click", () => {
if (!this._hiddenCategories.delete(category)) this._hiddenCategories.add(category);
this._updateLegend();
this._applyCategoryFilter();
});
this._legendContainer.appendChild(item);
});
}
// Re-clusters each visible file's points around only the categories still
// toggled on — a plain layer `filter` can't do this: MapLibre clusters a
// GeoJSON source's *entire* data set regardless of what a layer filter
// hides, so a cluster's count/expansion would stay wrong. Cheap to redo
// in full: _pointsByPath already holds each file's complete features in
// memory, this is just re-slicing it, no re-parsing or network involved.
_applyCategoryFilter() {
for (const [path, points] of this._pointsByPath) {
const source = this._map.getSource(pointsSourceId(path));
if (!source) continue;
const visible = points.features.filter((f) => !this._hiddenCategories.has(f.properties.category));
source.setData({ type: "FeatureCollection", features: visible });
}
}
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