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