Compare commits
3
Commits
5c9516d038
...
53b0e8961b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53b0e8961b | ||
|
|
bc9ea58614 | ||
|
|
d1f2e7376e |
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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,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>
|
||||
|
||||
Reference in new issue
Block a user