City map
CLARA's map product serves street-level environmental model output as standard vector tiles: thermal comfort, pedestrian wind, air quality and 3D buildings, updated hourly with a forecast roughly two days ahead. If your map library speaks TileJSON, MapLibre GL, Mapbox GL, OpenLayers or deck.gl, it can draw CLARA layers with no extra tooling.
You call one endpoint, the catalog. Everything else, tile URLs, timestamps, which layers exist, arrives in its response, already signed and ready to hand to your map library.
Available cities
The table below is read live from
GET /v1/cities?product=map, the same endpoint your own code
can use.
| City | Country | Identifier | Layers | Forecast |
|---|---|---|---|---|
| Loading the city list from the API... | ||||
A city's identifier is what goes in the catalog URL:
https://api.clara.city/v1/map/{city}. Your key is scoped to
the cities in your agreement; GET /v1/key lists yours.
How the integration works
Your application hardcodes exactly two things: the catalog URL for your city, and your key. Everything else is discovered at runtime.
- Fetch the catalog. One authenticated GET. It returns the most recent live hour, the list of forecast hours, and a signed TileJSON URL for every layer at every one of those timesteps.
- Hand a TileJSON URL to your map library. That is the whole wiring: the library reads the TileJSON, learns the tile URL template and zoom range, and starts fetching tiles on its own.
- To change the hour, swap the URL. Scrubbing through the forecast is just pointing the source at a different entry of the same catalog.
Tile URLs carry their own key id and signature in the query string, so your map library needs no custom headers and no request hooks. Two rules keep this robust: never construct a tile URL yourself, and never store one beyond the catalog's stated expiry. The catalog is the only URL you write down.
Choosing your key setup
The catalog call is authenticated, and where that call runs decides which key you use. Both patterns end in the same map code.
Publishable key, straight from the browser
The fastest way to a working map, and fine for production on registered
origins. A clara_pk_ key may live in your page source; it
is locked to your origins and rate limited. Register every origin you
use, including localhost and staging.
const catalog = await fetch("https://api.clara.city/v1/map/ravenna", {
headers: { Authorization: "Bearer clara_pk_YOUR_KEY" },
}).then((r) => r.json());
Secret key, behind your own endpoint
Keep a clara_sk_ key on your server and expose a tiny
proxy route to your frontend. Use this when you want the key out of the
page entirely, or when your backend already gates who may see the map.
// Your backend, e.g. an Express route. The frontend calls /api/clara-catalog.
app.get("/api/clara-catalog", async (req, res) => {
const r = await fetch("https://api.clara.city/v1/map/ravenna", {
headers: { Authorization: "Bearer " + process.env.CLARA_SK_KEY },
});
res.status(r.status).json(await r.json());
});
A secret key sent from a browser is rejected at the edge, deliberately: if that happens to you, you are one leaked page-source away from someone else using your quota, and the error is the cheapest way to find out.
What your key includes
A key carries four independent scopes. What you can actually draw is their intersection with what the city models:
- Cities. Which cities the key may read.
- Products. Which surfaces it may use. The map is one of them.
- Metrics. Which environmental variables it may read:
air,wind,thermal,noise. On the map product this is the scope that decides your layer list. - Horizons. Which slices of time:
live,forecast, or both. This one scopes the map alone. The point products name their own time, so holdingGET /v1/forecastsays nothing about whether you receive forecast tiles, and the reverse.
GET /v1/key returns all four for the key you present, so none
of it has to be guessed or remembered.
thermal entry, and cannot reach one by editing
a URL: the signature covers the layer as well as the city. In the response
that is indistinguishable from a city which does not model thermal at all,
so if a layer you expected is missing, check GET /v1/key
before looking for a data problem. buildings is scenery rather
than a measurement and is never gated.
Horizon scope decides which blocks you get. The same rule, applied to time: a live-only key receives a catalog with no
forecast
entries, and cannot reach them by editing a URL, because the signature
covers the block as well as the layer and the city. Ranges follow along
without a rule of their own, since a range lives inside a layer entry and
a layer entry lives inside a block.
Quick start
A complete, working page, using Ravenna as the worked example. An OpenStreetMap basemap, a CLARA layer drawn over it, and 3D buildings on top so the street pattern stays readable. It opens on the mean wind speed. One selector switches layer, a second switches what is plotted from it, and a third appears only when that view offers more than one palette. The title, the blurb and the legend all follow, a checkbox toggles the layer off, and clicking anywhere reads the values underneath. Drop in your publishable key and open it.
The three wind views are the interesting part. Wind and
Gust each plot one field on a rainbow ramp, so their legend is a
colour bar. Wind comfort and danger plots the five classes and offers
both the standard and the colour-blind palette, so its legend is a swatch
list and the colours control appears. Thermal has two views and one scale
each, so the view control appears and the colours control hides itself.
None of that is hardcoded: the controls are filled from
GET /v1/scales, and a view or a palette added there shows up
here with no change to the page.
The second thermal view, Temperature UTCI, is the one place a
scale is not self-contained. Its stops are positions from 0 to 1 rather than
temperatures, flagged by relative, and the client maps them
onto the range that
GET /v1/map/{city} publishes for that
layer at that hour. The reason is seasonal: a fixed ramp wide enough for a
January night leaves a July afternoon using a fraction of the palette, and
the map comes out flat. Fitting the ramp to the hour on screen keeps it
readable all year. Where no range is published the scale's own
fallback_range stands in, so the view never renders nothing.
It is in three labelled parts. Your map is a plain MapLibre map with an OpenStreetMap basemap and no CLARA in it. CLARA is the integration, and the only part that talks to us. Everything below that is optional: the title, the legend, the switcher and the popup are there so the page looks finished, and the map works without any of them.
The CLARA part does four things: fetch the catalog and the scales index,
add the buildings, add a data layer beneath them, and re-fetch on an interval
so the hour and the signed URLs stay current. Nothing about the colouring is
written into the page. The paint expressions, the class boundaries, the units
and the legend all come out of GET /v1/scales, which needs no
key and is cached for an hour, so the map and its legend cannot disagree and
neither can drift from the API.
One colorFor() covers all three shapes a scale can take: a
step for the rainbow bands, a rule-driven case for
the comfort classes, and a bounds-driven one for UTCI. It never names a
colour or a threshold. See Choosing what to plot for
what the scales carry.
Three things the catalog and the scales cannot tell you are written into the page and commented where they appear: the name of each layer inside the vector tile, the range a real reading falls in, and the styling of the buildings, which are scenery rather than a measurement.
The browser SDK page builds the same map with the SDK instead. Same three parts, same result; there the CLARA part is four calls.
Origin: null, which matches no allowlist, so your
key will be refused. Any local server works, for example
python3 -m http.server 5173 in the folder, then open
http://localhost:5173/. Make sure that exact origin, scheme,
host and port, is registered on your key.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Street-level comfort in Ravenna</title>
<link href="https://unpkg.com/maplibre-gl@4/dist/maplibre-gl.css" rel="stylesheet">
<script src="https://unpkg.com/maplibre-gl@4/dist/maplibre-gl.js"></script>
<style>
html, body { height: 100%; margin: 0; font: 14px/1.5 system-ui, sans-serif; }
#map { position: absolute; inset: 0; }
/* Everything below styles the optional panels, not the map. */
.panel { position: absolute; z-index: 1; background: rgba(255,255,255,.95);
padding: 12px 16px; border-radius: 8px; box-shadow: 0 1px 8px rgba(0,0,0,.25); }
#head { top: 12px; left: 12px; max-width: 320px; }
#head h1 { margin: 0; font-size: 16px; }
#head p { margin: 4px 0 10px; color: #555; font-size: 13px; }
#legend { bottom: 12px; left: 12px; }
/* Not uppercased: CSS uppercase turns the micro sign into a Greek capital
mu, so "µg/m3" would read "MG/M3". */
#legend h2 { margin: 0 0 8px; font-size: 11px; letter-spacing: .06em;
font-weight: 600; color: #555; }
.when { font-weight: 600; }
/* Wide enough for a tick per m/s without the labels touching. */
#legend { max-width: 340px; min-width: 300px; }
/* Room for the first and last labels, which now overhang the bar. */
#legend #classes { padding: 0 10px; }
.row { display: flex; align-items: flex-start; gap: 9px; padding: 5px 0; }
.row + .row { border-top: 1px solid rgba(0,0,0,.07); }
.sw { flex: none; width: 14px; height: 14px; margin-top: 2px; border-radius: 3px;
border: 1px solid rgba(0,0,0,.2); }
.txt { flex: 1; min-width: 0; }
.top { display: flex; align-items: baseline; gap: 10px; }
.lbl { font-weight: 600; }
.rng { color: #777; margin-left: auto; font-size: 12px; white-space: nowrap; }
.mean { color: #555; font-size: 12px; line-height: 1.35; margin-top: 1px; }
.bar { height: 12px; border-radius: 2px; border: 1px solid rgba(0,0,0,.25); }
.ticks { position: relative; height: 15px; margin-top: 5px; }
/* Every label sits centred on its own tick, the ends included: an axis
whose first and last numbers are aligned differently from the rest
reads as though they mean something different. */
.ticks span::before { content: ""; position: absolute; left: 50%; top: -5px;
width: 1px; height: 3px; background: rgba(0,0,0,.3); }
.ticks span { position: absolute; top: 0; font-size: 10px; color: #777;
transform: translateX(-50%); white-space: nowrap; }
.bar { position: relative; }
.bar::after { content: ""; position: absolute; inset: 0;
border-radius: 2px; pointer-events: none; }
label { display: block; margin-top: 6px; }
select { width: 100%; padding: 4px; }
</style>
</head>
<body>
<div id="map"></div>
<!-- Optional page furniture. CLARA needs none of it. -->
<div class="panel" id="head">
<h1 id="title">Loading…</h1>
<p id="blurb"></p>
<div class="when" id="when"></div>
<label>
Layer
<select id="layer">
<option value="wind">Wind</option>
<option value="thermal">Thermal comfort</option>
</select>
</label>
<label id="viewrow">
Show
<select id="view"></select>
</label>
<label id="scalerow">
Colours
<select id="scale"></select>
</label>
<label><input type="checkbox" id="visible" checked> Show layer</label>
</div>
<div class="panel" id="legend"><h2></h2><div id="classes"></div></div>
<script type="module">
// Anything that throws below lands in the title panel instead of only the
// console. A CLARA error carries a machine-readable code; see /docs/errors.
addEventListener("unhandledrejection", ({ reason }) => {
document.getElementById("title").textContent = "Could not load CLARA";
document.getElementById("blurb").textContent = reason.message;
});
/* --- Your map --------------------------------------------------------
Any MapLibre map will do. This one has an OpenStreetMap basemap and no
CLARA in it at all.
--------------------------------------------------------------------- */
const map = new maplibregl.Map({
container: "map",
center: [12.2012, 44.4134],
zoom: 15,
pitch: 50,
style: {
version: 8,
sources: {
osm: {
type: "raster",
tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
tileSize: 256,
attribution: "© OpenStreetMap contributors",
},
},
layers: [{ id: "osm", type: "raster", source: "osm" }],
},
});
await new Promise((resolve) => map.on("load", resolve));
/* --- CLARA -----------------------------------------------------------
The whole integration. Nothing else on this page touches CLARA.
--------------------------------------------------------------------- */
const KEY = "clara_pk_YOUR_KEY";
const API = "https://api.clara.city/v1";
// Two things the catalog and the scales do not carry: the name of each
// layer inside the vector tile, and the range a real reading falls in.
// Cells with no value hold a sentinel far outside that range, and painting
// those would put solid blocks of colour on the map. (The third is the
// styling of the buildings, further down.)
const TILE = {
thermal: { sourceLayer: "UTCI", valid: [-1000, 20000] },
wind: { sourceLayer: "Velocity", valid: [0, 20000] },
};
async function get(path, headers) {
const res = await fetch(`${API}${path}`, { headers });
if (res.ok) return res.json();
// Errors are problem+json, with a machine-readable `code`.
const body = await res.json().catch(() => ({}));
throw new Error(`${body.code ?? res.status}: ${body.detail ?? res.statusText}`);
}
const getCatalog = () => get("/map/ravenna", { Authorization: `Bearer ${KEY}` });
// The catalog is per key and good for a minute. The scales index is
// public, needs no key and is cached for an hour; it says what each layer
// can be plotted as and which scales may colour each of those.
let catalog;
let index;
[catalog, index] = await Promise.all([getCatalog(), get("/scales")]);
// One definition per scale, fetched the first time it is used.
const loaded = new Map();
const scaleDef = async (id) => {
if (!loaded.has(id)) loaded.set(id, await get(`/scales/${id}`));
return loaded.get(id);
};
// A layer id is its metric, so the views of a layer are the views of that
// metric. Naming neither view nor scale takes the defaults the API marks.
const viewsOf = (layer) => index.views.filter((v) => v.metric === layer);
const resolve = (layer, view, scale) => {
const views = viewsOf(layer);
const chosen = views.find((v) => v.id === view) ?? views.find((v) => v.default) ?? views[0];
return { view: chosen, scale: scale && chosen.scales.includes(scale) ? scale : chosen.default_scale };
};
// No colour and no threshold is written into this page: every one comes
// out of the scale. Three shapes, told apart by what the scale carries.
const testExpr = (t) => {
const parts = [];
if (t.lt !== undefined) parts.push(["<", ["get", t.field], t.lt]);
if (t.lte !== undefined) parts.push(["<=", ["get", t.field], t.lte]);
if (t.gt !== undefined) parts.push([">", ["get", t.field], t.gt]);
if (t.gte !== undefined) parts.push([">=", ["get", t.field], t.gte]);
return parts.length === 1 ? parts[0] : ["all", ...parts];
};
// A relative scale's stops are positions from 0 to 1, not values. Map them
// onto the range the catalog publishes for the layer at this hour, so
// everything below works in real units and knows nothing about relative
// scales. Where the catalog publishes no usable range, the scale's own
// fallback stands in.
const usableRange = (r) =>
r && Number.isFinite(r.min) && Number.isFinite(r.max) && r.max > r.min;
function fitted(def, layer) {
if (!def.relative) return def;
const published = catalog.live.layers[layer]?.range;
const raw = usableRange(published) ? published : def.fallback_range;
if (!usableRange(raw)) return def;
// Whole units. A ramp that starts at 20.9 and ends at 31.3 forces every
// label into a decimal for no gain: the reading is a temperature, and
// nobody wants an axis of 20.9, 21.9, 22.9.
const span = { min: Math.floor(raw.min), max: Math.ceil(raw.max) };
if (!usableRange(span)) return def;
const at = (t) => span.min + t * (span.max - span.min);
return {
...def,
// What the ramp was fitted to. Also the signal that its top is a real
// maximum rather than an open-ended band.
fitted_to: { ...span },
classes: def.classes.map((c) => ({
...c,
bounds: {
from: at(c.bounds.from),
to: c.bounds.to === null ? null : at(c.bounds.to),
},
})),
};
}
function colorFor(scale) {
const field = scale.fields[0];
// Bands: `step` is lower-inclusive, which is what a gradient declares,
// so a reading of exactly 0 lands in the first band.
if (scale.kind === "gradient") {
// `interpolate` says which of the two a gradient is: stop positions to
// ramp between, or flat bands. Both use the same `bounds.from`.
if (scale.interpolate) {
const expr = ["interpolate", ["linear"], ["get", field]];
for (const c of scale.classes) expr.push(c.bounds.from, c.color);
return expr;
}
const expr = ["step", ["get", field], scale.classes[0].color];
for (const c of scale.classes.slice(1)) expr.push(c.bounds.from, c.color);
return expr;
}
const expr = ["case"];
if (scale.classes[0].when) {
// Rules are self-contained and a feature takes the highest class that
// matches, so they are tested worst first.
for (const c of [...scale.classes].sort((a, b) => b.value - a.value)) {
const tests = (c.when.all ?? c.when.any).map(testExpr);
expr.push(c.when.all ? ["all", ...tests] : ["any", ...tests], c.color);
}
} else if (scale.thresholds) {
// An index: the classes carry no bounds and the scale has no single
// field. Each pollutant is graded against its own bounds and the cell
// takes the worst of them. A pollutant marked zero_means_missing
// contributes nothing at zero rather than counting as clean air.
const perPollutant = Object.values(scale.thresholds).map((t) => {
const step = ["step", ["get", t.field], 1];
t.bounds.forEach((b, i) => step.push(b, i + 2));
const present = t.zero_means_missing
? [">", ["get", t.field], 0]
: [">=", ["get", t.field], 0];
return ["case", ["all", ["has", t.field], present], step, 0];
});
const byClass = ["step", ["max", ...perPollutant], scale.no_data_color ?? "#aaa4a4"];
scale.classes.forEach((c) => byClass.push(c.value, c.color));
return byClass;
} else {
for (const c of scale.classes) {
expr.push(
["all", [">", ["get", field], c.bounds.from], ["<=", ["get", field], c.bounds.to]],
c.color,
);
}
}
return [...expr, "#ffffff"];
}
const sourceId = (key) => `clara-${key}`;
const layerId = (key) => `clara-${key}-render`;
function drop(key) {
if (map.getLayer(layerId(key))) map.removeLayer(layerId(key));
if (map.getSource(sourceId(key))) map.removeSource(sourceId(key));
}
// Buildings go on first so they sit on top; the data layer is inserted
// beneath them, leaving the basemap visible below. Buildings are scenery
// rather than a measurement, so they have no scale and their styling is
// the one palette this page writes down.
map.addSource(sourceId("buildings"), {
type: "vector",
url: catalog.static.buildings.tilejson,
});
map.addLayer({
id: layerId("buildings"),
type: "fill-extrusion",
source: sourceId("buildings"),
"source-layer": "3d-buildings",
paint: {
"fill-extrusion-height": ["get", "Height"],
"fill-extrusion-base": 0,
"fill-extrusion-color": "#d9d9d9",
// Solid: a building hides whatever the model drew underneath it.
"fill-extrusion-opacity": 1,
},
});
// Draws a CLARA layer. `view` is what to plot, `scale` is how to colour
// it; omit either for its default. Switching any of the three is the same
// call. All views of a layer read the same tile, so a switch is a repaint.
let showing = { layer: null, view: null, scale: null };
async function showLayer(layer, view, scale) {
const picked = resolve(layer, view, scale);
const def = fitted(await scaleDef(picked.scale), layer);
showing = { layer, view: picked.view.id, scale: picked.scale };
for (const other of ["thermal", "wind"]) if (other !== layer) drop(other);
const ref = catalog.live.layers[layer];
if (!ref) return; // absent when the city, or the key, does not have it
const { sourceLayer, valid } = TILE[layer];
const field = def.fields[0];
const paint = {
"fill-color": colorFor(def),
"fill-opacity": ["case",
["all", [">=", ["get", field], valid[0]], ["<=", ["get", field], valid[1]]], 0.6,
0],
"fill-antialias": true,
};
// Already mounted from the same tile: repaint rather than rebuild.
if (map.getLayer(layerId(layer))) {
for (const [k, v] of Object.entries(paint)) map.setPaintProperty(layerId(layer), k, v);
return;
}
map.addSource(sourceId(layer), { type: "vector", url: ref.tilejson });
map.addLayer({
id: layerId(layer),
type: "fill",
source: sourceId(layer),
"source-layer": sourceLayer,
paint,
}, layerId("buildings"));
}
// A new hour lands hourly and signed URLs expire; one fresh catalog
// carries both. The scales are not re-fetched, they outlive the session.
setInterval(async () => {
try {
catalog = await getCatalog();
map.getSource(sourceId("buildings")).setUrl(catalog.static.buildings.tilejson);
drop(showing.layer);
await showLayer(showing.layer, showing.view, showing.scale);
} catch (err) {
console.error(err); // a blip should not blank a working map
}
}, 15 * 60 * 1000);
/* ---------------------------------------------------------------------
The map is finished. Everything below is optional: a title, a legend,
the controls that switch view and colours, and a click popup, so the
page reads as a product rather than a test.
--------------------------------------------------------------------- */
// What the layer can be plotted as, and what may colour the chosen view.
// Both come straight out of the index, so a new option appears here
// without a change to this page.
const viewsFor = (layer) => viewsOf(layer);
const scalesFor = (layer, view) =>
resolve(layer, view).view.scales.map((id) => index.scales.find((s) => s.id === id));
// The scale in use is the one that produced the colours, so the legend
// cannot disagree with the map. `kind` decides how to draw it.
const legendOf = async (layer) => {
const s = fitted(await scaleDef(showing.scale), showing.layer);
const last = s.classes[s.classes.length - 1];
return {
id: s.id, kind: s.kind, unit: s.unit,
from: s.classes[0].bounds?.from,
// The last band is open-ended: its top IS its start, and the tick
// says so with a +. Guessing a top from the first band's width was
// wrong the moment the bands stopped being equal.
to: last.bounds?.to ?? last.bounds?.from,
// A fitted ramp's top IS the highest value in the data, so nothing
// sits above it and a "+" would promise more than exists. Only an
// authored open-ended band, like wind's 15+, earns one.
openEnded: last.bounds ? last.bounds.to === null && !s.fitted_to : false,
classes: s.classes.map(({ label, color, bounds, meaning }) => ({
label, color, meaning,
from: bounds?.from,
range: bounds ? `${bounds.from} to ${bounds.to ?? ""}`.trim() : null,
})),
};
};
const idOf = layerId;
// Every field the layer's views read, which is more than the one being
// painted. Within a layer they share a unit.
const fieldsOf = (layer) => [...new Set(viewsOf(layer).flatMap((v) => v.fields))];
const valuesAt = (lngLat) => {
const point = map.project(lngLat);
const layer = showing.layer;
// Buildings are opaque: a cell drawn underneath one is not visible, so
// clicking the roof must not report what is hidden below it.
const roof = idOf("buildings");
if (map.getLayer(roof) && (map.queryRenderedFeatures(point, { layers: [roof] }) ?? []).length) {
return [];
}
// Building height is geometry, not a reading, so it never goes in a popup.
if (layer === "buildings") return [];
if (!map.getLayer(idOf(layer))) return [];
const [hit] = map.queryRenderedFeatures(point, { layers: [idOf(layer)] }) ?? [];
if (!hit) return [];
const unit = loaded.get(showing.scale).unit;
// The view names each field for a person; the property name is the
// model's and stays out of sight.
const labels = viewsOf(layer).find((v) => v.id === showing.view)?.field_labels ?? {};
return fieldsOf(layer)
.filter((field) => field in hit.properties)
.map((field) => ({ field: labels[field] ?? field, value: hit.properties[field], unit }));
};
const liveHour = new Date(catalog.live.timestamp_ms);
const $ = (id) => document.getElementById(id);
// Opens on the mean wind speed. Switching layer falls back to whichever
// view the API marks as that layer's default.
let current = { layer: "wind", view: "mean", scale: null };
function fill(select, items, selected) {
select.innerHTML = items
.map((i) => `<option value="${i.id}">${i.name}</option>`)
.join("");
select.value = selected;
// A control with one option is not a choice; hide the row.
select.parentElement.style.display = items.length > 1 ? "" : "none";
}
// Labels along the bar. The step adapts to the range so the axis stays
// readable: wind runs 0-15 and gets one label per m/s, while a pollutant
// running 0-140 gets one every 20 rather than 141 of them.
function ticksFor(legend) {
const span = legend.to - legend.from;
if (!(span > 0)) return "";
const step = [1, 2, 5, 10, 20, 25, 50, 100].find((s) => span / s <= 15) ?? span;
// Round numbers strictly inside the bar, starting on a multiple of the
// step rather than on the range's own edge: a range fitted to the data
// starts at whatever the coldest cell happens to be, and an axis reading
// 26.9, 27.9, 28.9 is worse than one reading 27, 28, 29 that begins a
// fraction inside the bar.
const values = [];
for (
let v = Math.ceil(legend.from / step - 1e-9) * step;
v < legend.to - 1e-9;
v += step
) values.push(+v.toFixed(2));
// The top of the bar is always labelled, whether or not it lands on a
// round number: it is where the last colour starts, so leaving it to the
// nearest tick below would misreport the ramp.
values.push(+legend.to.toFixed(2));
// Drop the tick before the top only when the two would genuinely crowd,
// which means closer than a step apart. A "+" no longer costs a tick on
// its own: with every label centred there is room for both.
if (values.length >= 2) {
const gap = values[values.length - 1] - values[values.length - 2];
if (gap < step * 0.75) values.splice(values.length - 2, 1);
}
return values
.map((v, i) => {
const pos = ((v - legend.from) / span) * 100;
const text = `${v}${i === values.length - 1 && legend.openEnded ? "+" : ""}`;
return `<span style="left:${pos.toFixed(2)}%">${text}</span>`;
})
.join("");
}
function drawLegend(title, legend) {
document.querySelector("#legend h2").textContent =
legend.unit ? `${title} (${legend.unit})` : title;
// A gradient wants a colour bar, not one row per stop. Each colour sits
// at its own value, not at an even interval: the stops are 0, 4, 6, 8
// and 15, so evenly spacing them would put the legend out of step with
// the map.
const span = (legend.to - legend.from) || 1;
const at = (c) => `${c.color} ${(((c.from - legend.from) / span) * 100).toFixed(1)}%`;
$("classes").innerHTML = legend.kind === "gradient"
? `<div class="bar" style="background:linear-gradient(to right,${
legend.classes.map(at).join(",")})"></div>
<div class="ticks">${ticksFor(legend)}</div>`
: legend.classes.map((c) => `
<div class="row">
<span class="sw" style="background:${c.color}"></span>
<div class="txt">
<div class="top">
<span class="lbl">${c.label}</span>
${c.range ? `<span class="rng">${c.range}</span>` : ""}
</div>
${c.meaning ? `<div class="mean">${c.meaning}</div>` : ""}
</div>
</div>`).join("");
}
function applyVisibility() {
const on = $("visible").checked;
const id = idOf(current.layer);
if (map.getLayer(id)) map.setLayoutProperty(id, "visibility", on ? "visible" : "none");
$("legend").style.display = on ? "" : "none";
}
async function apply() {
await showLayer(current.layer, current.view, current.scale);
const views = await viewsFor(current.layer);
const view = views.find((v) => v.id === current.view) ?? views[0];
current.view = view.id;
$("title").textContent = `Ravenna: ${view.name}`;
$("blurb").textContent = view.description ?? "";
fill($("view"), views, current.view);
const legend = await legendOf(current.layer);
fill($("scale"), await scalesFor(current.layer, current.view), legend.id);
drawLegend(view.name, legend);
applyVisibility();
}
$("layer").onchange = async (event) => {
current = { layer: event.target.value, view: null, scale: null };
await apply();
};
$("view").onchange = async (event) => {
current.view = event.target.value;
current.scale = null; // each view brings its own default scale
await apply();
};
$("scale").onchange = async (event) => {
current.scale = event.target.value;
await apply();
};
$("visible").onchange = () => applyVisibility();
// Click for the values underneath, including the fields not being drawn.
map.on("click", ({ lngLat }) => {
const values = valuesAt([lngLat.lng, lngLat.lat]);
if (!values.length) return;
new maplibregl.Popup().setLngLat(lngLat).setHTML(values
.map((v) => `<b>${v.field}</b>: ${Number(v.value).toFixed(2)} ${v.unit}`)
.join("<br>")).addTo(map);
});
$("when").textContent = `Live conditions · ${liveHour.toLocaleString()}`;
await apply();
</script>
</body>
</html>
tiles at your own provider; the CLARA layers
are unaffected either way.
To show a forecast hour instead of the live one, read the tilejson from an
entry of catalog.forecast rather than
catalog.live. The layer, the paint and the legend stay
identical; only the URL changes.
The catalog, field by field
Abridged response for GET /v1/map/ravenna:
{
"city": { "id": "ravenna", "name": "Ravenna", "country": "IT" },
"meta": {
"crs": "EPSG:3857",
"tile_format": "mvt",
"generated_utc": "2026-08-19T10:43:53Z",
"catalog_ttl_s": 60,
"url_expires_utc": "2026-08-20T11:00:00Z"
},
"static": {
"buildings": { "tilejson": "https://api.clara.city/v1/map/ravenna/tiles/buildings.json?kid=...&exp=...&sig=..." }
},
"live": {
"timestamp": "2026-08-19T10:00:00Z",
"timestamp_ms": 1787133600000,
"layers": {
"thermal": { "tilejson": "..." },
"wind": { "tilejson": "..." }
}
},
"forecast": [
{ "timestamp": "2026-08-19T12:00:00Z", "timestamp_ms": 1787140800000, "layers": { "thermal": { "tilejson": "..." }, "wind": { "tilejson": "..." } } }
]
}
| Field | What to do with it |
|---|---|
live |
One timestep, the most recent observed hour. Replaced hourly, a
few minutes past the hour. Absent if your key's horizon
scope does not include live: an object has no empty
form, so the field is omitted rather than nulled. |
forecast |
Upcoming timesteps in time order. Their number and spacing both
vary, so read each timestamp_ms instead of assuming
a fixed interval. Empty if your key's horizon scope does
not include forecast, which reads the same as having
no forecast data this hour; GET /v1/key tells the two
apart. |
static |
Layers without a time dimension, currently buildings. |
layers |
Keyed by layer id. Check for the key rather than assuming the full set: an entry is absent when the city does not carry that layer, when the variable has no data for that timestep, or when your key's metric scope does not include it. See What your key includes. |
meta.url_expires_utc |
When every signed URL in this catalog stops working. Re-fetch the catalog before then; a fresh catalog always carries valid URLs. |
meta.catalog_ttl_s |
The catalog is cached server-side for this many seconds, so polling it faster returns the same document. |
Layers and their data
Inside the vector tiles, each CLARA layer has a named source-layer and typed feature properties. These names go in your map style:
| Layer id | Shows | source-layer | Properties | Time |
|---|---|---|---|---|
thermal |
Thermal comfort (UTCI) | UTCI |
UTCI, degrees C |
live plus forecast |
wind |
Pedestrian wind comfort | Velocity |
Ucomfort, Ugust, m/s |
live plus forecast |
air |
Air quality | Concentration |
C (NO2), C_PM25, C_PM10, µg/m3 |
live plus forecast |
buildings |
3D building footprints | 3d-buildings |
Height, m |
static |
Which layers a city has is declared by its catalog, and the set differs
per city. What each layer can be plotted as, and the class definitions
and colours behind every one of those, are served at
GET /v1/scales, so your legend and the map share one source
of truth. See Choosing what to plot.
Choosing what to plot
Two independent choices. A view is what to plot from a layer's
data; a scale is how to colour that view, and only the scales
belonging to a view may be used with it. Both are discovered from
GET /v1/scales?metric=wind, so nothing has to be hardcoded
and a new option appears without a client change.
| Layer | View | Reads | Scales |
|---|---|---|---|
| Loading the views from the API... | |||
Every view of a layer reads the same vector tile, so switching between them is a repaint of a source you already hold. No second request, no flicker, and the tile is fetched once however many views your users cycle through.
The three wind options
Wind comfort and danger is the default and the only view that shows
the Danger condition, because that condition is decided by the mean speed
and the gusts together. It comes in two palettes: wind-nen8100,
the standard quality-class colours, and wind-cvd, chosen to
stay readable with colour vision deficiency. Same classes, same
boundaries, different colours.
| Class | Condition | wind-nen8100 | wind-cvd |
|---|---|---|---|
| Loading the palettes from the API... | |||
Wind and Gust plot one field on its own,
Ucomfort or Ugust, in 30 bands of 0.5 m/s from 0
to 15 m/s, coloured with ParaView's Blue to Red Rainbow. Bands are
lower-inclusive, so a reading of exactly 0 lands in the first one, and
anything above 15 m/s takes the top colour. Both stop at 15 because that is
where the comfort classification puts the Danger threshold, so red means
the same thing on either.
mean view. Only
comfort-safety weighs the two together, which is why it is
the default.
Building the paint
A scale's kind tells you how to consume it.
classes is a swatch list; gradient is a colour
bar, which matters when a scale has thirty bands and you do not want
thirty legend rows.
Comfort classes carry their condition twice: condition for a
human, and when for a machine. Build your expression from
when rather than reimplementing the classification, and it
cannot fall out of step with ours. Each rule is self-contained and a
feature takes the highest class whose rule matches, so evaluate them worst
first and stop at the first hit. That is what the quick start's
colorFor() does, in a dozen lines.
// GET /v1/scales/wind-nen8100
{
"kind": "classes",
"fields": ["Ucomfort", "Ugust"],
"unit": "m/s",
"classes": [
{ "value": 4, "label": "Very windy", "color": "#FFFF00",
"condition": "Ucomfort > 8 or Ugust >= 10",
"when": { "any": [ { "field": "Ucomfort", "gt": 8 },
{ "field": "Ugust", "gte": 10 } ] } }
]
}
Gradient scales carry no rule and need none: each class is a band, and
bounds.from is where it starts. With
interpolate: true, as the pollutant scales have, treat those
same values as stop positions and interpolate between them instead.
Air quality on the map
The air layer carries every pollutant as properties of the
same features, so switching pollutants, or switching between raw
concentrations and index classes, is a restyle of a source you already
loaded. No second request, no reload, no flicker.
Air has four views: the index, and one per pollutant. To colour by index
class, fetch air-belaqi and build your paint expression from
its thresholds: per-pollutant concentration bounds for classes 1 through
9, class 10 above the last bound, and the cell takes the worst pollutant's
class. The response carries the official class colours, Excellent through
Horrible, so the legend comes from the same fetch; they are tabulated
below.
const scale = await fetch("https://api.clara.city/v1/scales/air-belaqi")
.then((r) => r.json());
// scale.thresholds.no2 = { field: "C", bounds: [10, 15, 20, ...],
// zero_means_missing: true }
// scale.classes[i].color is the colour for class i + 1.
To colour one pollutant instead, fetch air-no2,
air-pm25 or air-pm10. Those are gradient scales:
each class's lower bound is a stop, and interpolating between them gives a
ramp whose colours agree with the index view. Every pollutant rides in the
same tile, so switching between the four views is a repaint.
The BelAQI classes
Read live from GET /v1/scales/air-belaqi. The columns after
the colour are each pollutant's upper concentration bound for that class,
in µg/m3, inclusive. NO2 treats a value of zero or below as absent rather
than as clean air; the particulates accept zero.
| Class | Label | Colour | NO2 | PM2.5 | PM10 |
|---|---|---|---|---|---|
| Loading the scale from the API... | |||||
Which pollutants a city carries is in its catalog city entry; a missing property simply contributes nothing to the index.
Staying current
Two things age on a map that stays open: the data and the URLs.
- The data. A new live hour lands every hour. Re-fetch the catalog on an interval and point your sources at the new URLs.
- The URLs. Signed URLs expire at
meta.url_expires_utc, many hours out. Any catalog re-fetch renews them, so one interval keeps both fresh.
That is the last block of the CLARA part in the quick start above, on the fifteen-minute cadence the SDK also uses:
setInterval(async () => {
catalog = await getCatalog(); // the new hour and new signatures together
map.getSource(sourceId("buildings")).setUrl(catalog.static.buildings.tilejson);
showComfort(showing);
}, 15 * 60 * 1000);
The scales behind the colours are not re-fetched. They change when an issuing authority revises them, years apart, and they carry a day of cache.
If a tile request ever answers 401 with code: url_expired,
for example after a laptop wakes from sleep, the recovery is the same
move: re-fetch the catalog, swap the URLs. With the
browser SDK this is one call,
clara.startAutoRefresh(map), and a 401 on a tile triggers the
same recovery on its own.
How much you can call
Two very different request patterns sit behind a CLARA map, and it is worth knowing which is which before you size a deployment.
- The catalog is the authenticated call, and there is one per
page load. It is cached server-side for the number of seconds in
meta.catalog_ttl_s, currently 60, so polling it faster returns the same document. - Tiles are the volume. They authenticate on the signature in the
URL rather than on your key, so they cost no lookup on our side and
need no headers on yours. They are served with
Cache-Control: public, max-age=3600, and theexpstamp rounds up to the hour, so every viewer inside the same window requests byte-identical URLs and the edge cache absorbs most of the traffic.
For scale: one user scrubbing a full 48-hour forecast with two layers switched on is in the order of a couple of thousand tile requests, nearly all of which are edge cache hits after the first viewer of that hour.
A signed URL is bound to one key, one city and one layer, and expires on the schedule the catalog announces, so a leaked URL is narrow and short-lived. Tile requests are not counted against a per-key quota; usage is attributed in our logs through the key id the URL carries, and abuse is answered by revoking the key. If you expect sustained high volume, tell us beforehand and we will size for it rather than discover it.
Errors
Same error format as the rest of the API: problem JSON with a
code to branch on. The ones specific to the map product:
| Code | Status | Meaning | Recovery |
|---|---|---|---|
url_expired | 401 | A signed tile URL outlived its expiry. | Re-fetch the catalog, swap the URLs. |
forbidden | 403 | Your key is not scoped to this city, lacks the map product, or carries no metric scope at all, in which case its catalog would contain no data layers. Likewise if it carries no horizon scope, in which case it would contain neither live nor forecast data. | Check GET /v1/key, then talk to us. |
not_found | 404 | Unknown city or tile address. | Check the identifier against GET /v1/cities?product=map. |
upstream_unavailable | 503 | The model service is unreachable and no cached catalog exists. | Retry shortly. When a cached catalog exists you get it instead,
marked X-Cache: stale, with valid URLs. |
An empty tile answers 204 No Content. That is normal at the
edge of the modelled area, not an error.
The machine-readable contract for everything on this page is the CLARA Maps API OpenAPI specification.