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.
This commit is contained in:
1 parent
bc9ea58614
commit
53b0e8961b
3 files changed
+90
-13
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user