diff --git a/TODO.adoc b/TODO.adoc index e5efb45..d277442 100644 --- a/TODO.adoc +++ b/TODO.adoc @@ -9,8 +9,9 @@ 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 +* [x] Shaded relief / DEM layer — hillshade toggle in the sidebar, sourced from public Terrarium tiles online; reads `data/terrain.pmtiles` for offline if one's been generated (see docs/development.adoc), otherwise the toggle is online-only for now +** [ ] Terrain slope readout for parking (numeric, not just visual shading) +* [ ] 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 @@ -25,7 +26,7 @@ independence from connectivity. ** [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 +** [ ] Elevation-tinted terrain (color by height band, not just shading — see the hillshade toggle above) ** [x] Road hierarchy: clearly distinguish paved roads from unpaved/gravel/track — critical for knowing if the camper can get there == Main menu reorganization (4 modules) diff --git a/app.rb b/app.rb index cfc551b..42868f0 100644 --- a/app.rb +++ b/app.rb @@ -58,6 +58,7 @@ module Maptool DATA_DIR = ENV.fetch("MAPTOOL_DATA_DIR") { File.join(__dir__, "data") } OFFLINE_PMTILES_PATH = File.join(DATA_DIR, "offline.pmtiles") + TERRAIN_PMTILES_PATH = File.join(DATA_DIR, "terrain.pmtiles") GPX_STORE = GpxStore.new(File.join(DATA_DIR, "gpx")) LIVETRACK_STORE = LiveTrackStore.new(File.join(DATA_DIR, "livetrack.json")) @@ -185,6 +186,19 @@ module Maptool send_file OFFLINE_PMTILES_PATH, type: "application/octet-stream" end + # Same optional-local-file story as offline.pmtiles above — see + # docs/development.adoc for how to generate one, until then hillshade + # just stays unavailable while the offline basemap is active. + get "/api/terrain-tiles" do + json available: File.exist?(TERRAIN_PMTILES_PATH) + end + + get "/tiles/terrain.pmtiles" do + halt 404 unless File.exist?(TERRAIN_PMTILES_PATH) + + send_file TERRAIN_PMTILES_PATH, type: "application/octet-stream" + end + get "/api/gpx/tree" do json GPX_STORE.tree end diff --git a/docs/development.adoc b/docs/development.adoc index 5c5be88..fe40131 100644 --- a/docs/development.adoc +++ b/docs/development.adoc @@ -43,6 +43,22 @@ build one locally: Once the file is in place, `GET /api/offline-tiles` reports `available: true` and the map defaults to the offline style. +== Offline terrain data (hillshade) + +The sidebar's "Terrain shading" toggle works online out of the box +(public https://github.com/tilezen/joerd[Terrarium] elevation tiles, +no API key). For it to work offline too, place a PMTiles archive of +Terrarium-encoded `raster-dem` tiles at `data/terrain.pmtiles` +(`GET /api/terrain-tiles` reports `available: true` once it's there, +same pattern as offline map data above). It is not part of the +repository — build one locally from a DEM source (e.g. Copernicus +GLO-30 or SRTM) with a tool like +https://github.com/mapbox/rio-rgbify[rio-rgbify] (GeoTIFF -> +Terrarium-encoded PNG tiles in an `.mbtiles`), then +`pmtiles convert out.mbtiles data/terrain.pmtiles` with +https://github.com/protomaps/go-pmtiles[go-pmtiles], same as +`data/offline.pmtiles`. + == GNSS position (gpsd) The "GNSS" control needs https://gpsd.io[gpsd] running and pointed at diff --git a/public/js/icons.js b/public/js/icons.js index 841f6c6..239e9b2 100644 --- a/public/js/icons.js +++ b/public/js/icons.js @@ -7,6 +7,12 @@ export const LAYERS_ICON = ` `; +export const HILLSHADE_ICON = ` + + + +`; + export const GPX_ICON = ` diff --git a/public/js/map.js b/public/js/map.js index d5a4a1d..4cbe966 100644 --- a/public/js/map.js +++ b/public/js/map.js @@ -11,7 +11,7 @@ import { LiveTrackPanelControl } from "/js/livetrack.js"; import { TrackProfilePanel } from "/js/track_profile.js"; import { MeasureControl } from "/js/measure.js"; import { LocateControl } from "/js/locate.js"; -import { LAYERS_ICON } from "/js/icons.js"; +import { LAYERS_ICON, HILLSHADE_ICON } from "/js/icons.js"; import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js"; import HomeButton from "/vendor/npmap-home-button/home-button.esm.js"; import OverviewMap from "/vendor/npmap-overview/overview.esm.js"; @@ -305,6 +305,73 @@ class LayerToggleControl { } } +const HILLSHADE_ID = "hillshade-dem"; +// Public, unauthenticated, no API key — the same open (SRTM/ArcticDEM-derived) +// elevation tiles OpenStreetMap's own hillshade layer and many other free +// tools use. Only reachable online; while the offline basemap is active this +// is skipped in favor of a local terrain.pmtiles if one's been generated +// (see docs/development.adoc), same optional-local-file story as +// offline.pmtiles itself. +const ONLINE_TERRAIN_TILES = ["https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"]; + +// A simple on/off sidebar toggle, not a panel — there's nothing to choose, +// just whether the shading shows. +class HillshadeControl { + constructor(map) { + this._map = map; + this._active = false; + this._offlineAvailable = false; + // setStyle() (switching Online/Offline) tears down all custom + // sources/layers — put hillshade back if it was on. + map.on("style.load", () => { + if (this._active) this._addLayer(); + }); + } + + mount(sidebar) { + this._container = document.createElement("div"); + this._container.className = "sidebar-item"; + this._container.innerHTML = ``; + this._button = this._container.querySelector(".sidebar-icon"); + this._button.addEventListener("click", () => this._toggle()); + sidebar.appendChild(this._container); + } + + setOfflineAvailable(available) { + this._offlineAvailable = available; + } + + _toggle() { + this._active = !this._active; + this._button.classList.toggle("active", this._active); + this._button.setAttribute("aria-pressed", String(this._active)); + if (this._active) this._addLayer(); + else this._removeLayer(); + } + + _addLayer() { + if (this._map.getSource(HILLSHADE_ID)) return; + + const source = this._offlineAvailable + ? { type: "raster-dem", url: `pmtiles://${window.location.origin}/tiles/terrain.pmtiles`, encoding: "terrarium" } + : { + type: "raster-dem", + tiles: ONLINE_TERRAIN_TILES, + tileSize: 256, + encoding: "terrarium", + maxzoom: 15, + attribution: "Terrain tiles: Mapzen, SRTM, ArcticDEM", + }; + this._map.addSource(HILLSHADE_ID, source); + this._map.addLayer({ id: HILLSHADE_ID, type: "hillshade", source: HILLSHADE_ID, paint: { "hillshade-exaggeration": 0.4 } }); + } + + _removeLayer() { + if (this._map.getLayer(HILLSHADE_ID)) this._map.removeLayer(HILLSHADE_ID); + if (this._map.getSource(HILLSHADE_ID)) this._map.removeSource(HILLSHADE_ID); + } +} + // A small map-corner button (grouped with Home/zoom, see below) that's a // second entry point to the same panel LayerToggleControl already manages // in the page's own sidebar — matching the National Park Service's own @@ -454,6 +521,8 @@ profilePanel.mount(); sidebar.insertAdjacentHTML("beforeend", ``); new GpxPanelControl(map, profilePanel).mount(sidebar); +const hillshadeControl = new HillshadeControl(map); +hillshadeControl.mount(sidebar); sidebar.insertAdjacentHTML("beforeend", ``); layerToggle.mount(sidebar); @@ -501,6 +570,11 @@ fetch("/api/offline-tiles") // failing, not evidence offline tiles are actually unavailable. }); +fetch("/api/terrain-tiles") + .then((response) => response.json()) + .then(({ available }) => hillshadeControl.setOfflineAvailable(available)) + .catch(() => {}); + // Alt+click anywhere shows every rendered feature at that point — base map // (OSM tags via OpenFreeMap/protomaps), GPX/FIT layers, radar, all of it — // with its raw properties, inspired by NPMap4's infoboxControl (a generic