CLARA Maps SDK
Add Ravenna's thermal comfort, pedestrian wind and 3D building layers to a MapLibre map, and read their values back.
https://cdn.clara.city/sdk/v1/clara-maps.jsRequires MapLibre GL JS 3 or later, which your page loads itself.
Every value the underlying API exposes is reachable through the SDK: the three layers, all five of their attributes, every available hour, and the metadata describing them. Hours are addressed by timestamp, never by position. The sections below list each method with what it takes and exactly what it gives back.
Authentication
Pass your API key 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_YOUR_TOKEN" });
Keys look like clara_ followed by 43 URL-safe characters. 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.
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. An OpenStreetMap basemap, the comfort layer drawn over it, and 3D buildings on top so the street pattern stays readable. A title says what is being shown and for which hour, a selector switches between thermal comfort and wind, the legend follows automatically, a checkbox toggles the layer off, and clicking anywhere reads the values underneath. Drop in your token and open it.
Two SDK details do most of the work here. before inserts the
comfort layer beneath the buildings, so the buildings are never hidden by it.
And ClaraMaps.legend() supplies the
legend, so the swatches always match what is drawn — switching layer
redraws it with no lookup table of your own.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Ravenna street-level comfort</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; }
.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; }
#head .when { font-weight: 600; color: #111; }
#legend { bottom: 12px; left: 12px; }
#legend h2 { margin: 0 0 8px; font-size: 11px; letter-spacing: .08em;
text-transform: uppercase; color: #666; }
.row { display: flex; align-items: center; gap: 8px; margin-bottom: 3px; }
.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; }
label { display: block; margin-top: 6px; }
select { width: 100%; padding: 4px; }
</style>
</head>
<body>
<div id="map"></div>
<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="utci">Thermal comfort</option>
<option value="velocity">Wind comfort</option>
</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">
import { ClaraMaps } from "https://cdn.clara.city/sdk/v1/clara-maps.js";
const ABOUT = {
utci: {
title: "Thermal comfort",
blurb: "Universal Thermal Climate Index — how hot or cold it actually " +
"feels at street level, accounting for sun, wind and humidity.",
},
velocity: {
title: "Wind comfort",
blurb: "Pedestrian-level wind conditions, classified from both the mean " +
"wind speed and the gusts.",
},
};
const map = new maplibregl.Map({
container: "map",
center: [12.2086, 44.4004],
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));
const clara = new ClaraMaps({ token: "clara_YOUR_TOKEN" });
// Buildings go on first so they sit at the top of the stack; the comfort
// layer is then inserted *beneath* them with `before`, leaving the
// basemap visible below and the buildings readable above.
const buildingsId = await clara.addLayer(map, { layer: "buildings" });
const live = await clara.liveTimestamp();
document.getElementById("when").textContent =
`Live conditions · ${live.date.toLocaleString()}`;
let current = "utci";
await show(current);
async function show(key) {
// Drop whichever comfort layer is currently mounted, then add the new
// one underneath the buildings.
for (const other of ["utci", "velocity"]) clara.removeLayer(map, other);
await clara.addLayer(map, { layer: key, before: buildingsId });
applyVisibility(key);
drawLegend(key);
document.getElementById("title").textContent = `Ravenna — ${ABOUT[key].title}`;
document.getElementById("blurb").textContent = ABOUT[key].blurb;
}
// The legend comes from the SDK, so it always describes what is drawn.
function drawLegend(key) {
const unit = ClaraMaps.fields(key)[ClaraMaps.layers[key].field].unit;
document.querySelector("#legend h2").textContent =
`${ABOUT[key].title}${unit ? ` (${unit})` : ""}`;
document.getElementById("classes").innerHTML = ClaraMaps.legend(key)
.map((c) => `
<div class="row" title="${c.condition ?? ""}">
<span class="sw" style="background:${c.color}"></span>
<span>${c.label}</span>
<span class="rng">${c.from !== undefined ? `${c.from} to ${c.to}` : ""}</span>
</div>`)
.join("");
}
function applyVisibility(key = current) {
const on = document.getElementById("visible").checked;
map.setLayoutProperty(clara.layerId(key), "visibility", on ? "visible" : "none");
document.getElementById("legend").style.display = on ? "" : "none";
}
document.getElementById("layer").onchange = async (event) => {
current = event.target.value;
await show(current);
};
document.getElementById("visible").onchange = () => applyVisibility();
// Click for the underlying values, whichever layer is showing.
map.on("click", (event) => {
const values = clara.valuesAt(map, [event.lngLat.lng, event.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 `<b>${field}</b>: ${value.toFixed(2)} ${unit}`;
}))
.join("<br>");
new maplibregl.Popup().setLngLat(event.lngLat).setHTML(html).addTo(map);
});
clara.startAutoRefresh(map);
</script>
</body>
</html>
file://, the
browser blocks the module import and you get a blank page with a CORS error.
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: "utci", before: "your-label-layer-id" });
Layers and fields
Three layers. Every attribute below is present on the rendered features, so
each can be styled with an expression or read with
valuesAt().
| Layer | Field | Unit | Meaning |
|---|---|---|---|
utcilive + forecast |
UTCI | °C | Universal Thermal Climate Index at pedestrian level. Styled by default. |
velocitylive + forecast |
Ucomfort | m/s | Pedestrian-level mean comfort wind speed. Styled by default. |
Ugust | m/s | Pedestrian-level gust wind speed. | |
buildingsstatic |
Height | m | Building height above ground. Drives the extrusion. |
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: "velocity",
paint: {
"fill-color": [
"interpolate", ["linear"], ["get", "Ugust"],
0, "#f7fbff", 1, "#6baed6", 2, "#08306b",
],
},
});
Interrogate the list at runtime with
ClaraMaps.fields(), which returns the same
units and descriptions shown above.
All three layers cover roughly 12.167, 44.384 to
12.234, 44.441 at zoom levels 14–16. Outside that range
nothing renders.
Constructor
const clara = new ClaraMaps({ token: "clara_…" });
| Option | Type | Default | Description |
|---|---|---|---|
token | string | required | Your API token. |
city | string | "ravenna" | Which city to read. |
baseUrl | string | API v1 | Override for staging environments. |
Constructing without a token throws immediately rather than failing later on the first request. See Authentication for how to obtain one.
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 | "utci", "velocity" 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 | — | Insert beneath this existing map layer id. |
paint | object | — | Paint properties merged over the defaults. |
Returns string | null
await clara.addLayer(map, { layer: "utci" });
// "clara-utci-render" the id of the map layer it created
await clara.addLayer(map, { layer: "velocity", timestamp: 1787162400000 });
// null that layer has no data at that hour
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: "utci", 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 is occasionally missing for one hour. 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.
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, "velocity");
ASYNCclara.setTimestamp(map, layer, timestamp)
Move a layer to a different hour, preserving whatever before
and paint it was added with. Equivalent to calling
addLayer() again with those options
carried over.
Returns string | null
await clara.setTimestamp(map, "utci", 1787162400000);
// "clara-utci-render"
await clara.setTimestamp(map, "velocity", "2026-08-19T18:00:00Z");
// "clara-velocity-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: ["utci", "velocity"]
// },
// {
// timestamp: "2026-08-19T18:00:00Z",
// timestamp_ms: 1787162400000,
// date: Date,
// kind: "forecast",
// layers: ["utci", "velocity"]
// }
// // … typically five 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: ["utci", "velocity"] }
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();
// ["utci", "velocity", "buildings"] the live hour
await clara.availableLayers(1787162400000);
// ["utci", "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.2086, 44.4004]);
// {
// utci: { UTCI: 31.2 },
// velocity: { Ucomfort: 0.41, Ugust: 0.88 }
// }
clara.valuesAt(map, [12.2086, 44.4004], { layer: "velocity" });
// { velocity: { 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. Cached for 60 seconds;
concurrent calls collapse into a single request. Pass
{ force: true } to bypass the cache.
Returns Catalog
{
id: "ravenna",
name: "Ravenna",
generated_at: "2026-08-19T14:03:11Z",
static: {
buildings: { tilejson: "https://api.clara.city/v1/map/ravenna/buildings.json?exp=…&sig=…" }
},
live: {
timestamp: "2026-08-19T14:00:00Z",
timestamp_ms: 1787148000000,
layers: { utci: { tilejson: "…" }, velocity: { tilejson: "…" } }
},
forecast: [
{ timestamp: "2026-08-19T18:00:00Z", timestamp_ms: 1787162400000, layers: { … } },
…
]
}
clara.invalidate() drops the cached copy without fetching a
replacement.
ASYNCclara.signatureExpiry()
When the tile URLs currently in use stop working. They are issued with a 24-hour life.
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 tile URLs
expire after 24 hours. 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.
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("utci"); // "clara-utci"
clara.layerId("utci"); // "clara-utci-render"
map.setLayoutProperty(clara.layerId("utci"), "visibility", "none");
map.moveLayer(clara.layerId("buildings"), clara.layerId("utci"));
STATICClaraMaps.layers
STATICClaraMaps.fields(layer)
STATICClaraMaps.legend(layer)
Metadata about the layers themselves, without needing a token or a network call. Useful for building legends and popups that cannot drift out of step with what is drawn.
Returns object
ClaraMaps.layers.utci;
// {
// sourceLayer: "UTCI",
// type: "fill",
// field: "UTCI", the field the default paint styles on
// fields: { UTCI: {…} },
// paint: { "fill-color": [...], "fill-opacity": 0.75 }
// }
ClaraMaps.fields("velocity");
// {
// Ucomfort: { unit: "m/s", description: "Pedestrian-level mean comfort wind speed" },
// Ugust: { unit: "m/s", description: "Pedestrian-level gust wind speed" }
// }
ClaraMaps.legend("utci");
// [ { label: "Extreme cold", from: -50, to: -40, color: "#000033" }, … ]
ClaraMaps.legend("velocity");
// [ { label: "Calm", color: "#375c4b", condition: "Ucomfort < 4 and Ugust < 10" }, … ]
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, "utci", 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, "utci", 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 ["utci", "velocity"]) {
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, "utci", 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.
Colours
The default styling is the same classification the CLARA app uses, so a map built with this SDK matches one built in the app. Both layers are drawn as discrete classes rather than a continuous gradient, at 60 % opacity, with out-of-range and sentinel values hidden rather than painted.
Thermal comfort
The standard UTCI thermal stress categories. Bounds are exclusive at the lower end and inclusive at the upper.
| Range (°C) | Class | Colour |
|---|---|---|
| −50 to −40 | Extreme cold | #000033 |
| −40 to −27 | Very strong cold | #0000A6 |
| −27 to −13 | Strong cold | #1312FF |
| −13 to 0 | Moderate cold | #0083F3 |
| 0 to 9 | Slight cold | #00CCFF |
| 9 to 26 | Comfortable | #CBCC01 |
| 26 to 32 | Moderate heat | #FFCC66 |
| 32 to 38 | Strong heat | #FF8000 |
| 38 to 46 | Very strong heat | #FF0000 |
| 46 to 60 | Extreme heat | #800000 |
Wind comfort
Wind classes depend on both Ucomfort and
Ugust — a location with a modest mean speed but strong gusts
is classified by the gusts. Conditions are evaluated in order and the first
match wins, so the more severe classes take precedence.
| Condition | Class | Colour |
|---|---|---|
Ucomfort < 4 and Ugust < 10 | Calm | #375c4b |
Ucomfort 4–6 and Ugust < 10 | Slightly windy | #c86ebe |
Ucomfort 6–8 and Ugust < 10 | Windy | #1effff |
Ucomfort > 8 or Ugust ≥ 10 | Very windy | #fab92d |
Ucomfort > 15 or Ugust > 15 | Danger | #de2d26 |
Drawing a legend
ClaraMaps.legend() returns exactly the classes the map draws,
in order, so a legend cannot fall out of step with the styling:
ClaraMaps.legend("utci");
// [ { label: "Extreme cold", from: -50, to: -40, color: "#000033" },
// { label: "Comfortable", from: 9, to: 26, color: "#CBCC01" }, … ]
ClaraMaps.legend("velocity");
// [ { label: "Calm", color: "#375c4b", condition: "Ucomfort < 4 and Ugust < 10" },
// { label: "Danger", color: "#de2d26", condition: "Ucomfort > 15 or Ugust > 15" }, … ]
for (const { label, color } of ClaraMaps.legend("utci")) {
legend.insertAdjacentHTML("beforeend",
`<div><span style="background:${color}"></span>${label}</div>`);
}
UTCI classes carry numeric from/to bounds; wind
classes carry a condition string instead, because they are
decided by two fields at once.
Overriding
Pass paint to use your own styling. Any
MapLibre expression
works, on any field from Layers and fields:
await clara.addLayer(map, {
layer: "velocity",
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.
Errors
import { ClaraMaps, ClaraError } from "https://cdn.clara.city/sdk/v1/clara-maps.js";
try {
await clara.addLayer(map, { layer: "utci" });
} 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);
}
}
| Status | code | Meaning |
|---|---|---|
| 401 | unauthorized | Token is missing or wrong. |
| 401 | url_expired | Tile URLs are past their 24-hour window. Call refresh(). |
| 403 | forbidden | Token is not valid for that city. |
| 503 | upstream_unavailable | Data service briefly unreachable. Retry. |
ClaraError carries status, code 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
Three complete pages, each exercising a different part of the SDK. Copy one, add your token, serve it over HTTP.
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%; }
#stamp { font-weight: 600; margin-top: 4px; }
#note { color: #b3261e; min-height: 1.2em; }
</style>
</head>
<body>
<div id="map"></div>
<div id="panel">
<label><input type="radio" name="layer" value="utci" checked> Thermal comfort</label>
<label><input type="radio" name="layer" value="velocity"> Wind</label>
<input type="range" id="time" min="0" max="0" value="0">
<div id="stamp">—</div>
<div id="note"></div>
</div>
<script type="module">
import { ClaraMaps } from "https://cdn.clara.city/sdk/v1/clara-maps.js";
const map = new maplibregl.Map({
container: "map", center: [12.2086, 44.4004], 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_YOUR_TOKEN" });
// Ask once which hours exist. Each entry knows which layers it has.
const hours = await clara.timestamps();
document.getElementById("time").max = hours.length - 1;
await clara.addLayer(map, { layer: "buildings" });
let layer = "utci";
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.
await clara.addLayer(map, { layer, timestamp: hour.timestamp_ms });
note("");
}
document.getElementById("stamp").textContent =
`${hour.date.toLocaleString()} ${hour.kind === "live" ? "· live" : "· forecast"}`;
}
const note = (text) => (document.getElementById("note").textContent = text);
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 built-in wind classification and builds its legend from
ClaraMaps.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: ClaraMaps.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); }
#legend h4 { margin: 0 0 6px; font-size: 12px; text-transform: uppercase; color: #555; }
.row { display: flex; align-items: center; gap: 8px; }
.sw { width: 26px; height: 12px; border-radius: 2px; }
button { margin-top: 8px; width: 100%; }
</style>
</head>
<body>
<div id="map"></div>
<div id="legend"><h4></h4><div id="scale"></div>
<button id="toggle">Hide buildings</button>
</div>
<script type="module">
import { ClaraMaps } from "https://cdn.clara.city/sdk/v1/clara-maps.js";
const map = new maplibregl.Map({
container: "map", center: [12.2086, 44.4004], 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_YOUR_TOKEN" });
// 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: "velocity",
paint: { "fill-opacity": 0.85 },
});
// Draw buildings above the wind layer by inserting nothing before it;
// to put wind *under* the buildings instead, pass before: buildingsId.
const buildingsId = await clara.addLayer(map, { layer: "buildings" });
// Legend straight from the SDK, so it always matches what is drawn.
document.querySelector("#legend h4").textContent = "Wind comfort";
const scale = document.getElementById("scale");
for (const { label, color, condition } of ClaraMaps.legend("velocity")) {
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);
}
// layerId() gives the map id the SDK used, for direct MapLibre calls.
let visible = true;
document.getElementById("toggle").onclick = () => {
visible = !visible;
map.setLayoutProperty(clara.layerId("buildings"), "visibility",
visible ? "visible" : "none");
document.getElementById("toggle").textContent =
visible ? "Hide buildings" : "Show buildings";
};
</script>
</body>
</html>
3 · Live conditions dashboard
A hover readout of every field from both 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
velocity 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://cdn.clara.city/sdk/v1/clara-maps.js";
const map = new maplibregl.Map({
container: "map", center: [12.2086, 44.4004], 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_YOUR_TOKEN" });
try {
await clara.addLayer(map, { layer: "buildings" });
// before: puts the comfort layer underneath the buildings
await clara.addLayer(map, {
layer: "utci",
before: clara.layerId("buildings"),
});
await clara.addLayer(map, { layer: "velocity", paint: { "fill-opacity": 0 } });
} catch (err) {
fail(err);
}
// Live readout of every field under the cursor, both layers at once.
map.on("mousemove", (event) => {
const values = clara.valuesAt(map, [event.lngLat.lng, event.lngLat.lat]);
const dl = document.getElementById("readout");
dl.innerHTML = "";
if (!values) return;
for (const [layer, props] of Object.entries(values)) {
for (const [field, value] of Object.entries(props)) {
const { unit = "" } = ClaraMaps.fields(layer)[field] ?? {};
dl.insertAdjacentHTML("beforeend",
`<dt>${field}</dt><dd>${value.toFixed(2)} ${unit}</dd>`);
}
}
});
// 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>