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.
This commit is contained in:
randy committed 2026-09-10 21:43:51 +02:00
1 parent a8d58abaca
commit c9561a5489
7 files changed
+357 -3

No files matched your search

+72
View File
@@ -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<Int16Array|null>, 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<number|null> — 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;
}
+9
View File
@@ -159,6 +159,15 @@ export const RADAR_CLOUD_ICON = `
<rect x="14.4" y="20" width="1.6" height="2"/>
</svg>`;
// 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 = `
<svg viewBox="0 0 22 22" fill="currentColor">
<circle cx="16" cy="6" r="3"/>
<polygon points="0,19 6,9 9,13 13,6 22,19"/>
</svg>`;
// 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 = `
+4 -1
View File
@@ -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")
+12 -2
View File
@@ -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 {
<li class="overlays-panel__item"></li>
<li class="overlays-panel__item"></li>
<li class="overlays-panel__item overlays-panel__item--expandable"></li>
<li class="overlays-panel__item overlays-panel__item--expandable"></li>
`;
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 = `
<label>
+237
View File
@@ -0,0 +1,237 @@
import { getPosition } from "/vendor/suncalc/suncalc.js";
import { elevationAt } from "/js/dem.js";
import { TERRAIN_SHADOW_ICON } from "/js/icons.js";
const MIN_ZOOM = 11; // below this, a screen is roughly the same scale as the 40km raymarch — "one screen, one shadow-casting neighborhood" stops holding
const GRID_COLS = 96;
const GRID_ROWS = 64;
const RAYMARCH_STEPS = 16;
const RAYMARCH_START_M = 150;
const RAYMARCH_MAX_M = 40000;
const RAYMARCH_RATIO = (RAYMARCH_MAX_M / RAYMARCH_START_M) ** (1 / (RAYMARCH_STEPS - 1)); // geometric spacing: dense near the point, coarse far out
const EARTH_RADIUS_M = 6371000;
const SHADOW_RGBA = [20, 20, 50, Math.round(0.45 * 255)];
const SLIDER_DEBOUNCE_MS = 300;
const IMAGE_SOURCE_ID = "terrain-shadow";
// An Overlays panel item (see overlays.js) shading the parts of the visible
// map currently blocked from the sun by surrounding terrain — offline
// equivalent of shademap.app, built on the same Terrarium DEM tiles
// HillshadeControl already renders (via dem.js) and the same sun-position
// math the toolbar readout uses (public/js/sun.js). No Web Worker: a
// recompute is dozens of small awaited fetches (mostly cache hits after the
// first pan), not sustained sync CPU work, so it already yields to the
// event loop on its own between grid rows without blocking a pan/zoom.
export class ShadowControl {
constructor(map) {
this._map = map;
this._enabled = false;
this._offlineAvailable = false;
this._seq = 0; // bumped on every recompute/disable so a superseded in-flight one (a fast second pan, or turning the layer off mid-compute) discards its result instead of overwriting newer data
this._moveHandler = null;
this._sliderMinutes = currentMinutesOfDay();
this._sliderTimer = null;
// setStyle() (switching Online/Offline) tears down all custom sources/
// layers — recompute puts the shadow layer back if it was on, same as
// every other Overlays control.
map.on("style.load", () => {
if (this._enabled) this._recompute();
});
}
mount(li) {
li.innerHTML = `
<div class="overlays-panel__row">
<input type="checkbox" class="overlays-panel__toggle">
<span class="overlays-panel__icon">${TERRAIN_SHADOW_ICON}</span>
<button type="button" class="overlays-panel__name">Terrain shadow</button>
</div>
<div class="overlays-panel__details" hidden>
<div class="shadow-control__time">
<input type="range" class="shadow-control__slider" min="0" max="1439" step="5" value="${this._sliderMinutes}">
<span class="shadow-control__clock">${formatMinutes(this._sliderMinutes)}</span>
</div>
<p class="shadow-control__hint" hidden>Zoom in further to compute terrain shadow.</p>
</div>
`;
this._checkbox = li.querySelector(".overlays-panel__toggle");
this._nameButton = li.querySelector(".overlays-panel__name");
this._details = li.querySelector(".overlays-panel__details");
this._slider = li.querySelector(".shadow-control__slider");
this._clock = li.querySelector(".shadow-control__clock");
this._hint = li.querySelector(".shadow-control__hint");
this._checkbox.addEventListener("change", (event) => this._setEnabled(event.target.checked));
this._nameButton.addEventListener("click", () => {
const expanding = this._details.hidden;
this._details.hidden = !expanding;
this._nameButton.setAttribute("aria-expanded", String(expanding));
});
this._slider.addEventListener("input", () => {
this._sliderMinutes = Number(this._slider.value);
this._clock.textContent = formatMinutes(this._sliderMinutes);
clearTimeout(this._sliderTimer);
this._sliderTimer = setTimeout(() => this._recompute(), SLIDER_DEBOUNCE_MS);
});
}
setOfflineAvailable(available) {
this._offlineAvailable = available;
}
_setEnabled(enabled) {
this._enabled = enabled;
if (enabled) {
this._moveHandler = () => this._recompute();
this._map.on("moveend", this._moveHandler);
this._recompute();
return;
}
this._seq++;
if (this._moveHandler) {
this._map.off("moveend", this._moveHandler);
this._moveHandler = null;
}
this._hint.hidden = true;
this._removeLayer();
}
async _recompute() {
const seq = ++this._seq;
if (this._map.getZoom() < MIN_ZOOM) {
this._hint.hidden = false;
this._removeLayer();
return;
}
this._hint.hidden = true;
const bounds = this._map.getBounds();
const center = this._map.getCenter();
const date = this._selectedDate();
// One sun position for the whole viewport — azimuth/altitude vary
// negligibly across it next to the raymarch's own 150m-step coarseness.
const sun = getPosition(date, center.lat, center.lng);
const west = bounds.getWest();
const east = bounds.getEast();
const south = bounds.getSouth();
const north = bounds.getNorth();
const canvas = document.createElement("canvas");
canvas.width = GRID_COLS;
canvas.height = GRID_ROWS;
const ctx = canvas.getContext("2d");
const imageData = ctx.createImageData(GRID_COLS, GRID_ROWS);
if (sun.altitude <= 0) {
// Sun's below the horizon everywhere in view — no point raymarching.
fillShadow(imageData);
} else {
for (let row = 0; row < GRID_ROWS; row++) {
const lat = north - ((row + 0.5) / GRID_ROWS) * (north - south);
const rowWork = [];
for (let col = 0; col < GRID_COLS; col++) {
const lng = west + ((col + 0.5) / GRID_COLS) * (east - west);
rowWork.push(
this._isShadowed(lng, lat, sun.azimuth, sun.altitude).then((shadowed) => setPixel(imageData, col, row, shadowed)),
);
}
await Promise.all(rowWork); // yields once per row, so a fast pan/zoom's newer recompute can cut in
if (seq !== this._seq) return;
}
}
ctx.putImageData(imageData, 0, 0);
if (seq !== this._seq) return;
this._publish(canvas.toDataURL(), bounds);
}
async _isShadowed(lng, lat, sunAzimuth, sunAltitude) {
const pointElev = await elevationAt(lng, lat, this._offlineAvailable);
if (pointElev === null) return false; // no DEM data here — render sunlit rather than guess
let dist = RAYMARCH_START_M;
const samples = [];
for (let i = 0; i < RAYMARCH_STEPS; i++) {
samples.push(this._horizonSample(lng, lat, pointElev, sunAzimuth, dist));
dist *= RAYMARCH_RATIO;
}
const angles = (await Promise.all(samples)).filter((a) => a !== null);
const horizonMax = angles.length ? Math.max(...angles) : -90;
return sunAltitude <= horizonMax;
}
async _horizonSample(lng, lat, pointElev, sunAzimuth, dist) {
const sample = destinationPoint(lng, lat, sunAzimuth, dist);
const sampleElev = await elevationAt(sample.lng, sample.lat, this._offlineAvailable);
if (sampleElev === null) return null;
const curvatureDrop = (dist * dist) / (2 * EARTH_RADIUS_M);
return (Math.atan2(sampleElev - pointElev - curvatureDrop, dist) * 180) / Math.PI;
}
_selectedDate() {
const date = new Date();
date.setHours(0, this._sliderMinutes, 0, 0);
return date;
}
// First image-source usage in this app — addSource/addLayer once, then
// updateImage on every later recompute rather than remove+re-add.
_publish(dataUrl, bounds) {
const coordinates = [
[bounds.getWest(), bounds.getNorth()],
[bounds.getEast(), bounds.getNorth()],
[bounds.getEast(), bounds.getSouth()],
[bounds.getWest(), bounds.getSouth()],
];
const source = this._map.getSource(IMAGE_SOURCE_ID);
if (source) {
source.updateImage({ url: dataUrl, coordinates });
} else {
this._map.addSource(IMAGE_SOURCE_ID, { type: "image", url: dataUrl, coordinates });
this._map.addLayer({ id: IMAGE_SOURCE_ID, type: "raster", source: IMAGE_SOURCE_ID });
}
}
_removeLayer() {
if (this._map.getLayer(IMAGE_SOURCE_ID)) this._map.removeLayer(IMAGE_SOURCE_ID);
if (this._map.getSource(IMAGE_SOURCE_ID)) this._map.removeSource(IMAGE_SOURCE_ID);
}
}
// Flat-earth destination point (bearing/distance -> lng/lat) — fine under
// 40km, and nothing in this codebase already does lng/lat offset math.
function destinationPoint(lng, lat, bearingDeg, distanceM) {
const bearing = (bearingDeg * Math.PI) / 180;
const latRad = (lat * Math.PI) / 180;
const dLat = (distanceM * Math.cos(bearing)) / EARTH_RADIUS_M;
const dLng = (distanceM * Math.sin(bearing)) / (EARTH_RADIUS_M * Math.cos(latRad));
return { lng: lng + (dLng * 180) / Math.PI, lat: lat + (dLat * 180) / Math.PI };
}
function setPixel(imageData, col, row, shadowed) {
if (!shadowed) return; // ImageData starts fully transparent — nothing to do for a sunlit cell
const i = (row * imageData.width + col) * 4;
imageData.data.set(SHADOW_RGBA, i);
}
function fillShadow(imageData) {
for (let i = 0; i < imageData.data.length; i += 4) imageData.data.set(SHADOW_RGBA, i);
}
function formatMinutes(minutes) {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
function currentMinutesOfDay() {
const now = new Date();
return now.getHours() * 60 + now.getMinutes();
}