diff --git a/public/css/style.css b/public/css/style.css
index fd48ec2..bbf5d68 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -1214,6 +1214,47 @@ html, body {
background: #f0f0f0;
}
+.overlays-panel__icon {
+ display: flex;
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+ color: #555;
+}
+
+.overlays-panel__icon svg {
+ width: 100%;
+ height: 100%;
+}
+
+.overlays-panel__row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 4px;
+}
+
+.overlays-panel__name {
+ border: none;
+ background: none;
+ padding: 0;
+ font: inherit;
+ color: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+
+.overlays-panel__name:hover {
+ text-decoration: underline;
+}
+
+.overlays-panel__details {
+ margin: 0 4px 6px 26px;
+ padding-top: 6px;
+ border-top: 1px solid #eee;
+ font-size: 12px;
+}
+
.livetrack-panel__list {
flex: 1;
overflow-y: auto;
diff --git a/public/js/icons.js b/public/js/icons.js
index eaff0de..e79a5b3 100644
--- a/public/js/icons.js
+++ b/public/js/icons.js
@@ -17,12 +17,6 @@ export const OVERLAYS_ICON = `
`;
-export const HILLSHADE_ICON = `
-
-
-
- `;
-
export const NOTE_PIN_ICON = `
@@ -36,11 +30,6 @@ export const GPX_ICON = `
`;
-export const RADAR_ICON = `
-
-
- `;
-
export const GNSS_ICON = `
@@ -151,6 +140,25 @@ export const MAP_MARKER_ICON = `
// tinted at render time, see gpx.js's _registerCategoryGlyphs) so only the
// alpha shape matters, not the actual `currentColor` value.
+// src/wilderness-22.svg — exported (unlike BICYCLE_TRAIL_ICON below, this
+// one's also used outside this file) as the Overlays panel's Terrain
+// shading glyph (overlays.js): the closest honest match in NPS's set for
+// "mountains/terrain" — it has no weather or elevation-specific pictogram.
+export const WILDERNESS_ICON = `
+ `;
+
+// Hand-drawn, not NPS-sourced — that set has no weather/precipitation
+// pictogram to reuse for the Overlays panel's Radar item, unlike Wikipedia
+// (WIKI_ICON) and Terrain shading (WILDERNESS_ICON) above.
+export const RADAR_CLOUD_ICON = `
+
+
+
+
+
+ `;
+
// src/bicycle-trail-22.svg — pulled out to its own constant since it's also
// used unrasterized, inline, as PARK4NIGHT_TAGS.vtt below.
const BICYCLE_TRAIL_ICON = `
@@ -213,8 +221,7 @@ export const POI_ICONS = {
`,
// src/wilderness-22.svg
- forest: `
- `,
+ forest: WILDERNESS_ICON,
// src/bicycle-trail-22.svg
sport_cycling: BICYCLE_TRAIL_ICON,
diff --git a/public/js/map.js b/public/js/map.js
index 14e787e..b0a4154 100644
--- a/public/js/map.js
+++ b/public/js/map.js
@@ -4,7 +4,6 @@ import {
Popup,
addProtocol,
} from "/vendor/maplibre-gl/maplibre-gl.mjs";
-import { RadarControl } from "/js/radar.js";
import { GpxPanelControl } from "/js/gpx.js";
import { OverlaysControl } from "/js/overlays.js";
import { GnssControl } from "/js/gnss.js";
@@ -14,7 +13,7 @@ import { MeasureControl } from "/js/measure.js";
import { NotesControl } from "/js/notes.js";
import { PluginsControl } from "/js/plugins.js";
import { LocateControl } from "/js/locate.js";
-import { LAYERS_ICON, HILLSHADE_ICON } from "/js/icons.js";
+import { LAYERS_ICON } from "/js/icons.js";
import { formatCoords, formatSunInfo } from "/js/sun.js";
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
import { isLoggedIn } from "/js/auth.js";
@@ -310,100 +309,6 @@ class LayerToggleControl {
}
}
-const HILLSHADE_ID = "hillshade-dem";
-const COLOR_RELIEF_ID = "elevation-tint";
-// Hypsometric tint reusing this app's own palette (wood_a's green at
-// valley floor, the road casing brown through the mid-slopes) rather than
-// a generic rainbow ramp, so it reads as part of the same map rather than
-// a bolted-on overlay. Stops picked for Central European terrain — the
-// Tatras top out around 2600m — not a general-purpose world ramp.
-const ELEVATION_COLOR_RAMP = [
- "interpolate",
- ["linear"],
- ["raster-value"],
- 0, "#c3e8ab",
- 400, "#dcd08f",
- 900, "#c9a66b",
- 1600, "#a89a86",
- 2600, "#f9f6ef",
-];
-// 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);
- // Elevation tint under, hillshade on top: the tint sets the base color
- // per elevation band, hillshade multiplies slope-shading over it — the
- // combined "characteristic relief look" the TODO asked for, not two
- // independent layers.
- this._map.addLayer({
- id: COLOR_RELIEF_ID,
- type: "color-relief",
- source: HILLSHADE_ID,
- paint: { "color-relief-color": ELEVATION_COLOR_RAMP, "color-relief-opacity": 0.7 },
- });
- 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.getLayer(COLOR_RELIEF_ID)) this._map.removeLayer(COLOR_RELIEF_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
@@ -591,8 +496,6 @@ sidebar.insertAdjacentHTML("beforeend", `
`);
layerToggle.mount(sidebar);
layerToggle.setActive("online");
-new RadarControl(map).mount(sidebar);
sidebar.insertAdjacentHTML("beforeend", ``);
gnssControl.mount(sidebar);
@@ -645,7 +547,7 @@ fetch("/api/offline-tiles")
fetch("/api/terrain-tiles")
.then((response) => response.json())
- .then(({ available }) => hillshadeControl.setOfflineAvailable(available))
+ .then(({ available }) => overlaysControl.setHillshadeOfflineAvailable(available))
.catch(() => {});
fetch("/api/kiwix")
diff --git a/public/js/overlays.js b/public/js/overlays.js
index 0b0be3e..4c21d39 100644
--- a/public/js/overlays.js
+++ b/public/js/overlays.js
@@ -1,7 +1,8 @@
import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs";
-import { OVERLAYS_ICON, WIKI_ICON, MAP_MARKER_ICON } from "/js/icons.js";
+import { OVERLAYS_ICON, WIKI_ICON, WILDERNESS_ICON, MAP_MARKER_ICON } from "/js/icons.js";
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
import { svgToImageData, badgeImageId, escapeHtml } from "/js/gpx.js";
+import { RadarControl } from "/js/radar.js";
const ICON_IMAGE_SIZE = 48; // rasterized once at this size, rendered much smaller by icon-size below
const ICON_SIZE = 0.3; // 48 * 0.3 = ~14px, sized to sit inside the marker badge below
@@ -11,16 +12,115 @@ const WIKI_SOURCE_ID = "wiki-points";
const WIKI_ICON_ID = "wiki-icon";
const WIKI_BADGE_COLOR = "#795548";
+const HILLSHADE_ID = "hillshade-dem";
+const COLOR_RELIEF_ID = "elevation-tint";
+// Hypsometric tint reusing this app's own palette (wood_a's green at
+// valley floor, the road casing brown through the mid-slopes) rather than
+// a generic rainbow ramp, so it reads as part of the same map rather than
+// a bolted-on overlay. Stops picked for Central European terrain — the
+// Tatras top out around 2600m — not a general-purpose world ramp.
+const ELEVATION_COLOR_RAMP = [
+ "interpolate",
+ ["linear"],
+ ["raster-value"],
+ 0, "#c3e8ab",
+ 400, "#dcd08f",
+ 900, "#c9a66b",
+ 1600, "#a89a86",
+ 2600, "#f9f6ef",
+];
+// 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 plain on/off Overlays item — there's nothing to choose, just whether
+// the shading shows, unlike Radar's expandable scrub/forecast.
+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(li) {
+ li.innerHTML = `
+
+
+ ${WILDERNESS_ICON}
+ Terrain shading
+
+ `;
+ this._checkbox = li.querySelector(".overlays-panel__toggle");
+ this._checkbox.addEventListener("change", (event) => this._toggle(event.target.checked));
+ }
+
+ setOfflineAvailable(available) {
+ this._offlineAvailable = available;
+ }
+
+ _toggle(active) {
+ this._active = active;
+ if (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);
+ // Elevation tint under, hillshade on top: the tint sets the base color
+ // per elevation band, hillshade multiplies slope-shading over it — the
+ // combined "characteristic relief look" the TODO asked for, not two
+ // independent layers.
+ this._map.addLayer({
+ id: COLOR_RELIEF_ID,
+ type: "color-relief",
+ source: HILLSHADE_ID,
+ paint: { "color-relief-color": ELEVATION_COLOR_RAMP, "color-relief-opacity": 0.7 },
+ });
+ 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.getLayer(COLOR_RELIEF_ID)) this._map.removeLayer(COLOR_RELIEF_ID);
+ if (this._map.getSource(HILLSHADE_ID)) this._map.removeSource(HILLSHADE_ID);
+ }
+}
+
// A sidebar side panel (same shell as LiveTrackPanelControl/#livetrack-panel)
-// listing live map layers that aren't a base map, a Tracks & Points file, or
-// a fixed on/off toggle (Hillshade) — each one refetched from its own
-// source as the map moves, not something to add/remove/reorder the way
-// Tracks & Points' file tree is, so this is a plain checklist. Wikipedia is
-// the first entry; more can join the same #overlays-panel list later
-// without a redesign.
+// listing live map layers that aren't a base map or a Tracks & Points file —
+// each one refetched/toggled live rather than something to add/remove/
+// reorder, so this is a plain checklist, its items built here (like
+// PluginsControl's own list) rather than as static markup. Radar and
+// Terrain shading used to be their own separate sidebar icons; both moved
+// here since neither has anything to "manage," just on/off (Radar's own
+// scrub/forecast controls live behind expanding its item, not a factor in
+// where it's mounted).
export class OverlaysControl {
constructor(map) {
this._map = map;
+ this._hillshade = new HillshadeControl(map);
+ this._radar = new RadarControl(map);
this._wikiEnabled = false; // the checkbox's own state
this._wikiMoveHandler = null; // the moveend listener driving its live refetch, while enabled
this._wikiRequestSeq = 0; // ignores an in-flight refetch a newer one has already superseded
@@ -28,7 +128,8 @@ export class OverlaysControl {
// setStyle() (switching the Online/Offline base map) tears down all
// custom sources/layers/images — put the wiki layer back afterwards,
- // same as GpxPanelControl does for visible GPX files.
+ // same as GpxPanelControl does for visible GPX files. Hillshade/Radar
+ // handle their own the same way, from their own constructors above.
map.on("style.load", () => {
if (this._wikiEnabled) this._addWikiLayers().then(() => this._refreshWikiLayer());
});
@@ -45,11 +146,17 @@ export class OverlaysControl {
this._panel
.querySelector(".basemap-panel__close")
.addEventListener("click", () => this._togglePanel(false));
- this._wikiToggle = this._panel.querySelector(".overlays-panel__wiki-toggle");
- this._wikiToggle.addEventListener("change", (event) => {
- if (event.target.checked) this._enableWikiLayer();
- else this._disableWikiLayer();
- });
+
+ const list = this._panel.querySelector(".overlays-panel__list");
+ list.innerHTML = `
+
+
+
+ `;
+ const [hillshadeItem, wikiItem, radarItem] = list.children;
+ this._hillshade.mount(hillshadeItem);
+ this._mountWiki(wikiItem);
+ this._radar.mount(radarItem);
onlyOneSidePanelOpen(this._panel, () => this._icon.classList.remove("active"));
@@ -63,13 +170,33 @@ export class OverlaysControl {
if (open) notifySidePanelOpen(this._panel);
}
+ setHillshadeOfflineAvailable(available) {
+ this._hillshade.setOfflineAvailable(available);
+ }
+
+ _mountWiki(li) {
+ li.innerHTML = `
+
+
+ ${WIKI_ICON}
+ Wikipedia
+
+ `;
+ this._wikiItem = li;
+ this._wikiToggle = li.querySelector(".overlays-panel__toggle");
+ this._wikiToggle.addEventListener("change", (event) => {
+ if (event.target.checked) this._enableWikiLayer();
+ else this._disableWikiLayer();
+ });
+ }
+
// Called once map.js's one-time GET /api/kiwix check resolves — disables
// (rather than hides) the checkbox rather than the whole panel/icon,
- // since a future second overlay could still be usable with no kiwix-serve
+ // since the other overlays are still usable with no kiwix-serve
// configured at all.
setWikiAvailable(available) {
this._wikiToggle.disabled = !available;
- if (!available) this._wikiToggle.closest(".overlays-panel__item").title = "No kiwix-serve configured";
+ if (!available) this._wikiItem.title = "No kiwix-serve configured";
}
async _enableWikiLayer() {
diff --git a/public/js/radar.js b/public/js/radar.js
index 6c7c506..f141d54 100644
--- a/public/js/radar.js
+++ b/public/js/radar.js
@@ -1,4 +1,4 @@
-import { RADAR_ICON } from "/js/icons.js";
+import { RADAR_CLOUD_ICON } from "/js/icons.js";
const RAINVIEWER_API = "https://api.rainviewer.com/public/weather-maps.json";
@@ -39,10 +39,10 @@ const OPACITY = 0.7;
const ATTRIBUTION =
'Weather data © RainViewer ';
-// A sidebar icon that fetches RainViewer's recent precipitation frames on
-// demand and animates them as a raster overlay, with play/pause and a scrub
-// slider in a flyout. No API key needed — see docs/architecture.adoc for
-// RainViewer's usage terms.
+// An Overlays panel item (see overlays.js) that fetches RainViewer's recent
+// precipitation frames on demand and animates them as a raster overlay,
+// with play/pause and a scrub slider revealed by expanding the item. No API
+// key needed — see docs/architecture.adoc for RainViewer's usage terms.
export class RadarControl {
constructor(map) {
this._map = map;
@@ -59,12 +59,19 @@ export class RadarControl {
});
}
- mount(sidebar) {
- this._container = document.createElement("div");
- this._container.className = "sidebar-item";
- this._container.innerHTML = `
-
-