Skip to content

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.

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

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

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.

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);

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.

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.

  • Set maxzoom and bounds on every source from the TileJSON or metadata.
  • Put weather layers below labels ("road-label" in Mapbox styles).
  • Show attribution from the document; see Attribution for the wording OpenStreetMap-derived layers need.
  • Use a browser-only key with a spend cap.