Files
randy c9561a5489 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.
2026-09-10 21:43:51 +02:00

238 lines
9.2 KiB
JavaScript

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