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