CLARA Maps SDK
Add street-level thermal comfort, pedestrian wind, air quality and 3D building layers to a MapLibre map, in any covered city, and read their values back.
https://sdk.clara.city/maps/v1/clara-maps.jsRequires MapLibre GL JS 3 or later, which your page loads itself.
What the URL promises
That address is permanent, because you are going to embed it. The path is
versioned by product and major version, and we treat both as fixed: the
hostname will not move, and /maps/v1/ will not be withdrawn.
- Inside
v1we ship fixes and additions only. New layers, new fields and new methods can appear; nothing that works today stops working. - A change that would break a working integration goes to
/maps/v2/as a new path, andv1keeps being served. You move when it suits you. - Every release is listed in the changelog.
Because the file is updated in place within v1, a fixed
integrity hash on the script tag would break at the next patch,
so we do not publish one. If your policy requires subresource integrity, or
you would rather control exactly when the code under your page changes, host
a copy yourself and update it deliberately; the package is a single file with
no dependencies.
Pages with a strict Content Security Policy need
https://sdk.clara.city in script-src and
https://api.clara.city in connect-src, plus
whatever your basemap requires. MapLibre has its own CSP requirements for the
web workers it creates; see its documentation.
Every value the underlying API exposes is reachable through the SDK: the layers, all of their attributes, every available hour, and the metadata describing them. Hours are addressed by timestamp, never by position. Which layers a city carries is declared by its catalog, so the same page works in every covered city. The sections below list each method with what it takes and exactly what it gives back. The worked examples use Ravenna and Brussels; swap the city id for any other covered city.
Authentication
Pass your API key and your city to the constructor. Everything else, the catalog request and the signatures the tile URLs carry, is handled for you.
const clara = new ClaraMaps({ token: "clara_pk_YOUR_KEY", city: "ravenna" });
There are two key types, told apart by prefix:
clara_pk_publishable. Safe inside a browser page. The API checks it against your origin allowlist and applies a low rate limit, so a copied key is useless on another website. Make sure your allowlist includes your localhost and staging origins, or your first day of integration is a confusing 403.clara_sk_secret. Server side only, with your full products and limits. The SDK refuses it in a browser and throws, namingclara_pk_as the fix; anyone who opens devtools could read it there.
Your key is shown once when issued, so store it somewhere safe. We keep only a SHA-256 hash of it, which means a lost key cannot be recovered, but we will replace it on request at any time.
What your key includes
A key carries three independent scopes, and what you can draw is their
intersection with what the city models: the cities it may read, the
products it may use, and the metrics it may read
(air, wind, thermal,
noise). GET /v1/key returns all three for the key
you present.
thermal entry, and
addLayer() returns
null for it. 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.
availableLayers() already reflects
your scope, which makes it the right thing to build UI controls from.
buildings is scenery rather than a measurement and is never
gated.
catalogEndpoint. The signed URLs inside the catalog are safe
to expose either way. They expire on a schedule the catalog itself
announces, and grant read access to one city's tiles and nothing else.
// Production pattern: a ten-line proxy keeps the secret key server-side.
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_SECRET_KEY}` },
});
res.status(r.status).json(await r.json());
});
// The page then needs no key at all.
const clara = new ClaraMaps({ catalogEndpoint: "/api/clara-catalog" });
An invalid key surfaces as a 401 unauthorized
ClaraError on the first call that needs it,
which is usually the first addLayer().
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 the catalog publishes for that
layer at that hour. The SDK does this for you: a mounted layer's
legend() already reports real
temperatures, and fitted_to says which range it used. 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 is four calls. Construct, add the buildings, add a data
layer beneath them, and start the refresh that keeps the hour and the signed
URLs current. before is what puts the data layer under the
buildings, and the single addLayer call takes the layer, the
view and the scale together, so switching any of the three is that same
call.
Below the line, views() and
scales() fill the two controls and
legend() returns the scale the layer is
actually drawn with, so the swatches cannot drift from the map. See
Views and colour scales.
The city map page builds the same map without the SDK, against the HTTP API. Same three parts, same result; the CLARA part is where the difference is.
<!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.
--------------------------------------------------------------------- */
import { ClaraMaps } from "https://sdk.clara.city/maps/v1/clara-maps.js";
const clara = new ClaraMaps({ token: "clara_pk_YOUR_KEY", city: "ravenna" });
// Buildings go on first so they sit on top; the data layer is inserted
// beneath them, leaving the basemap visible below.
const buildings = await clara.addLayer(map, { layer: "buildings" });
// The one call that 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, and repaints without re-fetching a tile.
async function showLayer(layer, view, scale) {
for (const other of ["thermal", "wind"]) {
if (other !== layer) clara.removeLayer(map, other);
}
await clara.addLayer(map, { layer, view, scale, before: buildings });
}
// A new hour lands hourly and signed URLs expire; one call covers both.
clara.startAutoRefresh(map);
/* ---------------------------------------------------------------------
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 from the API, so a new option appears here without a change.
const viewsFor = (layer) => clara.views(layer);
const scalesFor = (layer, view) => clara.scales(layer, { view });
// legend() returns the scale actually in use, so the swatches cannot
// disagree with the map. `kind` decides how to draw it.
const legendOf = async (layer) => {
const s = await clara.legend(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 = (layer) => clara.layerId(layer);
const valuesAt = (lngLat) => {
// Buildings are opaque: a cell drawn under one is not visible, so
// clicking the roof must not report what is hidden below it.
const roof = clara.layerId("buildings");
if (map.getLayer(roof) && (map.queryRenderedFeatures(map.project(lngLat), { layers: [roof] }) ?? []).length) {
return [];
}
// The model's property names are not what a person should read: the
// view renames them, and anything unlabelled keeps its own name.
const labels = current.fieldLabels ?? {};
return Object.entries(clara.valuesAt(map, lngLat) ?? {})
// Building height is geometry, not a reading.
.filter(([layer]) => layer !== "buildings")
.flatMap(([layer, props]) =>
Object.entries(props).map(([field, value]) =>
({ field: labels[field] ?? field, value, unit: ClaraMaps.fields(layer)[field].unit })));
};
const liveHour = (await clara.liveTimestamp()).date;
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;
// Kept for the popup, which runs on click and cannot await a fetch.
current.fieldLabels = view.field_labels ?? {};
$("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>
file:// two
things break: the browser blocks the module import, and it sends
Origin: null, which matches no allowlist, so the key is
refused. Any local server works, for example
python3 -m http.server 5173 in the folder, then open
http://localhost:5173/. That exact origin, scheme, host and
port, has to be registered on your key.
tiles at your own provider; the CLARA layers
are unaffected either way.
To place the layers over your own basemap, swap the style URL
and pass before so your labels stay on top:
await clara.addLayer(map, { layer: "thermal", before: "your-label-layer-id" });
Layers and fields
Four layers. Every attribute below is present on the rendered features, so
each can be styled with an expression or read with
valuesAt(). The field name is the
model's own; "Shown as" is the label the SDK carries for it, so
a popup can say Wind rather than Ucomfort.
| Layer | Field | Shown as | Unit | Meaning |
|---|---|---|---|---|
thermallive + forecast |
UTCI | Temperature UTCI | °C | Universal Thermal Climate Index at pedestrian level. Styled by default. |
windlive + forecast |
Ucomfort | Wind | m/s | Pedestrian-level mean comfort wind speed. Styled by default. |
Ugust | Gust | m/s | Pedestrian-level gust wind speed. | |
airlive + forecast |
C | NO2 | µg/m3 | NO2 concentration at pedestrian level. |
C_PM25 | PM2.5 | µg/m3 | PM2.5 concentration at pedestrian level. | |
C_PM10 | PM10 | µg/m3 | PM10 concentration at pedestrian level. | |
buildingsstatic |
Height | Height | m | Building height above ground. Drives the extrusion. |
Which layers a city carries is declared by its catalog, and the set
varies: Ravenna has thermal, wind and
buildings; Brussels adds air.
addLayer() returns null
for a layer the city does not offer, rather than throwing, and
GET /v1/cities?product=map lists every covered city with its
layers. Coverage extent and zoom range are announced by each layer's
TileJSON; outside them nothing renders.
The wind layer carries two fields; the default styling classifies on both.
To draw Ugust alone, pass your own
paint:
// gusts instead of mean wind speed
await clara.addLayer(map, {
layer: "wind",
paint: {
"fill-color": [
"interpolate", ["linear"], ["get", "Ugust"],
0, "#f7fbff", 1, "#6baed6", 2, "#08306b",
],
},
});
The air layer carries all three pollutants in the same features and has
four views; see Air quality. Interrogate the
field list at runtime with
ClaraMaps.fields(), which returns the
same units and descriptions shown above.
Views and colour scales
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. Omit both and you get the layer's default view in its default scale, which is what the quick start does.
| Layer | View | Reads | Scales |
|---|---|---|---|
| Loading the views from the API... | |||
await clara.addLayer(map, { layer: "wind" }); // comfort classes, standard palette
await clara.setScale(map, "wind", "wind-cvd"); // same classes, colour-blind palette
await clara.setView(map, "wind", "gust"); // gusts on a rainbow ramp
const views = await clara.views("wind"); // build a "what to show" control
const scales = await clara.scales("wind"); // and a "colours" one for that view
Every view of a layer reads the same vector tile, so switching between
them repaints in place: no second request, no flicker, and the tile is
fetched once however many views your users cycle through. Asking for a scale
that does not belong to the current view throws, naming the ones that do,
rather than painting the wrong classification.
setView() resets to the new view's
default scale, since a scale from the old view may not be valid.
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: the standard quality-class colours, and one 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, 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.
Where the colours come from
They are not in this library. Every palette and class boundary is fetched
from GET /v1/scales, which is public, needs no key and is cached
for an hour, so the SDK and any other client draw from one definition instead
of copies that drift. The index is fetched once per session and each scale
once when first used; the catalog request you were making anyway goes to the
same host.
legend() hands you the scale in use,
including its kind: classes for a swatch list,
gradient for a colour bar. That distinction matters, because a
rainbow scale has thirty bands and nobody wants thirty legend rows.
Constructor
const clara = new ClaraMaps({ token: "clara_pk_YOUR_KEY", city: "ravenna" });
| Option | Type | Default | Description |
|---|---|---|---|
token | string | optional | Your API key. clara_pk_ in a page; clara_sk_ only on a server. |
city | string | required | Which city to read, e.g. "ravenna". List them with GET /v1/cities?product=map. |
catalogEndpoint | string | optional | Your own backend endpoint that proxies the catalog call. When set, city is not needed: the endpoint embeds it. |
catalog | object | optional | A catalog your page already holds. Nothing is fetched until refresh(). |
baseUrl | string | API v1 | Override for staging environments. |
The constructor throws immediately, rather than failing later on the
first request, when no way to obtain a catalog is given, when
city is missing without a catalogEndpoint, or when
a clara_sk_ secret key is used in a browser. The last error
names clara_pk_ as the fix. See
Authentication for the two key types.
Methods
ASYNCclara.addLayer(map, options)
Adds a source and a layer to the map for one layer at one hour.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
map | Map | required | Your MapLibre or Mapbox map. |
layer | string | required | "thermal", "wind", "air" or "buildings". Anything else throws. |
timestamp | number \| string \| Date | live hour | An hour from timestamps(). Omit for the most recent observed hour. Ignored for buildings. |
before | string | optional | Insert beneath this existing map layer id. |
paint | object | optional | Paint properties merged over the defaults. |
view | string | layer default | What to plot. See Views and colour scales. |
scale | string | view default | How to colour it. Must belong to the view, or the call throws naming the valid ones. |
Returns string | null
await clara.addLayer(map, { layer: "thermal" });
// "clara-thermal-render" the id of the map layer it created
await clara.addLayer(map, { layer: "wind", timestamp: 1787162400000 });
// null that layer has no data at that hour
await clara.addLayer(map, { layer: "air" });
// null in Ravenna the city does not carry the layer at all
The timestamp may be given three ways, whichever is handiest:
{ timestamp: 1787162400000 } // epoch milliseconds
{ timestamp: "2026-08-19T18:00:00Z" } // ISO 8601 string
{ timestamp: new Date(1787162400000) } // Date object
An hour the catalog does not carry throws, naming the remedy rather than silently rendering the wrong data:
await clara.addLayer(map, { layer: "thermal", timestamp: 1787000000000 });
// Error: No data for 2026-08-17T22:13:20.000Z.
// Call timestamps() to see which hours are available.
A null return is a normal condition, not an error. A layer
the city does not carry, one missing for a single hour, and one your key's
metric scope does not include all report as null; see
What your key includes. Check it if the
distinction matters to your UI. The ids it creates are
clara-{layer} for the source and
clara-{layer}-render for the layer. Calling it again for a
mounted layer with a different view or scale
repaints in place; see Views and colour scales.
SYNCclara.removeLayer(map, layer)
Removes the layer and its source. Safe to call when they are not present, so no need to check first.
Returns void
clara.removeLayer(map, "wind");
ASYNCclara.setTimestamp(map, layer, timestamp)
Move a layer to a different hour, preserving whatever before,
paint, view and pollutant it was
added with. Equivalent to calling
addLayer() again with those options
carried over.
Returns string | null
await clara.setTimestamp(map, "thermal", 1787162400000);
// "clara-thermal-render"
await clara.setTimestamp(map, "wind", "2026-08-19T18:00:00Z");
// "clara-wind-render"
Returns null when that layer has no data at the requested
hour, exactly as addLayer does. To move several layers, call it
for each one.
ASYNCclara.timestamps()
Every hour the data is currently available for, in time order. Query this
first, then pass one of the timestamps back to
addLayer().
Returns Array<Timestamp>
await clara.timestamps();
// [
// {
// timestamp: "2026-08-19T14:00:00Z",
// timestamp_ms: 1787148000000,
// date: Date,
// kind: "live",
// layers: ["thermal", "wind"]
// },
// {
// timestamp: "2026-08-19T18:00:00Z",
// timestamp_ms: 1787162400000,
// date: Date,
// kind: "forecast",
// layers: ["thermal", "wind"]
// }
// // … more forecast hours
// ]
| Field | Type | Description |
|---|---|---|
timestamp | string | ISO 8601, always UTC. |
timestamp_ms | number | The same instant as epoch milliseconds. This is what you pass back. |
date | Date | Ready to format for a label. |
kind | string | "live" for the observed hour, "forecast" for the rest. |
layers | string[] | Which layers have data at this hour. |
The observed hour comes first, followed by the forecast. Both the number of hours and the spacing between them vary between calls, so read the list rather than assuming a shape.
ASYNCclara.liveTimestamp()
Just the most recent observed hour, without filtering the list yourself.
Returns Timestamp | null
await clara.liveTimestamp();
// { timestamp: "2026-08-19T14:00:00Z", timestamp_ms: 1787148000000,
// date: Date, kind: "live", layers: ["thermal", "wind"] }
ASYNCclara.availableLayers(timestamp)
Which layers actually have data at a given hour, static layers included.
Use it to disable UI controls rather than discovering absence from a
null return. Each entry from
timestamps() also carries its own
layers array, which saves a call.
Returns Array<string>
await clara.availableLayers();
// ["thermal", "wind", "buildings"] the live hour
await clara.availableLayers(1787162400000);
// ["thermal", "buildings"] no wind data at that hour
SYNCclara.valuesAt(map, lngLat, options)
The field values under a point: every attribute the tiles carry, not only the one being drawn. This is how you get numbers out of the SDK for a popup, a readout or a hover tooltip.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
map | Map | required | Your map. |
lngLat | [number, number] | required | Geographic position, or a MapLibre screen point. |
layer | string | all mounted | Restrict the reading to one layer. |
Returns object | null
clara.valuesAt(map, [12.2012, 44.4134]);
// {
// thermal: { UTCI: 31.2 },
// wind: { Ucomfort: 0.41, Ugust: 0.88 }
// }
clara.valuesAt(map, [12.2012, 44.4134], { layer: "wind" });
// { wind: { Ucomfort: 0.41, Ugust: 0.88 } }
clara.valuesAt(map, [0, 0]);
// null nothing rendered there
map.on("idle") if you are querying immediately
after moving the map.
ASYNCclara.getCatalog(options)
The raw API response, unmodified. Most pages never need this;
timestamps() is the friendlier
form. But it is here when you want the underlying document. It comes from
GET /v1/map/{city}, is cached for 60 seconds, and
concurrent calls collapse into a single request. Pass
{ force: true } to bypass the cache.
Returns Catalog
{
city: { id: "ravenna", name: "Ravenna", country: "IT" },
meta: {
crs: "EPSG:3857",
tile_format: "mvt",
resolution_m: 5,
generated_utc: "2026-08-19T14:03:11Z",
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?exp=…&sig=…" }
},
live: {
timestamp: "2026-08-19T14:00:00Z",
timestamp_ms: 1787148000000,
layers: { thermal: { tilejson: "…" }, wind: { tilejson: "…" } }
},
forecast: [
{ timestamp: "2026-08-19T18:00:00Z", timestamp_ms: 1787162400000, layers: { … } },
…
]
}
city identifies the city that answered; meta
describes the document itself, including
url_expires_utc, when its signed URLs stop working.
clara.invalidate() drops the cached copy without fetching a
replacement.
ASYNCclara.signatureExpiry()
When the tile URLs currently in use stop working. Read from the
catalog's meta.url_expires_utc, with the exp
parameter on the URLs as the fallback.
Returns Date | null
await clara.signatureExpiry();
// Date 2026-08-20T11:00:00.000Z
With startAutoRefresh running you do
not need to track this yourself.
ASYNCclara.refresh(map)
SYNCclara.startAutoRefresh(map, options)
Two things go stale: the live hour is replaced every hour, and the
signed URLs expire on the schedule
meta.url_expires_utc announces. refresh handles
both once; startAutoRefresh handles them continuously,
renewing URLs early when they are close to expiring and recovering
automatically if a tile request returns 401.
Returns void · function
await clara.refresh(map); // once, now
const stop = clara.startAutoRefresh(map); // default: check every 15 min
stop(); // or clara.stopAutoRefresh()
Call clara.destroy(map) when tearing the map down, or the
timer and the error listener outlive it.
ASYNCclara.views(layer)
ASYNCclara.scales(layer, options)
What a layer can be plotted as, and which colour scales are valid for
one of those views. Together they are everything a "what to show" control
and a "colours" control need, without hardcoding an id. Both are empty for
buildings, which has no scale. scales() takes
{ view }; omit it for the view currently mounted, or the
layer's default.
Returns Array<View> · Array<ScaleSummary>
await clara.views("wind");
// [
// { id: "comfort-safety", metric: "wind", name: "Wind comfort and danger",
// fields: ["Ucomfort", "Ugust"],
// field_labels: { Ucomfort: "Wind", Ugust: "Gust" },
// scales: ["wind-nen8100", "wind-cvd"],
// default_scale: "wind-nen8100", default: true },
// { id: "mean", name: "Wind", fields: ["Ucomfort"], … },
// { id: "gust", name: "Gust", fields: ["Ugust"], … }
// ]
await clara.scales("wind", { view: "comfort-safety" });
// [ { id: "wind-nen8100", name: "Standard", kind: "classes", default: true },
// { id: "wind-cvd", name: "Colour-blind", kind: "classes" } ]
ASYNCclara.setView(map, layer, view)
ASYNCclara.setScale(map, layer, scale)
Change what a mounted layer plots, or how it is coloured. Both repaint the source already on the map, so neither re-fetches a tile.
setView moves to the new view's default scale, because a
scale from the old view may not be valid for the new one.
setScale keeps the view and throws if the scale does not
belong to it, naming the ones that do; painting the wrong classification
silently would be worse than an error.
Returns string | null
await clara.setView(map, "wind", "gust"); // rainbow ramp on Ugust
await clara.setScale(map, "wind", "wind-cvd"); // throws: not a gust scale
// Error: Scale "wind-cvd" cannot be used with the "gust" view of "wind".
// Available: wind-gust-rainbow
ASYNCclara.legend(layer)
The scale a layer is currently drawn with, whichever view and scale are
in use, so a legend built from it cannot disagree with the map. Returns
null for a layer with no scale. Read kind to
decide the shape: classes is a swatch list,
gradient is a colour bar.
Returns Scale | null
const scale = await clara.legend("wind");
// {
// id: "wind-nen8100",
// kind: "classes",
// unit: "m/s",
// fields: ["Ucomfort", "Ugust"],
// classes: [
// { value: 1, label: "Calm", color: "#0000FF",
// condition: "Ucomfort < 4 and Ugust < 10",
// when: { all: [ { field: "Ucomfort", lt: 4 },
// { field: "Ugust", lt: 10 } ] } },
// …
// ],
// no_data_color: "#aaa4a4"
// }
for (const c of scale.classes) {
const range = c.bounds ? `${c.bounds.from} to ${c.bounds.to}` : c.condition;
addSwatch(c.color, c.label, range);
}
Classes carry their boundary in whichever form fits: bounds
for a range on one field, condition and its machine-readable
twin when for classes decided by two fields at once, and on
air-belaqi the per-pollutant thresholds at the
top level.
SYNCclara.sourceId(layer)
SYNCclara.layerId(layer)
The map ids the SDK uses, for reaching past it to MapLibre directly: reordering layers, toggling visibility, or querying features yourself.
Returns string
clara.sourceId("thermal"); // "clara-thermal"
clara.layerId("thermal"); // "clara-thermal-render"
map.setLayoutProperty(clara.layerId("thermal"), "visibility", "none");
map.moveLayer(clara.layerId("buildings"), clara.layerId("thermal"));
STATICClaraMaps.layers
STATICClaraMaps.fields(layer)
The structure of the tiles: source-layer names, the fields each layer
carries and their units. No key and no network call. Colour is not here,
because it depends on which scale is in use; call
legend() for that.
Returns object
ClaraMaps.layers.thermal;
// {
// sourceLayer: "UTCI", the name inside the vector tile
// type: "fill",
// metric: "thermal",
// fields: { UTCI: {…} },
// valid: [-1000, 20000] plausible range of a real reading
// }
ClaraMaps.fields("air");
// {
// C: { label: "NO2", unit: "µg/m3", description: "NO2 concentration…" },
// C_PM25: { label: "PM2.5", unit: "µg/m3", description: "PM2.5 concentration…" },
// C_PM10: { label: "PM10", unit: "µg/m3", description: "PM10 concentration…" }
// }
ClaraMaps.layers.wind.valid;
// [0, 20000] outside this range nothing is painted
For the classes and colours a layer is drawn with, call
legend() instead: those depend on the
scale in use, so they cannot come from a static table.
Choosing an hour
Ask which hours exist, then pass one back. Nothing is positional, so nothing breaks when the number of hours or the gaps between them change.
const hours = await clara.timestamps();
// show the third available hour
await clara.setTimestamp(map, "thermal", hours[2].timestamp_ms);
A time slider
const hours = await clara.timestamps();
slider.max = hours.length - 1;
slider.oninput = async (event) => {
const hour = hours[Number(event.target.value)];
await clara.setTimestamp(map, "thermal", hour.timestamp_ms);
label.textContent = hour.date.toLocaleString();
};
Showing more than one layer at a time? Call it once per layer:
for (const layer of ["thermal", "wind"]) {
await clara.setTimestamp(map, layer, hour.timestamp_ms);
}
A dropdown
const hours = await clara.timestamps();
select.innerHTML = hours
.map((h) => `<option value="${h.timestamp_ms}">
${h.date.toLocaleString()} ${h.kind === "live" ? "(now)" : ""}
</option>`)
.join("");
select.onchange = () => clara.setTimestamp(map, "thermal", Number(select.value));
timestamps() gives you and label each entry with
its own date.
For smoother scrubbing, add every hour as its own source up front and toggle visibility instead of swapping sources. That uses more memory but avoids a tile fetch on each move.
Air quality
Air quality is one layer, not one per pollutant. Every
feature carries all three concentrations (C for NO2,
C_PM25, C_PM10), so switching what is shown is a
paint change on a source that is already loaded: no second tile request, no
flicker. Ozone is not modelled yet and will join later without any change to
your code, since the index takes the worst of whichever pollutants are
present.
Four views
// BelAQI index, worst pollutant wins. The default.
await clara.addLayer(map, { layer: "air" });
// same as: { layer: "air", view: "index" }
// One pollutant's concentration on a continuous ramp.
await clara.setView(map, "air", "pm25");
// Switching view on a mounted air layer repaints in place.
await clara.setView(map, "air", "no2");
await clara.setView(map, "air", "index");
The index view computes each pollutant's BelAQI class from its raw concentration at paint time, takes the worst, and clamps to 1 to 10. A missing pollutant contributes nothing rather than defaulting to a clean class; where no pollutant has data, the cell paints the no-data grey. The concentration view paints a single pollutant with a linear ramp whose stops are that pollutant's BelAQI class bounds, coloured with the same ten-class palette, so the two views read consistently.
Where the classes and thresholds live
Not on this page. The ten BelAQI classes, their colours and the
per-pollutant concentration thresholds are served by
GET /v1/scales/air-belaqi and listed on the
city map page. Printing them here as well would be a
fourth copy of a table IRCEL owns, and copies drift.
NO2 treats a value of zero or below as absent rather than as clean air; the particulates accept zero. The same tables come back at runtime, from the scale rather than from this page:
const scale = await clara.legend("air"); // on the index view
scale.thresholds;
// { no2: { field: "C", bounds: [10, 15, 20, …], zero_means_missing: true },
// pm25: { field: "C_PM25", bounds: [3.5, 7.5, 10, …] },
// pm10: { field: "C_PM10", bounds: [10, 20, 35, …] } }
scale.classes[0]; // { value: 1, label: "Excellent", color: "#012BF5" }
scale.no_data_color; // "#aaa4a4"
Legends and styling
How the SDK draws, and how to draw over it. The classes and colours
themselves are not defined here: they come from the scale in use, listed in
Views and colour scales and served in full by
GET /v1/scales.
What the SDK decides is the rendering. Class-based scales are drawn as
discrete bands rather than a continuous gradient; gradient scales are banded
or interpolated according to the scale's own interpolate flag.
Everything is painted at 60 % opacity, and readings outside the
plausible range for that layer are hidden rather than painted, so sentinel
values do not appear as solid blocks of the first class.
Drawing a legend
legend() returns exactly the scale
the map is drawn with, whichever view and scale are in use, so a legend
cannot fall out of step with the styling:
const scale = await clara.legend("thermal");
// { id: "thermal-utci", kind: "classes", unit: "°C",
// classes: [ { label: "Extreme cold", bounds: { from: -50, to: -40 }, color: "#000033" },
// { label: "Comfortable", bounds: { from: 9, to: 26 }, color: "#CBCC01" }, … ] }
await clara.legend("wind");
// { id: "wind-nen8100", kind: "classes", unit: "m/s",
// classes: [ { value: 1, label: "Calm", color: "#0000FF",
// condition: "Ucomfort < 4 and Ugust < 10", when: {…} }, … ] }
for (const c of scale.classes) {
const range = c.bounds ? `${c.bounds.from} to ${c.bounds.to}` : c.condition;
legend.insertAdjacentHTML("beforeend",
`<div><span style="background:${c.color}"></span>${c.label} ${range}</div>`);
}
Classes carry their boundary in whichever form fits: bounds
for a range on one field, condition and its machine-readable
twin when for classes decided by two fields at once, and on
air-belaqi the per-pollutant thresholds at the top
level. Check kind first: a gradient scale has
thirty bands and wants a colour bar, not thirty rows.
Overriding
Pass paint to use your own styling. Any
MapLibre expression
works, on any field from Layers and fields:
await clara.addLayer(map, {
layer: "wind",
paint: {
"fill-color": [
"interpolate", ["linear"], ["get", "Ugust"],
0, "#f7fbff", 1, "#6baed6", 2, "#08306b",
],
"fill-opacity": 0.8,
},
});
The object merges over the defaults, so passing only
fill-opacity keeps the default colours. Note that overriding
fill-opacity with a plain number also discards the gate that
hides out-of-range values.
Popups
valuesAt() plus MapLibre's own popup
gives a readout on click, showing every field including the ones not being
drawn:
map.on("click", (e) => {
const values = clara.valuesAt(map, [e.lngLat.lng, e.lngLat.lat]);
if (!values) return;
const html = Object.entries(values)
.flatMap(([layer, props]) =>
Object.entries(props).map(([field, value]) => {
const { unit } = ClaraMaps.fields(layer)[field] ?? {};
return `${field}: ${value.toFixed(2)} ${unit ?? ""}`;
}))
.join("<br>");
new maplibregl.Popup().setLngLat(e.lngLat).setHTML(html).addTo(map);
});
Units come from ClaraMaps.fields(), so the popup stays correct
if fields are added later.
Limits and volume
Two very different request patterns sit behind a CLARA map, and the SDK makes both of them for you.
- The catalog is the authenticated call. The SDK fetches it once,
caches it for 60 seconds, and collapses concurrent calls into a single
request, so ten
addLayer()calls on load cost one. The API caches it server-side for the same window. - Tiles are the volume, and MapLibre requests them directly. They
authenticate on the signature in the URL rather than on your key, so they
need no headers and cost no lookup. They are served with
Cache-Control: public, max-age=86400, and the signature's expiry 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 them edge cache hits after the first viewer of that hour. Adding every hour as its own source up front, as Choosing an hour suggests, trades memory for far fewer of them.
A signed URL is bound to one key, one city and one layer, and expires on the schedule the catalog announces, so a URL copied out of devtools is narrow and short-lived. Tile requests are not counted against a per-key quota; usage is attributed 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
The API answers RFC 9457 application/problem+json. The SDK
turns those bodies into a ClaraError carrying the HTTP
status, the machine-readable code, the human
message (the body's detail), and the
requestId to quote when contacting support.
import { ClaraMaps, ClaraError } from "https://sdk.clara.city/maps/v1/clara-maps.js";
try {
await clara.addLayer(map, { layer: "thermal" });
} catch (err) {
if (err instanceof ClaraError && err.isExpired) {
await clara.refresh(map); // tile URLs rolled over; fixes itself
} else {
console.error(err.status, err.code, err.message, err.requestId);
}
}
| Status | code | Meaning |
|---|---|---|
| 401 | unauthorized | Key is missing, malformed or unknown. |
| 401 | url_expired | Tile URLs are past their signing window. Call refresh(). |
| 403 | forbidden | Key is valid but not entitled: disabled, expired, not scoped to that city, missing the map product, or carrying no metric scope at all. |
| 404 | not_found | Unknown city. |
| 503 | upstream_unavailable | Data service briefly unreachable. Retry. |
ClaraError carries status, code,
requestId and isExpired. Passing an unknown layer
name to addLayer throws a plain Error, not a
ClaraError: that one is a programming mistake rather than a
service condition.
With startAutoRefresh running you will rarely see
url_expired: the SDK renews URLs before they lapse and recovers
on its own if a tile request comes back 401.
Examples
Four complete pages, each exercising a different part of the SDK. Copy one, add your publishable key, serve it over HTTP. The first two use Ravenna, the air example uses Brussels; any covered city works with its own id and coordinates.
1 · Forecast browser
A time slider and a layer switcher. Shows
timestamps() driving the UI, the
per-hour layers array used to detect gaps before drawing, and
addLayer() handling both moves and
switches.
Uses: timestamps · addLayer ·
removeLayer · per-hour layers · kind
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Ravenna forecast browser</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 system-ui, sans-serif; }
#map { position: absolute; inset: 0; }
#panel { position: absolute; left: 12px; bottom: 12px; z-index: 1; min-width: 340px;
background: #fff; padding: 12px 16px; border-radius: 8px;
box-shadow: 0 1px 8px rgba(0,0,0,.25); }
#time { width: 100%; }
#when { display: flex; justify-content: space-between; gap: 12px; margin-top: 4px; }
#day { font-weight: 600; }
#hour { font-weight: 600; font-variant-numeric: tabular-nums; }
#kind { color: #666; }
#note { color: #b3261e; min-height: 1.2em; }
/* Not uppercased: CSS uppercase turns the micro sign into a Greek capital
mu, and a unit should read as it is written. */
#scaleName { margin: 10px 0 5px; font-size: 11px; letter-spacing: .06em;
font-weight: 600; color: #555; }
#legend .row { display: flex; align-items: center; gap: 7px; font-size: 12px;
line-height: 1.5; }
#legend .sw { flex: none; width: 13px; height: 13px; border-radius: 3px;
border: 1px solid rgba(0,0,0,.15); }
</style>
</head>
<body>
<div id="map"></div>
<div id="panel">
<label><input type="radio" name="layer" value="thermal" checked> Thermal comfort</label>
<label><input type="radio" name="layer" value="wind"> Wind</label>
<input type="range" id="time" min="0" max="0" value="0">
<div id="when"><span id="day"></span><span id="hour"></span><span id="kind"></span></div>
<div id="note"></div>
<h4 id="scaleName"></h4>
<div id="legend"></div>
</div>
<script type="module">
import { ClaraMaps } from "https://sdk.clara.city/maps/v1/clara-maps.js";
const map = new maplibregl.Map({
container: "map", center: [12.2012, 44.4134], zoom: 14.5, pitch: 45,
style: {
version: 8,
sources: { osm: { type: "raster", tileSize: 256,
tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
attribution: "© OpenStreetMap contributors" } },
layers: [{ id: "osm", type: "raster", source: "osm" }],
},
});
await new Promise((resolve) => map.on("load", resolve));
const clara = new ClaraMaps({ token: "clara_pk_YOUR_KEY", city: "ravenna" });
// Ask once which hours exist. Each entry knows which layers it has.
const hours = await clara.timestamps();
document.getElementById("time").max = hours.length - 1;
// Buildings first, so every data layer can be inserted underneath them.
const buildingsId = await clara.addLayer(map, { layer: "buildings" });
// Declared before show() runs, not after: a const is in its temporal dead
// zone until the line that defines it, so calling show() first would throw
// on the very first note().
const note = (text) => (document.getElementById("note").textContent = text);
let layer = "thermal";
let hour = hours[0];
await show();
async function show() {
// Each timestamp entry lists its own layers, so we can tell in advance
// whether there is anything to draw.
if (!hour.layers.includes(layer)) {
clara.removeLayer(map, layer);
note(`No ${layer} data at this hour`);
} else {
// addLayer both adds and moves, so it covers switching either control.
// before: keeps the cells under the buildings, which are solid.
await clara.addLayer(map, { layer, timestamp: hour.timestamp_ms, before: buildingsId });
note("");
}
// A forecast hour is only readable if it says which day and which hour.
document.getElementById("day").textContent =
hour.date.toLocaleDateString([], { weekday: "short", day: "numeric", month: "short" });
document.getElementById("hour").textContent =
hour.date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
document.getElementById("kind").textContent =
hour.kind === "live" ? "live" : "forecast";
await drawLegend();
}
// The scale actually in use, so the key cannot disagree with the map. Both
// layers default to a class scale here; a gradient would want a colour bar
// instead, which is what the wind-legend example shows.
async function drawLegend() {
const scale = await clara.legend(layer);
// The scale's own name is its palette ("Standard"), which says nothing
// about what is drawn. The view it belongs to is the readable label.
const view = (await clara.views(layer)).find((v) => v.id === scale.view);
const name = view?.name ?? scale.name;
document.getElementById("scaleName").textContent =
scale.unit ? `${name} (${scale.unit})` : name;
document.getElementById("legend").innerHTML = scale.classes
.map((c) => `<div class="row"><span class="sw" style="background:${c.color}"></span>${c.label ?? ""}</div>`)
.join("");
}
document.getElementById("time").oninput = async (event) => {
hour = hours[Number(event.target.value)];
await show();
};
for (const radio of document.querySelectorAll("input[name=layer]")) {
radio.onchange = async () => {
clara.removeLayer(map, layer); // drop the previous one
layer = radio.value;
await show();
};
}
</script>
</body>
</html>
2 · Wind comfort with a legend
Draws the wind comfort classification and builds its legend from
legend(), so the two cannot disagree;
each swatch also carries its full condition as a tooltip. Shows a partial
paint override (opacity only, colours kept) and uses
layerId() to toggle a layer through
MapLibre directly.
Uses: legend · partial paint override ·
layerId · removeLayer
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Ravenna wind comfort with legend</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 system-ui, sans-serif; }
#map { position: absolute; inset: 0; }
#legend { position: absolute; right: 12px; bottom: 12px; z-index: 1;
background: #fff; padding: 10px 14px; border-radius: 8px;
box-shadow: 0 1px 8px rgba(0,0,0,.25); }
/* Not uppercased: CSS uppercase turns the micro sign into a Greek capital
mu, so "µg/m3" would read "MG/M3". */
#legend h4 { margin: 0 0 6px; font-size: 12px; font-weight: 600;
letter-spacing: .04em; color: #555; }
.row { display: flex; align-items: center; gap: 8px; }
.sw { width: 26px; height: 12px; border-radius: 2px; }
</style>
</head>
<body>
<div id="map"></div>
<div id="legend"><h4></h4><div id="scale"></div></div>
<script type="module">
import { ClaraMaps } from "https://sdk.clara.city/maps/v1/clara-maps.js";
const map = new maplibregl.Map({
container: "map", center: [12.2012, 44.4134], zoom: 15, pitch: 55,
style: {
version: 8,
sources: { osm: { type: "raster", tileSize: 256,
tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
attribution: "© OpenStreetMap contributors" } },
layers: [{ id: "osm", type: "raster", source: "osm" }],
},
});
await new Promise((resolve) => map.on("load", resolve));
const clara = new ClaraMaps({ token: "clara_pk_YOUR_KEY", city: "ravenna" });
// Default wind classification, drawn more opaquely than the 0.6 default.
// Overriding fill-opacity with a plain number also drops the gate that
// hides out-of-range values, so re-state it if you need it.
await clara.addLayer(map, {
layer: "wind",
paint: { "fill-opacity": 0.85 },
});
// Adding buildings last draws them above the wind layer, which is what
// you want: they are solid, so they should hide the cells they cover.
// To put wind on top instead, pass before: clara.layerId("buildings").
await clara.addLayer(map, { layer: "buildings" });
// Legend from the scale actually in use, so it always matches what is drawn.
const windScale = await clara.legend("wind");
document.querySelector("#legend h4").textContent = `Wind comfort (${windScale.unit})`;
const scale = document.getElementById("scale");
for (const { label, color, condition } of windScale.classes) {
const row = document.createElement("div");
row.className = "row";
row.title = condition; // full rule on hover
row.innerHTML = `<div class="sw" style="background:${color}"></div>${label}`;
scale.appendChild(row);
}
</script>
</body>
</html>
3 · Air quality viewer
All four air views on one page, using Brussels. A radio group switches
between the BelAQI index and each pollutant's concentration; because every
pollutant rides in the same tiles, each switch is a repaint of the mounted
layer, not a re-fetch. The legend redraws from
legend(), which returns whichever
scale is now in use, and clicking reads all three concentrations.
Uses: setView ·
legend · valuesAt
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Brussels air quality viewer</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 system-ui, sans-serif; }
#map { position: absolute; inset: 0; }
.panel { position: absolute; z-index: 1; background: rgba(255,255,255,.95);
padding: 10px 14px; border-radius: 8px;
box-shadow: 0 1px 8px rgba(0,0,0,.25); }
#views { top: 12px; left: 12px; }
#views label { display: block; }
#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 h4 { margin: 0 0 6px; font-size: 12px; font-weight: 600;
letter-spacing: .04em; color: #555; }
.row { display: flex; align-items: center; gap: 8px; margin-bottom: 2px; }
.sw { width: 22px; height: 12px; border-radius: 2px; border: 1px solid rgba(0,0,0,.25); }
.rng { color: #777; margin-left: auto; padding-left: 12px; font-variant-numeric: tabular-nums; }
</style>
</head>
<body>
<div id="map"></div>
<div class="panel" id="views">
<strong>Air quality</strong>
<label><input type="radio" name="view" value="index" checked> BelAQI index</label>
<label><input type="radio" name="view" value="no2"> NO2 concentration</label>
<label><input type="radio" name="view" value="pm25"> PM2.5 concentration</label>
<label><input type="radio" name="view" value="pm10"> PM10 concentration</label>
</div>
<div class="panel" id="legend"><h4></h4><div id="classes"></div></div>
<script type="module">
import { ClaraMaps } from "https://sdk.clara.city/maps/v1/clara-maps.js";
const map = new maplibregl.Map({
container: "map", center: [4.3517, 50.8466], zoom: 15, pitch: 50,
style: {
version: 8,
sources: { osm: { type: "raster", tileSize: 256,
tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
attribution: "© OpenStreetMap contributors" } },
layers: [{ id: "osm", type: "raster", source: "osm" }],
},
});
await new Promise((resolve) => map.on("load", resolve));
const clara = new ClaraMaps({ token: "clara_pk_YOUR_KEY", city: "brussels" });
// Mount once in the default index view. Every later switch repaints the
// same source in place: no re-fetch, no flicker.
await clara.addLayer(map, { layer: "air" });
// Buildings last, so they sit above the air cells and hide what is under
// them. They are drawn solid, which is why the click handler below refuses
// to report a cell you cannot actually see.
await clara.addLayer(map, { layer: "buildings" });
await drawLegend();
for (const radio of document.querySelectorAll("input[name=view]")) {
radio.onchange = async () => {
await clara.setView(map, "air", radio.value); // index, no2, pm25, pm10
await drawLegend();
};
}
// Whichever scale is now in use. The index view has no bounds on its
// classes; a concentration view carries the band each colour covers.
async function drawLegend() {
const scale = await clara.legend("air");
document.querySelector("#legend h4").textContent =
scale.unit ? `${scale.name} (${scale.unit})` : scale.name;
document.getElementById("classes").innerHTML = scale.classes
.map((c) => `
<div class="row">
<span class="sw" style="background:${c.color}"></span>
<span>${c.value} ${c.label}</span>
<span class="rng">${
c.bounds ? (c.bounds.to === null ? "above " + c.bounds.from : "up to " + c.bounds.to) : ""
}</span>
</div>`)
.join("");
}
// All three concentrations under a click, whatever is being drawn.
const fields = ClaraMaps.fields("air");
map.on("click", (event) => {
// A cell under a building is not on screen, so clicking the roof must
// not report it.
const roof = clara.layerId("buildings");
if (map.queryRenderedFeatures(event.point, { layers: [roof] }).length) return;
const values = clara.valuesAt(map, [event.lngLat.lng, event.lngLat.lat], { layer: "air" });
if (!values?.air) return;
const html = Object.entries(values.air)
.map(([field, value]) => {
const { label = field, unit } = fields[field] ?? {};
return `<b>${label}</b>: ${value.toFixed(1)} ${unit}`;
})
.join("<br>");
new maplibregl.Popup().setLngLat(event.lngLat).setHTML(html).addTo(map);
});
</script>
</body>
</html>
4 · Live conditions dashboard
A hover readout of every field from both comfort layers at once,
provenance showing which hour is on screen and when its URLs lapse,
automatic hourly refresh, typed error handling, and teardown. Note the
invisible wind layer, added with fill-opacity: 0
purely so its values can be read without being drawn.
Uses: valuesAt · liveTimestamp ·
signatureExpiry · startAutoRefresh · before ·
ClaraError · destroy
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Ravenna live conditions dashboard</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 system-ui, sans-serif; }
#map { position: absolute; inset: 0; }
#hud { position: absolute; top: 12px; left: 12px; z-index: 1; min-width: 260px;
background: rgba(255,255,255,.95); padding: 12px 16px; border-radius: 8px;
box-shadow: 0 1px 8px rgba(0,0,0,.25); }
#hud dl { display: grid; grid-template-columns: auto auto; gap: 2px 12px; margin: 6px 0 0; }
#hud dt { color: #666; } #hud dd { margin: 0; font-variant-numeric: tabular-nums; }
#meta { margin-top: 10px; padding-top: 8px; border-top: 1px solid #eee;
font-size: 12px; color: #666; }
#err { color: #b3261e; }
</style>
</head>
<body>
<div id="map"></div>
<div id="hud">
<strong>Hover the map</strong>
<dl id="readout"></dl>
<div id="meta"></div>
<div id="err"></div>
</div>
<script type="module">
import { ClaraMaps, ClaraError } from "https://sdk.clara.city/maps/v1/clara-maps.js";
const map = new maplibregl.Map({
container: "map", center: [12.2012, 44.4134], zoom: 15, pitch: 45,
style: {
version: 8,
sources: { osm: { type: "raster", tileSize: 256,
tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
attribution: "© OpenStreetMap contributors" } },
layers: [{ id: "osm", type: "raster", source: "osm" }],
},
});
await new Promise((resolve) => map.on("load", resolve));
const clara = new ClaraMaps({ token: "clara_pk_YOUR_KEY", city: "ravenna" });
try {
await clara.addLayer(map, { layer: "buildings" });
// before: puts the comfort layer underneath the buildings
await clara.addLayer(map, {
layer: "thermal",
before: clara.layerId("buildings"),
});
await clara.addLayer(map, { layer: "wind", paint: { "fill-opacity": 0 } });
} catch (err) {
fail(err);
}
// Outline of whichever cell is under the cursor. A plain GeoJSON source
// fed from the hover handler: one black ring, drawn above everything so
// it stays visible against any colour underneath.
const EMPTY = { type: "FeatureCollection", features: [] };
map.addSource("hovered", { type: "geojson", data: EMPTY });
map.addLayer({
id: "hovered", type: "line", source: "hovered",
paint: { "line-color": "#000", "line-width": 2 },
});
// Live readout of every field under the cursor, both layers at once.
map.on("mousemove", (event) => {
const dl = document.getElementById("readout");
dl.innerHTML = "";
// Buildings are solid, so a cell under one is not on screen. Nothing to
// read, and nothing to outline.
const onRoof = map.queryRenderedFeatures(event.point,
{ layers: [clara.layerId("buildings")] }).length > 0;
if (onRoof) {
map.getSource("hovered").setData(EMPTY);
return;
}
const [cell] = map.queryRenderedFeatures(event.point,
{ layers: [clara.layerId("thermal")] });
map.getSource("hovered").setData(
cell ? { type: "Feature", geometry: cell.geometry, properties: {} } : EMPTY);
// Buildings carry a height, which is geometry rather than a reading.
const values = clara.valuesAt(map, [event.lngLat.lng, event.lngLat.lat]);
if (!values) return;
for (const [layer, props] of Object.entries(values)) {
if (layer === "buildings") continue;
for (const [field, value] of Object.entries(props)) {
// label is what to show a person; field is the model's own name.
const { label = field, unit = "" } = ClaraMaps.fields(layer)[field] ?? {};
dl.insertAdjacentHTML("beforeend",
`<dt>${label}</dt><dd>${value.toFixed(2)} ${unit}</dd>`);
}
}
});
// Leaving the canvas should clear the outline, not leave it stranded.
map.on("mouseout", () => map.getSource("hovered").setData(EMPTY));
// Provenance: which hour is on screen, and how long its URLs stay valid.
async function updateMeta() {
const live = await clara.liveTimestamp();
const expiry = await clara.signatureExpiry();
document.getElementById("meta").innerHTML =
`Data hour: ${live.date.toLocaleString()}<br>` +
`URLs valid until: ${expiry?.toLocaleString() ?? "unknown"}`;
}
await updateMeta();
// Hourly data and signature renewal, both handled by one timer.
clara.startAutoRefresh(map);
setInterval(updateMeta, 60_000);
function fail(err) {
const box = document.getElementById("err");
if (err instanceof ClaraError && err.isExpired) {
clara.refresh(map).then(updateMeta); // recoverable: just re-fetch
} else if (err instanceof ClaraError) {
box.textContent = `${err.status} ${err.code}: ${err.message}`;
} else {
box.textContent = String(err);
}
}
// Release the timer and listeners if this view is torn down.
window.addEventListener("beforeunload", () => clara.destroy(map));
</script>
</body>
</html>