Add a hillshade/terrain-shading toggle to the map

Sidebar toggle (new HillshadeControl) adds a raster-dem hillshade
layer: public Terrarium tiles when online, or a local
data/terrain.pmtiles when present (mirrors offline.pmtiles' optional-
local-file pattern, new /api/terrain-tiles + /tiles/terrain.pmtiles
endpoints). No offline terrain data is bundled, so the toggle is
online-only until one is generated — see docs/development.adoc for
how.
This commit is contained in:
randy committed 2026-09-09 09:27:17 +02:00
1 parent f49e44800a
commit 77a4c96b1c
5 files changed
+115 -4

No files matched your search

+4 -3
View File
@@ -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)
+14
View File
@@ -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
+16
View File
@@ -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
+6
View File
@@ -7,6 +7,12 @@ export const LAYERS_ICON = `
<path d="M2 14l10 6 10-6"/>
</svg>`;
export const HILLSHADE_ICON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round">
<path d="M2 18 9 7l3.5 5.5L15 9l7 9z"/>
<path d="M2 18h20"/>
</svg>`;
export const GPX_ICON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<circle cx="5" cy="6" r="2"/>
+75 -1
View File
@@ -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 = `<button type="button" class="sidebar-icon" title="Terrain shading" aria-label="Terrain shading" aria-pressed="false">${HILLSHADE_ICON}</button>`;
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", `<div class="sidebar-group-label" title="Explorer"></div>`);
new GpxPanelControl(map, profilePanel).mount(sidebar);
const hillshadeControl = new HillshadeControl(map);
hillshadeControl.mount(sidebar);
sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title="Offline hub"></div>`);
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