{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "land-treaty-inspector",
  "title": "Legal Land and Treaty Inspector",
  "description": "A page that answers a map click or a legal land description with one card: the survey cell, hatched on the map, and the treaty records at that point, dotted, with every survey unit there listed. Legal land needs the Legal Land add-on (Energy and Agriculture include it); without it the page says why, links pricing, and keeps the treaty context with a municipality lookup. Calls /identify and /geocode/reverse per point and /geocode/autocomplete as you type, after one add-on check.",
  "dependencies": [
    "@unmap/geocoding"
  ],
  "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/legal-land-search.json",
    "https://registry.unmap.dev/r/treaty-context.json",
    "card",
    "button",
    "alert",
    "spinner"
  ],
  "files": [
    {
      "path": "registry/blocks/land-treaty-inspector/page.tsx",
      "content": "/**\n * /land-treaty-inspector: the land and treaty inspector block as a Next.js App Router page,\n * full-bleed (the block keeps its own gutter). The key comes from NEXT_PUBLIC_UNMAP_KEY in\n * .env.local; installing the block added the empty line, paste your key.\n */\nimport { LandTreatyInspector } from \"@/components/ui/land-treaty-inspector\";\n\nexport const metadata = { title: \"Look up legal land and treaties\" };\n\nexport default function LandTreatyInspectorPage() {\n  return (\n    <main className=\"h-dvh\">\n      <LandTreatyInspector apiKey={process.env.NEXT_PUBLIC_UNMAP_KEY ?? \"\"} className=\"h-full\" />\n    </main>\n  );\n}\n",
      "type": "registry:page",
      "target": "app/land-treaty-inspector/page.tsx"
    },
    {
      "path": "registry/blocks/land-treaty-inspector/land-treaty-inspector.tsx",
      "content": "\"use client\";\n\n/**\n * <LandTreatyInspector> - click anywhere, or search a legal land description, and get one answer\n * card for that point: the survey cell (hatched on the map) and the treaty records the Crown has\n * published for it (dotted on the map). A full-bleed map with a floating panel, a bottom sheet on\n * a phone. Every survey unit at the point is listed under the answer; pick one to hatch it instead.\n *\n * Legal land (the `survey.dls` and `survey.nts` overlays, legal land search and survey identify)\n * needs an add-on with legal land: Legal Land, or Energy or Agriculture, which include it. The\n * block asks the gateway once whether your key holds one and composes the gated parts only if it\n * does: a refused overlay would make the whole style request fail. Without it the panel says why,\n * links the pricing page, and keeps the treaty context, which is on every plan, with a\n * municipality lookup in place of the survey cell.\n *\n * Metering: every authenticated request is one call. The block makes the add-on probe (one\n * `GET /layers/survey.dls/22/0/0`, in the Legal Land bucket), then the map's own style and tile\n * requests. Each map click or chosen search result makes one `GET /identify` and one\n * `GET /geocode/reverse` with `include=treaties` (the treaty panel's lookup; a coordinate typed\n * into the panel makes the same two). The search box makes one `GET /geocode/autocomplete` per\n * pause in typing.\n *\n * Treaty context is the Crown's published representation of historic and modern treaties (CIRNAC,\n * Open Government Licence - Canada), not a map of Indigenous nations or a determination of rights;\n * the treaty panel says so in its own words. Composes the map, legal-land-search and\n * treaty-context items on your shadcn Card, Alert, Button and Spinner. Installed by\n * `npx shadcn add` with its page at app/land-treaty-inspector/page.tsx. Edit freely.\n */\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { Geocoder as GeocoderClient, type GeocodeResult, type TreatySelection } 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 { LegalLandSearch } from \"@/components/ui/legal-land-search\";\nimport { Map } from \"@/components/ui/map\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { TreatyContext } from \"@/components/ui/treaty-context\";\nimport { useMap, useMapGeneration, useMapReady } from \"@/lib/map-context\";\nimport { HELPER_KEY, accentColor, tokenColor } from \"@/lib/map-layers\";\n\n// --- land-treaty-inspector helpers (kept identical across both lanes) ---\nexport type LandLang = \"en\" | \"fr\";\n\n/** The block's own map id, so a map elsewhere on the page never answers for it. */\nexport const LAND_MAP_ID = \"land-treaty-inspector\";\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 LAND_COPY = {\n  en: {\n    title: \"Legal land and treaties\",\n    intro: \"Click anywhere for the survey cell and the treaty records at that point, or search a legal land description.\",\n    introTouch: \"Tap anywhere for the survey cell and the treaty records at that point, or search a legal land description.\",\n    introDenied: \"Click anywhere for the municipality and the treaty records at that point.\",\n    introDeniedTouch: \"Tap anywhere for the municipality and the treaty records at that point.\",\n    checking: \"Checking this key's add-ons...\",\n    search: \"Search legal land\",\n    searchHint: \"A quarter, section or LSD, like NW-25-24-1-W5.\",\n    empty: \"Click anywhere on the map.\",\n    emptyTouch: \"Tap anywhere on the map.\",\n    emptyHint: \"The answer for that point lands here.\",\n    legend: \"On the map\",\n    legendCell: \"Survey cell\",\n    legendTreaty: \"Treaty records\",\n    loading: \"Looking up this point...\",\n    treatyLoading: \"Checking treaty records...\",\n    units: {\n      lsd: \"Legal subdivision\",\n      qtr: \"Quarter section\",\n      sec: \"Section\",\n      twp: \"Township\",\n    } as Record<string, string>,\n    provinces: { AB: \"Alberta\", SK: \"Saskatchewan\", MB: \"Manitoba\", BC: \"British Columbia\" } as Record<string, string>,\n    cells: \"Survey cells at this point\",\n    cellsHint: \"Pick one to hatch it on the map.\",\n    separator: \": \",\n    noCell: \"No survey cell here\",\n    noCellHint: \"This map hatches Dominion Land Survey cells on the Prairies and NTS cells in British Columbia.\",\n    municipalityLabel: \"Municipality\",\n    municipality: (name: string) => `Municipality: ${name}`,\n    noMunicipality: \"Not inside a municipality\",\n    noMunicipalityHint: \"The lookup covers incorporated places.\",\n    treatyNone: \"No treaty record in this dataset\",\n    /** The contract's own words for a point with no record (docs/indigenous-context/contract.md, \"Interface copy\"). */\n    treatyNoneHint: \"This does not determine Indigenous rights or whether land is unceded.\",\n    treatyUnavailable: \"Treaty context is unavailable\",\n    treatyUnavailableHint: \"Try the point again in a moment.\",\n    categories: { historic: \"Historic treaty\", modern: \"Modern treaty\" } as Record<string, string>,\n    more: (count: number) => `and ${count} more`,\n    hiddenOnMap: \"hidden on map\",\n    sayTreaty: (name: string, caption: string) => `${name}, ${caption.charAt(0).toLowerCase()}${caption.slice(1)}.`,\n    sayCell: (unit: string, name: string, province: string) => `${unit} ${name}${province ? `, ${province}` : \"\"}.`,\n    sayNoCell: \"No survey cell at this point.\",\n    sayNoMunicipality: \"Not inside a municipality.\",\n    keyRefused: \"The gateway refused this key. Check the key and its add-ons, then try again.\",\n    busy: \"Too many requests. Try again in a moment.\",\n    outside: \"That point is outside Canada. Legal land and treaty context cover Canada.\",\n    unavailable: \"The service didn't answer. Try again in a moment.\",\n    failed: \"The lookup failed. Try again.\",\n    retry: \"Try again\",\n    deniedTitle: {\n      addon_required: \"Legal land needs the Legal Land add-on\",\n      dev_only: \"Legal land is free for development only\",\n      usage_limit_reached: \"This month's development allowance is used\",\n    },\n    deniedBody: {\n      addon_required:\n        \"This key holds no add-on with legal land (Legal Land, or Energy or Agriculture, which include it), so survey cells and legal land search are off. Treaty context is part of every plan: pick a point on the map to look it up.\",\n      dev_only:\n        \"On the dev plan, legal land works from localhost and preview origins. From this origin it needs a paid plan with an add-on that includes it: Legal Land, Energy or Agriculture. Treaty context still works: pick a point on the map.\",\n      usage_limit_reached:\n        \"The dev plan's premium calls for this month are used. Legal land comes back next month, or now with a paid plan. Treaty context still works: pick a point on the map.\",\n    },\n    unknownTitle: \"Could not check the Legal Land 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 survey cells and legal land search are off. Treaty context still works: pick a point on the map.`,\n    pricing: \"Plans and add-ons\",\n    pricingUrl: \"https://unmap.dev/pricing\",\n    surveyCredit:\n      \"Survey grids: Alberta, Saskatchewan, Manitoba and British Columbia open data, and Natural Resources Canada, under open data licences.\",\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: \"Terres légales et traités\",\n    intro: \"Cliquez n'importe où pour la cellule d'arpentage et les traités à ce point, ou cherchez une description légale.\",\n    introTouch: \"Touchez n'importe où pour la cellule d'arpentage et les traités à ce point, ou cherchez une description légale.\",\n    introDenied: \"Cliquez n'importe où pour la municipalité et les traités à ce point.\",\n    introDeniedTouch: \"Touchez n'importe où pour la municipalité et les traités à ce point.\",\n    checking: \"Vérification des modules complémentaires de cette clé...\",\n    search: \"Chercher une terre légale\",\n    searchHint: \"Un quart, une section ou une subdivision légale, par exemple NW-25-24-1-W5.\",\n    empty: \"Cliquez n'importe où sur la carte.\",\n    emptyTouch: \"Touchez n'importe où sur la carte.\",\n    emptyHint: \"La réponse pour ce point s'affiche ici.\",\n    legend: \"Sur la carte\",\n    legendCell: \"Cellule d'arpentage\",\n    legendTreaty: \"Traités\",\n    loading: \"Recherche de ce point...\",\n    treatyLoading: \"Recherche des traités...\",\n    units: {\n      lsd: \"Subdivision légale\",\n      qtr: \"Quart de section\",\n      sec: \"Section\",\n      twp: \"Canton\",\n    } as Record<string, string>,\n    provinces: { AB: \"Alberta\", SK: \"Saskatchewan\", MB: \"Manitoba\", BC: \"Colombie-Britannique\" } as Record<string, string>,\n    cells: \"Cellules d'arpentage à ce point\",\n    cellsHint: \"Choisissez-en une pour la hachurer sur la carte.\",\n    separator: \"\\u00a0: \",\n    noCell: \"Aucune cellule d'arpentage ici\",\n    noCellHint: \"Cette carte hachure les cellules de l'arpentage fédéral des Prairies et du SNRC en Colombie-Britannique.\",\n    municipalityLabel: \"Municipalité\",\n    municipality: (name: string) => `Municipalité\\u00a0: ${name}`,\n    noMunicipality: \"Hors d'une municipalité\",\n    noMunicipalityHint: \"La recherche couvre les lieux constitués.\",\n    treatyNone: \"Aucun traité dans ce jeu de données\",\n    treatyNoneHint: \"Ce résultat ne détermine ni les droits autochtones ni si le territoire est non cédé.\",\n    treatyUnavailable: \"Le contexte des traités est indisponible\",\n    treatyUnavailableHint: \"Réessayez ce point dans un instant.\",\n    categories: { historic: \"Traité historique\", modern: \"Traité moderne\" } as Record<string, string>,\n    more: (count: number) => `et ${count} de plus`,\n    hiddenOnMap: \"masqué sur la carte\",\n    sayTreaty: (name: string, caption: string) => `${name}, ${caption.charAt(0).toLowerCase()}${caption.slice(1)}.`,\n    sayCell: (unit: string, name: string, province: string) => `${unit} ${name}${province ? `, ${province}` : \"\"}.`,\n    sayNoCell: \"Aucune cellule d'arpentage à ce point.\",\n    sayNoMunicipality: \"Hors d'une municipalité.\",\n    keyRefused: \"La passerelle a refusé cette clé. Vérifiez la clé et ses modules complémentaires, puis réessayez.\",\n    busy: \"Trop de requêtes. Réessayez dans un instant.\",\n    outside: \"Ce point est hors du Canada. Les terres légales et le contexte des traités couvrent le Canada.\",\n    unavailable: \"Le service n'a pas répondu. Réessayez dans un instant.\",\n    failed: \"La recherche a échoué. Réessayez.\",\n    retry: \"Réessayer\",\n    deniedTitle: {\n      addon_required: \"Les terres légales nécessitent le module complémentaire Terres légales\",\n      dev_only: \"Les terres légales 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 aucun module qui comprend les terres légales (Terres légales, ou Énergie ou Agriculture, qui les comprennent)\\u00a0: les cellules d'arpentage et la recherche de terres légales sont désactivées. Le contexte des traités fait partie de chaque forfait\\u00a0: choisissez un point sur la carte pour le consulter.\",\n      dev_only:\n        \"Avec le forfait de développement, les terres légales fonctionnent depuis localhost et les origines de prévisualisation. Depuis cette origine, il faut un forfait payant avec un module qui les comprend\\u00a0: Terres légales, Énergie ou Agriculture. Le contexte des traités fonctionne toujours\\u00a0: choisissez un point sur la carte.\",\n      usage_limit_reached:\n        \"Les appels premium du forfait de développement sont épuisés pour ce mois. Les terres légales reviennent le mois prochain, ou dès maintenant avec un forfait payant. Le contexte des traités fonctionne toujours\\u00a0: choisissez un point sur la carte.\",\n    },\n    unknownTitle: \"Impossible de vérifier le module complémentaire Terres légales\",\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}`}\\u00a0: les cellules d'arpentage et la recherche de terres légales sont désactivées. Le contexte des traités fonctionne toujours\\u00a0: choisissez un point sur la carte.`,\n    pricing: \"Forfaits et modules complémentaires\",\n    pricingUrl: \"https://unmap.dev/fr/pricing\",\n    surveyCredit:\n      \"Grilles d'arpentage\\u00a0: données ouvertes de l'Alberta, de la Saskatchewan, du Manitoba et de la Colombie-Britannique, et Ressources naturelles Canada, sous licences de données ouvertes.\",\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/** The identify layers legal land needs: the Dominion Land Survey and the NTS grid. */\nexport const LEGAL_LAYERS = [\"survey.dls\", \"survey.nts\"];\n\n/** The overlays to compose: legal land only when the key holds it, the core treaties always. */\nexport function gatedOverlays(gate: AddonGate): string[] {\n  return gate.state === \"granted\" ? [\"treaties\", ...LEGAL_LAYERS] : [\"treaties\"];\n}\n\n/** Survey units from the smallest up. The smallest under a point is the one outlined. */\nexport const LAND_UNITS = [\"lsd\", \"qtr\", \"sec\", \"twp\"] as const;\n\n/** An identify row, as `/identify` returns it. */\nexport interface LandRow {\n  layer: string;\n  id: string;\n  name: string;\n  unit?: string;\n  province?: string;\n  geometry?: { type: \"Polygon\" | \"MultiPolygon\"; coordinates: unknown };\n}\n\nfunction unitRank(unit: string | undefined): number {\n  const index = (LAND_UNITS as readonly string[]).indexOf(unit ?? \"\");\n  return index === -1 ? LAND_UNITS.length : index;\n}\n\n/** The survey rows, smallest unit first; municipality rows left out. */\nexport function sortCells(rows: LandRow[]): LandRow[] {\n  return rows\n    .filter((row) => (LEGAL_LAYERS as readonly string[]).includes(row.layer))\n    .sort((a, b) => unitRank(a.unit) - unitRank(b.unit));\n}\n\n/**\n * The survey cell to outline: the one named `preferred` (the description that was searched, so a\n * searched quarter outlines the quarter, not the legal subdivision at its centre), else the\n * smallest unit present.\n */\nexport function pickCell(rows: LandRow[], preferred?: string): LandRow | undefined {\n  const cells = sortCells(rows);\n  return (preferred ? cells.find((row) => row.name === preferred) : undefined) ?? cells[0];\n}\n\n/** A unit's name for people, in the interface language; an unknown unit as the gateway spells it. */\nexport function unitLabel(unit: string | undefined, lang: LandLang): string {\n  return LAND_COPY[lang].units[unit ?? \"\"] ?? unit ?? \"\";\n}\n\n/** A province's name for people, from its two-letter code; an unknown code as it came. */\nexport function provinceLabel(code: string | undefined, lang: LandLang): string {\n  return LAND_COPY[lang].provinces[code ?? \"\"] ?? code ?? \"\";\n}\n\n/** The treaty record ids a lookup matched, once each and sorted, for the highlight filter. */\nexport function matchedTreatyIds(context: { matches?: Array<{ id: string }> } | undefined): string[] {\n  return [...new Set((context?.matches ?? []).map((match) => match.id))].sort();\n}\n\n/**\n * A square RGBA tile for `fill-pattern`, in `colour` on transparent: `\"hatch\"` is diagonal stripes\n * (the selected survey cell), `\"dots\"` a dot grid (the treaties at the point). They differ by\n * shape, not only by colour. `colour` is `#rrggbb`; anything else draws in black.\n */\nexport function patternImage(\n  colour: string,\n  kind: \"hatch\" | \"dots\",\n  size = 8,\n): { width: number; height: number; data: Uint8ClampedArray } {\n  const valid = /^#[0-9a-f]{6}$/i.test(colour);\n  const channels = [1, 3, 5].map((at) => (valid ? Number.parseInt(colour.slice(at, at + 2), 16) : 0));\n  const data = new Uint8ClampedArray(size * size * 4);\n  for (let y = 0; y < size; y += 1) {\n    for (let x = 0; x < size; x += 1) {\n      // One thin line or one small dot per tile: sparse enough that the map under it still reads.\n      const on = kind === \"hatch\" ? (x + y) % size === 0 : (x % size === 1 || x % size === 2) && (y % size === 1 || y % size === 2);\n      if (!on) continue;\n      const at = (y * size + x) * 4;\n      data[at] = channels[0]!;\n      data[at + 1] = channels[1]!;\n      data[at + 2] = channels[2]!;\n      data[at + 3] = 255;\n    }\n  }\n  return { width: size, height: size, data };\n}\n\nexport const LAND_CELL_SOURCE = \"land-treaty-inspector-cell\";\nexport const LAND_CELL_FILL = \"land-treaty-inspector-cell-fill\";\nexport const LAND_CELL_LINE = \"land-treaty-inspector-cell-line\";\nexport const LAND_POINT_SOURCE = \"land-treaty-inspector-point\";\nexport const LAND_POINT = \"land-treaty-inspector-point\";\nexport const LAND_HATCH = \"land-treaty-inspector-hatch\";\nexport const TREATY_HIGHLIGHT = \"land-treaty-inspector-treaties\";\nexport const TREATY_DOTS = \"land-treaty-inspector-dots\";\nexport const TREATY_EDGE = \"land-treaty-inspector-treaty-edge\";\n/** The layers `overlays: [\"treaties\"]` composes (the ids createTreatyOverlay relies on too). */\nexport const TREATY_FILL_LAYER = \"overlay_treaties_fill_historic\";\nexport const TREATY_LINE_LAYER = \"overlay_treaties_line_historic\";\n\n/** The style's own treaty source, read off the gateway's treaty fill layer, or null without it. */\nexport function treatySource(\n  layers: Array<{ id: string; type?: string; source?: unknown; \"source-layer\"?: unknown }>,\n): { source: string; sourceLayer: string } | null {\n  const layer = layers.find((entry) => entry.id === TREATY_FILL_LAYER);\n  return layer && typeof layer.source === \"string\" && typeof layer[\"source-layer\"] === \"string\"\n    ? { source: layer.source, sourceLayer: layer[\"source-layer\"] }\n    : null;\n}\n\n/** The part of a MapLibre map the selection draws with. */\nexport interface SelectionMap {\n  getStyle(): { layers?: Array<{ id: string; type: string; source?: unknown; \"source-layer\"?: unknown }> } | undefined;\n  getLayer(id: string): unknown;\n  getSource(id: string): unknown;\n  addSource(id: string, source: Record<string, unknown>): unknown;\n  removeSource(id: string): unknown;\n  addLayer(layer: Record<string, unknown>, beforeId?: string): unknown;\n  removeLayer(id: string): unknown;\n  hasImage(id: string): boolean;\n  addImage(id: string, image: { width: number; height: number; data: Uint8ClampedArray }): unknown;\n  removeImage(id: string): unknown;\n}\n\nfunction setPattern(map: SelectionMap, id: string, image: { width: number; height: number; data: Uint8ClampedArray }) {\n  if (map.hasImage(id)) map.removeImage(id);\n  map.addImage(id, image);\n}\n\n/** Take the selection off the map: the point, the cell, their sources and the treaty highlight. */\nexport function clearSelection(map: SelectionMap): void {\n  for (const id of [LAND_POINT, TREATY_EDGE, TREATY_HIGHLIGHT, LAND_CELL_LINE, LAND_CELL_FILL]) {\n    if (map.getLayer(id)) map.removeLayer(id);\n  }\n  for (const id of [LAND_POINT_SOURCE, LAND_CELL_SOURCE]) {\n    if (map.getSource(id)) map.removeSource(id);\n  }\n}\n\n/**\n * Draw the selection from scratch: the survey cell hatched in `colours.cell` with an outline,\n * below the labels; the matched treaty records dotted in `colours.treaty` on the style's own\n * treaty source, below the treaty outlines; and, given `point`, the looked-up coordinate as a dot\n * ringed in `colours.halo`. The pattern images are replaced each time, so a new theme recolours\n * them. Call it again after a style swap.\n */\nexport function applySelection(\n  map: SelectionMap,\n  cell: LandRow | undefined,\n  treatyIds: string[],\n  colours: { cell: string; treaty: string; halo?: string },\n  point?: [number, number],\n): void {\n  clearSelection(map);\n  const layers = map.getStyle()?.layers ?? [];\n  const labels = layers.find((layer) => layer.type === \"symbol\")?.id;\n  const metadata = { [HELPER_KEY]: true };\n  if (cell?.geometry) {\n    setPattern(map, LAND_HATCH, patternImage(colours.cell, \"hatch\"));\n    map.addSource(LAND_CELL_SOURCE, { type: \"geojson\", data: { type: \"Feature\", properties: {}, geometry: cell.geometry } });\n    map.addLayer({ id: LAND_CELL_FILL, type: \"fill\", source: LAND_CELL_SOURCE, paint: { \"fill-pattern\": LAND_HATCH }, metadata }, labels);\n    map.addLayer(\n      { id: LAND_CELL_LINE, type: \"line\", source: LAND_CELL_SOURCE, paint: { \"line-color\": colours.cell, \"line-width\": 2 }, metadata },\n      labels,\n    );\n  }\n  const treaties = treatySource(layers);\n  if (treatyIds.length > 0 && treaties) {\n    setPattern(map, TREATY_DOTS, patternImage(colours.treaty, \"dots\"));\n    const before = map.getLayer(TREATY_LINE_LAYER) ? TREATY_LINE_LAYER : labels;\n    const filter = [\"in\", [\"get\", \"id\"], [\"literal\", treatyIds]];\n    // A treaty is often larger than the view: the dots fade as the map zooms in, and a dotted edge\n    // carries the shape where the fill would only be texture.\n    map.addLayer(\n      {\n        id: TREATY_HIGHLIGHT,\n        type: \"fill\",\n        source: treaties.source,\n        \"source-layer\": treaties.sourceLayer,\n        filter,\n        paint: { \"fill-pattern\": TREATY_DOTS, \"fill-opacity\": [\"interpolate\", [\"linear\"], [\"zoom\"], 4, 0.8, 8, 0.45, 12, 0.2] },\n        metadata,\n      },\n      before,\n    );\n    map.addLayer(\n      {\n        id: TREATY_EDGE,\n        type: \"line\",\n        source: treaties.source,\n        \"source-layer\": treaties.sourceLayer,\n        filter,\n        layout: { \"line-cap\": \"round\", \"line-join\": \"round\" },\n        paint: { \"line-color\": colours.treaty, \"line-width\": 2.5, \"line-dasharray\": [0, 2] },\n        metadata,\n      },\n      before,\n    );\n  }\n  if (point) {\n    map.addSource(LAND_POINT_SOURCE, {\n      type: \"geojson\",\n      data: { type: \"Feature\", properties: {}, geometry: { type: \"Point\", coordinates: point } },\n    });\n    map.addLayer(\n      {\n        id: LAND_POINT,\n        type: \"circle\",\n        source: LAND_POINT_SOURCE,\n        paint: {\n          \"circle-radius\": 5,\n          \"circle-color\": colours.cell,\n          \"circle-stroke-width\": colours.halo ? 2 : 0,\n          \"circle-stroke-color\": colours.halo ?? colours.cell,\n        },\n        metadata,\n      },\n      labels,\n    );\n  }\n}\n\n/** [[west, south], [east, north]] around a polygon's rings, for fitBounds. A loop: no spread into Math.min. */\nexport function geometryBounds(geometry: LandRow[\"geometry\"]): [[number, number], [number, number]] | null {\n  let west = Infinity;\n  let south = Infinity;\n  let east = -Infinity;\n  let north = -Infinity;\n  const visit = (value: unknown): void => {\n    if (!Array.isArray(value)) return;\n    if (typeof value[0] === \"number\" && typeof value[1] === \"number\") {\n      if (value[0] < west) west = value[0];\n      if (value[0] > east) east = value[0];\n      if (value[1] < south) south = value[1];\n      if (value[1] > north) north = value[1];\n      return;\n    }\n    for (const item of value) visit(item);\n  };\n  visit(geometry?.coordinates);\n  return west <= east && south <= north\n    ? [\n        [west, south],\n        [east, north],\n      ]\n    : null;\n}\n\n/** True when two [lng, lat] name the same point (the gateway echoes a coordinate back as it was sent). */\nexport function samePoint(a: [number, number] | undefined, b: [number, number] | undefined): boolean {\n  return !!a && !!b && Math.abs(a[0] - b[0]) < 1e-7 && Math.abs(a[1] - b[1]) < 1e-7;\n}\n\n/** What to tell the reader about a failed lookup, by what the gateway said. */\nexport function lookupError(caught: unknown, lang: LandLang): string {\n  const copy = LAND_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/** A box on screen, as getBoundingClientRect() gives it. */\nexport interface LandRect {\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 fitBounds takes it. */\nexport interface LandPadding {\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/** How close a searched cell is framed, and how long the camera takes to get there. */\nexport const CELL_MAX_ZOOM = 15;\nexport const CAMERA_MS = 600;\n\n/**\n * Camera padding that keeps what the camera frames 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 * never behind the panel.\n */\nexport function panelPadding(map: LandRect, panel: LandRect | null, gutter = GUTTER): LandPadding {\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/** The pixel offset that centres a point in the padded area: easeTo's `offset`, which, unlike its `padding`, does not stick. */\nexport function paddingOffset(padding: LandPadding): [number, number] {\n  return [(padding.left - padding.right) / 2, (padding.top - padding.bottom) / 2];\n}\n\n/** Downtown Calgary: DLS townships, sections and Treaty 7 in view. */\nexport const CALGARY: [number, number] = [-114.0719, 51.0447];\n\nexport function isAbort(caught: unknown): boolean {\n  return typeof caught === \"object\" && caught !== null && (caught as { name?: unknown }).name === \"AbortError\";\n}\n\n/** True when the reader asked for less motion: the camera jumps to a searched cell. */\nexport function prefersReducedMotion(): boolean {\n  return typeof window !== \"undefined\" && window.matchMedia?.(\"(prefers-reduced-motion: reduce)\")?.matches === true;\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 answer leads the sheet once there is one. False on the server. */\nexport function phoneWidth(): boolean {\n  return typeof window !== \"undefined\" && window.matchMedia?.(PHONE_QUERY)?.matches === true;\n}\n\n/** The pattern swatches the answer card and the legend share with the map: hatch for the cell, dots for treaties. */\nexport const SWATCH = {\n  hatch: \"size-4 shrink-0 border-2 border-primary bg-[repeating-linear-gradient(135deg,var(--primary)_0_1px,transparent_1px_4px)]\",\n  dots: \"size-4 shrink-0 border-2 border-dotted border-foreground bg-[radial-gradient(var(--foreground)_0.8px,transparent_1.2px)] bg-[length:5px_5px] bg-[position:0.5px_0.5px]\",\n  none: \"size-4 shrink-0\",\n};\n// --- end land-treaty-inspector helpers ---\n\nexport interface LandTreatyInspectorProps {\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 treaty panel and the map labels. Defaults to \"en\". */\n  lang?: LandLang;\n  /** Where the map opens, [longitude, latitude]: the initial camera only, a later change does not move the map. Defaults to downtown Calgary. */\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 LandTreatyInspector({ apiKey, gateway, lang = \"en\", center = CALGARY, zoom = 11, className }: LandTreatyInspectorProps) {\n  const copy = LAND_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 composed until it answers: a refused overlay would make\n  // the whole style request fail.\n  useEffect(() => {\n    if (!hasKey) return;\n    const controller = new AbortController();\n    setGate({ state: \"checking\" });\n    probeAddon(apiKey, \"survey.dls\", 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=\"land-treaty-inspector\"\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=\"land-treaty-inspector\"\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=\"land-treaty-inspector\" className={[\"relative h-full min-h-[480px]\", className].filter(Boolean).join(\" \")}>\n      <Map\n        id={LAND_MAP_ID}\n        apiKey={apiKey}\n        gateway={gateway}\n        style=\"muted\"\n        lang={lang}\n        overlays={gatedOverlays(gate)}\n        center={opening.center}\n        zoom={opening.zoom}\n        className=\"h-full\"\n      >\n        <Inspector apiKey={apiKey} gateway={gateway} lang={lang} gate={gate} center={opening.center} zoom={opening.zoom} />\n      </Map>\n    </div>\n  );\n}\n\n/** Where the land answer stands. `at` is the point it is for. */\ntype LandView =\n  | { kind: \"idle\" }\n  | { kind: \"loading\"; at: [number, number] }\n  | { kind: \"error\"; at: [number, number]; message: string; preferred?: string; framed: boolean }\n  | { kind: \"result\"; at: [number, number]; rows: LandRow[]; cell?: LandRow };\n\n/** The treaty panel's last word, kept with the point it was for: an answer, or a failed lookup. */\ntype TreatyAnswer = { at: [number, number] | undefined; selection?: TreatySelection; failed?: boolean };\n\n/** One line of the answer card: a pattern swatch, a value and a quieter caption. */\nfunction AnswerRow({\n  swatch,\n  value,\n  caption,\n  size,\n  busy,\n  wrap,\n  valueLang,\n  testId,\n}: {\n  swatch: keyof typeof SWATCH;\n  value: string;\n  caption: string;\n  size: \"headline\" | \"line\";\n  busy?: boolean;\n  /** The caption wraps instead of truncating: it is a sentence that must be read whole. */\n  wrap?: boolean;\n  /** The value's own language, when it is not the interface's. */\n  valueLang?: string;\n  testId: string;\n}) {\n  return (\n    <div data-testid={testId} className=\"flex min-w-0 items-start gap-3\">\n      <span className=\"flex h-7 shrink-0 items-center\">\n        {busy ? <Spinner className=\"size-4 text-muted-foreground\" /> : <span aria-hidden=\"true\" data-swatch={swatch} className={SWATCH[swatch]} />}\n      </span>\n      <div className=\"flex min-w-0 flex-1 flex-col\">\n        <p\n          lang={valueLang}\n          className={\n            busy\n              ? \"flex h-7 items-center truncate text-sm text-muted-foreground\"\n              : size === \"headline\"\n                ? \"truncate text-2xl leading-7 font-semibold tracking-tight tabular-nums\"\n                : \"truncate text-base leading-7 font-medium\"\n          }\n        >\n          {value}\n        </p>\n        <p className={wrap ? \"min-h-5 text-sm text-muted-foreground\" : \"min-h-5 truncate text-sm text-muted-foreground\"}>{caption}</p>\n      </div>\n    </div>\n  );\n}\n\n/** Everything that needs the map. It renders inside <Map>, where useMap() can see it. */\nfunction Inspector({\n  apiKey,\n  gateway,\n  lang,\n  gate,\n  center,\n  zoom,\n}: {\n  apiKey: string;\n  gateway?: string;\n  lang: LandLang;\n  gate: AddonGate;\n  center: [number, number];\n  zoom: number;\n}) {\n  const copy = LAND_COPY[lang];\n  const granted = gate.state === \"granted\";\n  const map = useMap(LAND_MAP_ID);\n  const ready = useMapReady(LAND_MAP_ID);\n  const generation = useMapGeneration(LAND_MAP_ID);\n  const client = useMemo(() => new GeocoderClient({ key: apiKey, gateway }), [apiKey, gateway]);\n  // The point handed to the treaty panel: a chosen search result. Cleared when the card moves to\n  // another point. A chosen result waits one render in `nextPoint`, so the panel always sees it\n  // as new: the same result chosen again (after a failed lookup, say) asks again.\n  const [point, setPoint] = useState<[number, number] | undefined>(undefined);\n  const [nextPoint, setNextPoint] = useState<[number, number] | null>(null);\n  const [land, setLand] = useState<LandView>({ kind: \"idle\" });\n  const [treaty, setTreaty] = useState<TreatyAnswer | null>(null);\n  // On a phone the sheet can fold to the answer, 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 answer leads the sheet in the DOM as well as on screen, so\n  // the 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 abortRef = useRef<AbortController | null>(null);\n  const panelRef = useRef<HTMLDivElement | null>(null);\n  const contentRef = useRef<HTMLDivElement | null>(null);\n  const movedRef = useRef(false);\n  const landAtRef = useRef<[number, number] | undefined>(undefined);\n  const uid = useId();\n  const gateId = `${uid}-gate`;\n  const searchId = `${uid}-search`;\n  const detailsId = `${uid}-details`;\n\n  const landAt = land.kind === \"idle\" ? undefined : land.at;\n\n  function padding(): LandPadding {\n    const container = map?.getContainer();\n    if (!container) return panelPadding({ left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }, null);\n    return panelPadding(container.getBoundingClientRect(), panelRef.current?.getBoundingClientRect() ?? null);\n  }\n\n  /** The camera eases to the cell, clear of the panel; with no cell drawn, to the point itself. */\n  function frame(cell: LandRow | undefined, at?: [number, number]) {\n    if (!map) return;\n    const bounds = geometryBounds(cell?.geometry);\n    const duration = prefersReducedMotion() ? 0 : CAMERA_MS;\n    if (bounds) map.fitBounds(bounds, { padding: padding(), maxZoom: CELL_MAX_ZOOM, duration });\n    else if (at) map.easeTo({ center: at, offset: paddingOffset(padding()), duration });\n    else return;\n    movedRef.current = true;\n  }\n\n  /**\n   * One identify at a time: a new one aborts the last, and only the latest may set state. A\n   * searched description or a typed coordinate is framed once it answers; a click is where the\n   * reader is already looking.\n   */\n  function lookUp(at: [number, number], preferred?: string, framed = preferred !== undefined) {\n    abortRef.current?.abort();\n    const controller = new AbortController();\n    abortRef.current = controller;\n    landAtRef.current = at;\n    setLand({ kind: \"loading\", at });\n    // On a phone the whole sheet scrolls: bring the answer, which leads it now, back into view.\n    if (contentRef.current) contentRef.current.scrollTop = 0;\n    client\n      .identify({ at, layers: granted ? LEGAL_LAYERS : [\"municipalities\"], geometry: granted, signal: controller.signal })\n      .then(({ results }) => {\n        if (abortRef.current !== controller) return;\n        const rows = results as LandRow[];\n        const view: LandView = { kind: \"result\", at, rows, cell: granted ? pickCell(rows, preferred) : undefined };\n        setLand(view);\n        if (framed) frame(view.cell, at);\n      })\n      .catch((caught: unknown) => {\n        if (abortRef.current !== controller || isAbort(caught)) return;\n        setLand({ kind: \"error\", at, message: lookupError(caught, lang), preferred, framed });\n      });\n  }\n  const lookUpRef = useRef(lookUp);\n  lookUpRef.current = lookUp;\n\n  // A map click looks up the point. The treaty panel answers the same click itself. The reader\n  // has chosen where to look, so the opening view never snaps back after it.\n  useEffect(() => {\n    if (!map) return;\n    const onClick = (event: { lngLat: { lng: number; lat: number } }) => {\n      movedRef.current = true;\n      setPoint(undefined);\n      setNextPoint(null);\n      lookUpRef.current([event.lngLat.lng, event.lngLat.lat]);\n    };\n    map.on(\"click\", onClick);\n    return () => {\n      map.off(\"click\", onClick);\n    };\n  }, [map]);\n\n  useEffect(\n    () => () => {\n      abortRef.current?.abort();\n      abortRef.current = null;\n    },\n    [],\n  );\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  useEffect(() => {\n    if (!nextPoint) return;\n    setPoint(nextPoint);\n    setNextPoint(null);\n  }, [nextPoint]);\n\n  // The opening view sits in the map the panel leaves, without a move to watch: now, again when\n  // the map resizes, the panel has slid in or the sheet folds, until the reader looks somewhere.\n  const placeRef = useRef({ center, zoom });\n  placeRef.current = { center, zoom };\n  useEffect(() => {\n    if (!map) return;\n    const place = () => {\n      if (movedRef.current) return;\n      map.easeTo({ ...placeRef.current, offset: paddingOffset(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    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [map, folded]);\n\n  // The treaty answer counts only for the point the card is about; a stale one draws nothing.\n  const treatyCurrent = treaty && landAt && (treaty.at === undefined || samePoint(treaty.at, landAt)) ? treaty : null;\n  const selection = treatyCurrent?.selection;\n  const hidden: string[] = selection?.hidden ?? [];\n  const matches = selection?.context.matches ?? [];\n  // The card and the dots speak for the categories the panel shows; a hidden one is only named.\n  const visible = matches.filter((match) => !hidden.includes(match.category));\n  const shownTreaty = visible[0] ?? matches[0];\n  const treatyFailed = !!treatyCurrent && (treatyCurrent.failed === true || selection?.context.status === \"unavailable\");\n  const treatyKey = treatyFailed ? \"\" : matchedTreatyIds({ matches: visible }).join(\"|\");\n  const cell = land.kind === \"result\" ? land.cell : undefined;\n  const pointKey = landAt ? landAt.join(\",\") : \"\";\n\n  // The patterned selection, drawn again whenever it changes and after every style swap.\n  useEffect(() => {\n    if (!map || !ready) return;\n    let removed = false;\n    const onRemove = () => {\n      removed = true;\n    };\n    map.on(\"remove\", onRemove);\n    if (map.getStyle()) {\n      const accent = accentColor(map, undefined, undefined);\n      applySelection(\n        map as unknown as SelectionMap,\n        cell,\n        treatyKey ? treatyKey.split(\"|\") : [],\n        { cell: accent, treaty: tokenColor(map, \"--foreground\") ?? accent, halo: tokenColor(map, \"--background\") ?? undefined },\n        pointKey ? (pointKey.split(\",\").map(Number) as [number, number]) : undefined,\n      );\n    }\n    return () => {\n      map.off(\"remove\", onRemove);\n      if (!removed && map.getStyle()) clearSelection(map as unknown as SelectionMap);\n    };\n  }, [map, ready, generation, cell, treatyKey, pointKey]);\n\n  function chooseLand(result: GeocodeResult) {\n    movedRef.current = true;\n    setPoint(undefined);\n    setNextPoint([result.lng, result.lat]);\n    lookUp([result.lng, result.lat], result.name);\n  }\n\n  function chooseCell(row: LandRow) {\n    if (land.kind !== \"result\") return;\n    setLand({ ...land, cell: row });\n    frame(row);\n  }\n\n  /** A treaty-panel point the card is not about yet (a typed coordinate): the card follows it. */\n  function follow(at: [number, number] | undefined) {\n    if (!at || samePoint(at, landAtRef.current)) return;\n    setPoint(undefined);\n    setNextPoint(null);\n    lookUpRef.current(at, undefined, true);\n  }\n\n  function onTreaties(next: TreatySelection) {\n    const echoed = next.context?.point;\n    const at: [number, number] | undefined = echoed ? [echoed.lng, echoed.lat] : undefined;\n    setTreaty({ at, selection: next });\n    follow(at);\n  }\n\n  function onTreatyError(_error: unknown, at: [number, number] | null) {\n    setTreaty({ at: at ?? landAtRef.current, failed: true });\n    follow(at ?? undefined);\n  }\n\n  function retry() {\n    if (land.kind === \"error\") lookUp(land.at, land.preferred, land.framed);\n  }\n\n  const cells = land.kind === \"result\" ? sortCells(land.rows) : [];\n  const municipality = land.kind === \"result\" ? land.rows.find((row) => row.layer === \"municipalities\") : undefined;\n  const intro = granted ? (touch ? copy.introTouch : copy.intro) : touch ? copy.introDeniedTouch : copy.introDenied;\n  const hideWhenFolded = \"max-sm:data-[folded=true]:hidden\";\n  const answered = land.kind !== \"idle\";\n  const answerLeads = phone && answered;\n  const treatyCaption = shownTreaty\n    ? [copy.categories[shownTreaty.category] ?? shownTreaty.category, visible.length === 0 ? copy.hiddenOnMap : visible.length > 1 ? copy.more(visible.length - 1) : \"\"]\n        .filter(Boolean)\n        .join(\", \")\n    : \"\";\n\n  // What the live region says, once both halves of the answer have settled: the cell or the\n  // municipality, then the treaty line. A failed land lookup speaks through its own alert.\n  const landSaid =\n    land.kind !== \"result\"\n      ? \"\"\n      : granted\n        ? cell\n          ? copy.sayCell(unitLabel(cell.unit, lang), cell.name, provinceLabel(cell.province, lang))\n          : copy.sayNoCell\n        : municipality\n          ? `${copy.municipality(municipality.name)}.`\n          : copy.sayNoMunicipality;\n  const treatySaid = !treatyCurrent\n    ? \"\"\n    : treatyFailed\n      ? `${copy.treatyUnavailable}.`\n      : shownTreaty\n        ? copy.sayTreaty(shownTreaty.name, treatyCaption)\n        : `${copy.treatyNone}.`;\n  const said = (land.kind === \"result\" || land.kind === \"error\") && treatyCurrent ? [landSaid, treatySaid].filter(Boolean).join(\" \") : \"\";\n\n  const landRow =\n    land.kind === \"loading\" ? (\n      <AnswerRow testId=\"land-answer-cell\" swatch=\"none\" value={copy.loading} caption=\"\" size=\"headline\" busy />\n    ) : land.kind !== \"result\" ? null : granted ? (\n      cell ? (\n        <AnswerRow\n          testId=\"land-answer-cell\"\n          swatch=\"hatch\"\n          value={cell.name}\n          caption={[unitLabel(cell.unit, lang), provinceLabel(cell.province, lang)].filter(Boolean).join(\", \")}\n          size=\"headline\"\n        />\n      ) : (\n        <AnswerRow testId=\"land-answer-cell\" swatch=\"none\" value={copy.noCell} caption={copy.noCellHint} size=\"line\" />\n      )\n    ) : municipality ? (\n      <AnswerRow testId=\"land-answer-cell\" swatch=\"none\" value={municipality.name} caption={copy.municipalityLabel} size=\"headline\" />\n    ) : (\n      <AnswerRow testId=\"land-answer-cell\" swatch=\"none\" value={copy.noMunicipality} caption={copy.noMunicipalityHint} size=\"line\" />\n    );\n\n  const treatyRow = !landAt ? null : !treatyCurrent ? (\n    <AnswerRow testId=\"land-answer-treaty\" swatch=\"none\" value={copy.treatyLoading} caption=\"\" size=\"line\" busy />\n  ) : treatyFailed ? (\n    <AnswerRow testId=\"land-answer-treaty\" swatch=\"none\" value={copy.treatyUnavailable} caption={copy.treatyUnavailableHint} size=\"line\" />\n  ) : shownTreaty ? (\n    <AnswerRow\n      testId=\"land-answer-treaty\"\n      swatch={visible.length > 0 ? \"dots\" : \"none\"}\n      value={shownTreaty.name}\n      valueLang={shownTreaty.name_lang && shownTreaty.name_lang !== lang ? shownTreaty.name_lang : undefined}\n      caption={treatyCaption}\n      size=\"line\"\n    />\n  ) : (\n    <AnswerRow testId=\"land-answer-treaty\" swatch=\"none\" value={copy.treatyNone} caption={copy.treatyNoneHint} size=\"line\" wrap />\n  );\n\n  // The answer card: always there and tall enough for both lines, or an error and its retry, so\n  // nothing jumps when it arrives. On a phone, once there is an answer, it leads the sheet: it is\n  // placed first in the DOM rather than reordered by CSS, so keyboard order matches what is seen.\n  const answerSlot = (\n    <div data-testid=\"land-answer\" className=\"flex min-h-[7.5rem] shrink-0 flex-col justify-center gap-3\">\n      {land.kind === \"error\" ? (\n        <>\n          <Alert variant=\"destructive\" role=\"alert\">\n            <AlertDescription className=\"flex flex-row flex-wrap items-center justify-between gap-2\">\n              <span className=\"min-w-0\">{land.message}</span>\n              <Button type=\"button\" variant=\"outline\" size=\"sm\" className=\"shrink-0\" onClick={retry}>\n                {copy.retry}\n              </Button>\n            </AlertDescription>\n          </Alert>\n          {treatyRow}\n        </>\n      ) : land.kind === \"idle\" ? (\n        <div className=\"flex flex-col gap-3\">\n          <div className=\"flex flex-col gap-0.5\">\n            <p className=\"text-base font-medium\">{touch ? copy.emptyTouch : copy.empty}</p>\n            <p className=\"text-sm text-muted-foreground\">{copy.emptyHint}</p>\n          </div>\n          <ul aria-label={copy.legend} className=\"flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground\">\n            {granted ? (\n              <li className=\"flex items-center gap-1.5\">\n                <span aria-hidden=\"true\" data-swatch=\"hatch\" className={SWATCH.hatch} />\n                {copy.legendCell}\n              </li>\n            ) : null}\n            <li className=\"flex items-center gap-1.5\">\n              <span aria-hidden=\"true\" data-swatch=\"dots\" className={SWATCH.dots} />\n              {copy.legendTreaty}\n            </li>\n          </ul>\n        </div>\n      ) : (\n        <div key={pointKey} className=\"flex flex-col gap-3 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200\">\n          {landRow}\n          {treatyRow}\n        </div>\n      )}\n    </div>\n  );\n\n  return (\n    <>\n      <p data-testid=\"land-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 ? searchId : gateId} ${detailsId}`}\n                onClick={() => setFolded((current) => !current)}\n              >\n                <span aria-hidden=\"true\">{folded ? \"▴\" : \"▾\"}</span>\n              </Button>\n            </div>\n            <CardDescription className=\"max-sm:hidden\">{intro}</CardDescription>\n          </CardHeader>\n          {/* The search and the answer stay put and the details below them scroll; on a phone the\n              whole sheet scrolls, and once there is an answer it leads the sheet. */}\n          <CardContent ref={contentRef} className=\"flex min-h-0 flex-1 flex-col gap-4 max-sm:overflow-y-auto max-sm:overscroll-contain\">\n            {answerLeads ? answerSlot : null}\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                  {/* On a phone, once there is an answer, the title and the way forward are enough. */}\n                  <p data-gate-body=\"\" className={answered ? \"max-sm:hidden\" : undefined}>\n                    {gate.state === \"denied\" ? copy.deniedBody[gate.code] : copy.unknownBody(gate.status)}\n                  </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              <div id={searchId} data-folded={folded} className={`flex shrink-0 flex-col gap-1 ${hideWhenFolded}`}>\n                {/* `[&>p]:hidden` hides the geocoder's own coverage line; the example below says more. */}\n                <LegalLandSearch\n                  mapId={LAND_MAP_ID}\n                  apiKey={apiKey}\n                  gateway={gateway}\n                  lang={lang}\n                  placeholder={copy.search}\n                  flyToZoom={13}\n                  errorText={(caught) => lookupError(caught, lang)}\n                  onSelect={chooseLand}\n                  className=\"[&>p]:hidden\"\n                />\n                <p className=\"text-xs text-muted-foreground\">{copy.searchHint}</p>\n              </div>\n            ) : null}\n            {answerLeads ? null : answerSlot}\n            <div\n              id={detailsId}\n              data-folded={folded}\n              className={`-mx-2 flex min-h-0 flex-1 flex-col gap-4 border-t px-2 pt-4 sm:overflow-y-auto sm:overscroll-contain max-sm:flex-none ${hideWhenFolded}`}\n            >\n              {land.kind === \"result\" && granted && cells.length > 0 ? (\n                <div className=\"flex flex-col gap-1\">\n                  <p className=\"text-sm font-medium\">{copy.cells}</p>\n                  <p className=\"text-xs text-muted-foreground\">{copy.cellsHint}</p>\n                  <ul aria-label={copy.cells} className=\"mt-1 flex flex-col\">\n                    {cells.map((row) => {\n                      const current = row.id === cell?.id;\n                      return (\n                        <li key={row.id} aria-current={current ? \"true\" : undefined}>\n                          <Button\n                            type=\"button\"\n                            variant={current ? \"secondary\" : \"ghost\"}\n                            size=\"sm\"\n                            aria-pressed={current}\n                            className=\"h-auto w-full justify-between gap-3 py-1.5 text-left font-normal whitespace-normal aria-pressed:font-semibold\"\n                            onClick={() => chooseCell(row)}\n                          >\n                            <span className=\"flex min-w-0 items-center gap-2\">\n                              {/* The hatched row is the one on the map: a swatch as well as the weight. */}\n                              <span aria-hidden=\"true\" data-swatch={current ? \"hatch\" : \"none\"} className={current ? SWATCH.hatch : SWATCH.none} />\n                              <span className=\"truncate tabular-nums\">{row.name}</span>\n                            </span>\n                            <span className=\"sr-only\">{copy.separator}</span>\n                            <span className=\"shrink-0 text-xs text-muted-foreground\">{unitLabel(row.unit, lang)}</span>\n                          </Button>\n                        </li>\n                      );\n                    })}\n                  </ul>\n                </div>\n              ) : null}\n              <TreatyContext\n                mapId={LAND_MAP_ID}\n                apiKey={apiKey}\n                gateway={gateway}\n                lang={lang}\n                point={point}\n                onSelect={onTreaties}\n                onError={onTreatyError}\n              />\n              {granted ? <p className=\"pb-1 text-xs text-muted-foreground\">{copy.surveyCredit}</p> : null}\n            </div>\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 /land-treaty-inspector. Legal land needs an add-on that includes it (Legal Land, Energy or Agriculture); without one the page says why. Guide: https://unmap.dev/docs/frontend/blocks#look-up-legal-land-and-treaties",
  "categories": [
    "blocks"
  ],
  "type": "registry:block"
}