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

+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();
}