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,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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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