PMTiles and COG Contours
The two protocols this app registers but does not compute — a third-party archive reader and a worker-backed contour generator
Most of the schemes in lib/*-protocol.ts are this app's own per-pixel maths. Two are not: pmtiles:// is a third-party handler registered as-is, and cog-contour:// is a thin main-thread shim over maplibre-contour running in a dedicated worker. Both are documented here together because in both cases the interesting part is the wiring, not the algorithm.
Neither is wrapped in withTileResultCache — each has its own reason, given below.
pmtiles://
import { Protocol as PmtilesProtocol } from 'pmtiles'
maplibregl.addProtocol('pmtiles', new PmtilesProtocol().tile)That is the whole integration. PMTiles is a single-file tile pyramid read over HTTP range requests: one archive on static hosting, no tile server, no directory of millions of files. The handler resolves pmtiles://<archive url>/{z}/{x}/{y} by reading the archive's directory and then the one tile it needs.
A URL source of type: "pmtiles" in the BYOD lists builds exactly that. The motivating library entry is Smart Maps GEL — NASADEM at 30 m as Terrain-RGB, z2–12, CC0, in one archive.
Two consequences worth knowing:
- The host must serve range requests and CORS. A PMTiles archive on a host that ignores
Rangewill download the entire file to read one tile. This is the single most common reason a PMTiles URL "doesn't work". - No result caching by us. The protocol's own directory cache already avoids re-reading the archive header, and its output is raw archived bytes rather than something we computed, so there is nothing for our LRU to save.
Elevation archives are ordinary Terrain-RGB or Terrarium once unwrapped, so everything downstream — hillshade, the viz-mode protocols, derived terrain — treats them like any other tiled DEM. An archive with MapLibre's custom encoding works too: a library entry (or your own source) carries redFactor, greenFactor, blueFactor and baseShift, and the decoder reads value = R·redFactor + G·greenFactor + B·blueFactor − baseShift.
Worked example: a painting as PMTiles
impasto.dev streams a condition-assessment scan of a painting as three PMTiles archives, and the library carries two of them as ART - Impasto: Almond Blossom, height and colour. What is in them, read from the archives' own headers and metadata:
| Archive | Tiles | Content |
|---|---|---|
rgb.pmtiles | PNG, 1024 px, z0–5 | the colour scan, 32768 × 32768 px of a 24 cm painting |
height.pmtiles | PNG, 1024 px, z0–5 | the height scan, custom encoding R*65536 + G*256 + B, integers of 0.25 µm |
r8.v2.pmtiles | JPEG, 512 px, z0–5 | their pre-rendered 8-bit shaded relief |
The painting is stretched over the whole Web Mercator world (bounds ±180, ±85), the same fake-geo trick as the non-geo relief recipe; the metadata keeps the real scale (conversionLengthPixels 3072 per conversionLengthMeters 0.024) and metersPerInteger 2.5e-7, so a tool can convert back. The viewer reads the height integers as raw units, as impasto.dev does with exaggeration 12: relief and slopes are relative, not metres. Nothing in the format is bespoke; the one obstacle is that the host answers Access-Control-Allow-Origin for impasto.dev only, so a browser elsewhere needs a proxy in front of the archive URL (a CORS extension is not enough: the Range header triggers a preflight the host does not answer). scripts/cors-proxy.mjs in the repository is one: node scripts/cors-proxy.mjs, then a source URL of pmtiles://http://localhost:5209/https://tiles.larsmaxfield.com/…/height.pmtiles/{z}/{x}/{y} with the same factors as the library entry.
cog-contour://
Contours over a normal tiled DEM are handled by maplibre-contour's own DemSource. That path breaks for a BYOD COG — a local file, or a remote one reached through a custom fetch — for a specific and unfixable reason:
maplibre-contour's worker configuration travels over postMessage, which cannot carry a function. A custom fetch — which is exactly what reading a local File handle or an authenticated remote COG requires — can never reach its worker.
So cog-contour:// runs its own worker instead. The split is:
lib/cog-contour-protocol.ts(main thread) — parse the tile request out of the URL,postMessageit to the worker with a numeric request id, resolve the pending promise when the matching response arrives. Nothing heavier than a round trip happens here.lib/cog-contour-worker.ts— owns the COG reader and the contour generation, and returns MVT bytes.
The worker is created lazily on first use and kept for the session. Requests are correlated by an incrementing id held in a Map, so several tiles can be in flight at once:
worker.onmessage = (e: MessageEvent<CogContourResponse>) => {
const entry = pending.get(e.data.id)
if (!entry) return
pending.delete(e.data.id)
if (e.data.error) entry.reject(new Error(e.data.error))
else entry.resolve(e.data.data!)
}Options
CogContourOptions mirrors maplibre-contour's own GlobalContourTileOptions closely enough that its documentation applies:
| Option | Meaning |
|---|---|
thresholds | zoom → [minor, major] elevation intervals |
multiplier | unit conversion (metres → feet) |
extent, buffer | MVT tile geometry |
overzoom | how far past the source's maxzoom to keep generating |
contourLayer, elevationKey, levelKey | names in the emitted vector tile, which the style then references |
subsampleBelow | skip DEM pixels at low zoom, where full resolution buys nothing |
Why no result cache
Contour output is already produced off the main thread, so the cost the result LRU exists to avoid — a blocking recompute on re-toggle — is not paid here in the same way. Wrapping it would also mean holding MVT bytes in the same 96 MB budget as the far more expensive raster modes, which is a poor trade.