// 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 tileCache = new Map(); // "z/x/y" -> Promise<{elevations,size}|null>, so concurrent lookups on the same not-yet-fetched tile share one fetch let offlinePmtiles = null; // fracX/fracY (not pixel coordinates): the tile hasn't been fetched yet at // this point, so its actual pixel size isn't known — online tiles are // 256px PNG, offline ones (Mapterhorn, see lib/maptool/pmtiles_builder.rb) // are 512px WebP. elevationAt scales these once the real size is known. 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, fracX: x - tileX, fracY: y - tileY }; } async function decodeTerrarium(bytes, mime) { const bitmap = await createImageBitmap(new Blob([bytes], { type: mime })); 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, size: bitmap.width }; } 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 ? { bytes: new Uint8Array(result.data), mime: "image/webp" } : null; } const url = ONLINE_TERRAIN_TILES[0].replace("{z}", z).replace("{x}", x).replace("{y}", y); const response = await fetch(url); return response.ok ? { bytes: new Uint8Array(await response.arrayBuffer()), mime: "image/png" } : 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((result) => (result ? decodeTerrarium(result.bytes, result.mime) : null)) .catch(() => null); tileCache.set(key, promise); return promise; } // -> Promise — 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, fracX, fracY } = lngLatToTile(lng, lat, DEM_ZOOM); const tile = await fetchTile(DEM_ZOOM, tileX, tileY, offlineAvailable); if (!tile) return null; const px = Math.min(tile.size - 1, Math.floor(fracX * tile.size)); const py = Math.min(tile.size - 1, Math.floor(fracY * tile.size)); return tile.elevations[py * tile.size + px]; }