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

+1 -1
View File
@@ -22,7 +22,7 @@ independence from connectivity.
* [ ] Restyle popovers, side panels, and point detail views after the NPS Unigrid layout system (Massimo Vignelli) with Frutiger/Rawlinson Roadside-style typography
** [ ] Top black banner (full width): NPS-style logo + "MAPTOOL" on the left, location telemetry (DMS/DD coordinates, DEM elevation) in the center, hamburger menu + offline-data status on the right, white sans-serif type
** [ ] Point detail/sidepanel: black header banner with white location name and an NPS pictogram in the corner; amenities (water, waste, power, slope) shown as a pictogram grid, no extra text; action buttons for "route here" (Valhalla), "start leveling", "save offline"
** [ ] Filter panel as a "Unigrid Matrix" — an icon grid to toggle categories on/off, styled like the legend on an official NPS paper map, instead of plain dropdowns
** [x] Filter panel as a "Unigrid Matrix" — an icon grid to toggle categories on/off, styled like the legend on an official NPS paper map, instead of plain dropdowns
* [ ] Custom vector style (MVT/MapLibre) using the NPS color palette — muted natural tones for forests, water, and contours — so POIs and routes stand out at high zoom
** [ ] Landuse & protections: distinct styling for national parks, nature reserves (translucent green/gray-green), military zones, and private land (important for wild camping)
** [ ] Elevation-tinted terrain combined with offline hillshade for a characteristic relief look
+41 -3
View File
@@ -494,10 +494,13 @@ html, body {
.gpx-legend {
flex-shrink: 0;
max-height: 140px;
max-height: 160px;
overflow-y: auto;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2px 8px;
padding: 8px 12px;
border-top: 1px solid #eee;
border-top: 1px solid #1a1a1a;
}
.gpx-legend[hidden] {
@@ -508,7 +511,42 @@ html, body {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 0;
padding: 3px 4px;
border: none;
border-radius: 3px;
background: none;
font: inherit;
text-align: left;
cursor: pointer;
color: #1a1a1a;
overflow: hidden;
}
.gpx-legend__item:hover {
background: #f0f0f0;
}
.gpx-legend__item--hidden {
opacity: 0.35;
}
.gpx-legend__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gpx-legend__icon {
display: flex;
width: 14px;
height: 14px;
flex-shrink: 0;
color: #1a1a1a;
}
.gpx-legend__icon svg {
width: 100%;
height: 100%;
}
.gpx-legend__swatch {
+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