{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "urban-noise-map",
  "title": "Urban Noise Map",
  "description": "A page that answers how loud a street is, day and night, in Calgary, Edmonton, Vancouver, Toronto, Montréal and Québec City: the modelled noise overlay with a legend, a Day (Lden) / Night (Lnight) switch that recolours the map, and, for a spot you click or an address you search, a card with its level against the WHO guideline (53 dB by day, 45 dB at night), its score, its main source and a bar per source. A place outside the six cities is said to be not covered, with the cities to jump to. Credited to OpenStreetMap under the ODbL and to the PickYourPlace noise model; the levels are modelled, not measured. Needs the Urban Noise add-on; without it the page says why and shows the basemap. Calls /noise/point per spot and /geocode/autocomplete as you type, after one add-on check.",
  "dependencies": [
    "@unmap/sdk"
  ],
  "registryDependencies": [
    "https://registry.unmap.dev/r/map.json",
    "https://registry.unmap.dev/r/map-context.json",
    "https://registry.unmap.dev/r/map-layers.json",
    "https://registry.unmap.dev/r/geocoder.json",
    "card",
    "toggle-group",
    "badge",
    "button",
    "alert",
    "skeleton",
    "spinner"
  ],
  "files": [
    {
      "path": "registry/blocks/urban-noise-map/page.tsx",
      "content": "/**\n * /urban-noise-map: the urban noise block as a Next.js App Router page, full-bleed (the block\n * keeps its own gutter). The key comes from NEXT_PUBLIC_UNMAP_KEY in .env.local; installing the\n * block added the empty line, paste your key. The noise data needs the Urban Noise add-on; without\n * it the page says why.\n */\nimport { UrbanNoiseMap } from \"@/components/ui/urban-noise-map\";\n\nexport const metadata = { title: \"Check how loud a street is\" };\n\nexport default function UrbanNoiseMapPage() {\n  return (\n    <main className=\"h-dvh\">\n      <UrbanNoiseMap apiKey={process.env.NEXT_PUBLIC_UNMAP_KEY ?? \"\"} className=\"h-full\" />\n    </main>\n  );\n}\n",
      "type": "registry:page",
      "target": "app/urban-noise-map/page.tsx"
    },
    {
      "path": "registry/blocks/urban-noise-map/urban-noise-map.tsx",
      "content": "\"use client\";\n\n/**\n * <UrbanNoiseMap> - how loud a street is, day and night, in six Canadian cities: the modelled\n * noise overlay on a quiet basemap, a Day / Night switch that recolours the whole map from the\n * day-evening-night level (Lden) to the night level (Lnight), and, for a spot you click or an\n * address you search, a card with that spot's level against the WHO 2018 guideline (53 dB Lden by\n * day, 45 dB Lnight at night), its WHO-anchored score and a bar per noise source. A full-bleed map\n * with a floating panel, a bottom sheet on a phone.\n *\n * Coverage is Calgary, Edmonton, Vancouver, Toronto, Montréal and Québec City. A spot outside them\n * gets a \"not covered\" answer with the six cities to jump to, never a number. A spot with no\n * modelled source in range says so, never 0 dB and never \"quiet\".\n *\n * The overlay and the lookup need the Urban Noise add-on. The block asks the gateway once whether\n * your key holds it and composes the overlay only if it does: a refused overlay would make the\n * whole style request fail. Without it the map still renders and the panel says why.\n *\n * Metering: every authenticated request is one call. The block makes the add-on probe (one\n * `GET /layers/urban-noise/22/0/0`), then the map's own style and tile requests. Each map click or\n * chosen search result makes one `GET /noise/point`; the search box makes one\n * `GET /geocode/autocomplete` per pause in typing. Switching Day and Night makes no call: both\n * levels are in the tiles already.\n *\n * The levels are modelled, not measured: a screening estimate, not a compliance assessment. They\n * are computed from OpenStreetMap, so the data is ODbL; the panel credits OpenStreetMap and the\n * noise model (PickYourPlace) at all times, and the map attribution carries the same credit.\n *\n * Composes the map, map-layers and geocoder items on your shadcn Card, ToggleGroup, Badge, Alert,\n * Button, Skeleton and Spinner. Installed by `npx shadcn add` with its page at\n * app/urban-noise-map/page.tsx. Edit freely.\n */\n\nimport { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from \"react\";\nimport { Noise, type NoisePoint } from \"@unmap/sdk\";\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { Geocoder } from \"@/components/ui/geocoder\";\nimport { Map } from \"@/components/ui/map\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { ToggleGroup, ToggleGroupItem } from \"@/components/ui/toggle-group\";\nimport { useMap, useMapGeneration, useMapReady } from \"@/lib/map-context\";\nimport { HELPER_KEY, accentColor, tokenColor } from \"@/lib/map-layers\";\n\n// --- urban-noise-map helpers (kept identical across both lanes) ---\nexport type NoiseLang = \"en\" | \"fr\";\n\n/** Which level the map is coloured by: the day-evening-night level (Lden) or the night level (Lnight). */\nexport type NoisePeriod = \"day\" | \"night\";\n\n/** The block's own map id, so a map elsewhere on the page never answers for it. */\nexport const NOISE_MAP_ID = \"urban-noise-map\";\n\n/** Where a reader gets an unmap key: the no-key state's first action. */\nexport const KEY_URL = \"https://unmap.dev/dashboard\";\n\n/** Where the map opens: Calgary, at the zoom its noise reads street by street. */\nexport const OPENING_VIEW: { center: [number, number]; zoom: number } = { center: [-114.07, 51.045], zoom: 12 };\n\n/** The catalog overlay the block composes, once the key is known to hold the add-on. */\nexport const NOISE_OVERLAYS: string[] = [\"urban-noise\"];\n\n/** The fill the `urban-noise` overlay draws (apps/gateway/src/overlays.ts). */\nexport const NOISE_FILL_LAYER = \"overlay_urban-noise_fill\";\n\n/** The tile property each period colours by. */\nexport const PERIOD_FIELD: Record<NoisePeriod, \"lden\" | \"lnight\"> = { day: \"lden\", night: \"lnight\" };\n\n/** The WHO 2018 Environmental Noise Guidelines for road traffic: 53 dB Lden by day, 45 dB Lnight at night. */\nexport const WHO_DB: Record<NoisePeriod, number> = { day: 53, night: 45 };\n\n/**\n * Where each period's colour steps, in dB: five classes, the second boundary always the WHO\n * guideline, so every warm class on the map is above it. Day: below 45, 45 to 53, 53 to 60, 60 to\n * 65, 65 and up. Night: below 40, 40 to 45, 45 to 50, 50 to 55, 55 and up.\n */\nexport const PERIOD_STEPS: Record<NoisePeriod, readonly [number, number, number, number]> = {\n  day: [45, 53, 60, 65],\n  night: [40, 45, 50, 55],\n};\n\n/** The range every bar in the card is drawn on, in dB: a quiet suburb to a highway shoulder. */\nexport const GAUGE_DB: { min: number; max: number } = { min: 30, max: 80 };\n\n/** The zoom a looked-up point is shown at: the 25 m hexagons draw from 15. */\nexport const POINT_ZOOM = 16;\n\n/** How long the map fades out and back when the period switches. */\nexport const PERIOD_FADE_MS = 180;\n\n/** The noise sources the model sums, in the order the card lists them. */\nexport const NOISE_SOURCES = [\"road\", \"rail\", \"aircraft\", \"commercial\"] as const;\nexport type NoiseSourceKey = (typeof NOISE_SOURCES)[number];\n\n/** A city Urban Noise covers. `bbox` is [west, south, east, north]. */\nexport interface NoiseCityRef {\n  slug: string;\n  name: { en: string; fr: string };\n  bbox: [number, number, number, number];\n}\n\n/** The six covered cities, from the release manifest, for the \"start in a city\" buttons. */\nexport const NOISE_CITIES: readonly NoiseCityRef[] = [\n  { slug: \"calgary\", name: { en: \"Calgary\", fr: \"Calgary\" }, bbox: [-114.334, 50.832, -113.856, 51.219] },\n  { slug: \"edmonton\", name: { en: \"Edmonton\", fr: \"Edmonton\" }, bbox: [-113.735, 53.371, -113.235, 53.719] },\n  { slug: \"vancouver\", name: { en: \"Vancouver\", fr: \"Vancouver\" }, bbox: [-123.293, 49.092, -122.987, 49.328] },\n  { slug: \"toronto\", name: { en: \"Toronto\", fr: \"Toronto\" }, bbox: [-79.663, 43.571, -79.107, 43.869] },\n  { slug: \"montreal\", name: { en: \"Montréal\", fr: \"Montréal\" }, bbox: [-73.984, 45.381, -73.446, 45.729] },\n  { slug: \"quebec-city\", name: { en: \"Québec City\", fr: \"Québec\" }, bbox: [-71.585, 46.702, -71.106, 47.007] },\n];\n\nexport const NOISE_COPY = {\n  en: {\n    title: \"How loud is it here?\",\n    intro: \"Click the map or search an address for its modelled noise, day and night.\",\n    introTouch: \"Tap the map or search an address for its modelled noise, day and night.\",\n    checking: \"Checking this key's add-ons...\",\n    search: \"Search an address\",\n    period: \"Colour the map by\",\n    periods: { day: \"Day\", night: \"Night\" },\n    metrics: { day: \"Lden\", night: \"Lnight\" },\n    startIn: \"Or start in a city\",\n    goTo: (city: string) => `Go to ${city}`,\n    loading: \"Looking up this spot...\",\n    droppedPin: \"Dropped pin\",\n    clear: \"Clear\",\n    collapse: \"Hide\",\n    expand: \"Show\",\n    level: { day: \"Day level, Lden\", night: \"Night level, Lnight\" },\n    above: { day: \"Above the WHO day guideline\", night: \"Above the WHO night guideline\" },\n    within: { day: \"Within the WHO day guideline\", night: \"Within the WHO night guideline\" },\n    guideline: (db: string) => `Guideline ${db}`,\n    versus: (above: boolean | null, db: string) => (above === null ? `WHO ${db}` : above ? `Above WHO ${db}` : `Within WHO ${db}`),\n    score: \"Score\",\n    outOf: \"of 100\",\n    labels: { Quiet: \"Quiet\", Moderate: \"Moderate\", Noisy: \"Noisy\", \"Very noisy\": \"Very noisy\", Severe: \"Severe\" } as Record<string, string>,\n    bySource: \"By source\",\n    main: \"Main source\",\n    mainIs: (source: string) => `Main source: ${source}`,\n    notModelled: \"Not modelled here\",\n    sources: { road: \"Road traffic\", rail: \"Rail\", aircraft: \"Aircraft\", commercial: \"Commercial\" },\n    noSourceTitle: \"No modelled noise source here\",\n    noSourceBody:\n      \"No road, rail, aircraft or commercial source is modelled within range of this spot. That is not the same as quiet: the model has nothing to say here.\",\n    outsideTitle: \"This place is not covered\",\n    outsideBody: \"Urban Noise covers six cities. Pick one to look there.\",\n    unavailableTitle: \"Urban noise is not available yet\",\n    unavailableBody: \"The gateway is not serving the noise data yet, so the map shows no overlay. Try again later.\",\n    deniedTitle: {\n      addon_required: \"Noise levels need the Urban Noise add-on\",\n      dev_only: \"Urban noise is free for development only\",\n      usage_limit_reached: \"This month's development allowance is used\",\n    },\n    deniedBody: {\n      addon_required: \"This key does not hold the Urban Noise add-on, so the map shows the basemap without noise levels and lookups are off.\",\n      dev_only: \"On the dev plan, urban noise works from localhost. From this origin it needs a paid plan with the Urban Noise add-on.\",\n      usage_limit_reached: \"The dev plan's premium calls for this month are used. Noise levels come back next month, or now with a paid plan.\",\n    },\n    unknownTitle: \"Could not check the Urban Noise add-on\",\n    unknownBody: (status: number) =>\n      status === 401\n        ? \"The gateway refused this key. Check the key and its allowed origins.\"\n        : `The gateway answered ${status === 0 ? \"nothing (a network error)\" : `HTTP ${status}`}, so the noise overlay is off.`,\n    busy: \"Too many requests. Try again in a moment.\",\n    keyRefused: \"The gateway refused this key. Check it and try again.\",\n    failed: \"The lookup failed. Try again.\",\n    retry: \"Try again\",\n    pricing: \"Plans and add-ons\",\n    pricingUrl: \"https://unmap.dev/pricing\",\n    legend: \"Legend\",\n    legendTitle: { day: \"Day-evening-night level, Lden\", night: \"Night level, Lnight, 23:00 to 07:00\" },\n    below: (db: string) => `Below ${db}`,\n    range: (from: string, to: string) => `${from} to ${to}`,\n    andUp: (db: string) => `${db} and up`,\n    whoLine: (db: string) => `WHO guideline, ${db}`,\n    noData: \"No colour: no modelled source\",\n    modelled: \"Modelled, not measured: a screening estimate, not a compliance assessment.\",\n    attribution: \"© OpenStreetMap contributors\",\n    attributionUrl: \"https://www.openstreetmap.org/copyright\",\n    licence: \"ODbL\",\n    licenceUrl: \"https://opendatacommons.org/licenses/odbl/\",\n    model: \"noise model: PickYourPlace\",\n    say: (place: string, level: string, status: string, score: string) => [place, level, status, score].filter((part) => part !== \"\").join(\". \") + \".\",\n    sayScore: (score: string, label: string) => `Score ${score} of 100, ${label}`,\n    noKeyTitle: \"Add your unmap key to load the map.\",\n    getKey: \"Get a key\",\n    noKey:\n      'No unmap key. Pass apiKey. On Next.js, set NEXT_PUBLIC_UNMAP_KEY in .env.local. On Nuxt, set NUXT_PUBLIC_UNMAP_KEY in .env. If you installed with unmap add, nuxt.config already declares runtimeConfig: { public: { unmapKey: \"\" } }; otherwise add it there. Then restart the dev server.',\n  },\n  fr: {\n    title: \"Quel est le niveau de bruit ici ?\",\n    intro: \"Cliquez sur la carte ou cherchez une adresse pour voir son bruit modélisé, le jour et la nuit.\",\n    introTouch: \"Touchez la carte ou cherchez une adresse pour voir son bruit modélisé, le jour et la nuit.\",\n    checking: \"Vérification des modules complémentaires de cette clé...\",\n    search: \"Chercher une adresse\",\n    period: \"Colorer la carte selon\",\n    periods: { day: \"Jour\", night: \"Nuit\" },\n    metrics: { day: \"Lden\", night: \"Lnight\" },\n    startIn: \"Ou commencez dans une ville\",\n    goTo: (city: string) => `Aller à ${city}`,\n    loading: \"Recherche de ce point...\",\n    droppedPin: \"Point choisi\",\n    clear: \"Effacer\",\n    collapse: \"Masquer\",\n    expand: \"Afficher\",\n    level: { day: \"Niveau de jour, Lden\", night: \"Niveau de nuit, Lnight\" },\n    above: { day: \"Au-dessus de la ligne directrice de l'OMS pour le jour\", night: \"Au-dessus de la ligne directrice de l'OMS pour la nuit\" },\n    within: { day: \"Sous la ligne directrice de l'OMS pour le jour\", night: \"Sous la ligne directrice de l'OMS pour la nuit\" },\n    guideline: (db: string) => `Ligne directrice ${db}`,\n    versus: (above: boolean | null, db: string) => (above === null ? `OMS ${db}` : above ? `Au-dessus de l'OMS, ${db}` : `Sous l'OMS, ${db}`),\n    score: \"Score\",\n    outOf: \"sur 100\",\n    labels: { Quiet: \"Calme\", Moderate: \"Modéré\", Noisy: \"Bruyant\", \"Very noisy\": \"Très bruyant\", Severe: \"Sévère\" } as Record<string, string>,\n    bySource: \"Par source\",\n    main: \"Source principale\",\n    mainIs: (source: string) => `Source principale\\u00a0: ${source}`,\n    notModelled: \"Non modélisé ici\",\n    sources: { road: \"Circulation routière\", rail: \"Train\", aircraft: \"Avions\", commercial: \"Commerces\" },\n    noSourceTitle: \"Aucune source de bruit modélisée ici\",\n    noSourceBody:\n      \"Aucune source routière, ferroviaire, aérienne ou commerciale n'est modélisée à portée de ce point. Ce n'est pas la même chose que le calme : le modèle n'a rien à dire ici.\",\n    outsideTitle: \"Ce lieu n'est pas couvert\",\n    outsideBody: \"Bruit urbain couvre six villes. Choisissez-en une pour y regarder.\",\n    unavailableTitle: \"Le bruit urbain n'est pas encore offert\",\n    unavailableBody: \"La passerelle ne sert pas encore les données de bruit : la carte s'affiche sans la couche. Réessayez plus tard.\",\n    deniedTitle: {\n      addon_required: \"Les niveaux de bruit nécessitent le module complémentaire Bruit urbain\",\n      dev_only: \"Le bruit urbain est gratuit en développement seulement\",\n      usage_limit_reached: \"L'allocation de développement de ce mois est épuisée\",\n    },\n    deniedBody: {\n      addon_required:\n        \"Cette clé n'a pas le module complémentaire Bruit urbain : la carte montre le fond de carte sans les niveaux de bruit, et les recherches sont désactivées.\",\n      dev_only:\n        \"Avec le forfait de développement, le bruit urbain fonctionne depuis localhost. Depuis cette origine, il faut un forfait payant avec le module complémentaire Bruit urbain.\",\n      usage_limit_reached:\n        \"Les appels premium du forfait de développement sont épuisés pour ce mois. Les niveaux de bruit reviennent le mois prochain, ou dès maintenant avec un forfait payant.\",\n    },\n    unknownTitle: \"Impossible de vérifier le module complémentaire Bruit urbain\",\n    unknownBody: (status: number) =>\n      status === 401\n        ? \"La passerelle a refusé cette clé. Vérifiez la clé et ses origines autorisées.\"\n        : `La passerelle a répondu ${status === 0 ? \"rien (une erreur réseau)\" : `HTTP ${status}`} : la couche de bruit est désactivée.`,\n    busy: \"Trop de requêtes. Réessayez dans un instant.\",\n    keyRefused: \"La passerelle a refusé cette clé. Vérifiez-la et réessayez.\",\n    failed: \"La recherche a échoué. Réessayez.\",\n    retry: \"Réessayer\",\n    pricing: \"Forfaits et modules complémentaires\",\n    pricingUrl: \"https://unmap.dev/fr/pricing\",\n    legend: \"Légende\",\n    legendTitle: { day: \"Niveau jour-soir-nuit, Lden\", night: \"Niveau de nuit, Lnight, de 23 h à 7 h\" },\n    below: (db: string) => `Moins de ${db}`,\n    range: (from: string, to: string) => `De ${from} à ${to}`,\n    andUp: (db: string) => `${db} et plus`,\n    whoLine: (db: string) => `Ligne directrice de l'OMS, ${db}`,\n    noData: \"Sans couleur : aucune source modélisée\",\n    modelled: \"Modélisé, pas mesuré : une estimation de dépistage, pas une évaluation de conformité.\",\n    attribution: \"© les contributeurs d'OpenStreetMap\",\n    attributionUrl: \"https://www.openstreetmap.org/copyright\",\n    licence: \"ODbL\",\n    licenceUrl: \"https://opendatacommons.org/licenses/odbl/\",\n    model: \"modèle de bruit : PickYourPlace\",\n    say: (place: string, level: string, status: string, score: string) => [place, level, status, score].filter((part) => part !== \"\").join(\". \") + \".\",\n    sayScore: (score: string, label: string) => `Score de ${score} sur 100, ${label.toLowerCase()}`,\n    noKeyTitle: \"Ajoutez votre clé unmap pour charger la carte.\",\n    getKey: \"Obtenir une clé\",\n    noKey:\n      'Aucune clé unmap. Passez apiKey. Avec Next.js, définissez NEXT_PUBLIC_UNMAP_KEY dans .env.local. Avec Nuxt, définissez NUXT_PUBLIC_UNMAP_KEY dans .env. Si vous avez installé le bloc avec unmap add, nuxt.config déclare déjà runtimeConfig: { public: { unmapKey: \"\" } } ; sinon, ajoutez-le. Redémarrez ensuite le serveur de développement.',\n  },\n};\n\n/** The 403 codes that mean \"this key may not read the noise data\", and what unlocks it. */\nexport type NoiseDeniedCode = \"addon_required\" | \"dev_only\" | \"usage_limit_reached\";\n\n/** Whether this key may read the noise data, as the add-on probe answered. */\nexport type NoiseGate =\n  | { state: \"checking\" }\n  | { state: \"granted\" }\n  | { state: \"denied\"; code: NoiseDeniedCode; addon: string }\n  /** The gateway serves no noise release yet (404 or 503). */\n  | { state: \"unavailable\" }\n  | { state: \"unknown\"; status: number };\n\nfunction deniedCode(code: unknown): NoiseDeniedCode | null {\n  return code === \"addon_required\" || code === \"dev_only\" || code === \"usage_limit_reached\" ? code : null;\n}\n\n/**\n * Ask the gateway whether `apiKey` may read the `urban-noise` layer. One request, metered like any\n * other: a tile at zoom 22, past every archive, which the gateway answers 204 once the add-on check\n * has passed, 403 with a reason when it has not, and 404 while no release is served. Nothing is\n * drawn from it. A network failure or any other status is `unknown`; an aborted probe rejects\n * with the AbortError.\n */\nexport async function probeNoise(apiKey: string, gateway = \"https://api.unmap.dev\", signal?: AbortSignal): Promise<NoiseGate> {\n  const url = `${gateway.replace(/\\/+$/, \"\")}/layers/urban-noise/22/0/0?key=${encodeURIComponent(apiKey)}`;\n  let response: { ok: boolean; status: number; json(): Promise<unknown> };\n  try {\n    response = await fetch(url, { signal });\n  } catch (caught) {\n    if ((caught as { name?: unknown } | null)?.name === \"AbortError\") throw caught;\n    return { state: \"unknown\", status: 0 };\n  }\n  if (response.ok) return { state: \"granted\" };\n  if (response.status === 404 || response.status === 503) return { state: \"unavailable\" };\n  if (response.status === 403) {\n    const body = (await response.json().catch(() => null)) as { code?: unknown; addon?: unknown } | null;\n    const code = deniedCode(body?.code);\n    if (code) return { state: \"denied\", code, addon: typeof body?.addon === \"string\" ? body.addon : \"\" };\n  }\n  return { state: \"unknown\", status: response.status };\n}\n\n/** Why a lookup gave no level, by what the gateway said. */\nexport type NoiseFailure =\n  | { kind: \"outside\"; cities: NoiseCityRef[] }\n  | { kind: \"denied\"; code: NoiseDeniedCode; addon: string }\n  | { kind: \"unavailable\" }\n  | { kind: \"busy\" }\n  | { kind: \"keyRefused\" }\n  | { kind: \"failed\" };\n\n/** The failure a rejected `/noise/point` means. A 422 lists the covered cities; the six known ones stand in if it did not. */\nexport function failureFrom(caught: unknown): NoiseFailure {\n  const failure = typeof caught === \"object\" && caught !== null ? (caught as { status?: unknown; code?: unknown; addon?: unknown; cities?: unknown }) : {};\n  const status = typeof failure.status === \"number\" ? failure.status : 0;\n  if (status === 422 && failure.code === \"outside_coverage\") {\n    const cities = Array.isArray(failure.cities) && failure.cities.length > 0 ? (failure.cities as NoiseCityRef[]) : [...NOISE_CITIES];\n    return { kind: \"outside\", cities };\n  }\n  const code = deniedCode(failure.code);\n  if (status === 403 && code) return { kind: \"denied\", code, addon: typeof failure.addon === \"string\" ? failure.addon : \"\" };\n  if (status === 503) return { kind: \"unavailable\" };\n  if (status === 429) return { kind: \"busy\" };\n  if (status === 401 || status === 403) return { kind: \"keyRefused\" };\n  return { kind: \"failed\" };\n}\n\nconst locale = (lang: NoiseLang) => (lang === \"fr\" ? \"fr-CA\" : \"en-CA\");\n\n/** A level in whole decibels, the precision a modelled screening value carries: \"49 dB\". */\nexport function formatDb(db: number, lang: NoiseLang): string {\n  return `${new Intl.NumberFormat(locale(lang), { maximumFractionDigits: 0 }).format(db)} dB`;\n}\n\n/** A point as latitude and longitude to four decimals (about 10 m): \"51.0540, -114.0884\", \"51,0540 ; -114,0884\". */\nexport function formatCoords(at: [number, number], lang: NoiseLang): string {\n  const number = new Intl.NumberFormat(locale(lang), { minimumFractionDigits: 4, maximumFractionDigits: 4, useGrouping: false });\n  return `${number.format(at[1])}${lang === \"fr\" ? \" ; \" : \", \"}${number.format(at[0])}`;\n}\n\n/** A city's name in the reader's language, by its slug, among `cities`; the slug when it is not there. */\nexport function cityName(slug: string, lang: NoiseLang, cities: readonly NoiseCityRef[] = NOISE_CITIES): string {\n  const city = cities.find((entry) => entry.slug === slug) ?? NOISE_CITIES.find((entry) => entry.slug === slug);\n  return city ? city.name[lang] : slug;\n}\n\n/** Where `db` falls on the card's bar, 0 to 1. */\nexport function gaugeShare(db: number): number {\n  return Math.min(1, Math.max(0, (db - GAUGE_DB.min) / (GAUGE_DB.max - GAUGE_DB.min)));\n}\n\n/** The noise fields the card reads, as `/noise/point` returns them. */\nexport interface NoiseReading {\n  lden: number | null;\n  lnight: number | null;\n  score: number | null;\n  label: string | null;\n  exceeds_who: { day: boolean | null; night: boolean | null };\n  dominant_source: string | null;\n  sources_db: Partial<Record<NoiseSourceKey, number | null>>;\n}\n\n/** One period's level and where it stands against the WHO guideline; null `db` when nothing is modelled. */\nexport function periodReading(point: NoiseReading, period: NoisePeriod): { db: number | null; who: number; above: boolean | null } {\n  const db = period === \"day\" ? point.lden : point.lnight;\n  const who = WHO_DB[period];\n  if (db === null || !Number.isFinite(db)) return { db: null, who, above: null };\n  const flagged = point.exceeds_who[period];\n  return { db, who, above: typeof flagged === \"boolean\" ? flagged : db > who };\n}\n\n/** The card's per-source rows, in a fixed order: each source's level, its bar, and whether it is the main one. */\nexport function sourceRows(point: NoiseReading, lang: NoiseLang): Array<{ key: NoiseSourceKey; label: string; db: number | null; value: string; share: number; main: boolean }> {\n  const copy = NOISE_COPY[lang];\n  return NOISE_SOURCES.map((key) => {\n    const raw = point.sources_db[key];\n    const db = typeof raw === \"number\" && Number.isFinite(raw) ? raw : null;\n    return {\n      key,\n      label: copy.sources[key],\n      db,\n      value: db === null ? copy.notModelled : formatDb(db, lang),\n      share: db === null ? 0 : gaugeShare(db),\n      main: point.dominant_source === key && db !== null,\n    };\n  });\n}\n\n/** The score label in the reader's language: \"Moderate\", \"Modéré\". */\nexport function labelText(label: string | null, lang: NoiseLang): string {\n  return label ? (NOISE_COPY[lang].labels[label] ?? label) : \"\";\n}\n\n/** What the live region says once a spot is answered. */\nexport function announce(point: NoiseReading, place: string, period: NoisePeriod, lang: NoiseLang): string {\n  const copy = NOISE_COPY[lang];\n  const reading = periodReading(point, period);\n  if (reading.db === null) return `${place}. ${copy.noSourceTitle}.`;\n  const status = reading.above ? copy.above[period] : copy.within[period];\n  const score = point.score === null ? \"\" : copy.sayScore(String(point.score), labelText(point.label, lang));\n  return copy.say(place, `${copy.level[period]} ${formatDb(reading.db, lang)}`, status, score);\n}\n\n/** The five colours of the overlay's ramp, quiet to severe. */\nexport interface NoiseRamp {\n  quiet: string;\n  moderate: string;\n  noisy: string;\n  veryNoisy: string;\n  severe: string;\n}\n\n/**\n * The ramp the gateway's overlay is drawn with, read from its `fill-color`: a `step` on\n * `noise_score_who` from severe (below 20) to quiet (80 and up). The block recolours with the same\n * five colours, so it carries none of its own and follows the gateway if the ramp changes. Null\n * for any other expression, including one this block has already set.\n */\nexport function readRamp(expression: unknown): NoiseRamp | null {\n  if (!Array.isArray(expression) || expression[0] !== \"step\" || expression.length !== 11) return null;\n  const input = expression[1];\n  if (!Array.isArray(input) || input[0] !== \"get\" || input[1] !== \"noise_score_who\") return null;\n  const colours = [expression[2], expression[4], expression[6], expression[8], expression[10]];\n  if (!colours.every((colour) => typeof colour === \"string\" && colour !== \"\")) return null;\n  const [severe, veryNoisy, noisy, moderate, quiet] = colours as string[];\n  return { quiet: quiet!, moderate: moderate!, noisy: noisy!, veryNoisy: veryNoisy!, severe: severe! };\n}\n\n/** The fill colour and filter that draw `period`: stepped on its level at `PERIOD_STEPS`, a hexagon without that level left out. */\nexport function periodPaint(period: NoisePeriod, ramp: NoiseRamp): { color: unknown[]; filter: unknown[] } {\n  const field = PERIOD_FIELD[period];\n  const [a, b, c, d] = PERIOD_STEPS[period];\n  return {\n    color: [\"step\", [\"get\", field], ramp.quiet, a, ramp.moderate, b, ramp.noisy, c, ramp.veryNoisy, d, ramp.severe],\n    filter: [\"has\", field],\n  };\n}\n\n/** The legend's rows for `period`, quiet first, and which rows are above the WHO guideline. */\nexport function legendRows(period: NoisePeriod, ramp: NoiseRamp, lang: NoiseLang): Array<{ colour: string; label: string; above: boolean }> {\n  const copy = NOISE_COPY[lang];\n  const [a, b, c, d] = PERIOD_STEPS[period].map((db) => formatDb(db, lang));\n  const who = WHO_DB[period];\n  const steps = PERIOD_STEPS[period];\n  return [\n    { colour: ramp.quiet, label: copy.below(a!), above: false },\n    { colour: ramp.moderate, label: copy.range(a!, b!), above: steps[0] >= who },\n    { colour: ramp.noisy, label: copy.range(b!, c!), above: steps[1] >= who },\n    { colour: ramp.veryNoisy, label: copy.range(c!, d!), above: steps[2] >= who },\n    { colour: ramp.severe, label: copy.andUp(d!), above: steps[3] >= who },\n  ];\n}\n\n/** The part of a MapLibre map the period switch changes. */\nexport interface NoisePaintMap {\n  getLayer(id: string): unknown;\n  setPaintProperty(id: string, name: string, value: unknown): unknown;\n  setFilter(id: string, filter: unknown): unknown;\n}\n\n/**\n * Colour the overlay by `period`. With `animate`, the fill fades out, changes, and fades back in\n * over `PERIOD_FADE_MS` each way, so the switch reads as the city changing rather than a flicker;\n * without it (the first paint, a style swap, or reduced motion) it changes at once. Returns the\n * function that cancels a fade in progress and puts the opacity back.\n */\nexport function applyPeriod(map: NoisePaintMap, period: NoisePeriod, ramp: NoiseRamp, opacity: number, animate: boolean): () => void {\n  if (!map.getLayer(NOISE_FILL_LAYER)) return () => {};\n  const paint = periodPaint(period, ramp);\n  const swap = () => {\n    if (!map.getLayer(NOISE_FILL_LAYER)) return;\n    map.setPaintProperty(NOISE_FILL_LAYER, \"fill-color\", paint.color);\n    map.setFilter(NOISE_FILL_LAYER, paint.filter);\n  };\n  if (!animate) {\n    swap();\n    return () => {};\n  }\n  map.setPaintProperty(NOISE_FILL_LAYER, \"fill-opacity-transition\", { duration: PERIOD_FADE_MS, delay: 0 });\n  map.setPaintProperty(NOISE_FILL_LAYER, \"fill-opacity\", 0);\n  let done = false;\n  const timer = setTimeout(() => {\n    done = true;\n    swap();\n    if (map.getLayer(NOISE_FILL_LAYER)) map.setPaintProperty(NOISE_FILL_LAYER, \"fill-opacity\", opacity);\n  }, PERIOD_FADE_MS);\n  return () => {\n    clearTimeout(timer);\n    if (!done && map.getLayer(NOISE_FILL_LAYER)) map.setPaintProperty(NOISE_FILL_LAYER, \"fill-opacity\", opacity);\n  };\n}\n\n/** The source and layers that mark the looked-up spot. */\nexport const PICK_SOURCE = \"urban-noise-map-pick\";\nexport const PICK_HEX = \"urban-noise-map-pick-hex\";\nexport const PICK_HALO = \"urban-noise-map-pick-halo\";\nexport const PICK_DOT = \"urban-noise-map-pick-dot\";\n\n/** The part of a MapLibre map the spot marker draws on. */\nexport interface NoisePickMap {\n  getLayer(id: string): unknown;\n  getSource(id: string): unknown;\n  addSource(id: string, source: Record<string, unknown>): unknown;\n  addLayer(layer: Record<string, unknown>): unknown;\n  removeLayer(id: string): unknown;\n  removeSource(id: string): unknown;\n}\n\n/** Take the spot marker off the map. */\nexport function clearPick(map: NoisePickMap): void {\n  for (const id of [PICK_DOT, PICK_HALO, PICK_HEX]) if (map.getLayer(id)) map.removeLayer(id);\n  if (map.getSource(PICK_SOURCE)) map.removeSource(PICK_SOURCE);\n}\n\n/**\n * Mark the looked-up spot: a dot in your accent ringed in your background, and, from zoom 15\n * where the 25 m hexagons draw, the hexagon that answered outlined on the overlay's own source.\n * Drawn from scratch; call it again after a style swap.\n */\nexport function applyPick(map: NoisePickMap, pick: { at: [number, number]; h3: string | null } | null, colours: { accent: string; halo: string | null }): void {\n  clearPick(map);\n  if (!pick) return;\n  const metadata = { [HELPER_KEY]: true };\n  const fill = map.getLayer(NOISE_FILL_LAYER) as { source?: unknown } | undefined;\n  if (pick.h3 && typeof fill?.source === \"string\") {\n    map.addLayer({\n      id: PICK_HEX,\n      type: \"line\",\n      source: fill.source,\n      \"source-layer\": \"noise\",\n      filter: [\"==\", [\"get\", \"h3_id\"], pick.h3],\n      paint: { \"line-color\": colours.accent, \"line-width\": 2.5 },\n      metadata,\n    });\n  }\n  map.addSource(PICK_SOURCE, { type: \"geojson\", data: { type: \"Feature\", properties: {}, geometry: { type: \"Point\", coordinates: pick.at } } });\n  if (colours.halo) {\n    map.addLayer({ id: PICK_HALO, type: \"circle\", source: PICK_SOURCE, paint: { \"circle-radius\": 11, \"circle-color\": colours.halo, \"circle-opacity\": 0.85 }, metadata });\n  }\n  map.addLayer({\n    id: PICK_DOT,\n    type: \"circle\",\n    source: PICK_SOURCE,\n    paint: { \"circle-radius\": 6, \"circle-color\": colours.accent, ...(colours.halo ? { \"circle-stroke-color\": colours.halo, \"circle-stroke-width\": 2 } : {}) },\n    metadata,\n  });\n}\n\n/** A box on screen, as getBoundingClientRect() gives it. */\nexport interface NoiseRect {\n  left: number;\n  top: number;\n  right: number;\n  bottom: number;\n  width: number;\n  height: number;\n}\n\n/** Camera padding in pixels, as MapLibre takes it. */\nexport interface NoisePadding {\n  top: number;\n  right: number;\n  bottom: number;\n  left: number;\n}\n\n/** The panel's inset from the map edge, and the gutter the camera keeps. */\nexport const GUTTER = 24;\n/** The least map, in pixels, a padded camera must have to frame at its full gutter. */\nconst MIN_FRAME = 120;\n/** The tightened gutter, and the least strip the camera keeps to frame in beside a tall panel. */\nconst TIGHT = 8;\nconst MIN_STRIP = 32;\n\n/**\n * Camera padding that keeps the framed spot clear of the floating panel: its width on the left\n * when it floats beside the map, its height at the bottom when it is a sheet across it. An even\n * gutter when nothing is measured. When the full gutter would leave too little map, the gutter\n * tightens and the frame stays in the strip the panel leaves.\n */\nexport function panelPadding(map: NoiseRect, panel: NoiseRect | null, gutter = GUTTER): NoisePadding {\n  const even = { top: gutter, right: gutter, bottom: gutter, left: gutter };\n  if (!panel || panel.width <= 0 || panel.height <= 0 || map.width <= 0 || map.height <= 0) return even;\n  const sheet = panel.width >= map.width * 0.6;\n  const cover = Math.round(sheet ? map.bottom - panel.top : panel.right - map.left);\n  const size = sheet ? map.height : map.width;\n  if (size - 2 * gutter - cover >= MIN_FRAME) return sheet ? { ...even, bottom: cover + gutter } : { ...even, left: cover + gutter };\n  const near = Math.min(cover + TIGHT, size - TIGHT - MIN_STRIP);\n  return sheet ? { ...even, top: TIGHT, bottom: near } : { ...even, left: near, right: TIGHT };\n}\n\n/** True when the reader asked for less motion: the camera jumps and the map recolours at once. */\nexport function reducedMotion(): boolean {\n  return typeof window !== \"undefined\" && window.matchMedia?.(\"(prefers-reduced-motion: reduce)\")?.matches === true;\n}\n\n/** True on a screen with no hover, where the reader taps rather than clicks. */\nexport function touchScreen(): boolean {\n  return typeof window !== \"undefined\" && window.matchMedia?.(\"(hover: none)\")?.matches === true;\n}\n\n/** The part of a MapLibre map the camera uses. */\nexport interface NoiseCameraMap {\n  getContainer(): { getBoundingClientRect(): NoiseRect };\n  getZoom(): number;\n  flyTo(options: Record<string, unknown>): unknown;\n  jumpTo(options: Record<string, unknown>): unknown;\n  fitBounds(bounds: [[number, number], [number, number]], options: Record<string, unknown>): unknown;\n}\n\n/** Ease to a searched spot at street zoom, clear of the panel; jump there when the reader asked for less motion. */\nexport function flyToPoint(map: NoiseCameraMap, at: [number, number], panel: NoiseRect | null): void {\n  const padding = panelPadding(map.getContainer().getBoundingClientRect(), panel);\n  const camera = { center: at, zoom: Math.max(map.getZoom(), POINT_ZOOM), padding };\n  if (reducedMotion()) map.jumpTo(camera);\n  else map.flyTo({ ...camera, duration: 1200, essential: true });\n}\n\n/** Frame a covered city, clear of the panel. */\nexport function flyToCity(map: NoiseCameraMap, city: NoiseCityRef, panel: NoiseRect | null): void {\n  const [west, south, east, north] = city.bbox;\n  const padding = panelPadding(map.getContainer().getBoundingClientRect(), panel);\n  map.fitBounds(\n    [\n      [west, south],\n      [east, north],\n    ],\n    { padding, maxZoom: 13, ...(reducedMotion() ? { animate: false } : { duration: 1400, essential: true }) },\n  );\n}\n// --- end urban-noise-map helpers ---\n\nexport interface UrbanNoiseMapProps {\n  /** unmap API key with the Urban Noise add-on. The page passes NEXT_PUBLIC_UNMAP_KEY. With none, the block asks for one and loads nothing. */\n  apiKey: string;\n  /** Gateway base URL. Defaults to the unmap production gateway. */\n  gateway?: string;\n  /** Language of the copy and the map labels. Defaults to \"en\". */\n  lang?: NoiseLang;\n  /** Where the map opens, [longitude, latitude]: the initial camera only. Defaults to Calgary. */\n  center?: [number, number];\n  /** The opening zoom: the initial camera only, like `center`. Defaults to 12. */\n  zoom?: number;\n  /** The basemap under the noise. Defaults to \"muted\", which keeps the colour for the data. */\n  mapStyle?: string;\n  /** The level the map is coloured by. Omit it and the block keeps its own, starting with \"day\". */\n  period?: NoisePeriod;\n  /** Fires when the reader switches between Day and Night. */\n  onPeriodChange?: (period: NoisePeriod) => void;\n  className?: string;\n}\n\n/** What the card shows: nothing yet, a spot being looked up, its answer, or why there is none. */\ntype NoiseLookup =\n  | { state: \"idle\" }\n  | { state: \"loading\"; at: [number, number]; name: string | null }\n  | { state: \"result\"; at: [number, number]; name: string | null; point: NoisePoint }\n  | { state: \"failed\"; at: [number, number]; name: string | null; failure: NoiseFailure };\n\n/** A camera move the map binding makes: to a searched spot or a city, once per `seq`. */\ntype NoiseFlight = { seq: number; to: { kind: \"point\"; at: [number, number] } | { kind: \"city\"; city: NoiseCityRef } };\n\nexport function UrbanNoiseMap({\n  apiKey,\n  gateway,\n  lang = \"en\",\n  center = OPENING_VIEW.center,\n  zoom = OPENING_VIEW.zoom,\n  mapStyle = \"muted\",\n  period: periodProp,\n  onPeriodChange,\n  className,\n}: UrbanNoiseMapProps) {\n  const copy = NOISE_COPY[lang];\n  const hasKey = apiKey.trim() !== \"\";\n  const noise = useMemo(() => new Noise({ key: apiKey, gateway }), [apiKey, gateway]);\n  // The opening camera, kept: after this the reader moves the map, not the props.\n  const [opening] = useState(() => ({ center, zoom }));\n  const [gate, setGate] = useState<NoiseGate>({ state: \"checking\" });\n  const [attempt, setAttempt] = useState(0);\n  const [ownPeriod, setOwnPeriod] = useState<NoisePeriod>(periodProp ?? \"day\");\n  const period = periodProp ?? ownPeriod;\n  const [lookup, setLookup] = useState<NoiseLookup>({ state: \"idle\" });\n  const [ramp, setRamp] = useState<NoiseRamp | null>(null);\n  const [flight, setFlight] = useState<NoiseFlight | null>(null);\n  const [touch] = useState(touchScreen);\n  // Below `sm` the sheet folds down to its title so the map shows; a new spot unfolds it.\n  const [collapsed, setCollapsed] = useState(false);\n  const panelRef = useRef<HTMLDivElement | null>(null);\n  const lookupRef = useRef<AbortController | null>(null);\n\n  // A new key starts over: its add-on answer is its own.\n  useEffect(() => {\n    setGate({ state: \"checking\" });\n    setLookup({ state: \"idle\" });\n  }, [apiKey, gateway]);\n\n  // One probe per key. Nothing gated is composed until it answers.\n  useEffect(() => {\n    if (!hasKey) return;\n    const controller = new AbortController();\n    probeNoise(apiKey, gateway, controller.signal).then(\n      (next) => {\n        if (!controller.signal.aborted) setGate(next);\n      },\n      () => {\n        // Aborted: a newer probe or the unmount owns the state.\n      },\n    );\n    return () => controller.abort();\n  }, [apiKey, gateway, hasKey, attempt]);\n\n  useEffect(() => () => lookupRef.current?.abort(), []);\n\n  /** One `/noise/point` for a spot; a newer spot cancels an older one still in flight. */\n  const lookUp = useCallback(\n    (at: [number, number], name: string | null) => {\n      lookupRef.current?.abort();\n      const controller = new AbortController();\n      lookupRef.current = controller;\n      setCollapsed(false);\n      setLookup({ state: \"loading\", at, name });\n      noise.point({ lat: at[1], lng: at[0], signal: controller.signal }).then(\n        ({ data }) => {\n          if (!controller.signal.aborted) setLookup({ state: \"result\", at, name, point: data });\n        },\n        (caught: unknown) => {\n          if (!controller.signal.aborted) setLookup({ state: \"failed\", at, name, failure: failureFrom(caught) });\n        },\n      );\n    },\n    [noise],\n  );\n\n  function choosePeriod(next: NoisePeriod) {\n    setOwnPeriod(next);\n    onPeriodChange?.(next);\n  }\n\n  function chooseCity(city: NoiseCityRef) {\n    lookupRef.current?.abort();\n    setLookup({ state: \"idle\" });\n    setFlight((current) => ({ seq: (current?.seq ?? 0) + 1, to: { kind: \"city\", city } }));\n  }\n\n  function clear() {\n    lookupRef.current?.abort();\n    setLookup({ state: \"idle\" });\n  }\n\n  function retryGate() {\n    setGate({ state: \"checking\" });\n    setAttempt((current) => current + 1);\n  }\n\n  if (!hasKey) {\n    // Where the map would be: the block's gutter, the Alert at a readable width, the first action first.\n    return (\n      <div\n        data-unmap-block=\"urban-noise-map\"\n        data-state=\"no-key\"\n        className={[\"flex h-full min-h-[480px] items-start justify-center bg-muted/40 p-4 sm:items-center sm:p-6\", className].filter(Boolean).join(\" \")}\n      >\n        <Alert role=\"alert\" className=\"w-full max-w-md motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200\">\n          <AlertTitle className=\"line-clamp-none\">{copy.noKeyTitle}</AlertTitle>\n          <AlertDescription className=\"flex flex-col items-start gap-3\">\n            <a className=\"font-medium text-primary underline underline-offset-4\" href={KEY_URL} target=\"_blank\" rel=\"noreferrer\">\n              {copy.getKey}\n            </a>\n            <p>{copy.noKey}</p>\n          </AlertDescription>\n        </Alert>\n      </div>\n    );\n  }\n  if (gate.state === \"checking\") {\n    // Where the map will be: the answer is one request away, so a quiet line, not a skeleton of a map.\n    return (\n      <div\n        data-unmap-block=\"urban-noise-map\"\n        data-state=\"checking\"\n        className={[\"flex h-full min-h-[480px] items-center justify-center gap-2 bg-muted/40 p-4 sm:p-6\", className].filter(Boolean).join(\" \")}\n      >\n        <Spinner className=\"size-4 text-muted-foreground\" />\n        <p aria-live=\"polite\" className=\"text-sm text-muted-foreground\">\n          {copy.checking}\n        </p>\n      </div>\n    );\n  }\n\n  const granted = gate.state === \"granted\";\n  const place = lookup.state === \"idle\" ? \"\" : (lookup.name ?? copy.droppedPin);\n  const said = lookup.state === \"result\" ? announce(lookup.point, place, period, lang) : lookup.state === \"loading\" ? copy.loading : \"\";\n  const headline = lookup.state === \"result\" ? periodReading(lookup.point, period).db : null;\n  const summary = lookup.state === \"idle\" ? null : headline === null ? place : `${place} · ${formatDb(headline, lang)}`;\n  // Folded below `sm`: everything under the title goes, and the title carries the spot.\n  const fold = collapsed ? \"max-sm:hidden\" : undefined;\n  const pick =\n    lookup.state === \"idle\" ? null : { at: lookup.at, h3: lookup.state === \"result\" && lookup.point.lden !== null ? lookup.point.h3 : null };\n\n  return (\n    <div data-unmap-block=\"urban-noise-map\" data-period={period} className={[\"relative h-full min-h-[480px]\", className].filter(Boolean).join(\" \")}>\n      <Map\n        id={NOISE_MAP_ID}\n        apiKey={apiKey}\n        gateway={gateway}\n        lang={lang}\n        style={mapStyle}\n        {...(granted ? { overlays: NOISE_OVERLAYS } : {})}\n        center={opening.center}\n        zoom={opening.zoom}\n        className=\"h-full\"\n      >\n        {granted ? (\n          <NoiseBinding\n            period={period}\n            pick={pick}\n            flight={flight}\n            panelRef={panelRef}\n            onRamp={setRamp}\n            onPoint={(at) => lookUp(at, null)}\n          />\n        ) : null}\n        {granted && ramp ? <NoiseLegend lang={lang} period={period} ramp={ramp} /> : null}\n        <p data-testid=\"noise-announce\" aria-live=\"polite\" className=\"sr-only\">\n          {said}\n        </p>\n        {/* A card floating on the map at 24 px; below `sm`, a sheet across the bottom (at most 55% of the map) that clears the attribution, scrolls as one piece and folds down to its title. */}\n        <div\n          ref={panelRef}\n          data-collapsed={collapsed ? \"true\" : undefined}\n          className=\"absolute inset-x-4 bottom-10 z-10 flex max-h-[55%] flex-col sm:inset-x-auto sm:top-6 sm:bottom-auto sm:left-6 sm:max-h-[calc(100%-3rem)] sm:w-[23rem] motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-4 motion-safe:duration-300 sm:motion-safe:slide-in-from-bottom-0 sm:motion-safe:slide-in-from-left-4\"\n        >\n          <Card className=\"flex min-h-0 flex-1 flex-col gap-0 py-0 max-sm:overflow-y-auto max-sm:overscroll-contain\">\n            <CardHeader className={[\"shrink-0 gap-1 pt-4 pb-3\", collapsed ? \"max-sm:pb-4\" : \"\"].join(\" \")}>\n              <div className=\"flex items-start justify-between gap-3\">\n                <CardTitle role=\"heading\" aria-level={2}>\n                  {copy.title}\n                </CardTitle>\n                <Button\n                  type=\"button\"\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  data-testid=\"noise-fold\"\n                  aria-expanded={!collapsed}\n                  className=\"-mt-1.5 -mr-2 shrink-0 text-muted-foreground sm:hidden\"\n                  onClick={() => setCollapsed((current) => !current)}\n                >\n                  {collapsed ? copy.expand : copy.collapse}\n                </Button>\n              </div>\n              {granted && lookup.state === \"idle\" ? <CardDescription className={fold}>{touch ? copy.introTouch : copy.intro}</CardDescription> : null}\n              {collapsed && summary ? (\n                <CardDescription data-testid=\"noise-summary\" className=\"truncate tabular-nums sm:hidden\">\n                  {summary}\n                </CardDescription>\n              ) : null}\n            </CardHeader>\n            {granted ? (\n              <div data-testid=\"noise-tools\" className={[\"flex shrink-0 flex-col gap-2.5 border-b px-6 pb-4\", fold].filter(Boolean).join(\" \")}>\n                <Geocoder\n                  mapId={NOISE_MAP_ID}\n                  apiKey={apiKey}\n                  gateway={gateway}\n                  lang={lang}\n                  placeholder={copy.search}\n                  proximity=\"map\"\n                  flyToZoom={POINT_ZOOM}\n                  onSelect={(result) => {\n                    const at: [number, number] = [result.lng, result.lat];\n                    lookUp(at, result.name);\n                    setFlight((current) => ({ seq: (current?.seq ?? 0) + 1, to: { kind: \"point\", at } }));\n                  }}\n                  className=\"[&>p]:hidden\"\n                />\n                <ToggleGroup\n                  variant=\"outline\"\n                  size=\"sm\"\n                  aria-label={copy.period}\n                  className=\"w-full\"\n                  value={[period]}\n                  // Pressing the pressed item empties the group; the period keeps its value.\n                  onValueChange={(next: unknown[]) => {\n                    const value = next[0];\n                    if (value === \"day\" || value === \"night\") choosePeriod(value);\n                  }}\n                >\n                  {([\"day\", \"night\"] as const).map((value) => (\n                    <ToggleGroupItem key={value} value={value} className=\"flex-1 gap-1.5 aria-pressed:font-semibold\">\n                      {copy.periods[value]}\n                      <span className=\"text-xs font-normal text-muted-foreground\">{copy.metrics[value]}</span>\n                    </ToggleGroupItem>\n                  ))}\n                </ToggleGroup>\n              </div>\n            ) : null}\n            <CardContent className={[\"flex flex-col gap-4 py-4 max-sm:shrink-0 sm:min-h-0 sm:flex-1 sm:overflow-y-auto sm:overscroll-contain\", fold].filter(Boolean).join(\" \")}>\n              {gate.state === \"denied\" || gate.state === \"unknown\" || gate.state === \"unavailable\" ? (\n                <Alert data-unmap-addon-gate={gate.state === \"denied\" ? gate.code : gate.state}>\n                  <AlertTitle className=\"line-clamp-none\">\n                    {gate.state === \"denied\" ? copy.deniedTitle[gate.code] : gate.state === \"unavailable\" ? copy.unavailableTitle : copy.unknownTitle}\n                  </AlertTitle>\n                  <AlertDescription className=\"flex flex-col items-start gap-2\">\n                    <p>{gate.state === \"denied\" ? copy.deniedBody[gate.code] : gate.state === \"unavailable\" ? copy.unavailableBody : copy.unknownBody(gate.status)}</p>\n                    {gate.state === \"denied\" ? (\n                      <a className=\"font-medium text-primary underline underline-offset-4\" href={copy.pricingUrl} target=\"_blank\" rel=\"noreferrer\">\n                        {copy.pricing}\n                      </a>\n                    ) : (\n                      <Button type=\"button\" variant=\"outline\" size=\"sm\" onClick={retryGate}>\n                        {copy.retry}\n                      </Button>\n                    )}\n                  </AlertDescription>\n                </Alert>\n              ) : null}\n              {granted && lookup.state === \"idle\" ? <CityButtons lang={lang} label={copy.startIn} onCity={chooseCity} /> : null}\n              {granted && lookup.state === \"loading\" ? <NoiseCardSkeleton lang={lang} /> : null}\n              {granted && lookup.state === \"result\" ? (\n                <NoiseAnswer key={`${lookup.at.join(\",\")}`} lang={lang} period={period} place={place} at={lookup.at} point={lookup.point} onClear={clear} />\n              ) : null}\n              {granted && lookup.state === \"failed\" ? (\n                <NoiseFailureCard\n                  lang={lang}\n                  failure={lookup.failure}\n                  onRetry={() => lookUp(lookup.at, lookup.name)}\n                  onCity={chooseCity}\n                  onClear={clear}\n                />\n              ) : null}\n            </CardContent>\n            <div data-testid=\"noise-credits\" className={[\"flex shrink-0 flex-col gap-1 border-t px-6 py-3 text-xs text-muted-foreground\", fold].filter(Boolean).join(\" \")}>\n              <p>\n                <a className=\"underline underline-offset-2\" href={copy.attributionUrl} target=\"_blank\" rel=\"noreferrer\">\n                  {copy.attribution}\n                </a>{\" \"}\n                (\n                <a className=\"underline underline-offset-2\" href={copy.licenceUrl} target=\"_blank\" rel=\"noreferrer\">\n                  {copy.licence}\n                </a>\n                ), {copy.model}.\n              </p>\n              <p>{copy.modelled}</p>\n            </div>\n          </Card>\n        </div>\n      </Map>\n    </div>\n  );\n}\n\n/** The six cities as buttons that frame each one: the empty state's first action, and the way out of \"not covered\". */\nfunction CityButtons({ lang, label, cities = NOISE_CITIES, onCity }: { lang: NoiseLang; label: string; cities?: readonly NoiseCityRef[]; onCity: (city: NoiseCityRef) => void }) {\n  const copy = NOISE_COPY[lang];\n  return (\n    <div data-testid=\"noise-cities\" className=\"flex flex-col gap-2\">\n      <p className=\"text-xs font-medium text-muted-foreground\">{label}</p>\n      <ul className=\"flex flex-wrap gap-1.5\">\n        {cities.map((city) => (\n          <li key={city.slug}>\n            <Button type=\"button\" variant=\"outline\" size=\"sm\" aria-label={copy.goTo(city.name[lang])} onClick={() => onCity(city)}>\n              {city.name[lang]}\n            </Button>\n          </li>\n        ))}\n      </ul>\n    </div>\n  );\n}\n\n/** Where the answer will be, in its shape: a heading, the headline level, two bars and four sources. */\nfunction NoiseCardSkeleton({ lang }: { lang: NoiseLang }) {\n  return (\n    <section data-testid=\"noise-loading\" aria-busy=\"true\" aria-label={NOISE_COPY[lang].loading} className=\"flex flex-col gap-4\">\n      <div className=\"flex flex-col gap-1.5\">\n        <Skeleton className=\"h-5 w-2/3\" />\n        <Skeleton className=\"h-3.5 w-1/2\" />\n      </div>\n      <Skeleton className=\"h-10 w-28\" />\n      <div className=\"flex flex-col gap-3\">\n        <Skeleton className=\"h-2 w-full\" />\n        <Skeleton className=\"h-2 w-full\" />\n      </div>\n      <div className=\"flex flex-col gap-2\">\n        {NOISE_SOURCES.map((key) => (\n          <Skeleton key={key} className=\"h-3.5 w-full\" />\n        ))}\n      </div>\n    </section>\n  );\n}\n\n/** A bar on the card's 30 to 80 dB scale, with the WHO guideline as a tick when one applies. */\nfunction LevelBar({ share, strong, who, whoLabel }: { share: number; strong: boolean; who?: number; whoLabel?: string }) {\n  const tick = who === undefined ? null : gaugeShare(who);\n  return (\n    <span aria-hidden=\"true\" className=\"relative block h-2 w-full rounded-full bg-muted\">\n      <span\n        data-strong={strong ? \"true\" : undefined}\n        className=\"absolute inset-y-0 left-0 rounded-full bg-muted-foreground/40 data-[strong=true]:bg-primary motion-safe:transition-[width] motion-safe:duration-500 motion-safe:ease-out\"\n        style={{ width: `${Math.round(share * 1000) / 10}%` }}\n      />\n      {tick !== null ? (\n        <span className=\"absolute -inset-y-1 w-0.5 -translate-x-1/2 rounded-full bg-foreground\" style={{ left: `${Math.round(tick * 1000) / 10}%` }} title={whoLabel} />\n      ) : null}\n    </span>\n  );\n}\n\n/** The answer for one spot: the active period's level as the headline, both periods against the WHO line, the score, and a bar per source. */\nfunction NoiseAnswer({\n  lang,\n  period,\n  place,\n  at,\n  point,\n  onClear,\n}: {\n  lang: NoiseLang;\n  period: NoisePeriod;\n  place: string;\n  at: [number, number];\n  point: NoisePoint;\n  onClear: () => void;\n}) {\n  const copy = NOISE_COPY[lang];\n  const reading = periodReading(point, period);\n  const header = (\n    <div className=\"flex items-start justify-between gap-3\">\n      <div className=\"flex min-w-0 flex-col gap-0.5\">\n        <h3 className=\"text-base leading-tight font-semibold text-balance\">{place}</h3>\n        <p className=\"text-xs text-muted-foreground tabular-nums\">\n          {cityName(point.city, lang)}, {formatCoords(at, lang)}\n        </p>\n      </div>\n      <Button type=\"button\" variant=\"ghost\" size=\"sm\" className=\"-mr-2 shrink-0 text-muted-foreground\" onClick={onClear}>\n        {copy.clear}\n      </Button>\n    </div>\n  );\n  if (reading.db === null) {\n    return (\n      <section data-testid=\"noise-answer\" data-state=\"no-source\" aria-label={place} className=\"flex flex-col gap-3 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200\">\n        {header}\n        <Alert>\n          <AlertTitle className=\"line-clamp-none\">{copy.noSourceTitle}</AlertTitle>\n          <AlertDescription>\n            <p>{copy.noSourceBody}</p>\n          </AlertDescription>\n        </Alert>\n      </section>\n    );\n  }\n  const rows = sourceRows(point, lang);\n  return (\n    <section data-testid=\"noise-answer\" data-state=\"result\" aria-label={place} className=\"flex flex-col gap-4 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200\">\n      {header}\n      <div className=\"flex flex-col gap-1\">\n        <p className=\"text-xs text-muted-foreground\">{copy.level[period]}</p>\n        <div className=\"flex flex-wrap items-baseline gap-x-3 gap-y-1\">\n          <p data-testid=\"noise-headline\" className=\"text-4xl leading-none font-semibold tracking-tight tabular-nums\">\n            {formatDb(reading.db, lang)}\n          </p>\n          <Badge variant={reading.above ? \"destructive\" : \"secondary\"} data-above={reading.above ? \"true\" : \"false\"}>\n            {reading.above ? copy.above[period] : copy.within[period]}\n          </Badge>\n        </div>\n      </div>\n      <dl className=\"flex flex-col gap-3\">\n        {([\"day\", \"night\"] as const).map((value) => {\n          const row = periodReading(point, value);\n          const active = value === period;\n          return (\n            <div key={value} data-active={active ? \"true\" : undefined} className=\"flex flex-col gap-1.5 opacity-60 data-[active=true]:opacity-100 motion-safe:transition-opacity\">\n              <div className=\"flex items-baseline justify-between gap-2 text-sm\">\n                <dt className=\"font-medium\">\n                  {copy.periods[value]} <span className=\"text-xs font-normal text-muted-foreground\">{copy.metrics[value]}</span>\n                </dt>\n                <dd className=\"font-semibold tabular-nums\">\n                  {row.db === null ? copy.notModelled : formatDb(row.db, lang)}\n                  <span className=\"ml-2 text-xs font-normal text-muted-foreground\">{copy.versus(row.above, formatDb(row.who, lang))}</span>\n                </dd>\n              </div>\n              <LevelBar share={row.db === null ? 0 : gaugeShare(row.db)} strong={active} who={row.who} whoLabel={copy.guideline(formatDb(row.who, lang))} />\n            </div>\n          );\n        })}\n      </dl>\n      {point.score !== null ? (\n        <div className=\"flex items-center justify-between gap-3 rounded-lg bg-muted/60 px-3 py-2.5\">\n          <p className=\"text-sm text-muted-foreground\">{copy.score}</p>\n          <p className=\"flex items-baseline gap-1.5\">\n            <span className=\"text-lg leading-none font-semibold tabular-nums\">{point.score}</span>\n            <span className=\"text-xs text-muted-foreground\">{copy.outOf}</span>\n            {point.label ? (\n              <Badge variant=\"outline\" className=\"ml-1\">\n                {labelText(point.label, lang)}\n              </Badge>\n            ) : null}\n          </p>\n        </div>\n      ) : null}\n      <div className=\"flex flex-col gap-2\">\n        <p className=\"text-sm font-medium\">{copy.bySource}</p>\n        <dl data-testid=\"noise-sources\" className=\"grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-3 gap-y-2 text-sm\">\n          {rows.map((row) => (\n            <div key={row.key} data-main={row.main ? \"true\" : undefined} className=\"contents\">\n              <dt className=\"text-muted-foreground\">\n                <span className={row.main ? \"font-medium text-foreground\" : undefined}>{row.label}</span>\n                {row.main ? <span className=\"sr-only\">, {copy.main}</span> : null}\n              </dt>\n              <span aria-hidden=\"true\" className=\"block\">\n                {row.db === null ? <span className=\"block h-1.5 w-full rounded-full border border-dashed border-border\" /> : <LevelBar share={row.share} strong={row.main} />}\n              </span>\n              <dd className={[\"text-right tabular-nums\", row.db === null ? \"text-xs text-muted-foreground\" : \"font-medium\"].join(\" \")}>{row.value}</dd>\n            </div>\n          ))}\n        </dl>\n        {rows.some((row) => row.main) ? (\n          <p className=\"text-xs text-muted-foreground\">{copy.mainIs(rows.find((row) => row.main)?.label ?? \"\")}</p>\n        ) : null}\n      </div>\n    </section>\n  );\n}\n\n/** Why a spot has no level, and the way forward: the six cities, the pricing page, or a retry. */\nfunction NoiseFailureCard({\n  lang,\n  failure,\n  onRetry,\n  onCity,\n  onClear,\n}: {\n  lang: NoiseLang;\n  failure: NoiseFailure;\n  onRetry: () => void;\n  onCity: (city: NoiseCityRef) => void;\n  onClear: () => void;\n}) {\n  const copy = NOISE_COPY[lang];\n  if (failure.kind === \"outside\") {\n    return (\n      <section data-testid=\"noise-answer\" data-state=\"outside\" className=\"flex flex-col gap-3 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200\">\n        <Alert>\n          <AlertTitle className=\"line-clamp-none\">{copy.outsideTitle}</AlertTitle>\n          <AlertDescription>\n            <p>{copy.outsideBody}</p>\n          </AlertDescription>\n        </Alert>\n        <CityButtons lang={lang} label={copy.startIn} cities={failure.cities} onCity={onCity} />\n      </section>\n    );\n  }\n  if (failure.kind === \"denied\") {\n    return (\n      <Alert data-testid=\"noise-answer\" data-state=\"denied\">\n        <AlertTitle className=\"line-clamp-none\">{copy.deniedTitle[failure.code]}</AlertTitle>\n        <AlertDescription className=\"flex flex-col items-start gap-2\">\n          <p>{copy.deniedBody[failure.code]}</p>\n          <a className=\"font-medium text-primary underline underline-offset-4\" href={copy.pricingUrl} target=\"_blank\" rel=\"noreferrer\">\n            {copy.pricing}\n          </a>\n        </AlertDescription>\n      </Alert>\n    );\n  }\n  const title = failure.kind === \"unavailable\" ? copy.unavailableTitle : null;\n  const body = failure.kind === \"unavailable\" ? copy.unavailableBody : failure.kind === \"busy\" ? copy.busy : failure.kind === \"keyRefused\" ? copy.keyRefused : copy.failed;\n  return (\n    <Alert variant={failure.kind === \"unavailable\" ? \"default\" : \"destructive\"} role=\"alert\" data-testid=\"noise-answer\" data-state={failure.kind}>\n      {title ? <AlertTitle className=\"line-clamp-none\">{title}</AlertTitle> : null}\n      <AlertDescription className=\"flex flex-col items-start gap-2\">\n        <p>{body}</p>\n        <div className=\"flex gap-2\">\n          <Button type=\"button\" variant=\"outline\" size=\"sm\" onClick={onRetry}>\n            {copy.retry}\n          </Button>\n          <Button type=\"button\" variant=\"ghost\" size=\"sm\" onClick={onClear}>\n            {copy.clear}\n          </Button>\n        </div>\n      </AlertDescription>\n    </Alert>\n  );\n}\n\n/** The map side of the block: the period's colours, the spot marker, map clicks and camera moves. It renders inside <Map>. */\nfunction NoiseBinding({\n  period,\n  pick,\n  flight,\n  panelRef,\n  onRamp,\n  onPoint,\n}: {\n  period: NoisePeriod;\n  pick: { at: [number, number]; h3: string | null } | null;\n  flight: NoiseFlight | null;\n  panelRef: RefObject<HTMLDivElement | null>;\n  onRamp: (ramp: NoiseRamp) => void;\n  onPoint: (at: [number, number]) => void;\n}) {\n  const map = useMap(NOISE_MAP_ID);\n  const ready = useMapReady(NOISE_MAP_ID);\n  const generation = useMapGeneration(NOISE_MAP_ID);\n  const rampRef = useRef<{ ramp: NoiseRamp; opacity: number } | null>(null);\n  // What was last painted, so only a period switch on the same style fades; a new style paints at once.\n  const paintedRef = useRef<{ generation: number; period: NoisePeriod } | null>(null);\n  const pointRef = useRef(onPoint);\n  pointRef.current = onPoint;\n\n  // Read the overlay's ramp from the style the gateway sent, then colour the map by the period.\n  useEffect(() => {\n    if (!map || !ready) return;\n    const paint = map as unknown as NoisePaintMap & { getPaintProperty(id: string, name: string): unknown };\n    if (!paint.getLayer(NOISE_FILL_LAYER)) return;\n    const live = readRamp(paint.getPaintProperty(NOISE_FILL_LAYER, \"fill-color\"));\n    if (live) {\n      const opacity = paint.getPaintProperty(NOISE_FILL_LAYER, \"fill-opacity\");\n      rampRef.current = { ramp: live, opacity: typeof opacity === \"number\" ? opacity : 0.45 };\n      onRamp(live);\n    }\n    const known = rampRef.current;\n    if (!known) return;\n    const last = paintedRef.current;\n    const animate = !!last && last.generation === generation && last.period !== period && !reducedMotion();\n    paintedRef.current = { generation, period };\n    return applyPeriod(paint, period, known.ramp, known.opacity, animate);\n  }, [map, ready, generation, period, onRamp]);\n\n  // The spot marker, drawn again whenever the spot changes and after every style swap.\n  const pickKey = pick ? `${pick.at.join(\",\")}|${pick.h3 ?? \"\"}` : \"\";\n  useEffect(() => {\n    if (!map || !ready) return;\n    let removed = false;\n    const onRemove = () => {\n      removed = true;\n    };\n    map.on(\"remove\", onRemove);\n    const target = map as unknown as NoisePickMap;\n    if (map.getStyle()) {\n      applyPick(target, pickKey ? pick : null, { accent: accentColor(map, undefined, undefined), halo: tokenColor(map, \"--background\") });\n    }\n    return () => {\n      map.off(\"remove\", onRemove);\n      if (!removed && map.getStyle()) clearPick(target);\n    };\n    // `pickKey` stands for `pick`, which is a new object every render.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [map, ready, generation, pickKey]);\n\n  // A click anywhere asks for that spot; a crosshair says the map answers clicks.\n  useEffect(() => {\n    if (!map || typeof map.on !== \"function\") return;\n    const onClick = (event: { lngLat: { lng: number; lat: number } }) => pointRef.current([event.lngLat.lng, event.lngLat.lat]);\n    map.on(\"click\", onClick);\n    const canvas = map.getCanvas?.();\n    if (canvas) canvas.style.cursor = \"crosshair\";\n    return () => {\n      map.off(\"click\", onClick);\n      if (canvas) canvas.style.cursor = \"\";\n    };\n  }, [map]);\n\n  // Camera moves the panel asked for: a searched spot, or a city.\n  useEffect(() => {\n    if (!map || !flight) return;\n    const camera = map as unknown as NoiseCameraMap;\n    const panel = panelRef.current?.getBoundingClientRect() ?? null;\n    if (flight.to.kind === \"point\") flyToPoint(camera, flight.to.at, panel);\n    else flyToCity(camera, flight.to.city, panel);\n    // One move per `seq`; the target travels with it.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [map, flight?.seq]);\n\n  return null;\n}\n\n/**\n * What the colours mean for the period the map is drawn by, in the colours the style draws them,\n * with the WHO guideline drawn as a rule between the classes it separates. Open by default on a\n * wide screen, folded on a phone.\n */\nfunction NoiseLegend({ lang, period, ramp }: { lang: NoiseLang; period: NoisePeriod; ramp: NoiseRamp }) {\n  const copy = NOISE_COPY[lang];\n  const [wide] = useState(() => typeof window === \"undefined\" || window.matchMedia?.(\"(min-width: 640px)\")?.matches !== false);\n  const rows = legendRows(period, ramp, lang);\n  const firstAbove = rows.findIndex((row) => row.above);\n  return (\n    <details\n      open={wide}\n      data-testid=\"noise-legend\"\n      className=\"group absolute top-3 left-3 z-10 max-w-[15rem] rounded-lg border bg-popover/95 text-popover-foreground shadow-md backdrop-blur sm:top-auto sm:right-3 sm:bottom-10 sm:left-auto\"\n    >\n      <summary className=\"cursor-pointer list-none px-3 py-2 text-xs font-semibold select-none [&::-webkit-details-marker]:hidden\">{copy.legend}</summary>\n      <div className=\"flex flex-col gap-2 px-3 pb-3 text-xs\">\n        <p key={period} className=\"text-muted-foreground motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200\">\n          {copy.legendTitle[period]}\n        </p>\n        <ul className=\"flex flex-col gap-1\">\n          {rows.map((row, index) => (\n            <li key={`${period}-${index}`} className=\"flex flex-col gap-1\">\n              {index === firstAbove ? (\n                <span data-testid=\"noise-legend-who\" className=\"flex items-center gap-2 py-0.5 text-[0.6875rem] font-medium\">\n                  <span aria-hidden=\"true\" className=\"h-0.5 w-4 shrink-0 rounded-full bg-foreground\" />\n                  {copy.whoLine(formatDb(WHO_DB[period], lang))}\n                </span>\n              ) : null}\n              <span className=\"flex items-center gap-2 tabular-nums\">\n                <span aria-hidden=\"true\" className=\"size-3 shrink-0 rounded-sm border border-border/60\" style={{ backgroundColor: row.colour }} />\n                {row.label}\n              </span>\n            </li>\n          ))}\n        </ul>\n        <p className=\"flex items-center gap-2 text-muted-foreground\">\n          <span aria-hidden=\"true\" className=\"size-3 shrink-0 rounded-sm border border-dashed border-border\" />\n          {copy.noData}\n        </p>\n      </div>\n    </details>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "envVars": {
    "NEXT_PUBLIC_UNMAP_KEY": ""
  },
  "docs": "Paste your unmap key into NEXT_PUBLIC_UNMAP_KEY in .env.local, then open /urban-noise-map. Needs the Urban Noise add-on; without it the page says why. Guide: https://unmap.dev/docs/frontend/blocks#check-how-loud-a-street-is",
  "categories": [
    "blocks"
  ],
  "type": "registry:block"
}