From 53b0e8961b0d60fa28e30b8a518a5bb93161668c Mon Sep 17 00:00:00 2001 From: Roman Priesol Date: Wed, 9 Sep 2026 09:09:07 +0200 Subject: [PATCH] Turn the GPX category legend into a clickable Unigrid filter matrix 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. --- TODO.adoc | 2 +- public/css/style.css | 44 +++++++++++++++++++++++++++++++--- public/js/gpx.js | 57 +++++++++++++++++++++++++++++++++++++------- 3 files changed, 90 insertions(+), 13 deletions(-) diff --git a/TODO.adoc b/TODO.adoc index 0837ae4..7cf4633 100644 --- a/TODO.adoc +++ b/TODO.adoc @@ -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 diff --git a/public/css/style.css b/public/css/style.css index 7dbfed2..9e76b6f 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -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 { diff --git a/public/js/gpx.js b/public/js/gpx.js index 4359eed..cde888f 100644 --- a/public/js/gpx.js +++ b/public/js/gpx.js @@ -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