CLARA docs

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.

CDN  https://sdk.clara.city/maps/v1/clara-maps.js
Requires 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 v1 we 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, and v1 keeps 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" });
An API key is required to use the CLARA API or SDK. To request one, contact the CLARA team at [email protected].

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, naming clara_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.

Metric scope decides which layers you get. The catalog signs only the layers your key includes, so a key bought for wind alone receives a catalog with no 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.
Where the key lives. A publishable key directly in the page is the quick start and a supported way to work. The production pattern is a secret key on your own server: call the catalog from your backend and give the SDK that endpoint with 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: "&copy; 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>
Serve the file over HTTP. Opened from disk as 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.
The basemap above uses OpenStreetMap's public tile servers, which are fine for development but ask that production traffic go elsewhere. For anything you ship, point 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.

LayerFieldShown asUnitMeaning
thermal
live + forecast
UTCITemperature UTCI°C Universal Thermal Climate Index at pedestrian level. Styled by default.
wind
live + forecast
UcomfortWindm/s Pedestrian-level mean comfort wind speed. Styled by default.
UgustGustm/s Pedestrian-level gust wind speed.
air
live + forecast
CNO2µg/m3 NO2 concentration at pedestrian level.
C_PM25PM2.5µg/m3 PM2.5 concentration at pedestrian level.
C_PM10PM10µg/m3 PM10 concentration at pedestrian level.
buildings
static
HeightHeightm 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.

LayerViewReadsScales
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.

ClassConditionwind-nen8100wind-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.

Neither rainbow view reads the other field. A cell with a light mean wind and violent gusts paints calm blue on the 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" });
OptionTypeDefaultDescription
tokenstringoptionalYour API key. clara_pk_ in a page; clara_sk_ only on a server.
citystringrequiredWhich city to read, e.g. "ravenna". List them with GET /v1/cities?product=map.
catalogEndpointstringoptionalYour own backend endpoint that proxies the catalog call. When set, city is not needed: the endpoint embeds it.
catalogobjectoptionalA catalog your page already holds. Nothing is fetched until refresh().
baseUrlstringAPI v1Override 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

NameTypeDefaultDescription
mapMaprequiredYour MapLibre or Mapbox map.
layerstringrequired"thermal", "wind", "air" or "buildings". Anything else throws.
timestampnumber \| string \| Datelive hourAn hour from timestamps(). Omit for the most recent observed hour. Ignored for buildings.
beforestringoptionalInsert beneath this existing map layer id.
paintobjectoptionalPaint properties merged over the defaults.
viewstringlayer defaultWhat to plot. See Views and colour scales.
scalestringview defaultHow 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
// ]
FieldTypeDescription
timestampstringISO 8601, always UTC.
timestamp_msnumberThe same instant as epoch milliseconds. This is what you pass back.
dateDateReady to format for a label.
kindstring"live" for the observed hour, "forecast" for the rest.
layersstring[]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

NameTypeDefaultDescription
mapMaprequiredYour map.
lngLat[number, number]requiredGeographic position, or a MapLibre screen point.
layerstringall mountedRestrict 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
This reads what MapLibre has already drawn, so the point must be inside the current viewport and its tiles must have loaded. It cannot see off-screen positions. Call it after 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));
The hours are unevenly spaced, with gaps of 2, 4 and 12 hours all occurring, and their number varies between calls. Because you address them by timestamp rather than by position, that variation needs no handling: use the list 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);
  }
}
StatuscodeMeaning
401unauthorizedKey is missing, malformed or unknown.
401url_expiredTile URLs are past their signing window. Call refresh().
403forbiddenKey is valid but not entitled: disabled, expired, not scoped to that city, missing the map product, or carrying no metric scope at all.
404not_foundUnknown city.
503upstream_unavailableData 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: "&copy; 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: "&copy; 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: "&copy; 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: "&copy; 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>