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 = `