Files
maptool/public/js/dem.js
T
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

73 lines
2.9 KiB
JavaScript

// 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;
}