Skip to content

Leaflet

Leaflet draws tiles as DOM elements, so a value-encoded tile is coloured by drawing the PNG into a canvas and mapping the red channel through a colour table built from the layer’s stops. The same canvas gives you the physical value under the cursor for free, which a pre-coloured tile never could.

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.

const API = "https://api.vertexmaps.com";
const KEY = "vtx_live_…";
/** 255 RGBA entries indexed by the red channel, from a layer's encode range and stops. */
function colourTable({ 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);
return 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 VertexTileLayer = L.TileLayer.extend({
initialize(tj, options) {
L.TileLayer.prototype.initialize.call(this, tj.tiles[0], {
tileSize: 256, maxNativeZoom: tj.maxzoom, maxZoom: 18,
bounds: tj.bounds && L.latLngBounds([[tj.bounds[1], tj.bounds[0]], [tj.bounds[3], tj.bounds[2]]]),
attribution: tj.attribution, opacity: 0.7, crossOrigin: "anonymous", ...options,
});
this.encode = tj.encode;
this.table = colourTable(tj);
this.raw = new Map(); // tile key -> {r: Uint8ClampedArray, a: Uint8ClampedArray}
},
createTile(coords, done) {
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 256;
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const ctx = canvas.getContext("2d", { willReadFrequently: true });
ctx.drawImage(img, 0, 0, 256, 256);
const data = ctx.getImageData(0, 0, 256, 256);
const px = data.data, n = 256 * 256;
const r = new Uint8ClampedArray(n), a = new Uint8ClampedArray(n);
for (let i = 0, p = 0; i < px.length; i += 4, p++) {
r[p] = px[i]; a[p] = px[i + 3];
if (a[p] === 0) continue; // alpha 0 = no data
const k = Math.min(px[i], 254) * 4;
px[i] = this.table[k]; px[i + 1] = this.table[k + 1]; px[i + 2] = this.table[k + 2]; px[i + 3] = this.table[k + 3];
}
ctx.putImageData(data, 0, 0);
this.raw.set(this._tileCoordsToKey(coords), { r, a });
done(null, canvas);
};
img.onerror = () => done(null, canvas); // 204 above maxzoom / outside bounds: stay transparent
img.src = this.getTileUrl(coords);
return canvas;
},
_removeTile(key) {
this.raw.delete(key);
L.TileLayer.prototype._removeTile.call(this, key);
},
/** Physical value at a LatLng, or null where there is no data or no tile yet. */
valueAt(latlng) {
const z = Math.min(this._map.getZoom(), this.options.maxNativeZoom);
const p = this._map.project(latlng, z);
const coords = L.point(Math.floor(p.x / 256), Math.floor(p.y / 256));
coords.z = z;
const tile = this.raw.get(this._tileCoordsToKey(coords));
if (!tile) return null;
const i = Math.floor(p.y % 256) * 256 + Math.floor(p.x % 256);
if (tile.a[i] === 0) return null;
const [lo, hi] = this.encode;
return lo + (Math.min(tile.r[i], 254) / 254) * (hi - lo);
},
});

The API sends access-control-allow-origin: * on tiles, so the canvas stays readable after drawing a cross-origin image. maxNativeZoom matters: above it the API answers 204 and Leaflet scales the last zoom’s tiles, which is the intended look for 3 km data.

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.

const map = L.map("map").setView([44.6, -111.8], 6);
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { attribution: "© OpenStreetMap contributors" }).addTo(map);
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 toplift = new VertexTileLayer(tj).addTo(map);
map.on("mousemove", (e) => {
const v = toplift.valueAt(e.latlng);
document.getElementById("readout").textContent =
v === null ? "no lift" : `${Math.round(v)} ${tj.units} (${Math.round(v / 0.3048)} ft)`;
});

The stops that built the colour table also draw the legend, so the two cannot disagree.

const legend = L.control({ position: "bottomright" });
legend.onAdd = () => {
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(", ");
const el = L.DomUtil.create("div", "legend");
el.innerHTML = `
<div style="width:160px;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>`;
return el;
};
legend.addTo(map);

One layer per hour, all added at opacity 0 so their tiles load together, 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())));
const frames = docs.map((d, i) => new VertexTileLayer(d, { opacity: i ? 0 : 0.7 }).addTo(map));
let current = 0;
setInterval(() => {
const next = (current + 1) % frames.length;
if (frames[next]._loading) return; // wait for its tiles
frames[current].setOpacity(0);
frames[next].setOpacity(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.

The GeoJSON companions load with L.geoJSON. Isobars:

const iso = `${API}/v1/isobars/hrrr/${tj.run}/f${String(tj.fxx).padStart(2, "0")}.geojson?apikey=${KEY}`;
L.geoJSON(await (await fetch(iso)).json(), {
style: { color: "#1a2a3a", weight: 1, opacity: 0.8 },
onEachFeature: (f, l) => l.bindTooltip(`${f.properties.hpa} hPa`, { sticky: true }),
}).addTo(map);

Wind barbs come as a point grid with speed_kn and direction_deg; render them with pointToLayer returning an L.marker whose divIcon is rotated by direction_deg.

Slope angle is a value-encoded raster in degrees, so the same class draws it with your own ramp:

const slope = new VertexTileLayer({
tiles: [`${API}/v1/static/slope-conus/{z}/{x}/{y}.png?apikey=${KEY}`],
maxzoom: 13, 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]],
}, { tileSize: 512, zoomOffset: -1, opacity: 0.6 }).addTo(map);

Vector static layers are Mapbox Vector Tiles. Leaflet needs a plugin for those; with Leaflet.VectorGrid:

const meta = await (await fetch(`${API}/v1/static/lands-conus/metadata?apikey=${KEY}`)).json();
L.vectorGrid.protobuf(`${API}/v1/static/lands-conus/{z}/{x}/{y}.mvt?apikey=${KEY}`, {
minZoom: meta.minzoom, maxNativeZoom: meta.maxzoom, attribution: meta.attribution,
vectorTileLayerStyles: {
fee: (p) => ({ fill: true, fillColor: { USFS: "#7fb27a", BLM: "#e8c97a", NPS: "#9b7fb2" }[p.owner] ?? "#cccccc", fillOpacity: 0.35, weight: 0 }),
designation: { color: "#5a4a8a", weight: 1, fill: false },
},
}).addTo(map);

GET /v1/radar/index.json lists observed frames and the nowcast. Each is a value-encoded tile set in dBZ; build a VertexTileLayer per frame with the refl stops from the catalogue and animate exactly like the forecast hours above.

  • Set maxNativeZoom and bounds on every layer from the TileJSON or metadata.
  • Keep crossOrigin: "anonymous" so the canvas stays readable.
  • Show attribution from the document; see Attribution for the wording OpenStreetMap-derived layers need.
  • Use a browser-only key with a spend cap.