Compare commits

..
3 Commits
Author SHA1 Message Date
randy 53b0e8961b Turn the GPX category legend into a clickable Unigrid filter matrix
CI / deploy (push) Successful in 13s
CI / check (push) Successful in 2m5s
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.
2026-09-09 09:09:07 +02:00
randy bc9ea58614 Start the NPS Unigrid visual redesign
Black topbar banner with live map-center coordinates, a black header
(with NPS pictogram) on waypoint popups, offline basemap wood/park
colors unified with the online Liberty green, and the sidebar grouped
into the planned Explorer / Offline hub / Navigation menu sections.
2026-09-09 09:02:29 +02:00
randy d1f2e7376e Add offline vanlife feature ideas to TODO
Captures ideas for offline maps/POI data, an NPS Unigrid-inspired
visual redesign, a 4-module menu reorg, and custom layer support.
Hardware-dependent features (leveling, Victron/HA telemetry) are
parked under Nice to have for later.
2026-09-09 08:18:07 +02:00
5 changed files with 258 additions and 49 deletions

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
= TODO — implementation ideas
Ideas for offline use in a camper van. Implement incrementally, grouped by topic.
The local server has access to both the vehicle's hardware and offline data — the
vision is to grow it into a complete offline control center: resources, data, and
independence from connectivity.
== Offline data and cartography
* [ ] Offline vector tiles (PMTiles/MBTiles) for Europe/world via Planetiler or MapTiler
* [ ] Shaded relief / DEM layer (terrain slope for parking)
* [ ] Hillshade & contour lines from OSM for hikes and mountain passes
* [ ] Overpass/OSM scripts to extract POIs (drinking water, dump stations, LPG, public toilets, laundromats, shops, springs)
* [ ] Import Park4Night / iOverlander / Freecampsites data
* [ ] Kiwix/ZIM offline Wikipedia linked to POI GPS coordinates
* [ ] Cache weather and precipitation radar when temporarily connected (4G/5G/Wi-Fi)
== NPS visual style
* [ ] Map Park4Night/OSM place types to NPS pictograms (campground, primitive camp, drinking water, dump station, viewpoint, picnic table) for instant recognition without reading text
* [ ] 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"
** [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
** [ ] Road hierarchy: clearly distinguish paved roads from unpaved/gravel/track — critical for knowing if the camper can get there
== Main menu reorganization (4 modules)
* [ ] Explorer: place filters (Park4Night + OSM layers: water, waste, LPG, showers); map layers (contours, hillshade, satellite cache)
* [ ] Navigation & route: offline router (Valhalla) with vehicle dimensions (height, weight, width); route elevation profile and isochrones
* [ ] Offline hub: manager for downloaded PMTiles (country vector maps); downloaded Wikipedia archives; cached radar and weather
* [ ] Camper & HW: level gauge (wheel-chock leveling); solar calculator (sun path on the map); telemetry (Victron / Home Assistant)
== Custom layers & data
* [ ] GPX/KML/GeoJSON drag-and-drop import (Wikiloc, Komoot, own GPX tracks), auto-styled in the NPS visual language with a generated elevation profile
* [ ] User overlay: offline drawing of custom points/polygons with notes (e.g. "turning space for a long vehicle", "narrow gate", "good swimming spot")
* [ ] Offline satellite tile caching (e.g. ESRI) for a selected route area — key for judging surface quality and obstacles when wild camping
== Nice to have (later)
Deprioritized for now — revisit after the sections above land.
=== Vanlife features
* [ ] Offline routing (Valhalla/OSRM) with vehicle parameters (height, weight, width)
* [ ] Solar calculator — sun path for location/date, shadows from surroundings, camper orientation
=== HW integration
* [ ] Level gauge from IMU (ESP32/RPi) — camper tilt, wheel-chock leveling calculation
* [ ] Victron/Home Assistant via MQTT or WebSocket — battery SoC, tank levels, temperatures
* [ ] Plot GPS track with historical battery/speed data
+93 -9
View File
@@ -14,16 +14,24 @@ html, body {
flex: 0 0 auto;
display: flex;
align-items: center;
height: 48px;
height: 44px;
padding: 0 16px;
background: #fff;
border-bottom: 1px solid #ddd;
background: #1a1a1a;
}
.topbar__brand {
font-size: 15px;
font-size: 14px;
font-weight: 700;
color: #333;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #fff;
}
.topbar__coords {
margin: 0 auto;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: #bbb;
}
.topbar__auth {
@@ -32,7 +40,11 @@ html, body {
align-items: center;
gap: 8px;
font-size: 13px;
color: #555;
color: #ccc;
}
.topbar__auth a {
color: #fff;
}
.app-body {
@@ -61,6 +73,17 @@ html, body {
position: relative;
}
.sidebar-group-label {
width: 28px;
height: 1px;
margin: 4px 0;
background: #ddd;
}
.sidebar-group-label:first-child {
display: none;
}
.sidebar-icon {
width: 36px;
height: 36px;
@@ -471,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] {
@@ -485,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 {
@@ -649,6 +710,29 @@ html, body {
user-select: none;
}
.gpx-popup__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: -15px -10px 8px;
padding: 10px 12px;
background: #1a1a1a;
color: #fff;
}
.gpx-popup__header-icon {
flex-shrink: 0;
width: 18px;
height: 18px;
color: #fff;
}
.gpx-popup__header-icon svg {
width: 100%;
height: 100%;
}
.gpx-popup__time {
color: #888;
font-size: 12px;
+53 -11
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
@@ -916,9 +955,12 @@ function gpxFileUrl(path) {
// properties come straight from an uploaded GPX file — untrusted input —
// so every value is escaped before landing in the popup's HTML.
function popupHtml({ name, description, time, category, photos }) {
function popupHtml({ name, description, time, category, photos, icon }) {
const parts = [];
if (name) parts.push(`<strong>${escapeHtml(name)}</strong>`);
if (name) {
const glyph = icon && POI_ICONS[icon] ? `<span class="gpx-popup__header-icon">${POI_ICONS[icon]}</span>` : "";
parts.push(`<div class="gpx-popup__header"><strong>${escapeHtml(name)}</strong>${glyph}</div>`);
}
if (category) parts.push(`<p class="gpx-popup__category">${escapeHtml(category)}</p>`);
if (photos?.length > 0) parts.push(formatPhotos(photos));
if (description) parts.push(formatDescription(description));
+54 -29
View File
@@ -22,22 +22,23 @@ const ZOOM = 13;
const ONLINE_STYLE_URL = "https://tiles.openfreemap.org/styles/liberty";
// Recolors OpenFreeMap Liberty to the same NPS-inspired palette as
// OFFLINE_THEME below — warm cream ground, brown/khaki land cover, green
// reserved for parks, muted roads/boundaries — mapped onto Liberty's
// OpenMapTiles layer ids instead of protomaps' theme roles (the two
// schemas don't share layer names, so this is a separate hand-built
// table, not a shared object). Only the layers that dominate the map's
// overall color read are touched; labels, halos, rail, and minor
// OFFLINE_THEME below — warm cream ground, muted roads/boundaries — mapped
// onto Liberty's OpenMapTiles layer ids instead of protomaps' theme roles
// (the two schemas don't share layer names, so this is a separate
// hand-built table, not a shared object). Only the layers that dominate the
// map's overall color read are touched; labels, halos, rail, and minor
// overlays (cemetery/hospital/school/pitch/track shading, building
// outlines) are left at Liberty's own defaults — same "not a full
// recolor" scope as OFFLINE_THEME. Road casings have no protomaps-role
// equivalent to reuse (offline roads don't get a separate outline), so
// #c9a66b there is reasoned to fit the family rather than sourced from
// anywhere real.
//
// wood/grass are deliberately left unpatched — Liberty's own green reads
// better than this app's earlier brown/khaki recolor, and OFFLINE_THEME's
// wood_a/scrub_a below were changed to match Liberty's green instead.
const ONLINE_STYLE_PATCH = {
background: { "background-color": "#f9f6ef" },
landcover_wood: { "fill-color": "#c9bd9c" },
landcover_grass: { "fill-color": "#d9cfb0" },
landcover_sand: { "fill-color": "#f0e4c4" },
water: { "fill-color": "#a3cedf" },
park: { "fill-color": "#b3e378" },
@@ -155,19 +156,25 @@ const OFFLINE_THEME = {
background: "#f9f6ef", // real: Park Tiles' own background, hsl(40, 37%, 97%)
earth: "#f9f6ef",
water: "#a3cedf",
// Brown/khaki, not green: in Park Tiles' own Imagery style, green is
// specifically the protected-area/park color (below), not a general land
// cover fill — actual forest there is just the aerial photo underneath,
// which this offline style has no equivalent of. Using that same vivid
// green for ordinary wood/scrub as well (an earlier version of this
// theme did) made everything read as "green," not "brown with green
// parks" — reversed here to match the real distinction.
wood_a: "#c9bd9c",
wood_b: "#bcae8a",
scrub_a: "#d9cfb0",
scrub_b: "#cfc29d",
park_a: "#c8e2a0",
park_b: "#b3e378", // real: Park Tiles' protected-area/park-point green
// Matches OpenFreeMap Liberty's own wood/grass green (its landcover_wood
// hsla(98,61%,72%,0.7) and landcover_grass rgba(176,213,154,1) at 0.3
// opacity, blended over the cream background) — the online map's actual
// look whenever ONLINE_STYLE_PATCH's brown/khaki recolor fails to apply
// (see loadOnlineStyle's fetch fallback) turned out to be the look this
// app's users actually want, so offline was changed to match it instead
// of chasing the patch's own reliability.
wood_a: "#c3e8ab",
wood_b: "#a8d888",
scrub_a: "#dcecc9",
scrub_b: "#cfe3b8",
// Close to wood_b, not the more saturated Park Tiles green: protomaps'
// theme routes "forest"/"nature_reserve"/"protected_area" (e.g. the Malé
// Karpaty CHKO boundary) through this same park_a/park_b pair as actual
// named parks — Liberty's online style doesn't make that reserve-boundary
// distinction at all, so a visibly different green here reads as an
// offline-only blotch rather than a deliberate distinction.
park_a: "#c3e8ab",
park_b: "#a8d888",
sand: "#f0e4c4",
beach: "#f0e4c4",
buildings: "#ded7cc",
@@ -388,25 +395,43 @@ map.once("style.load", () => {
map.addControl(new OverviewMapControl({}), "top-right");
});
const coordsLabel = document.querySelector(".topbar__coords");
const updateCoords = () => {
const { lng, lat } = map.getCenter();
coordsLabel.textContent = `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
};
updateCoords();
map.on("move", updateCoords);
const sidebar = document.getElementById("sidebar");
// Grouped into the TODO's planned 4-module menu (Explorer / Offline hub /
// Navigation / Camper & HW) ahead of that split actually happening —
// Camper & HW has no sidebar item yet (leveling/solar/telemetry are all
// still unbuilt), so it's left out rather than shown empty.
const layerToggle = new LayerToggleControl(applyStyle);
const profilePanel = new TrackProfilePanel(map);
profilePanel.mount();
sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title="Explorer"></div>`);
new GpxPanelControl(map, profilePanel).mount(sidebar);
sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title="Offline hub"></div>`);
layerToggle.mount(sidebar);
layerToggle.setActive("online");
new RadarControl(map).mount(sidebar);
sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title="Navigation"></div>`);
gnssControl.mount(sidebar);
new LiveTrackPanelControl(map).mount(sidebar);
// The sidebar already has a "Base map" icon (opens the same panel) —
// this is a second entry point for it, grouped with Home/zoom on the map
// itself rather than off in the page's own side rail, matching NPS's
// layout. Added after mount() since it reuses that panel/toggle state.
// layout. layerToggle only needs to exist (not be mounted yet) for this.
const mapLayersButton = new MapLayersButton(layerToggle);
map.addControl(mapLayersButton, "top-left");
const profilePanel = new TrackProfilePanel(map);
profilePanel.mount();
new GpxPanelControl(map, profilePanel).mount(sidebar);
new RadarControl(map).mount(sidebar);
gnssControl.mount(sidebar);
new LiveTrackPanelControl(map).mount(sidebar);
function applyStyle(name) {
layerToggle.setActive(name);
map.setStyle(name === "offline" ? offlineStyle() : ONLINE_STYLE);
+1
View File
@@ -1,6 +1,7 @@
<div id="app">
<header class="topbar">
<span class="topbar__brand" title="rev <%= revision %>">MapTool</span>
<span class="topbar__coords"></span>
<div class="topbar__auth">
<% if logged_in %>
<span><%= user %></span>