CLARA Maps SDK
v0.1.0 Targets API v1 · www.clara.city

CLARA Maps SDK

Add Ravenna's thermal comfort, pedestrian wind and 3D building layers to a MapLibre map, and read their values back.

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

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.

Where the key lives. Using the key directly in browser code is the simplest integration and is a supported way to work. Anyone who opens devtools can then read it and request catalogs of their own, so if that matters for your deployment, call the catalog from your own server instead and pass the signed URLs down to the page. The signed URLs are safe to expose either way — they expire after 24 hours and grant read access to one city’s tiles and nothing else.

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: "&copy; 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>
Serve the file over HTTP. Opened from disk as file://, the browser blocks the module import and you get a blank page with a CORS error.
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: "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().

LayerFieldUnitMeaning
utci
live + forecast
UTCI°C Universal Thermal Climate Index at pedestrian level. Styled by default.
velocity
live + forecast
Ucomfortm/s Pedestrian-level mean comfort wind speed. Styled by default.
Ugustm/s Pedestrian-level gust wind speed.
buildings
static
Heightm 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_…" });
OptionTypeDefaultDescription
tokenstringrequiredYour API token.
citystring"ravenna"Which city to read.
baseUrlstringAPI v1Override 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

NameTypeDefaultDescription
mapMaprequiredYour MapLibre or Mapbox map.
layerstringrequired"utci", "velocity" or "buildings". Anything else throws.
timestampnumber \| string \| Datelive hourAn hour from timestamps(). Omit for the most recent observed hour. Ignored for buildings.
beforestringInsert beneath this existing map layer id.
paintobjectPaint 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
// ]
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: ["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

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.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
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. 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));
The hours are unevenly spaced — gaps of 2, 4 and 12 hours all occur — 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.

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)ClassColour
−50 to −40Extreme cold#000033
−40 to −27Very strong cold#0000A6
−27 to −13Strong cold#1312FF
−13 to 0Moderate cold#0083F3
0 to 9Slight cold#00CCFF
9 to 26Comfortable#CBCC01
26 to 32Moderate heat#FFCC66
32 to 38Strong heat#FF8000
38 to 46Very strong heat#FF0000
46 to 60Extreme 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.

ConditionClassColour
Ucomfort < 4 and Ugust < 10Calm#375c4b
Ucomfort 4–6 and Ugust < 10Slightly windy#c86ebe
Ucomfort 6–8 and Ugust < 10Windy#1effff
Ucomfort > 8 or Ugust ≥ 10Very windy#fab92d
Ucomfort > 15 or Ugust > 15Danger#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);
  }
}
StatuscodeMeaning
401unauthorizedToken is missing or wrong.
401url_expiredTile URLs are past their 24-hour window. Call refresh().
403forbiddenToken is not valid for that city.
503upstream_unavailableData 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">&mdash;</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: "&copy; 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: "&copy; 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: "&copy; 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>