{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "energy-explorer",
  "title": "Energy Explorer",
  "description": "A page with Alberta wells, pipelines and facilities: UWI and licence search, a layer legend and click-to-identify. Needs the Energy add-on; without it the page says why and shows the basemap. Calls /geocode/autocomplete per search and /identify per click, after one add-on check.",
  "dependencies": [
    "@unmap/geocoding"
  ],
  "registryDependencies": [
    "https://registry.unmap.dev/r/energy-map.json",
    "https://registry.unmap.dev/r/map-context.json",
    "https://registry.unmap.dev/r/map-layers.json",
    "https://registry.unmap.dev/r/well-search.json",
    "https://registry.unmap.dev/r/energy-popup.json",
    "https://registry.unmap.dev/r/energy-layer-control.json",
    "card",
    "button",
    "alert",
    "spinner"
  ],
  "files": [
    {
      "path": "registry/blocks/energy-explorer/page.tsx",
      "content": "/**\n * /energy-explorer: the energy explorer 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 energy layers need the Energy add-on; without it\n * the page says why.\n */\nimport { EnergyExplorer } from \"@/components/ui/energy-explorer\";\n\nexport const metadata = { title: \"Explore Alberta's wells and pipelines\" };\n\nexport default function EnergyExplorerPage() {\n  return (\n    <main className=\"h-dvh\">\n      <EnergyExplorer apiKey={process.env.NEXT_PUBLIC_UNMAP_KEY ?? \"\"} className=\"h-full\" />\n    </main>\n  );\n}\n",
      "type": "registry:page",
      "target": "app/energy-explorer/page.tsx"
    },
    {
      "path": "registry/blocks/energy-explorer/energy-explorer.tsx",
      "content": "\"use client\";\n\n/**\n * <EnergyExplorer> - Alberta wells, pipelines and facilities on an unmap map, as a console: UWI and\n * licence search, a layer legend and click-to-identify, with the picked feature's record under\n * them. A full-bleed map with a floating panel, a bottom sheet on a phone.\n *\n * The energy overlays, well search and identify need the Energy add-on. The block asks the gateway\n * once whether your key holds it. If it does not, the map still renders (a `profile=\"energy\"`\n * style leaves out the layers a key cannot read) and the panel says why the energy layers are\n * missing and what unlocks them, instead of mounting a search box and a click handler that could\n * only be refused.\n *\n * Metering: every authenticated request is one call. The block makes the add-on probe (one\n * `GET /layers/energy.wells/22/0/0`), then the map's own style and tile requests. The search box\n * makes one `GET /geocode/autocomplete` per pause in typing, and each map click one `GET /identify`.\n *\n * Composes the energy-map, well-search, energy-popup and energy-layer-control items (the last is\n * a layer-legend preset) on your shadcn Card, Alert, Button and Spinner. Installed by\n * `npx shadcn add` with its page at app/energy-explorer/page.tsx. Edit freely.\n */\n\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport type { GeocodeResult, IdentifyResult } from \"@unmap/geocoding\";\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { EnergyLayerControl } from \"@/components/ui/energy-layer-control\";\nimport { EnergyMap } from \"@/components/ui/energy-map\";\nimport { EnergyPopup } from \"@/components/ui/energy-popup\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { WellSearch } from \"@/components/ui/well-search\";\nimport { useMap, useMapGeneration, useMapReady } from \"@/lib/map-context\";\nimport { chartRamp } from \"@/lib/map-layers\";\n\n// --- energy-explorer helpers (kept identical across both lanes) ---\nexport type EnergyLang = \"en\" | \"fr\";\n\n/** The block's own map id, so a map elsewhere on the page never answers for it. */\nexport const ENERGY_MAP_ID = \"energy-explorer\";\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\nexport const ENERGY_COPY = {\n  en: {\n    title: \"Energy explorer\",\n    description: \"Wells, pipelines and facilities in Alberta. Switch layers, click a feature for its record, or search a UWI or licence.\",\n    checking: \"Checking this key's add-ons...\",\n    search: \"Search UWI or well licence\",\n    searchHint: \"A UWI like 100/06-12-048-07W5/00, or a licence number.\",\n    searchEmpty: \"No well matches that UWI or licence.\",\n    hint: \"Click a well, pipeline or facility on the map.\",\n    hintTouch: \"Tap a well, pipeline or facility on the map.\",\n    hintMore: \"Anything recorded there shows here.\",\n    selected: \"Selected\",\n    loading: \"Looking up this point...\",\n    miss: \"Nothing recorded at this point.\",\n    fields: { uwi: \"UWI\", licence: \"Licence\", operator: \"Operator\", status: \"Status\" },\n    kinds: {\n      \"energy.wells\": \"Well\",\n      \"energy.pipelines\": \"Pipeline\",\n      \"energy.facilities\": \"Facility\",\n      well: \"Well\",\n      facility: \"Facility\",\n    } as Record<string, string>,\n    busy: \"Too many requests. Try again in a moment.\",\n    keyRefused: \"The API key was refused. Check it and try again.\",\n    unavailable: \"The service didn't answer. Try again in a moment.\",\n    outside: \"That point is outside Canada. The energy layers cover Alberta.\",\n    failed: \"The lookup failed. Try again.\",\n    deniedTitle: {\n      addon_required: \"Energy layers need the Energy add-on\",\n      dev_only: \"Energy layers are free for development only\",\n      usage_limit_reached: \"This month's development allowance is used\",\n    },\n    deniedBody: {\n      addon_required:\n        \"This key does not hold the Energy add-on, so the map shows the basemap without wells, pipelines or facilities, and search and identify are off.\",\n      dev_only:\n        \"On the dev plan, energy layers work from localhost and preview origins. From this origin they need a paid plan with the Energy add-on.\",\n      usage_limit_reached:\n        \"The dev plan's premium calls for this month are used. Energy layers come back next month, or now with a paid plan.\",\n    },\n    unknownTitle: \"Could not check the Energy 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 energy search and identify are off. The map shows what this key can read.`,\n    pricing: \"Plans and add-ons\",\n    pricingUrl: \"https://unmap.dev/pricing\",\n    credit: \"Energy layers: Alberta Energy Regulator open data (ST37, ST76, ST102), refreshed monthly.\",\n    fold: \"Hide details\",\n    unfold: \"Show details\",\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: \"Explorateur énergétique\",\n    description:\n      \"Puits, pipelines et installations en Alberta. Changez de couches, cliquez sur un élément pour sa fiche, ou cherchez un UWI ou un permis.\",\n    checking: \"Vérification des modules complémentaires de cette clé...\",\n    search: \"Chercher UWI ou permis\",\n    searchHint: \"Un UWI comme 100/06-12-048-07W5/00, ou un numéro de permis.\",\n    searchEmpty: \"Aucun puits ne correspond à cet UWI ni à ce permis.\",\n    hint: \"Cliquez sur un puits, un pipeline ou une installation sur la carte.\",\n    hintTouch: \"Touchez un puits, un pipeline ou une installation sur la carte.\",\n    hintMore: \"Ce qui y est enregistré s'affiche ici.\",\n    selected: \"Sélection\",\n    loading: \"Recherche de ce point...\",\n    miss: \"Rien n'est enregistré à cet endroit.\",\n    fields: { uwi: \"UWI\", licence: \"Permis\", operator: \"Exploitant\", status: \"Statut\" },\n    kinds: {\n      \"energy.wells\": \"Puits\",\n      \"energy.pipelines\": \"Pipeline\",\n      \"energy.facilities\": \"Installation\",\n      well: \"Puits\",\n      facility: \"Installation\",\n    } as Record<string, string>,\n    busy: \"Trop de requêtes. Réessayez dans un instant.\",\n    keyRefused: \"La clé API a été refusée. Vérifiez-la et réessayez.\",\n    unavailable: \"Le service n'a pas répondu. Réessayez dans un instant.\",\n    outside: \"Ce point est hors du Canada. Les couches énergétiques couvrent l'Alberta.\",\n    failed: \"La recherche a échoué. Réessayez.\",\n    deniedTitle: {\n      addon_required: \"Les couches énergétiques nécessitent le module complémentaire Énergie\",\n      dev_only: \"Les couches énergétiques sont gratuites 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 Énergie : la carte montre le fond sans puits, pipelines ni installations, et la recherche et l'identification sont désactivées.\",\n      dev_only:\n        \"Avec le forfait de développement, les couches énergétiques fonctionnent depuis localhost et les origines de prévisualisation. Depuis cette origine, il faut un forfait payant avec le module complémentaire Énergie.\",\n      usage_limit_reached:\n        \"Les appels premium du forfait de développement sont épuisés pour ce mois. Les couches énergétiques reviennent le mois prochain, ou dès maintenant avec un forfait payant.\",\n    },\n    unknownTitle: \"Impossible de vérifier le module complémentaire Énergie\",\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 recherche et l'identification énergétiques sont désactivées. La carte montre ce que cette clé peut lire.`,\n    pricing: \"Forfaits et modules complémentaires\",\n    pricingUrl: \"https://unmap.dev/fr/pricing\",\n    credit:\n      \"Couches énergétiques : données ouvertes de l'Alberta Energy Regulator (ST37, ST76, ST102), mises à jour chaque mois.\",\n    fold: \"Masquer les détails\",\n    unfold: \"Afficher les détails\",\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/** Whether this key may read an add-on layer, as the gateway answered. */\nexport type AddonGate =\n  | { state: \"checking\" }\n  | { state: \"granted\" }\n  | {\n      state: \"denied\";\n      /** `addon_required`: buy the add-on. `dev_only`: free from localhost only. `usage_limit_reached`: the dev plan's month is spent. */\n      code: \"addon_required\" | \"dev_only\" | \"usage_limit_reached\";\n      /** The SKU that unlocks it, e.g. `addon-legal-land`. */\n      addon: string;\n      /** The gateway's own explanation, in English. */\n      message: string;\n    }\n  | { state: \"unknown\"; status: number };\n\n/**\n * Ask the gateway whether `apiKey` may read the catalog layer `layer`. One request, metered like\n * any other: a tile at zoom 22, past every archive, which the gateway answers 204 once the add-on\n * check has passed and 403 with a reason when it has not. Nothing is drawn from it. A network\n * failure or any other status is `unknown`; an aborted probe rejects with the AbortError.\n */\nexport async function probeAddon(\n  apiKey: string,\n  layer: string,\n  gateway = \"https://api.unmap.dev\",\n  signal?: AbortSignal,\n): Promise<AddonGate> {\n  const url = `${gateway.replace(/\\/+$/, \"\")}/layers/${encodeURIComponent(layer)}/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 === 403) {\n    const body = (await response.json().catch(() => null)) as { code?: unknown; addon?: unknown; message?: unknown } | null;\n    const code = body?.code;\n    if (code === \"addon_required\" || code === \"dev_only\" || code === \"usage_limit_reached\") {\n      return {\n        state: \"denied\",\n        code,\n        addon: typeof body?.addon === \"string\" ? body.addon : \"\",\n        message: typeof body?.message === \"string\" ? body.message : \"\",\n      };\n    }\n  }\n  return { state: \"unknown\", status: response.status };\n}\n\n/** What the record shows, from a search result or an identify hit. */\nexport interface WellDetails {\n  name: string;\n  uwi?: string;\n  licence?: string;\n  operator?: string;\n  status?: string;\n}\n\n/** The fields a search result and an identify hit share, without empty ones. */\nexport function wellDetails(hit: { name: string; uwi?: string; licence?: string; operator?: string; status?: string }): WellDetails {\n  return {\n    name: hit.name,\n    ...(hit.uwi ? { uwi: hit.uwi } : {}),\n    ...(hit.licence ? { licence: hit.licence } : {}),\n    ...(hit.operator ? { operator: hit.operator } : {}),\n    ...(hit.status ? { status: hit.status } : {}),\n  };\n}\n\n/** The labelled fields a picked feature has, in a fixed order. */\nexport function detailRows(well: WellDetails, lang: EnergyLang): Array<[string, string]> {\n  const labels = ENERGY_COPY[lang].fields;\n  const rows: Array<[string, string | undefined]> = [\n    [labels.uwi, well.uwi],\n    [labels.licence, well.licence],\n    [labels.operator, well.operator],\n    [labels.status, well.status],\n  ];\n  return rows.filter((row): row is [string, string] => typeof row[1] === \"string\" && row[1] !== \"\");\n}\n\n/** What a picked feature is, from its identify layer or its search layer; \"\" for any other. */\nexport function kindLabel(layer: string | undefined, lang: EnergyLang): string {\n  return ENERGY_COPY[lang].kinds[layer ?? \"\"] ?? \"\";\n}\n\n/** A [lng, lat] as people read it: degrees north and west, a decimal comma in French. */\nexport function formatPoint(at: [number, number], lang: EnergyLang): string {\n  const [lng, lat] = at;\n  const number = (value: number) => {\n    const text = Math.abs(value).toFixed(4);\n    return lang === \"fr\" ? text.replace(\".\", \",\") : text;\n  };\n  const west = lang === \"fr\" ? \"O\" : \"W\";\n  return `${number(lat)}° ${lat < 0 ? \"S\" : \"N\"}, ${number(lng)}° ${lng < 0 ? west : \"E\"}`;\n}\n\n/** What to tell the reader about a failed search or identify, by what the gateway said. */\nexport function requestError(caught: unknown, lang: EnergyLang): string {\n  const copy = ENERGY_COPY[lang];\n  const failure = typeof caught === \"object\" && caught !== null ? (caught as { code?: unknown; status?: unknown }) : {};\n  const status = typeof failure.status === \"number\" ? failure.status : null;\n  if (failure.code === \"outside_coverage\") return copy.outside;\n  if (status === 429) return copy.busy;\n  if (status === 401 || status === 403) return copy.keyRefused;\n  if (status === null || status >= 500) return copy.unavailable;\n  return copy.failed;\n}\n\n/** The click targets: the overlays identify answers for, painted from the customer's chart tokens. */\nexport const ENERGY_TARGETS = [\"energy.wells\", \"energy.pipelines\", \"energy.facilities\"] as const;\nexport type EnergyTarget = (typeof ENERGY_TARGETS)[number];\n\n/** The area overlays: left in the style's own neutral colour, with no swatch in the legend. */\nexport const ENERGY_AREAS = [\"energy.fields\", \"energy.petroleum-tenure\", \"energy.mineral-tenure\", \"energy.ccs\", \"energy.geothermal\"] as const;\n\n/** One colour per click target from a ramp (`--chart-1`, `--chart-2`, `--chart-3`), cycling a short one. */\nexport function targetColors(ramp: string[]): Record<EnergyTarget, string> {\n  const at = (index: number) => ramp[index % ramp.length] ?? \"\";\n  return { \"energy.wells\": at(0), \"energy.pipelines\": at(1), \"energy.facilities\": at(2) };\n}\n\n/** The click target a search or identify layer belongs to: a searched `well` is a well. */\nexport function targetOf(layer: string | undefined): EnergyTarget | undefined {\n  if (layer === \"well\") return \"energy.wells\";\n  if (layer === \"facility\") return \"energy.facilities\";\n  return (ENERGY_TARGETS as readonly string[]).includes(layer ?? \"\") ? (layer as EnergyTarget) : undefined;\n}\n\n/** The legend's swatches: the painted colour for a click target (none until painted), none for an area. */\nexport function legendColors(colors: Record<EnergyTarget, string> | null): Record<string, string | false> {\n  const swatches: Record<string, string | false> = {};\n  for (const id of ENERGY_TARGETS) swatches[id] = colors?.[id] ?? false;\n  for (const id of ENERGY_AREAS) swatches[id] = false;\n  return swatches;\n}\n\n/** The part of a MapLibre map the paint uses. */\nexport interface EnergyPaintMap {\n  getStyle(): { layers?: Array<{ id: string; type: string }> } | undefined;\n  setPaintProperty(id: string, name: string, value: unknown): unknown;\n}\n\nconst PAINT_COLOUR: Record<string, string> = { circle: \"circle-color\", line: \"line-color\", fill: \"fill-color\" };\n\n/**\n * Paint every drawn layer of each click target (`overlay_energy.wells_circle`, the pipelines line)\n * in its colour. Labels keep the style's colour, which is solved for contrast. Call it again after\n * every style load: a new style brings the gateway's own paint back.\n */\nexport function paintTargets(map: EnergyPaintMap, colors: Record<EnergyTarget, string>): void {\n  for (const layer of map.getStyle()?.layers ?? []) {\n    const key = PAINT_COLOUR[layer.type];\n    const target = ENERGY_TARGETS.find((id) => layer.id.startsWith(`overlay_${id}_`));\n    if (key && target && colors[target]) map.setPaintProperty(layer.id, key, colors[target]);\n  }\n}\n\n/** A box on screen, as getBoundingClientRect() gives it. */\nexport interface EnergyRect {\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 EnergyPadding {\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 centre of the view clear of the floating panel: its width on the\n * left when it floats beside the map, its height at the bottom when it is a sheet across it. An\n * even gutter when nothing is measured. When the full gutter would leave too little map (a tall\n * sheet on a small phone), the gutter tightens and the frame stays in the strip the panel leaves.\n */\nexport function panelPadding(map: EnergyRect, panel: EnergyRect | null, gutter = GUTTER): EnergyPadding {\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 on a touch screen, where the copy says tap. Read after mount. */\nexport function coarsePointer(): boolean {\n  return typeof window !== \"undefined\" && window.matchMedia?.(\"(pointer: coarse)\")?.matches === true;\n}\n\n/** Below Tailwind's `sm` the panel is a bottom sheet. */\nexport const PHONE_QUERY = \"not all and (min-width: 40rem)\";\n\n/** True on a phone-width screen, where the record leads the sheet. False on the server. */\nexport function phoneWidth(): boolean {\n  return typeof window !== \"undefined\" && window.matchMedia?.(PHONE_QUERY)?.matches === true;\n}\n\n/** Drayton Valley, in the Pembina field: at zoom 11 single wells, pipelines and facilities can be picked by eye. */\nexport const DRAYTON_VALLEY: [number, number] = [-114.977, 53.222];\n// --- end energy-explorer helpers ---\n\nexport interface EnergyExplorerProps {\n  /** unmap API key. 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, the search, the legend and the map labels. Defaults to \"en\". */\n  lang?: EnergyLang;\n  /** Where the map opens, [longitude, latitude]: the initial camera only, a later change does not move the map. Defaults to Drayton Valley. */\n  center?: [number, number];\n  /** The opening zoom: the initial camera only, like `center`. Defaults to 11. */\n  zoom?: number;\n  className?: string;\n}\n\nexport function EnergyExplorer({ apiKey, gateway, lang = \"en\", center = DRAYTON_VALLEY, zoom = 11, className }: EnergyExplorerProps) {\n  const copy = ENERGY_COPY[lang];\n  const hasKey = apiKey.trim() !== \"\";\n  const [gate, setGate] = useState<AddonGate>({ state: \"checking\" });\n  // The opening camera, kept: after this the reader moves the map, not the props.\n  const [opening] = useState(() => ({ center, zoom }));\n\n  // One probe per key. Nothing gated is mounted until it answers.\n  useEffect(() => {\n    if (!hasKey) return;\n    const controller = new AbortController();\n    setGate({ state: \"checking\" });\n    probeAddon(apiKey, \"energy.wells\", gateway, controller.signal).then(\n      (answer) => {\n        if (!controller.signal.aborted) setGate(answer);\n      },\n      () => {\n        // Aborted: a newer probe or the unmount owns the state.\n      },\n    );\n    return () => controller.abort();\n  }, [apiKey, gateway, hasKey]);\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=\"energy-explorer\"\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=\"energy-explorer\"\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  return (\n    <div data-unmap-block=\"energy-explorer\" className={[\"relative h-full min-h-[480px]\", className].filter(Boolean).join(\" \")}>\n      <EnergyMap id={ENERGY_MAP_ID} apiKey={apiKey} gateway={gateway} lang={lang} center={opening.center} zoom={opening.zoom} className=\"h-full\">\n        <Console apiKey={apiKey} gateway={gateway} lang={lang} gate={gate} center={opening.center} zoom={opening.zoom} />\n      </EnergyMap>\n    </div>\n  );\n}\n\n/** What the record slot holds: a hint, a lookup in flight, nothing there, a failure, or a record. */\ntype EnergyRecord =\n  | { kind: \"idle\" }\n  | { kind: \"loading\"; at: [number, number] }\n  | { kind: \"miss\"; at: [number, number] }\n  | { kind: \"error\"; at: [number, number]; message: string }\n  | { kind: \"picked\"; details: WellDetails; layer?: string; at?: [number, number] };\n\n/** Everything that needs the map. It renders inside <EnergyMap>, where useMap() can see it. */\nfunction Console({\n  apiKey,\n  gateway,\n  lang,\n  gate,\n  center,\n  zoom,\n}: {\n  apiKey: string;\n  gateway?: string;\n  lang: EnergyLang;\n  gate: AddonGate;\n  center: [number, number];\n  zoom: number;\n}) {\n  const copy = ENERGY_COPY[lang];\n  const granted = gate.state === \"granted\";\n  const map = useMap(ENERGY_MAP_ID);\n  const ready = useMapReady(ENERGY_MAP_ID);\n  const generation = useMapGeneration(ENERGY_MAP_ID);\n  const [record, setRecord] = useState<EnergyRecord>({ kind: \"idle\" });\n  // The click targets' colours, from the customer's chart tokens once the style has loaded.\n  const [colors, setColors] = useState<Record<EnergyTarget, string> | null>(null);\n  // On a phone the sheet can fold to the record, to give the map back.\n  const [folded, setFolded] = useState(false);\n  // Read after mount: the server cannot know the pointer, so the first pass says \"click\".\n  const [touch, setTouch] = useState(false);\n  // A phone-width screen, where the record leads the sheet in the DOM as well as on screen, so the\n  // keyboard meets it first. The panel mounts only after the add-on probe, so on the client.\n  const [phone, setPhone] = useState(phoneWidth);\n  const panelRef = useRef<HTMLDivElement | null>(null);\n  const contentRef = useRef<HTMLDivElement | null>(null);\n  const movedRef = useRef(false);\n  const uid = useId();\n  const gateId = `${uid}-gate`;\n  const toolsId = `${uid}-tools`;\n\n  useEffect(() => setTouch(coarsePointer()), []);\n\n  useEffect(() => {\n    const media = typeof window === \"undefined\" ? undefined : window.matchMedia?.(PHONE_QUERY);\n    if (!media) return;\n    const update = () => setPhone(media.matches);\n    update();\n    media.addEventListener?.(\"change\", update);\n    return () => media.removeEventListener?.(\"change\", update);\n  }, []);\n\n  // The gateway paints every overlay one colour. The click targets take the chart tokens instead,\n  // read again and painted again on every style load (a mode swap brings new tokens and new paint).\n  useEffect(() => {\n    if (!map || !ready || !granted || !map.getStyle()) return;\n    const next = targetColors(chartRamp(map));\n    paintTargets(map as unknown as EnergyPaintMap, next);\n    setColors((previous) => (JSON.stringify(previous) === JSON.stringify(next) ? previous : next));\n  }, [map, ready, generation, granted]);\n\n  // The camera keeps the panel's padding, so the opening view, a searched well and every later\n  // move centre in the map the panel leaves. The opening view is placed again (resize, the panel\n  // sliding in, the sheet folding) until the reader looks somewhere; after that only the padding moves.\n  const placeRef = useRef({ center, zoom });\n  placeRef.current = { center, zoom };\n  useEffect(() => {\n    if (!map) return;\n    const place = () => {\n      const container = map.getContainer();\n      const padding = panelPadding(container.getBoundingClientRect(), panelRef.current?.getBoundingClientRect() ?? null);\n      map.easeTo(movedRef.current ? { padding, duration: 0 } : { ...placeRef.current, padding, duration: 0 });\n    };\n    // The camera's own moves carry no originalEvent; a drag, a wheel or a key does.\n    const onMoveStart = (event?: { originalEvent?: unknown }) => {\n      if (event?.originalEvent) movedRef.current = true;\n    };\n    // Measured again once the panel has slid in: mid-slide it sits off its final place.\n    const panel = panelRef.current;\n    const onSlidIn = (event: Event) => {\n      if (event.target === panel) place();\n    };\n    place();\n    map.on(\"resize\", place);\n    map.on(\"movestart\", onMoveStart as never);\n    panel?.addEventListener(\"animationend\", onSlidIn);\n    return () => {\n      map.off(\"resize\", place);\n      map.off(\"movestart\", onMoveStart as never);\n      panel?.removeEventListener(\"animationend\", onSlidIn);\n    };\n  }, [map, folded]);\n\n  // Stable, so the popup keeps its click handler across renders. On a phone the whole sheet\n  // scrolls: bring the record, which leads it once there is one, back into view.\n  const show = useCallback((next: EnergyRecord) => {\n    movedRef.current = true;\n    setRecord(next);\n    if (contentRef.current) contentRef.current.scrollTop = 0;\n  }, []);\n  const onLookup = useCallback((at: [number, number]) => show({ kind: \"loading\", at }), [show]);\n  const onIdentify = useCallback(\n    (hit: IdentifyResult, at: [number, number]) => show({ kind: \"picked\", details: wellDetails(hit), layer: hit.layer, at }),\n    [show],\n  );\n  const onMiss = useCallback((at: [number, number]) => show({ kind: \"miss\", at }), [show]);\n  const onError = useCallback(\n    (error: unknown, at: [number, number]) => show({ kind: \"error\", at, message: requestError(error, lang) }),\n    [show, lang],\n  );\n  const searchError = useCallback((error: unknown) => requestError(error, lang), [lang]);\n\n  function pickResult(result: GeocodeResult) {\n    show({ kind: \"picked\", details: wellDetails(result), layer: result.layer, at: [result.lng, result.lat] });\n  }\n\n  const hideWhenFolded = \"max-sm:data-[folded=true]:hidden\";\n  const kind = record.kind === \"picked\" ? kindLabel(record.layer, lang) : \"\";\n  const target = record.kind === \"picked\" ? targetOf(record.layer) : undefined;\n  const kindColor = target && colors ? colors[target] : undefined;\n  // What the live region says once a lookup settles. A failure speaks through its own alert.\n  const said =\n    record.kind === \"picked\" ? `${kind ? `${kind}: ` : \"\"}${record.details.name}.` : record.kind === \"miss\" ? copy.miss : \"\";\n  const where = record.kind !== \"idle\" && record.at ? formatPoint(record.at, lang) : \"\";\n\n  // The record: always there and tall enough for its answer, so nothing jumps when it arrives. On\n  // a phone it leads the sheet, so the first action shows without scrolling: it is placed first in\n  // the DOM rather than reordered by CSS, so keyboard order matches what is seen.\n  const recordSlot = (\n    <section\n      aria-label={copy.selected}\n      data-testid=\"energy-record\"\n      className=\"flex min-h-20 shrink-0 flex-col justify-center gap-2 border-t pt-4 max-sm:border-t-0 max-sm:pt-0 sm:min-h-[7.5rem]\"\n    >\n      {record.kind === \"idle\" ? (\n        <div className=\"flex flex-col gap-0.5\">\n          <p className=\"text-base font-medium\">{touch ? copy.hintTouch : copy.hint}</p>\n          <p className=\"text-sm text-muted-foreground\">{copy.hintMore}</p>\n        </div>\n      ) : (\n        <div key={`${record.kind}:${where}`} className=\"flex min-w-0 flex-col gap-2 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200\">\n          {record.kind === \"loading\" ? (\n            <p className=\"flex h-7 items-center gap-2 text-sm text-muted-foreground\">\n              <Spinner className=\"size-4\" />\n              {copy.loading}\n            </p>\n          ) : record.kind === \"miss\" ? (\n            <p className=\"text-base font-medium\">{copy.miss}</p>\n          ) : record.kind === \"error\" ? (\n            <Alert variant=\"destructive\" role=\"alert\">\n              <AlertDescription>{record.message}</AlertDescription>\n            </Alert>\n          ) : (\n            <>\n              {kind ? (\n                <p className=\"flex items-center gap-1.5 text-xs font-medium tracking-wide text-muted-foreground uppercase\">\n                  {/* The colour this kind has on the map and in the legend. */}\n                  {kindColor ? (\n                    <span aria-hidden=\"true\" data-energy-swatch=\"\" className=\"size-2.5 shrink-0 rounded-full\" style={{ backgroundColor: kindColor }} />\n                  ) : null}\n                  {kind}\n                </p>\n              ) : null}\n              <p className=\"truncate text-xl leading-7 font-semibold tracking-tight tabular-nums\" title={record.details.name}>\n                {record.details.name}\n              </p>\n              {detailRows(record.details, lang).length > 0 ? (\n                <dl className=\"grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1 text-sm\">\n                  {detailRows(record.details, lang).map(([label, value]) => (\n                    <div key={label} className=\"contents\">\n                      <dt className=\"text-muted-foreground\">{label}</dt>\n                      <dd className=\"truncate font-medium tabular-nums\">{value}</dd>\n                    </div>\n                  ))}\n                </dl>\n              ) : null}\n            </>\n          )}\n          {where ? <p className=\"text-xs text-muted-foreground tabular-nums\">{where}</p> : null}\n        </div>\n      )}\n    </section>\n  );\n\n  return (\n    <>\n      {granted ? <EnergyPopup mapId={ENERGY_MAP_ID} apiKey={apiKey} gateway={gateway} onLookup={onLookup} onIdentify={onIdentify} onMiss={onMiss} onError={onError} /> : null}\n      <p data-testid=\"energy-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 half the map) that clears the attribution. */}\n      <div\n        ref={panelRef}\n        className=\"absolute inset-x-4 bottom-10 z-10 flex max-h-[50%] flex-col sm:inset-x-auto sm:top-6 sm:bottom-auto sm:left-6 sm:max-h-[calc(100%-3rem)] sm:w-[22rem] 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\">\n          <CardHeader>\n            <div className=\"flex items-center justify-between gap-2\">\n              <CardTitle role=\"heading\" aria-level={2} className=\"min-w-0\">\n                {copy.title}\n              </CardTitle>\n              {/* An icon, so the title keeps the width of a phone's sheet; its name says what it does. */}\n              <Button\n                type=\"button\"\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"shrink-0 sm:hidden\"\n                aria-label={folded ? copy.unfold : copy.fold}\n                title={folded ? copy.unfold : copy.fold}\n                aria-expanded={!folded}\n                aria-controls={granted ? toolsId : gateId}\n                onClick={() => setFolded((current) => !current)}\n              >\n                <span aria-hidden=\"true\">{folded ? \"▴\" : \"▾\"}</span>\n              </Button>\n            </div>\n            <CardDescription className=\"max-sm:hidden\">{copy.description}</CardDescription>\n          </CardHeader>\n          <CardContent ref={contentRef} className=\"flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto overscroll-contain\">\n            {gate.state === \"denied\" || gate.state === \"unknown\" ? (\n              <Alert\n                id={gateId}\n                data-folded={folded}\n                data-unmap-addon-gate={gate.state === \"denied\" ? gate.code : \"unknown\"}\n                className={`shrink-0 ${hideWhenFolded}`}\n              >\n                <AlertTitle className=\"line-clamp-none\">{gate.state === \"denied\" ? copy.deniedTitle[gate.code] : copy.unknownTitle}</AlertTitle>\n                <AlertDescription className=\"flex flex-col items-start gap-2\">\n                  <p>{gate.state === \"denied\" ? copy.deniedBody[gate.code] : copy.unknownBody(gate.status)}</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            ) : null}\n            {granted ? (\n              <>\n                {phone ? recordSlot : null}\n                {/* The tools: search, then the legend. On a phone they fold away and the record stays. */}\n                <div id={toolsId} data-unmap-energy-tools=\"\" data-folded={folded} className={`flex shrink-0 flex-col gap-4 ${hideWhenFolded}`}>\n                  <div className=\"flex flex-col gap-1\">\n                    {/* `[&>p]:hidden` hides the geocoder's own coverage line; the example below says more. */}\n                    <WellSearch\n                      mapId={ENERGY_MAP_ID}\n                      apiKey={apiKey}\n                      gateway={gateway}\n                      lang={lang}\n                      placeholder={copy.search}\n                      flyToZoom={14}\n                      emptyText={copy.searchEmpty}\n                      errorText={searchError}\n                      onSelect={pickResult}\n                      className=\"[&>p]:hidden\"\n                    />\n                    <p className=\"text-xs text-muted-foreground\">{copy.searchHint}</p>\n                  </div>\n                  {/* Two columns of layers from `sm` up: eight rows read as a legend, not a list. */}\n                  <EnergyLayerControl\n                    mapId={ENERGY_MAP_ID}\n                    lang={lang}\n                    colors={legendColors(colors)}\n                    className=\"sm:[&>[data-slot=field-group]]:grid sm:[&>[data-slot=field-group]]:grid-cols-2 sm:[&>[data-slot=field-group]]:gap-x-3\"\n                  />\n                </div>\n                {phone ? null : recordSlot}\n                <p data-folded={folded} className={`shrink-0 pb-1 text-xs text-muted-foreground ${hideWhenFolded}`}>\n                  {copy.credit}\n                </p>\n              </>\n            ) : null}\n          </CardContent>\n        </Card>\n      </div>\n    </>\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 /energy-explorer. Needs the Energy add-on; without it the page says why. Guide: https://unmap.dev/docs/frontend/blocks#explore-albertas-wells-and-pipelines",
  "categories": [
    "blocks"
  ],
  "type": "registry:block"
}