Files
maptool/public/js/dem.js
T
randy f89dea5805
CI / check (push) Successful in 18s
CI / deploy (push) Successful in 1m52s
Build terrain.pmtiles from Mapterhorn instead of fetching tiles ourselves
Mapterhorn (mapterhorn.com, Protomaps-affiliated) publishes pre-built
Terrarium-encoded terrain as PMTiles: a global planet.pmtiles (z0-12)
plus ~450 regional chunks (z13+). Same pmtiles extract --bbox=...
mechanism offline.pmtiles already uses, just against their files —
confirmed live against their real manifest and the documented
Interlaken example. Replaces fetching every individual tile ourselves
(even after this session's earlier concurrency fix, still tens of
minutes for a real region) with 1-3 extract calls plus a merge when
the bbox spans more than one chunk: seconds, not minutes.

Removes CONCURRENT_TILE_FETCHES, write_terrarium_mbtiles, tile_range,
lng/lat_to_tile_x/y, fetch_terrarium_tile, and the sqlite3 gem
entirely — none of it is needed once the tiles arrive pre-packaged.

dem.js's point-elevation lookup (shadow.js's terrain-shadow overlay)
hardcoded a 256px PNG tile assumption that Mapterhorn's 512px WebP
tiles would have silently broken — now reads the actually-decoded
tile's own size and mime type instead of assuming either.
2026-09-11 12:26:03 +02:00

75 lines
3.4 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 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<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, 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];
}