MapLibre GL JS
MapLibre has no raster-color, so value-encoded tiles are coloured before the GPU sees them: a custom protocol fetches each PNG, maps the red channel through a colour table built from the layer’s stops, and hands MapLibre a coloured PNG. It is about forty lines, runs off the main thread in an OffscreenCanvas, and works for every layer and palette.
Everything below assumes a browser key in KEY. Keys in tile URLs are visible to anyone who opens devtools, so use a key you created for the browser, give it a spend cap in the console, and rotate it there if it leaks.
The protocol
Section titled “The protocol”import maplibregl from "maplibre-gl";
const API = "https://api.vertexmaps.com";const KEY = "vtx_live_…";
/** Colour tables by palette id; 255 RGBA entries indexed by the red channel. */const palettes = new Map();
export function registerPalette(id, { encode: [lo, hi], stops }) { const table = new Uint8ClampedArray(255 * 4); for (let r = 0; r < 255; r++) table.set(sample(stops, lo + (r / 254) * (hi - lo)), r * 4); palettes.set(id, table);}
function sample(stops, v) { if (v <= stops[0][0]) return stops[0].slice(1); const j = stops.findIndex((s) => s[0] >= v); if (j < 0) return stops[stops.length - 1].slice(1); const a = stops[j - 1], b = stops[j]; const t = b[0] === a[0] ? 0 : (v - a[0]) / (b[0] - a[0]); return [1, 2, 3, 4].map((k) => a[k] + (b[k] - a[k]) * t);}
const EMPTY = new OffscreenCanvas(1, 1).convertToBlob().then((b) => b.arrayBuffer());
// vertex://<palette>/<https URL of the tile>maplibregl.addProtocol("vertex", async ({ url }, abort) => { const [, id, real] = /^vertex:\/\/([^/]+)\/(.+)$/.exec(url); const table = palettes.get(id); const res = await fetch(real, { signal: abort.signal }); if (res.status === 204 || !res.ok) return { data: await EMPTY }; // empty above maxzoom / outside bounds const bitmap = await createImageBitmap(await res.blob()); const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); const ctx = canvas.getContext("2d", { willReadFrequently: true }); ctx.drawImage(bitmap, 0, 0); const img = ctx.getImageData(0, 0, canvas.width, canvas.height); const px = img.data; for (let i = 0; i < px.length; i += 4) { if (px[i + 3] === 0) continue; // alpha 0 = no data const r = Math.min(px[i], 254) * 4; px[i] = table[r]; px[i + 1] = table[r + 1]; px[i + 2] = table[r + 2]; px[i + 3] = table[r + 3]; } ctx.putImageData(img, 0, 0); return { data: await (await canvas.convertToBlob({ type: "image/png" })).arrayBuffer() };});The API sends access-control-allow-origin: * on tiles, so the fetch and the pixel read work cross-origin.
One layer
Section titled “One layer”Ask for the TileJSON of a layer at a valid hour. It resolves the model run and forecast hour, carries the encode range and stops, and, because the key came in ?apikey=, puts the key on the tile template. Register its stops as a palette and prefix the tile template with the protocol.
const valid = new Date(Date.now() + 3600e3).toISOString().slice(0, 13); // next hour, UTCconst tj = await (await fetch(`${API}/v1/layers/hrrr/toplift/tilejson.json?valid=${valid}&apikey=${KEY}`)).json();registerPalette("toplift", tj);
const map = new maplibregl.Map({ container: "map", style: "https://demotiles.maplibre.org/style.json", center: [-111.8, 44.6], zoom: 6 });
map.on("load", () => { map.addSource("toplift", { type: "raster", tiles: [`vertex://toplift/${tj.tiles[0]}`], tileSize: 256, maxzoom: tj.maxzoom, bounds: tj.bounds, attribution: tj.attribution, }); map.addLayer({ id: "toplift", type: "raster", source: "toplift", paint: { "raster-opacity": 0.7, "raster-resampling": "linear" } });});maxzoom on the source matters: above it the API answers 204, the protocol returns an empty tile, and MapLibre oversamples the last zoom, which is the intended look for 3 km data.
Legend
Section titled “Legend”The stops that built the palette also draw the legend, so the two cannot disagree.
function legend(tj, el) { const [lo, hi] = tj.encode; const gradient = tj.stops .map(([v, r, g, b, a]) => `rgba(${r},${g},${b},${a / 255}) ${((v - lo) / (hi - lo)) * 100}%`) .join(", "); el.innerHTML = ` <div style="height:10px;background:linear-gradient(90deg, ${gradient})"></div> <div style="display:flex;justify-content:space-between"><span>${lo} ${tj.units}</span><span>${hi} ${tj.units}</span></div>`;}Animating the day
Section titled “Animating the day”One source and layer per hour at opacity 0, then swap opacities. The catalogue lists the valid hours that exist.
const cat = await (await fetch(`${API}/v1/layers?apikey=${KEY}`)).json();const layer = cat.layers.find((l) => l.id === "hrrr/toplift");const hours = layer.valid.filter((v) => v > valid).slice(0, 12);
const docs = await Promise.all(hours.map((v) => fetch(`${API}/v1/layers/hrrr/toplift/tilejson.json?valid=${v}&apikey=${KEY}`).then((r) => r.json())));
docs.forEach((tj, i) => { map.addSource(`toplift-${i}`, { type: "raster", tiles: [`vertex://toplift/${tj.tiles[0]}`], tileSize: 256, maxzoom: tj.maxzoom, bounds: tj.bounds }); map.addLayer({ id: `toplift-${i}`, type: "raster", source: `toplift-${i}`, paint: { "raster-opacity": i ? 0 : 0.7, "raster-fade-duration": 0, "raster-resampling": "linear" } });});
let current = 0;setInterval(() => { const next = (current + 1) % docs.length; if (!map.isSourceLoaded(`toplift-${next}`)) return; map.setPaintProperty(`toplift-${current}`, "raster-opacity", 0); map.setPaintProperty(`toplift-${next}`, "raster-opacity", 0.7); current = next;}, 1000);A different palette for the same data is a second palette id in the source URL; the browser’s cache serves the raw PNGs a second time for free.
Vector overlays
Section titled “Vector overlays”The GeoJSON companions load as ordinary sources. Isobars:
const iso = `${API}/v1/isobars/hrrr/${tj.run}/f${String(tj.fxx).padStart(2, "0")}.geojson?apikey=${KEY}`;map.addSource("isobars", { type: "geojson", data: iso });map.addLayer({ id: "isobars", type: "line", source: "isobars", paint: { "line-color": "#1a2a3a", "line-width": 1, "line-opacity": 0.8 } });map.addLayer({ id: "isobar-labels", type: "symbol", source: "isobars", layout: { "symbol-placement": "line", "text-field": ["get", "hpa"], "text-size": 11 } });Wind barbs come as a point grid with speed_kn and direction_deg; draw them with a symbol layer using icon-rotate: ["get", "direction_deg"] and a barb image per speed class.
Static layers
Section titled “Static layers”Vector static layers are Mapbox Vector Tiles; the metadata document names the source layers. Public lands:
const meta = await (await fetch(`${API}/v1/static/lands-conus/metadata?apikey=${KEY}`)).json();map.addSource("lands", { type: "vector", tiles: [`${API}/v1/static/lands-conus/{z}/{x}/{y}.mvt?apikey=${KEY}`], minzoom: meta.minzoom, maxzoom: meta.maxzoom, bounds: meta.bounds, attribution: meta.attribution,});map.addLayer({ id: "lands-fee", type: "fill", source: "lands", "source-layer": "fee", paint: { "fill-color": ["match", ["get", "owner"], "USFS", "#7fb27a", "BLM", "#e8c97a", "NPS", "#9b7fb2", "#cccccc"], "fill-opacity": 0.35 } });Slope angle is a value-encoded raster in degrees, so the protocol colours it too, with your own ramp:
registerPalette("slope", { encode: [0, 90], stops: [[0, 0, 0, 0, 0], [27, 255, 230, 0, 200], [35, 255, 120, 0, 220], [45, 200, 0, 0, 240], [60, 80, 0, 80, 255]] });map.addSource("slope", { type: "raster", tiles: [`vertex://slope/${API}/v1/static/slope-conus/{z}/{x}/{y}.png?apikey=${KEY}`], tileSize: 512, maxzoom: 13 });map.addLayer({ id: "slope", type: "raster", source: "slope", paint: { "raster-opacity": 0.6 } });GET /v1/radar/index.json lists observed frames and the nowcast. Each is a value-encoded tile set in dBZ; register the refl stops from the catalogue as a palette and animate exactly like the forecast hours above.
Checklist
Section titled “Checklist”- Set
maxzoomandboundson every source from the TileJSON or metadata. - Register a palette before adding a source that uses it.
- Show
attributionfrom the document; see Attribution for the wording OpenStreetMap-derived layers need. - Use a browser-only key with a spend cap.