From c9561a5489775204fe9e636d11d1873f407da628 Mon Sep 17 00:00:00 2001 From: Roman Priesol Date: Thu, 10 Sep 2026 21:43:51 +0200 Subject: [PATCH] Add an offline terrain shadow overlay to the Overlays panel Shades the visible map by whether each point is currently blocked from the sun by surrounding terrain (shademap.app-style), computed fully offline via the same Terrarium DEM tiles the Terrain shading layer already renders, plus this session's sun-position math and Radar's checkbox+expandable UI shape. dem.js pulls elevation-at-a-point out on its own since TODO.adoc's slope readout and solar calculator will need the same lookup. --- public/css/style.css | 21 ++++ public/js/dem.js | 72 +++++++++++++ public/js/icons.js | 9 ++ public/js/map.js | 5 +- public/js/overlays.js | 14 ++- public/js/shadow.js | 237 ++++++++++++++++++++++++++++++++++++++++++ views/layout.erb | 2 + 7 files changed, 357 insertions(+), 3 deletions(-) create mode 100644 public/js/dem.js create mode 100644 public/js/shadow.js diff --git a/public/css/style.css b/public/css/style.css index bbf5d68..1e701d7 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -242,6 +242,27 @@ html, body { font-size: 11px; } +.shadow-control__time { + display: flex; + align-items: center; + gap: 6px; +} + +.shadow-control__slider { + width: 110px; +} + +.shadow-control__clock { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.shadow-control__hint { + margin: 6px 0 0; + color: #888; + font-size: 11px; +} + .basemap-panel { position: fixed; top: 0; diff --git a/public/js/dem.js b/public/js/dem.js new file mode 100644 index 0000000..eee9ffa --- /dev/null +++ b/public/js/dem.js @@ -0,0 +1,72 @@ +// Elevation lookups against the same Terrarium-encoded terrain tiles +// overlays.js's HillshadeControl already renders — pulled into its own file +// since TODO.adoc lists two more near-future features (slope readout, solar +// calculator) that will need the same "elevation at a point" primitive, +// shadow.js's raymarch being the first real consumer. + +import { ONLINE_TERRAIN_TILES } from "/js/overlays.js"; + +const DEM_ZOOM = 10; // fixed lookup zoom, independent of the map's own display zoom +const TILE_SIZE = 256; + +const tileCache = new Map(); // "z/x/y" -> Promise, so concurrent lookups on the same not-yet-fetched tile share one fetch +let offlinePmtiles = null; + +function lngLatToTile(lng, lat, zoom) { + const n = 2 ** zoom; + const x = ((lng + 180) / 360) * n; + const latRad = (lat * Math.PI) / 180; + const y = ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n; + const tileX = Math.floor(x); + const tileY = Math.floor(y); + return { + tileX, + tileY, + px: Math.min(TILE_SIZE - 1, Math.floor((x - tileX) * TILE_SIZE)), + py: Math.min(TILE_SIZE - 1, Math.floor((y - tileY) * TILE_SIZE)), + }; +} + +async function decodeTerrarium(pngBytes) { + const bitmap = await createImageBitmap(new Blob([pngBytes], { type: "image/png" })); + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const ctx = canvas.getContext("2d"); + ctx.drawImage(bitmap, 0, 0); + const { data } = ctx.getImageData(0, 0, bitmap.width, bitmap.height); + const elevations = new Int16Array(bitmap.width * bitmap.height); + for (let i = 0; i < elevations.length; i++) { + elevations[i] = data[i * 4] * 256 + data[i * 4 + 1] + data[i * 4 + 2] / 256 - 32768; + } + return elevations; +} + +async function fetchTileBytes(z, x, y, offlineAvailable) { + if (offlineAvailable) { + if (!offlinePmtiles) offlinePmtiles = new window.pmtiles.PMTiles(`${window.location.origin}/tiles/terrain.pmtiles`); + const result = await offlinePmtiles.getZxy(z, x, y); + return result ? new Uint8Array(result.data) : null; + } + const url = ONLINE_TERRAIN_TILES[0].replace("{z}", z).replace("{x}", x).replace("{y}", y); + const response = await fetch(url); + return response.ok ? new Uint8Array(await response.arrayBuffer()) : null; +} + +function fetchTile(z, x, y, offlineAvailable) { + const key = `${z}/${x}/${y}`; + let cached = tileCache.get(key); + if (cached) return cached; + + const promise = fetchTileBytes(z, x, y, offlineAvailable) + .then((bytes) => (bytes ? decodeTerrarium(bytes) : null)) + .catch(() => null); + tileCache.set(key, promise); + return promise; +} + +// -> Promise — null on a fetch/decode failure, or a point +// falling outside a partial offline extract's zoom range. +export async function elevationAt(lng, lat, offlineAvailable) { + const { tileX, tileY, px, py } = lngLatToTile(lng, lat, DEM_ZOOM); + const elevations = await fetchTile(DEM_ZOOM, tileX, tileY, offlineAvailable); + return elevations ? elevations[py * TILE_SIZE + px] : null; +} diff --git a/public/js/icons.js b/public/js/icons.js index e79a5b3..a32bcda 100644 --- a/public/js/icons.js +++ b/public/js/icons.js @@ -159,6 +159,15 @@ export const RADAR_CLOUD_ICON = ` `; +// Hand-drawn, not NPS-sourced — same story as RADAR_CLOUD_ICON above, no +// terrain-shadow/sun-behind-a-ridge pictogram in that set either. Used as +// the Overlays panel's Terrain shadow glyph (shadow.js via overlays.js). +export const TERRAIN_SHADOW_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 = ` diff --git a/public/js/map.js b/public/js/map.js index b0a4154..13bd015 100644 --- a/public/js/map.js +++ b/public/js/map.js @@ -547,7 +547,10 @@ fetch("/api/offline-tiles") fetch("/api/terrain-tiles") .then((response) => response.json()) - .then(({ available }) => overlaysControl.setHillshadeOfflineAvailable(available)) + .then(({ available }) => { + overlaysControl.setHillshadeOfflineAvailable(available); + overlaysControl.setShadowOfflineAvailable(available); + }) .catch(() => {}); fetch("/api/kiwix") diff --git a/public/js/overlays.js b/public/js/overlays.js index 4c21d39..3861e5f 100644 --- a/public/js/overlays.js +++ b/public/js/overlays.js @@ -3,6 +3,7 @@ import { OVERLAYS_ICON, WIKI_ICON, WILDERNESS_ICON, MAP_MARKER_ICON } from "/js/ import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js"; import { svgToImageData, badgeImageId, escapeHtml } from "/js/gpx.js"; import { RadarControl } from "/js/radar.js"; +import { ShadowControl } from "/js/shadow.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 @@ -35,7 +36,9 @@ const ELEVATION_COLOR_RAMP = [ // 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"]; +// Exported: dem.js's elevation lookups (shadow.js's raymarch) hit the same +// online tile source, rather than duplicating the URL template. +export 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. @@ -121,6 +124,7 @@ export class OverlaysControl { this._map = map; this._hillshade = new HillshadeControl(map); this._radar = new RadarControl(map); + this._shadow = new ShadowControl(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 @@ -152,11 +156,13 @@ export class OverlaysControl {
  • +
  • `; - const [hillshadeItem, wikiItem, radarItem] = list.children; + const [hillshadeItem, wikiItem, radarItem, shadowItem] = list.children; this._hillshade.mount(hillshadeItem); this._mountWiki(wikiItem); this._radar.mount(radarItem); + this._shadow.mount(shadowItem); onlyOneSidePanelOpen(this._panel, () => this._icon.classList.remove("active")); @@ -174,6 +180,10 @@ export class OverlaysControl { this._hillshade.setOfflineAvailable(available); } + setShadowOfflineAvailable(available) { + this._shadow.setOfflineAvailable(available); + } + _mountWiki(li) { li.innerHTML = `