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:
1 parent
a8d58abaca
commit
c9561a5489
7 files changed
+357
-3
No files matched your search
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user