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.
The layer class
Section titled “The layer class”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.
One layer, with a readout
Section titled “One layer, with a readout”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, UTCconst 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)`;});Legend
Section titled “Legend”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);Animating the day
Section titled “Animating the day”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.
Vector overlays
Section titled “Vector overlays”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.
Static layers
Section titled “Static layers”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.
Checklist
Section titled “Checklist”- Set
maxNativeZoomandboundson every layer from the TileJSON or metadata. - Keep
crossOrigin: "anonymous"so the canvas stays readable. - Show
attributionfrom the document; see Attribution for the wording OpenStreetMap-derived layers need. - Use a browser-only key with a spend cap.