Skip to content

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.

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.

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, UTC
const 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.

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

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.

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.

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.

  • Set maxzoom and bounds on every source from the TileJSON or metadata.
  • Register a palette before adding a source that uses it.
  • Show attribution from the document; see Attribution for the wording OpenStreetMap-derived layers need.
  • Use a browser-only key with a spend cap.