Mapbox GL JS
Mapbox GL JS v3 decodes value-encoded tiles natively: raster-color-mix turns the red channel back into the physical value and raster-color maps it through the layer’s stops. No custom loader, no canvas, and the palette is a paint property you can change at runtime.
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.
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.
import mapboxgl from "mapbox-gl";
const API = "https://api.vertexmaps.com";const KEY = "vtx_live_…";
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();
const map = new mapboxgl.Map({ container: "map", style: "mapbox://styles/mapbox/outdoors-v12", center: [-111.8, 44.6], zoom: 6 });
map.on("load", () => { map.addSource("toplift", { type: "raster", tiles: tj.tiles, tileSize: 256, maxzoom: tj.maxzoom, bounds: tj.bounds, attribution: tj.attribution, }); map.addLayer({ id: "toplift", type: "raster", source: "toplift", paint: paintFor(tj) }, "road-label");});
/** raster-color paint from a TileJSON document's encode range and stops. */function paintFor(tj, opacity = 0.7) { const [lo, hi] = tj.encode; return { // value = R * (hi - lo) / 254 + lo (alpha 0 pixels stay transparent) "raster-color-mix": [((hi - lo) * 255) / 254, 0, 0, lo], "raster-color-range": [lo, hi], "raster-color": ["interpolate", ["linear"], ["raster-value"], ...tj.stops.flatMap(([v, r, g, b, a]) => [v, `rgba(${r},${g},${b},${a / 255})`])], "raster-resampling": "linear", "raster-opacity": opacity, };}maxzoom on the source matters: above it the API answers 204 and Mapbox oversamples the last zoom, which is the intended look for 3 km data. Inserting the layer before road-label keeps labels readable over the weather.
Legend
Section titled “Legend”The stops that colour the map 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”Preload one source and layer per hour at opacity 0, then swap opacities. Tiles for every hour arrive together, and each step is two paint writes with no reload, which is how the animation on the front page runs. 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: tj.tiles, tileSize: 256, maxzoom: tj.maxzoom, bounds: tj.bounds }); map.addLayer({ id: `toplift-${i}`, type: "raster", source: `toplift-${i}`, paint: { ...paintFor(tj), "raster-opacity": i ? 0 : 0.7, "raster-fade-duration": 0 } }, "road-label");});
let current = 0;setInterval(() => { const next = (current + 1) % docs.length; if (!map.isSourceLoaded(`toplift-${next}`)) return; // wait for its tiles map.setPaintProperty(`toplift-${current}`, "raster-opacity", 0); map.setPaintProperty(`toplift-${next}`, "raster-opacity", 0.7); current = next;}, 1000);Tiles are immutable and cached for a year, so a viewer who scrubs back and forth pays for each tile once.
Changing the palette or units
Section titled “Changing the palette or units”Because colour is a paint property, switching palettes or quantising into bands is a setPaintProperty call, never a tile reload. A stepped ramp in 1,000 ft classes:
const FT = 0.3048;const steps = ["step", ["raster-value"]];for (let m = lo, i = 0; m < hi; m += 1000 * FT, i++) { if (i) steps.push(m); steps.push(BAND_COLOURS[i % BAND_COLOURS.length]);}map.setPaintProperty("toplift", "raster-color", steps);Vector overlays
Section titled “Vector overlays”The GeoJSON companions load as ordinary sources. Isobars over the temperature layer:
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 sprite 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 same raster-color recipe applies with encode: [0, 90] and your own ramp:
map.addSource("slope", { type: "raster", tiles: [`${API}/v1/static/slope-conus/{z}/{x}/{y}.png?apikey=${KEY}`], tileSize: 512, maxzoom: 13 });map.addLayer({ id: "slope", type: "raster", source: "slope", paint: paintFor({ 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]] }) });GET /v1/radar/index.json lists observed frames and the nowcast. Each is a value-encoded tile set in dBZ; colour it with the refl stops from the catalogue and animate exactly like the forecast hours above.
Checklist
Section titled “Checklist”- Set
maxzoomandboundson every source from the TileJSON or metadata. - Put weather layers below labels (
"road-label"in Mapbox styles). - Show
attributionfrom the document; see Attribution for the wording OpenStreetMap-derived layers need. - Use a browser-only key with a spend cap.