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 * [ ] 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 ** [ ] 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" ** [ ] 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 * [ ] 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) ** [ ] 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 ** [ ] Elevation-tinted terrain combined with offline hillshade for a characteristic relief look
+41 -3
View File
@@ -494,10 +494,13 @@ html, body {
.gpx-legend { .gpx-legend {
flex-shrink: 0; flex-shrink: 0;
max-height: 140px; max-height: 160px;
overflow-y: auto; overflow-y: auto;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2px 8px;
padding: 8px 12px; padding: 8px 12px;
border-top: 1px solid #eee; border-top: 1px solid #1a1a1a;
} }
.gpx-legend[hidden] { .gpx-legend[hidden] {
@@ -508,7 +511,42 @@ html, body {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; 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 { .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._visible = new Set(); // file paths currently shown on the map
this._interactiveLayers = new Set(); // point layer ids with click/hover wired up 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._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 this._openDownloadMenu = null; // the one gpx-tree__download-menu currently open, if any
// setStyle() (switching the Online/Offline base map) tears down all // 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); if (points.features.length > 0) this._pointsByPath.set(path, points);
this._updateLegend(); this._updateLegend();
this._applyCategoryFilter();
return true; return true;
} }
@@ -713,32 +715,69 @@ export class GpxPanelControl {
// Rebuilt from whichever visible files actually carry categorized // Rebuilt from whichever visible files actually carry categorized
// waypoints (a park4night/OsmAnd POI export) — a plain GPS track has none // 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() { _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 points of this._pointsByPath.values()) {
for (const feature of points.features) { for (const feature of points.features) {
const category = feature.properties.category; 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.innerHTML = "";
this._legendContainer.hidden = categories.size === 0; this._legendContainer.hidden = categories.size === 0;
[...categories].sort(([a], [b]) => a.localeCompare(b)).forEach(([category, color]) => { [...categories].sort(([a], [b]) => a.localeCompare(b)).forEach(([category, { color, icon }]) => {
const item = document.createElement("div"); const item = document.createElement("button");
item.type = "button";
item.className = "gpx-legend__item"; item.className = "gpx-legend__item";
const swatch = document.createElement("span"); item.classList.toggle("gpx-legend__item--hidden", this._hiddenCategories.has(category));
swatch.className = "gpx-legend__swatch"; item.title = category;
swatch.style.background = color; // a malformed/malicious value from an uploaded file is simply ignored by the CSSOM, never parsed as markup 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"); const label = document.createElement("span");
label.className = "gpx-legend__label";
label.textContent = category; label.textContent = category;
item.appendChild(swatch); item.appendChild(glyph);
item.appendChild(label); item.appendChild(label);
item.addEventListener("click", () => {
if (!this._hiddenCategories.delete(category)) this._hiddenCategories.add(category);
this._updateLegend();
this._applyCategoryFilter();
});
this._legendContainer.appendChild(item); 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) { async _uploadFiles(fileList, folder) {
// .zip: Garmin's bulk/FIT export format — the server expands it into // .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 // its .gpx/.fit/.geojson entries, so it's accepted here even though the