{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ski-resort-explorer",
  "title": "Ski Resort Explorer",
  "description": "A page with every ski area in Canada on the winter style in 3D: name search, a province filter and an activity toggle, and, for the area you pick, a tilted camera facing its slopes with vertical, runs, lifts, aspect and drive time, click cards for runs and lifts, a run legend, credited to OpenStreetMap under the ODbL. Needs the Ski add-on; without it the page says why and shows the 3D map. Calls /ski/areas on load and per filter change.",
  "dependencies": [
    "@unmap/sdk",
    "@unmap/deck",
    "@deck.gl/core@~9.4.0",
    "@deck.gl/layers@~9.4.0",
    "@deck.gl/maplibre@~9.4.0",
    "@deck.gl/mesh-layers@~9.4.0",
    "@luma.gl/core@~9.4.0"
  ],
  "registryDependencies": [
    "https://registry.unmap.dev/r/map.json",
    "https://registry.unmap.dev/r/map-context.json",
    "card",
    "field",
    "input",
    "select",
    "toggle-group",
    "table",
    "badge",
    "button",
    "alert",
    "spinner"
  ],
  "files": [
    {
      "path": "registry/blocks/ski-resort-explorer/page.tsx",
      "content": "/**\n * /ski-resort-explorer: the ski resort explorer block as a Next.js App Router page, full-bleed (the\n * block keeps its own gutter). The key comes from NEXT_PUBLIC_UNMAP_KEY in .env.local; installing\n * the block added the empty line, paste your key. The ski areas need the Ski add-on; without it\n * the page says why.\n */\nimport { SkiResortExplorer } from \"@/components/ui/ski-resort-explorer\";\n\nexport const metadata = { title: \"Explore Canada's ski areas\" };\n\nexport default function SkiResortExplorerPage() {\n  return (\n    <main className=\"h-dvh\">\n      <SkiResortExplorer apiKey={process.env.NEXT_PUBLIC_UNMAP_KEY ?? \"\"} className=\"h-full\" />\n    </main>\n  );\n}\n",
      "type": "registry:page",
      "target": "app/ski-resort-explorer/page.tsx"
    },
    {
      "path": "registry/blocks/ski-resort-explorer/ski-resort-explorer.tsx",
      "content": "\"use client\";\n\n/**\n * <SkiResortExplorer> - every ski area in Canada on the winter style in 3D: a searchable list\n * (name, province, activity) beside the map, and, for the area you pick, its vertical, runs,\n * lifts, aspect and drive time. Picking an area flies to it, turned to face its slopes and tilted\n * onto its terrain. Click a run or a lift on the map for its card (difficulty, pitch and aspect;\n * kind, rise and length), and the legend says what each colour and line means. A full-bleed map\n * with a floating panel, a bottom sheet on a phone.\n *\n * The list needs the Ski add-on. The block's first `/ski/areas` call says whether your key holds\n * it. If it does not, the map still renders (the winter style on 3D terrain, without the `ski`\n * overlay, which would be refused) and the panel says why and what unlocks it.\n *\n * Metering: every authenticated request is one call. The block calls `GET /ski/areas` once on\n * load and again when a filter changes (a search waits for a pause in typing), then the map's own\n * style and tile requests. Picking an area makes no call: the list already holds it. With\n * `lifts3d` on, picking an area also calls `GET /ski/areas/{id}/lifts-3d` (again after a style swap, which redraws the map), and the first such\n * pick imports @unmap/deck (deck.gl); with it off, neither is ever loaded.\n *\n * The data is derived from OpenStreetMap and is ODbL; the panel carries the attribution and the\n * licence. It is terrain information, not avalanche or safety advice, and the panel says so.\n *\n * Composes the map item on your shadcn Card, Field, Input, Select, ToggleGroup, Table, Badge,\n * Alert, Button and Spinner. Installed by `npx shadcn add` with its page at\n * app/ski-resort-explorer/page.tsx. Edit freely.\n */\n\nimport { useCallback, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type RefObject } from \"react\";\nimport { Ski, type SkiArea } from \"@unmap/sdk\";\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { Field, FieldLabel } from \"@/components/ui/field\";\nimport { Input } from \"@/components/ui/input\";\nimport { Map } from \"@/components/ui/map\";\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"@/components/ui/select\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from \"@/components/ui/table\";\nimport { ToggleGroup, ToggleGroupItem } from \"@/components/ui/toggle-group\";\nimport { useMap, useMapReady } from \"@/lib/map-context\";\n\n// --- ski-resort-explorer helpers (kept identical across both lanes) ---\nexport type SkiLang = \"en\" | \"fr\";\n\n/** The block's own map id, so a map elsewhere on the page never answers for it. */\nexport const SKI_MAP_ID = \"ski-resort-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\n/** All of Canada: where the map opens. */\nexport const CANADA_VIEW: { center: [number, number]; zoom: number } = { center: [-100, 56], zoom: 3 };\n\n/** The camera tilt when an area is picked, so its terrain reads in 3D. */\nexport const AREA_PITCH = 60;\n\n/** How long a pause in typing waits before the search asks the gateway. */\nexport const SEARCH_DEBOUNCE_MS = 250;\n\n/** The activity filter: both, or one. */\nexport type SkiActivityFilter = \"all\" | \"downhill\" | \"nordic\";\n\n/** The eight directions the aspect histogram is binned into, clockwise from north. */\nexport const ASPECTS = [\"n\", \"ne\", \"e\", \"se\", \"s\", \"sw\", \"w\", \"nw\"] as const;\nexport type SkiAspect = (typeof ASPECTS)[number];\n\n/** Provinces and territories, in the order the filter lists them. */\nexport const PROVINCES = [\"AB\", \"BC\", \"MB\", \"NB\", \"NL\", \"NS\", \"NT\", \"NU\", \"ON\", \"PE\", \"QC\", \"SK\", \"YT\"] as const;\n\nexport const SKI_COPY = {\n  en: {\n    title: \"Ski areas\",\n    description: \"Every ski area in Canada on 3D terrain. Search by name, filter by province or activity, and pick one for its numbers.\",\n    checking: \"Loading ski areas...\",\n    search: \"Search ski areas\",\n    searchPlaceholder: \"Whistler, Mont-Sainte-Anne...\",\n    province: \"Province or territory\",\n    allProvinces: \"All provinces and territories\",\n    activity: \"Activity\",\n    activities: { all: \"All\", downhill: \"Downhill\", nordic: \"Nordic\" },\n    list: \"Ski areas\",\n    count: (n: number) => (n === 1 ? \"1 ski area\" : `${n.toLocaleString(\"en-CA\")} ski areas`),\n    empty: \"No ski area matches. Clear the search or the filters.\",\n    loading: \"Searching...\",\n    back: \"Back to all ski areas\",\n    selected: \"Selected ski area\",\n    noPlace: \"Location unknown\",\n    vertical: \"Vertical\",\n    topBase: \"Top / base\",\n    noElevation: \"Not sampled\",\n    runs: \"Runs\",\n    downhillKm: \"Downhill\",\n    nordicKm: \"Nordic\",\n    lifts: \"Lifts\",\n    drive: \"Drive\",\n    noDrive: \"No city within reach\",\n    driveFrom: (time: string, from: string) => `${time} from ${from}`,\n    aspect: \"Aspect\",\n    aspectCaption: \"Share of downhill run length facing each direction.\",\n    aspectNone: \"No downhill runs to measure.\",\n    facing: (direction: string, share: string) => `Mostly faces ${direction} (${share}).`,\n    directions: { n: \"N\", ne: \"NE\", e: \"E\", se: \"SE\", s: \"S\", sw: \"SW\", w: \"W\", nw: \"NW\" },\n    directionNames: {\n      n: \"north\",\n      ne: \"northeast\",\n      e: \"east\",\n      se: \"southeast\",\n      s: \"south\",\n      sw: \"southwest\",\n      w: \"west\",\n      nw: \"northwest\",\n    },\n    websites: \"Website\",\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    badSearch: \"That search can't be run. Use letters or digits.\",\n    failed: \"The search failed. Try again.\",\n    retry: \"Try again\",\n    liftsDenied: \"3D lifts need the Ski add-on on this key.\",\n    liftsMissing: \"This ski area has no 3D lifts yet.\",\n    liftsFailed: \"The 3D lifts did not load.\",\n    summary: \"Every ski area in Canada on 3D terrain.\",\n    legend: \"Legend\",\n    legendRuns: \"Runs\",\n    legendKeys: {\n      easy: \"Easy\",\n      intermediate: \"Intermediate\",\n      advanced: \"Advanced\",\n      expert: \"Expert\",\n      park: \"Terrain park\",\n      nordic: \"Nordic trail\",\n      lift: \"Lift\",\n      lift3d: \"Lift, cable in 3D\",\n    },\n    difficulties: {\n      easy: \"Easy, green circle\",\n      intermediate: \"Intermediate, blue square\",\n      advanced: \"Advanced, black diamond\",\n      expert: \"Expert, double black diamond\",\n      park: \"Terrain park\",\n      unrated: \"No difficulty given\",\n    },\n    nordicTrail: \"Nordic trail\",\n    unnamedRun: \"Unnamed run\",\n    unnamedLift: \"Unnamed lift\",\n    pitch: \"Average pitch\",\n    faces: \"Faces\",\n    rise: \"Rise\",\n    length: \"Length\",\n    towers: \"Towers\",\n    aerialways: {\n      cable_car: \"Cable car\",\n      gondola: \"Gondola\",\n      mixed_lift: \"Chairs and gondolas\",\n      chair_lift: \"Chairlift\",\n      drag_lift: \"Drag lift\",\n      \"t-bar\": \"T-bar\",\n      \"j-bar\": \"J-bar\",\n      platter: \"Platter lift\",\n      rope_tow: \"Rope tow\",\n      magic_carpet: \"Magic carpet\",\n      zip_line: \"Zip line\",\n      goods: \"Goods lift\",\n    } as Record<string, string>,\n    lift: \"Lift\",\n    deniedTitle: {\n      addon_required: \"Ski areas need the Ski add-on\",\n      dev_only: \"Ski areas 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 Ski add-on, so the map shows the winter style on 3D terrain without ski areas, runs or lifts, and the list is off.\",\n      dev_only:\n        \"On the dev plan, ski areas work from localhost and preview origins. From this origin they need a paid plan with the Ski add-on.\",\n      usage_limit_reached:\n        \"The dev plan's premium calls for this month are used. Ski areas come back next month, or now with a paid plan.\",\n    },\n    unknownTitle: \"Could not load ski areas\",\n    unknownBody: (status: number) =>\n      status === 401\n        ? \"The gateway refused this key. Check the key and its allowed origins.\"\n        : `The gateway answered ${status === 0 ? \"nothing (a network error)\" : `HTTP ${status}`}, so the list is off. The map shows the winter style on 3D terrain.`,\n    pricing: \"Plans and add-ons\",\n    pricingUrl: \"https://unmap.dev/pricing\",\n    attribution: \"Ski data © OpenStreetMap contributors\",\n    attributionUrl: \"https://www.openstreetmap.org/copyright\",\n    licence: \"Open Database Licence (ODbL)\",\n    licenceUrl: \"https://opendatacommons.org/licenses/odbl/\",\n    disclaimer:\n      \"Terrain information only, not avalanche or safety advice. Check the area's own conditions and avalanche forecasts before you go.\",\n    provinceNames: {\n      AB: \"Alberta\",\n      BC: \"British Columbia\",\n      MB: \"Manitoba\",\n      NB: \"New Brunswick\",\n      NL: \"Newfoundland and Labrador\",\n      NS: \"Nova Scotia\",\n      NT: \"Northwest Territories\",\n      NU: \"Nunavut\",\n      ON: \"Ontario\",\n      PE: \"Prince Edward Island\",\n      QC: \"Quebec\",\n      SK: \"Saskatchewan\",\n      YT: \"Yukon\",\n    } as Record<string, string>,\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: \"Stations de ski\",\n    description:\n      \"Toutes les stations de ski du Canada sur relief 3D. Cherchez par nom, filtrez par province ou par activité, et choisissez-en une pour ses chiffres.\",\n    checking: \"Chargement des stations de ski...\",\n    search: \"Chercher une station\",\n    searchPlaceholder: \"Whistler, Mont-Sainte-Anne...\",\n    province: \"Province ou territoire\",\n    allProvinces: \"Toutes les provinces et tous les territoires\",\n    activity: \"Activité\",\n    activities: { all: \"Toutes\", downhill: \"Ski alpin\", nordic: \"Ski de fond\" },\n    list: \"Stations de ski\",\n    count: (n: number) => (n === 1 ? \"1 station de ski\" : `${n.toLocaleString(\"fr-CA\")} stations de ski`),\n    empty: \"Aucune station ne correspond. Effacez la recherche ou les filtres.\",\n    loading: \"Recherche...\",\n    back: \"Retour à toutes les stations\",\n    selected: \"Station choisie\",\n    noPlace: \"Emplacement inconnu\",\n    vertical: \"Dénivelé\",\n    topBase: \"Sommet / base\",\n    noElevation: \"Non mesuré\",\n    runs: \"Pistes\",\n    downhillKm: \"Ski alpin\",\n    nordicKm: \"Ski de fond\",\n    lifts: \"Remontées\",\n    drive: \"En voiture\",\n    noDrive: \"Aucune ville à portée\",\n    driveFrom: (time: string, from: string) => `${time} de ${from}`,\n    aspect: \"Orientation\",\n    aspectCaption: \"Part de la longueur des pistes de ski alpin orientée dans chaque direction.\",\n    aspectNone: \"Aucune piste de ski alpin à mesurer.\",\n    facing: (direction: string, share: string) => `Surtout orientée ${direction} (${share}).`,\n    directions: { n: \"N\", ne: \"NE\", e: \"E\", se: \"SE\", s: \"S\", sw: \"SO\", w: \"O\", nw: \"NO\" },\n    directionNames: {\n      n: \"au nord\",\n      ne: \"au nord-est\",\n      e: \"à l'est\",\n      se: \"au sud-est\",\n      s: \"au sud\",\n      sw: \"au sud-ouest\",\n      w: \"à l'ouest\",\n      nw: \"au nord-ouest\",\n    },\n    websites: \"Site Web\",\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    badSearch: \"Cette recherche est impossible. Utilisez des lettres ou des chiffres.\",\n    failed: \"La recherche a échoué. Réessayez.\",\n    retry: \"Réessayer\",\n    liftsDenied: \"Les remontées 3D nécessitent le module complémentaire Ski sur cette clé.\",\n    liftsMissing: \"Cette station n'a pas encore de remontées 3D.\",\n    liftsFailed: \"Les remontées 3D ne se sont pas chargées.\",\n    summary: \"Toutes les stations de ski du Canada sur relief 3D.\",\n    legend: \"Légende\",\n    legendRuns: \"Pistes\",\n    legendKeys: {\n      easy: \"Facile\",\n      intermediate: \"Intermédiaire\",\n      advanced: \"Difficile\",\n      expert: \"Expert\",\n      park: \"Parc à neige\",\n      nordic: \"Piste de ski de fond\",\n      lift: \"Remontée\",\n      lift3d: \"Remontée, câble en 3D\",\n    },\n    difficulties: {\n      easy: \"Facile, cercle vert\",\n      intermediate: \"Intermédiaire, carré bleu\",\n      advanced: \"Difficile, losange noir\",\n      expert: \"Expert, double losange noir\",\n      park: \"Parc à neige\",\n      unrated: \"Difficulté non indiquée\",\n    },\n    nordicTrail: \"Piste de ski de fond\",\n    unnamedRun: \"Piste sans nom\",\n    unnamedLift: \"Remontée sans nom\",\n    pitch: \"Pente moyenne\",\n    faces: \"Orientée\",\n    rise: \"Dénivelé\",\n    length: \"Longueur\",\n    towers: \"Pylônes\",\n    aerialways: {\n      cable_car: \"Téléphérique\",\n      gondola: \"Télécabine\",\n      mixed_lift: \"Télémixte\",\n      chair_lift: \"Télésiège\",\n      drag_lift: \"Téléski\",\n      \"t-bar\": \"Téléski à archets\",\n      \"j-bar\": \"Téléski à perche en J\",\n      platter: \"Téléski à assiettes\",\n      rope_tow: \"Fil-neige\",\n      magic_carpet: \"Tapis roulant\",\n      zip_line: \"Tyrolienne\",\n      goods: \"Monte-charge\",\n    } as Record<string, string>,\n    lift: \"Remontée\",\n    deniedTitle: {\n      addon_required: \"Les stations de ski nécessitent le module complémentaire Ski\",\n      dev_only: \"Les stations de ski 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 Ski : la carte montre le style hiver sur relief 3D sans stations, pistes ni remontées, et la liste est désactivée.\",\n      dev_only:\n        \"Avec le forfait de développement, les stations de ski fonctionnent depuis localhost et les origines de prévisualisation. Depuis cette origine, il faut un forfait payant avec le module complémentaire Ski.\",\n      usage_limit_reached:\n        \"Les appels premium du forfait de développement sont épuisés pour ce mois. Les stations de ski reviennent le mois prochain, ou dès maintenant avec un forfait payant.\",\n    },\n    unknownTitle: \"Impossible de charger les stations de ski\",\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 liste est désactivée. La carte montre le style hiver sur relief 3D.`,\n    pricing: \"Forfaits et modules complémentaires\",\n    pricingUrl: \"https://unmap.dev/fr/pricing\",\n    attribution: \"Données de ski © les contributeurs d'OpenStreetMap\",\n    attributionUrl: \"https://www.openstreetmap.org/copyright\",\n    licence: \"Licence ouverte de bases de données (ODbL)\",\n    licenceUrl: \"https://opendatacommons.org/licenses/odbl/\",\n    disclaimer:\n      \"Information sur le relief seulement, pas un avis sur les avalanches ni sur la sécurité. Consultez les conditions de la station et les bulletins d'avalanche avant de partir.\",\n    provinceNames: {\n      AB: \"Alberta\",\n      BC: \"Colombie-Britannique\",\n      MB: \"Manitoba\",\n      NB: \"Nouveau-Brunswick\",\n      NL: \"Terre-Neuve-et-Labrador\",\n      NS: \"Nouvelle-Écosse\",\n      NT: \"Territoires du Nord-Ouest\",\n      NU: \"Nunavut\",\n      ON: \"Ontario\",\n      PE: \"Île-du-Prince-Édouard\",\n      QC: \"Québec\",\n      SK: \"Saskatchewan\",\n      YT: \"Yukon\",\n    } as Record<string, string>,\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 ski areas, as the first `/ski/areas` answered. */\nexport type SkiGate =\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-ski`. */\n      addon: string;\n    }\n  | { state: \"unknown\"; status: number };\n\n/** The gate a failed first request means: a 403 with an add-on code is denied, anything else unknown. */\nexport function gateFrom(caught: unknown): SkiGate {\n  const failure = typeof caught === \"object\" && caught !== null ? (caught as { status?: unknown; code?: unknown; addon?: unknown }) : {};\n  const status = typeof failure.status === \"number\" ? failure.status : 0;\n  const code = failure.code;\n  if (status === 403 && (code === \"addon_required\" || code === \"dev_only\" || code === \"usage_limit_reached\")) {\n    return { state: \"denied\", code, addon: typeof failure.addon === \"string\" ? failure.addon : \"\" };\n  }\n  return { state: \"unknown\", status };\n}\n\n/** What to tell the reader about a failed search once the list is up, by what the gateway said. */\nexport function requestError(caught: unknown, lang: SkiLang): string {\n  const copy = SKI_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 (status === 400) return copy.badSearch;\n  if (status === 429) return copy.busy;\n  if (status === 401 || status === 403) return copy.keyRefused;\n  if (status === null || status === 0 || status >= 500) return copy.unavailable;\n  return copy.failed;\n}\n\n/** The `areas()` options for the filters: nothing for \"all\", nothing for an empty search. */\nexport function areaQuery(q: string, province: string, activity: SkiActivityFilter): { q?: string; province?: string; activity?: \"downhill\" | \"nordic\" } {\n  const text = q.trim();\n  return {\n    ...(text ? { q: text } : {}),\n    ...(province && province !== \"all\" ? { province } : {}),\n    ...(activity !== \"all\" ? { activity } : {}),\n  };\n}\n\nconst locale = (lang: SkiLang) => (lang === \"fr\" ? \"fr-CA\" : \"en-CA\");\n\n/** Whole metres, grouped the way the language reads them: \"1,631 m\", \"1 631 m\". */\nexport function formatMetres(metres: number, lang: SkiLang): string {\n  return `${new Intl.NumberFormat(locale(lang), { maximumFractionDigits: 0 }).format(metres)} m`;\n}\n\n/** Kilometres to one decimal: \"150.5 km\", \"150,5 km\". */\nexport function formatKm(km: number, lang: SkiLang): string {\n  return `${new Intl.NumberFormat(locale(lang), { maximumFractionDigits: 1, minimumFractionDigits: 0 }).format(km)} km`;\n}\n\n/** A whole percentage: \"30%\", \"30 %\". */\nexport function formatShare(share: number, lang: SkiLang): string {\n  return new Intl.NumberFormat(locale(lang), { style: \"percent\", maximumFractionDigits: 0 }).format(share);\n}\n\n/** A drive time: \"45 min\", \"2 h\", \"2 h 3 min\". The same in both languages. */\nexport function formatMinutes(minutes: number): string {\n  const total = Math.max(0, Math.round(minutes));\n  const hours = Math.floor(total / 60);\n  const rest = total % 60;\n  if (hours === 0) return `${rest} min`;\n  return rest === 0 ? `${hours} h` : `${hours} h ${rest} min`;\n}\n\n/** \"2 h 3 min from Vancouver\", or null when no city is within reach. */\nexport function formatDrive(drive: { from: string; minutes: number } | null, lang: SkiLang): string | null {\n  return drive ? SKI_COPY[lang].driveFrom(formatMinutes(drive.minutes), drive.from) : null;\n}\n\n/** \"Whistler, British Columbia\"; whichever half is known; \"\" when neither is. */\nexport function placeLine(area: { locality: string; province: string }, lang: SkiLang): string {\n  const province = SKI_COPY[lang].provinceNames[area.province] ?? area.province;\n  return [area.locality, province].filter((part) => part !== \"\").join(\", \");\n}\n\n/** The aspect readout: one cell per direction in compass order, and the direction most runs face. */\nexport function aspectReadout(\n  aspect: Partial<Record<SkiAspect, number>>,\n  lang: SkiLang,\n): { cells: Array<{ direction: SkiAspect; label: string; share: string; dominant: boolean }>; dominant: SkiAspect | null; summary: string } {\n  const copy = SKI_COPY[lang];\n  const value = (direction: SkiAspect) => (Number.isFinite(aspect[direction]) ? (aspect[direction] as number) : 0);\n  const total = ASPECTS.reduce((sum, direction) => sum + value(direction), 0);\n  const dominant = total > 0 ? ASPECTS.reduce((best, direction) => (value(direction) > value(best) ? direction : best), ASPECTS[0]) : null;\n  return {\n    cells: ASPECTS.map((direction) => ({\n      direction,\n      label: copy.directions[direction],\n      share: formatShare(value(direction), lang),\n      dominant: direction === dominant,\n    })),\n    dominant,\n    summary: dominant ? copy.facing(copy.directionNames[dominant], formatShare(value(dominant), lang)) : copy.aspectNone,\n  };\n}\n\n/** The websites safe to link: http and https only, since anyone can edit OSM. */\nexport function safeWebsites(urls: readonly string[]): string[] {\n  const out: string[] = [];\n  for (const url of urls) {\n    try {\n      const parsed = new URL(url);\n      if (parsed.protocol === \"https:\" || parsed.protocol === \"http:\") out.push(parsed.href);\n    } catch {\n      // Not a URL: not a link.\n    }\n  }\n  return out;\n}\n\n/** A website as a reader recognizes it: the host without `www.`. */\nexport function siteLabel(url: string): string {\n  try {\n    return new URL(url).hostname.replace(/^www\\./, \"\");\n  } catch {\n    return url;\n  }\n}\n\n/** An area name as a URL slug: accents dropped, lower case, words joined by hyphens. */\nexport function slugify(name: string): string {\n  return name\n    .normalize(\"NFD\")\n    .replace(/[\\u0300-\\u036f]/g, \"\")\n    .toLowerCase()\n    .replace(/[^a-z0-9]+/g, \"-\")\n    .replace(/^-+|-+$/g, \"\");\n}\n\n/**\n * The short key a link names an area by: its name as a slug (`nakiska`). Two areas of the same\n * name each add their OSM type and number (`fortress-mountain-resort-w909322254`), and a name\n * with nothing left to slug (no Latin letters) is the id. Clashes are found among `known`, every\n * area the block has listed, which the first, unfiltered list already holds in full.\n */\nexport function areaSlug(area: { id: string; name: string }, known: ReadonlyArray<{ id: string; name: string }>): string {\n  const base = slugify(area.name);\n  if (base === \"\") return area.id;\n  if (!known.some((other) => other.id !== area.id && slugify(other.name) === base)) return base;\n  const id = /^osm-(way|relation|node)-(\\d+)$/.exec(area.id);\n  return id ? `${base}-${id[1]![0]}${id[2]}` : `${base}-${slugify(area.id)}`;\n}\n\n/** The area a link names, by its slug or by its id, among `areas`. */\nexport function findArea<T extends { id: string; name: string }>(key: string, areas: readonly T[], known: ReadonlyArray<{ id: string; name: string }>): T | undefined {\n  return areas.find((entry) => entry.id === key) ?? areas.find((entry) => areaSlug(entry, known) === key);\n}\n\n/** The labelled numbers the panel shows for an area, in a fixed order. */\nexport function statRows(\n  area: {\n    elevation: { top_m: number; base_m: number; vertical_m: number } | null;\n    runs: { count: number; downhill_km: number; nordic_km: number };\n    lifts: { count: number };\n    drive: { from: string; minutes: number } | null;\n  },\n  lang: SkiLang,\n): Array<[string, string]> {\n  const copy = SKI_COPY[lang];\n  const count = (n: number) => new Intl.NumberFormat(locale(lang)).format(n);\n  const elevation = area.elevation;\n  return [\n    [copy.vertical, elevation ? formatMetres(elevation.vertical_m, lang) : copy.noElevation],\n    [copy.topBase, elevation ? `${formatMetres(elevation.top_m, lang)} / ${formatMetres(elevation.base_m, lang)}` : copy.noElevation],\n    [copy.runs, count(area.runs.count)],\n    [copy.downhillKm, formatKm(area.runs.downhill_km, lang)],\n    [copy.nordicKm, formatKm(area.runs.nordic_km, lang)],\n    [copy.lifts, count(area.lifts.count)],\n    [copy.drive, formatDrive(area.drive, lang) ?? copy.noDrive],\n  ];\n}\n\n/** Where focus goes in the list for a key: the next, previous, first or last row; null for any other key. */\nexport function nextRow(key: string, index: number, count: number): number | null {\n  if (count <= 0) return null;\n  if (key === \"ArrowDown\") return Math.min(index + 1, count - 1);\n  if (key === \"ArrowUp\") return Math.max(index - 1, 0);\n  if (key === \"Home\") return 0;\n  if (key === \"End\") return count - 1;\n  return null;\n}\n\n/** A box on screen, as getBoundingClientRect() gives it. */\nexport interface SkiRect {\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 SkiPadding {\n  top: number;\n  right: number;\n  bottom: number;\n  left: number;\n}\n\n/** The panel's inset from the map edge, and the gutter the camera keeps. */\nexport const GUTTER = 24;\n/** The least map, in pixels, a padded camera must have to frame at its full gutter. */\nconst MIN_FRAME = 120;\n/** The tightened gutter, and the least strip the camera keeps to frame in beside a tall panel. */\nconst TIGHT = 8;\nconst MIN_STRIP = 32;\n\n/**\n * Camera padding that keeps the framed area clear of the floating panel: its width on the left\n * when it floats beside the map, its height at the bottom when it is a sheet across it. An even\n * gutter when nothing is measured. When the full gutter would leave too little map, the gutter\n * tightens and the frame stays in the strip the panel leaves.\n */\nexport function panelPadding(map: SkiRect, panel: SkiRect | null, gutter = GUTTER): SkiPadding {\n  const even = { top: gutter, right: gutter, bottom: gutter, left: gutter };\n  if (!panel || panel.width <= 0 || panel.height <= 0 || map.width <= 0 || map.height <= 0) return even;\n  const sheet = panel.width >= map.width * 0.6;\n  const cover = Math.round(sheet ? map.bottom - panel.top : panel.right - map.left);\n  const size = sheet ? map.height : map.width;\n  if (size - 2 * gutter - cover >= MIN_FRAME) return sheet ? { ...even, bottom: cover + gutter } : { ...even, left: cover + gutter };\n  const near = Math.min(cover + TIGHT, size - TIGHT - MIN_STRIP);\n  return sheet ? { ...even, top: TIGHT, bottom: near } : { ...even, left: near, right: TIGHT };\n}\n\n/** True when the reader asked for less motion: the camera jumps instead of flying. */\nexport function reducedMotion(): boolean {\n  return typeof window !== \"undefined\" && window.matchMedia?.(\"(prefers-reduced-motion: reduce)\")?.matches === true;\n}\n\n/** The part of a MapLibre map the camera uses. */\nexport interface SkiCameraMap {\n  getContainer(): { getBoundingClientRect(): SkiRect };\n  cameraForBounds(bounds: [[number, number], [number, number]], options: Record<string, unknown>): { center: unknown; zoom: number } | undefined;\n  flyTo(options: Record<string, unknown>): unknown;\n  jumpTo(options: Record<string, unknown>): unknown;\n}\n\n/** The closest the camera comes to an area, so a small hill still shows its surroundings. */\nexport const AREA_MAX_ZOOM = 15;\n\n/**\n * The bearing that faces an area's slopes: opposite the way most of its downhill run length\n * faces, so the camera looks up the mountain at the runs, as a resort's trail map does. North (0)\n * when the runs face every way about equally, or there are none to measure.\n */\nexport function slopeBearing(aspect: Partial<Record<SkiAspect, number>>): number {\n  let x = 0;\n  let y = 0;\n  ASPECTS.forEach((direction, index) => {\n    const share = Number.isFinite(aspect[direction]) ? (aspect[direction] as number) : 0;\n    x += share * Math.sin((index * Math.PI) / 4);\n    y += share * Math.cos((index * Math.PI) / 4);\n  });\n  if (Math.hypot(x, y) < 0.1) return 0;\n  const facing = ((Math.atan2(x, y) * 180) / Math.PI + 360) % 360;\n  return Math.round((facing + 180) % 360);\n}\n\n/**\n * The camera on an area: its bbox fitted clear of the panel, turned to face its slopes\n * (`slopeBearing`), tilted to `AREA_PITCH` and centred halfway up its vertical, so on 3D terrain\n * the runs sit in the middle of the free map rather than below a sea-level centre. Null when the\n * map cannot fit the bbox (a map with no size yet).\n */\nexport function areaCamera(\n  map: SkiCameraMap,\n  area: { bbox: [number, number, number, number]; aspect: Partial<Record<SkiAspect, number>>; elevation: { top_m: number; base_m: number } | null },\n  panel: SkiRect | null,\n): Record<string, unknown> | null {\n  const [west, south, east, north] = area.bbox;\n  const padding = panelPadding(map.getContainer().getBoundingClientRect(), panel);\n  const bearing = slopeBearing(area.aspect);\n  const fit = map.cameraForBounds(\n    [\n      [west, south],\n      [east, north],\n    ],\n    { padding, bearing },\n  );\n  if (!fit || !Number.isFinite(fit.zoom)) return null;\n  return {\n    center: fit.center,\n    // The fit is top-down; tilted, the far half shrinks as the near half grows, so the bbox still fits.\n    zoom: Math.min(fit.zoom, AREA_MAX_ZOOM),\n    bearing,\n    pitch: AREA_PITCH,\n    padding,\n    ...(area.elevation ? { elevation: (area.elevation.top_m + area.elevation.base_m) / 2 } : {}),\n  };\n}\n\n/** Fly to an area (`areaCamera`), or jump there when the reader asked for less motion. */\nexport function flyToArea(map: SkiCameraMap, area: Parameters<typeof areaCamera>[1], panel: SkiRect | null): void {\n  const camera = areaCamera(map, area, panel);\n  if (!camera) return;\n  if (reducedMotion()) map.jumpTo(camera);\n  else map.flyTo({ ...camera, duration: 1800, essential: true });\n}\n\n/**\n * The MapLibre options 3D lifts need when the map is created: an antialiased WebGL context, so\n * cables draw smooth. The same value as `deckMapOptions` in @unmap/deck, kept here so the block\n * does not import that package before 3D lifts are on.\n */\nexport const LIFTS_MAP_OPTIONS: { canvasContextAttributes: { antialias: true } } = { canvasContextAttributes: { antialias: true } };\n\n/**\n * How far the map tilts. MapLibre stops at 60 degrees; a ski area reads best from lower, looking\n * across the slope, so the explorer allows 85 (a little short of the horizon, where terrain breaks).\n */\nexport const SKI_MAX_PITCH = 85;\n\n/** The MapLibre options the explorer's map is created with, plus the 3D lifts' when they are on. */\nexport function skiMapOptions(lifts3d: boolean): { maxPitch: number; canvasContextAttributes?: { antialias: true } } {\n  return lifts3d ? { maxPitch: SKI_MAX_PITCH, ...LIFTS_MAP_OPTIONS } : { maxPitch: SKI_MAX_PITCH };\n}\n\n/** What to tell the reader when an area's 3D lifts did not load, by what the gateway said. */\nexport function liftsError(caught: unknown, lang: SkiLang): string {\n  const copy = SKI_COPY[lang];\n  const failure = typeof caught === \"object\" && caught !== null ? (caught as { code?: unknown; status?: unknown }) : {};\n  const code = failure.code;\n  if (failure.status === 403 && (code === \"addon_required\" || code === \"dev_only\" || code === \"usage_limit_reached\")) return copy.liftsDenied;\n  if (failure.status === 404) return copy.liftsMissing;\n  return `${copy.liftsFailed} ${requestError(caught, lang)}`;\n}\n\n/** The style layers the `ski` overlay draws (apps/gateway/src/overlays.ts). */\nexport const SKI_LAYERS = {\n  downhill: \"overlay_ski_runs_downhill\",\n  nordic: \"overlay_ski_runs_nordic\",\n  expertCore: \"overlay_ski_runs_expert_core\",\n  lifts: \"overlay_ski_lifts\",\n  liftsCasing: \"overlay_ski_lifts_casing\",\n  liftsTowers: \"overlay_ski_lifts_towers\",\n  labels: \"overlay_ski_ski_area_labels\",\n} as const;\n\n/** The 2D layers that draw a lift, hidden while the 3D lifts draw them instead. */\nconst LIFT_2D_LAYERS = [SKI_LAYERS.liftsCasing, SKI_LAYERS.lifts, SKI_LAYERS.liftsTowers] as const;\n\n/** A `#rrggbb` colour as deck.gl's RGBA, or null for anything else. */\nexport function hexToRgba(color: unknown, alpha = 255): [number, number, number, number] | null {\n  if (typeof color !== \"string\" || !/^#[0-9a-f]{6}$/i.test(color)) return null;\n  const n = Number.parseInt(color.slice(1), 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255, alpha];\n}\n\n/** The part of a MapLibre map 3D lifts read and change. */\nexport interface SkiLiftsMap {\n  getTerrain(): { exaggeration?: number } | null | undefined;\n  getLayer?(id: string): unknown;\n  getPaintProperty?(id: string, name: string): unknown;\n  setLayoutProperty?(id: string, name: string, value: unknown): unknown;\n}\n\n/** One lift as the 3D file holds it: the part the cables, the towers and a popup read. */\nexport interface SkiLift3d {\n  id: string;\n  name: string | null;\n  aerialway: string;\n  stations: [[number, number, number], [number, number, number]];\n  pylons: Array<[number, number, number]>;\n  cable: Array<[number, number, number]>;\n}\n\n/** How far a tower is drawn below the cable: about the height the pipeline hangs a cable above the ground. */\nexport const TOWER_DROP_M = 9;\n\n/** Every pylon as a short vertical line under the cable, carrying the lift it holds up. */\nexport function liftTowers(lifts: readonly SkiLift3d[]): Array<SkiLift3d & { of: SkiLift3d }> {\n  return lifts.flatMap((lift) =>\n    lift.pylons.map((pylon, index) => ({\n      ...lift,\n      id: `${lift.id}:tower:${index}`,\n      cable: [pylon, [pylon[0], pylon[1], pylon[2] - TOWER_DROP_M]] as Array<[number, number, number]>,\n      of: lift,\n    })),\n  );\n}\n\n/** The deck.gl layer ids the 3D lifts draw, bottom first. */\nexport const LIFT_LAYER_IDS = [\"unmap-lift-cables-casing\", \"unmap-lift-towers\", \"unmap-lift-cables\"] as const;\n\n/** A light casing under every cable, so it reads against dark woods and above the 2D lift line. */\nconst CASING: [number, number, number, number] = [255, 255, 255, 220];\n\n/** Finds the lift drawn under a point on screen, or null. */\nexport type SkiLiftPicker = (x: number, y: number) => SkiLift3d | null;\n\n/**\n * Draw one area's lifts in 3D: import @unmap/deck (only now, never at module load), fetch the\n * area's lifts-3d (one call), and add one interleaved deck.gl overlay at the map's terrain\n * exaggeration: each cable on a light casing, and a tower under every pylon. The 2D lift lines are\n * hidden while it is on, so a lift is drawn once. `onPicker` receives the function that finds the\n * lift under a point, and null when the overlay comes off. Returns the function that cancels a\n * pending load and takes the overlay off.\n */\nexport function showLifts3d(\n  map: SkiLiftsMap,\n  options: { key: string; areaId: string; gateway?: string; onError: (caught: unknown) => void; onPicker?: (pick: SkiLiftPicker | null) => void },\n): () => void {\n  const controller = new AbortController();\n  let overlay: { remove(): void; overlay?: { pickObject?(opts: Record<string, unknown>): { object?: unknown } | null } } | null = null;\n  let hidden = false;\n  import(\"@unmap/deck\")\n    .then(async ({ createDeckOverlay, fetchLifts3d, liftCablesLayer, LIFT_COLORS }) => {\n      const doc = await fetchLifts3d({ key: options.key, areaId: options.areaId, gateway: options.gateway, signal: controller.signal });\n      if (controller.signal.aborted) return;\n      const exaggeration = map.getTerrain()?.exaggeration ?? 1;\n      const lifts = ((doc as { lifts?: SkiLift3d[] }).lifts ?? []) as SkiLift3d[];\n      const every = (color: [number, number, number, number]) => Object.fromEntries(Object.keys(LIFT_COLORS ?? {}).map((key) => [key, color]));\n      const casing = every(CASING);\n      // One colour for every lift, the style's lift red, so 3D and 2D lifts agree and neither\n      // reads as a run; the aerialway is in the popup.\n      const red = hexToRgba(map.getLayer?.(SKI_LAYERS.lifts) ? map.getPaintProperty?.(SKI_LAYERS.lifts, \"line-color\") : null);\n      const cable = red ? { colors: every(red) } : {};\n      // Drawn over the terrain, not into it: the rendered terrain is coarser than the DEM the heights\n      // were sampled from, so a cable a few metres up would otherwise vanish in stretches into it.\n      const onTop = <L,>(layer: L): L =>\n        typeof (layer as { clone?: unknown }).clone === \"function\"\n          ? (layer as unknown as { clone(props: Record<string, unknown>): L }).clone({ parameters: { depthCompare: \"always\" } })\n          : layer;\n      overlay = createDeckOverlay(map as unknown as Parameters<typeof createDeckOverlay>[0], {\n        layers: [\n          onTop(liftCablesLayer(lifts as never, { id: LIFT_LAYER_IDS[0], exaggeration, width: 4, colors: casing, pickable: true })),\n          onTop(liftCablesLayer(liftTowers(lifts) as never, { id: LIFT_LAYER_IDS[1], exaggeration, width: 1.5, pickable: true, ...cable })),\n          onTop(liftCablesLayer(doc, { id: LIFT_LAYER_IDS[2], exaggeration, width: 2.5, pickable: true, ...cable })),\n        ],\n      });\n      for (const id of LIFT_2D_LAYERS) {\n        if (!map.getLayer?.(id)) continue;\n        map.setLayoutProperty?.(id, \"visibility\", \"none\");\n        hidden = true;\n      }\n      const drawn = overlay;\n      options.onPicker?.((x, y) => {\n        const hit = drawn.overlay?.pickObject?.({ x, y, radius: 6, layerIds: [...LIFT_LAYER_IDS] })?.object as (SkiLift3d & { of?: SkiLift3d }) | undefined;\n        return hit ? (hit.of ?? hit) : null;\n      });\n    })\n    .catch((caught: unknown) => {\n      if (!controller.signal.aborted) options.onError(caught ?? new Error(\"failed\"));\n    });\n  return () => {\n    controller.abort();\n    if (overlay) options.onPicker?.(null);\n    overlay?.remove();\n    overlay = null;\n    // A style swap has already put the 2D lines back; only an overlay taken off here restores them.\n    if (hidden) for (const id of LIFT_2D_LAYERS) if (map.getLayer?.(id)) map.setLayoutProperty?.(id, \"visibility\", \"visible\");\n  };\n}\n\n/** The compass direction a bearing in degrees falls in. */\nexport function aspectOf(degrees: number): SkiAspect {\n  return ASPECTS[Math.round((((degrees % 360) + 360) % 360) / 45) % 8]!;\n}\n\n/** Ground length of a path in metres, ignoring height. */\nexport function pathLength(path: ReadonlyArray<readonly [number, number, ...number[]]>): number {\n  let total = 0;\n  for (let i = 1; i < path.length; i++) {\n    const [lng1, lat1] = path[i - 1]!;\n    const [lng2, lat2] = path[i]!;\n    const r = Math.PI / 180;\n    const dx = (lng2 - lng1) * r * Math.cos(((lat1 + lat2) / 2) * r) * 6371000;\n    const dy = (lat2 - lat1) * r * 6371000;\n    total += Math.hypot(dx, dy);\n  }\n  return total;\n}\n\n/** What a popup says about a run, a 2D lift or a 3D lift. */\nexport type SkiFeature =\n  | { kind: \"run\"; name: string | null; pisteType: string; difficulty: string | null; pitch: number | null; aspect: number | null }\n  | { kind: \"lift\"; name: string | null; aerialway: string | null; lift: SkiLift3d | null };\n\n/** A run's tile properties as a popup reads them. */\nexport function runFeature(properties: Record<string, unknown>): SkiFeature {\n  const text = (value: unknown) => (typeof value === \"string\" && value !== \"\" ? value : null);\n  const number = (value: unknown) => (typeof value === \"number\" && Number.isFinite(value) ? value : null);\n  return {\n    kind: \"run\",\n    name: text(properties.name),\n    pisteType: text(properties.piste_type) ?? \"downhill\",\n    difficulty: text(properties.difficulty),\n    pitch: number(properties.pitch_avg_deg),\n    aspect: number(properties.aspect),\n  };\n}\n\n/** A lift, from its tile properties (2D) or its 3D record. */\nexport function liftFeature(source: Record<string, unknown> | SkiLift3d): SkiFeature {\n  const lift = Array.isArray((source as SkiLift3d).cable) ? (source as SkiLift3d) : null;\n  const name = typeof source.name === \"string\" && source.name !== \"\" ? source.name : null;\n  const aerialway = lift ? lift.aerialway : typeof source.aerialway === \"string\" ? source.aerialway : null;\n  return { kind: \"lift\", name, aerialway, lift };\n}\n\n/** Identifies a feature, so hovering along one run or lift keeps one card open. */\nexport function featureKey(feature: SkiFeature): string {\n  if (feature.kind === \"lift\") return `lift:${feature.lift?.id ?? \"\"}:${feature.name ?? \"\"}:${feature.aerialway ?? \"\"}`;\n  return `run:${feature.name ?? \"\"}:${feature.pisteType}:${feature.difficulty ?? \"\"}:${feature.pitch ?? \"\"}:${feature.aspect ?? \"\"}`;\n}\n\n/** The run difficulty a tile value is drawn as (overlays.ts groups OSM's values the same way). */\nexport function difficultyKey(difficulty: string | null): \"easy\" | \"intermediate\" | \"advanced\" | \"expert\" | \"park\" | \"unrated\" {\n  if (difficulty === \"novice\" || difficulty === \"easy\") return \"easy\";\n  if (difficulty === \"intermediate\" || difficulty === \"advanced\" || difficulty === \"park\") return difficulty;\n  if (difficulty === \"expert\" || difficulty === \"freeride\" || difficulty === \"extreme\") return \"expert\";\n  return \"unrated\";\n}\n\n/** A popup's title, its kind line and its rows. */\nexport function featureDetails(feature: SkiFeature, lang: SkiLang): { title: string; kind: string; rows: Array<[string, string]> } {\n  const copy = SKI_COPY[lang];\n  const degrees = (n: number) => `${new Intl.NumberFormat(locale(lang), { maximumFractionDigits: 0 }).format(n)}°`;\n  if (feature.kind === \"run\") {\n    const rows: Array<[string, string]> = [];\n    if (feature.pitch !== null) rows.push([copy.pitch, degrees(feature.pitch)]);\n    if (feature.aspect !== null) rows.push([copy.faces, copy.directionNames[aspectOf(feature.aspect)]]);\n    return {\n      title: feature.name ?? copy.unnamedRun,\n      kind: feature.pisteType === \"nordic\" ? copy.nordicTrail : copy.difficulties[difficultyKey(feature.difficulty)],\n      rows,\n    };\n  }\n  const rows: Array<[string, string]> = [];\n  const lift = feature.lift;\n  if (lift) {\n    const [bottom, top] = lift.stations;\n    rows.push([copy.rise, formatMetres(Math.abs(top[2] - bottom[2]), lang)]);\n    const metres = pathLength(lift.cable.length >= 2 ? lift.cable : lift.stations);\n    rows.push([copy.length, metres >= 1000 ? formatKm(metres / 1000, lang) : formatMetres(metres, lang)]);\n    if (lift.pylons.length > 0) rows.push([copy.towers, new Intl.NumberFormat(locale(lang)).format(lift.pylons.length)]);\n  }\n  return {\n    title: feature.name ?? copy.unnamedLift,\n    kind: (feature.aerialway && copy.aerialways[feature.aerialway]) || copy.lift,\n    rows,\n  };\n}\n\n/** The legend's rows, in the order a trail map lists them. */\nexport const LEGEND_KEYS = [\"easy\", \"intermediate\", \"advanced\", \"expert\", \"park\", \"nordic\", \"lift\"] as const;\nexport type SkiLegendKey = (typeof LEGEND_KEYS)[number];\n\n/**\n * The colour of each legend row, read from the style the map is drawing, so the legend always\n * matches the lines (and the night palette on a dark style). The downhill colours are the\n * `match` on `difficulty` in the runs layer's `line-color`; a row the style does not give is null.\n */\nexport function legendColors(paint: { downhill?: unknown; nordic?: unknown; lifts?: unknown }): Record<SkiLegendKey, string | null> {\n  const colors: Record<SkiLegendKey, string | null> = { easy: null, intermediate: null, advanced: null, expert: null, park: null, nordic: null, lift: null };\n  const expr = paint.downhill;\n  if (Array.isArray(expr) && expr[0] === \"match\") {\n    for (let i = 2; i + 1 < expr.length; i += 2) {\n      const labels = Array.isArray(expr[i]) ? (expr[i] as unknown[]) : [expr[i]];\n      const color = expr[i + 1];\n      if (typeof color !== \"string\") continue;\n      for (const label of labels) {\n        const key = typeof label === \"string\" ? difficultyKey(label) : \"unrated\";\n        if (key !== \"unrated\" && colors[key] === null) colors[key] = color;\n      }\n    }\n  }\n  if (typeof paint.nordic === \"string\") colors.nordic = paint.nordic;\n  if (typeof paint.lifts === \"string\") colors.lift = paint.lifts;\n  return colors;\n}\n\n/** Tailwind heights for an aspect bar, so a bar is sized by a class and never an inline style. */\nconst BAR_HEIGHTS = [\"h-0\", \"h-0.5\", \"h-1\", \"h-1.5\", \"h-2\", \"h-2.5\", \"h-3\", \"h-3.5\", \"h-4\", \"h-5\", \"h-6\", \"h-7\", \"h-8\"] as const;\n\n/** The bar class for a share, scaled to the largest share. */\nexport function barHeight(share: number, largest: number): string {\n  if (!(largest > 0) || !(share > 0)) return BAR_HEIGHTS[0];\n  return BAR_HEIGHTS[Math.max(1, Math.round((share / largest) * (BAR_HEIGHTS.length - 1)))]!;\n}\n\n/** The three figures an area's header shows large, and the rest of its numbers as rows. */\nexport function areaFigures(area: Parameters<typeof statRows>[0], lang: SkiLang): { tiles: Array<[string, string]>; rows: Array<[string, string]> } {\n  const [vertical, topBase, runs, downhill, nordic, lifts, drive] = statRows(area, lang);\n  return { tiles: [vertical!, runs!, lifts!], rows: [topBase!, downhill!, nordic!, drive!] };\n}\n// --- end ski-resort-explorer helpers ---\n\nexport interface SkiResortExplorerProps {\n  /** unmap API key with the Ski add-on. The page passes NEXT_PUBLIC_UNMAP_KEY. With none, the block asks for one and loads nothing. */\n  apiKey: string;\n  /** Gateway base URL. Defaults to the unmap production gateway. */\n  gateway?: string;\n  /** Language of the copy, the province names and the map labels. Defaults to \"en\". */\n  lang?: SkiLang;\n  /** Where the map opens, [longitude, latitude]: the initial camera only. Defaults to the middle of Canada. */\n  center?: [number, number];\n  /** The opening zoom: the initial camera only, like `center`. Defaults to 3, all of Canada. */\n  zoom?: number;\n  /** Vertical exaggeration of the 3D terrain, 0.1 to 5. Omit for the gateway's default, 1. */\n  exaggeration?: number;\n  /**\n   * Draw the picked area's lift cables in 3D, on its terrain. Off by default. When on, the map is\n   * created antialiased (switching it recreates the map), and picking an area imports\n   * @unmap/deck and calls `/ski/areas/{id}/lifts-3d` per area, and again after a style swap.\n   */\n  lifts3d?: boolean;\n  /**\n   * The area to show, by its slug (`nakiska`, see `areaSlug`) or its id, such as one read from the\n   * page URL. Picks that area once the list holds it; `null` closes it. Omit it and the block keeps\n   * its own selection.\n   */\n  area?: string | null;\n  /** Fires with the area's slug, and the area, when the reader picks one; with nulls when they close it. */\n  onAreaChange?: (slug: string | null, area: SkiArea | null) => void;\n  className?: string;\n}\n\n/** What the list holds: the first answer pending, the areas, or a failed search over the last areas. */\ntype SkiList = { areas: SkiArea[]; pending: boolean; error: unknown };\n\nexport function SkiResortExplorer({\n  apiKey,\n  gateway,\n  lang = \"en\",\n  center = CANADA_VIEW.center,\n  zoom = CANADA_VIEW.zoom,\n  exaggeration,\n  lifts3d = false,\n  area,\n  onAreaChange,\n  className,\n}: SkiResortExplorerProps) {\n  const copy = SKI_COPY[lang];\n  const hasKey = apiKey.trim() !== \"\";\n  const ski = useMemo(() => new Ski({ key: apiKey, gateway }), [apiKey, gateway]);\n  // The opening camera, kept: after this the reader moves the map, not the props.\n  const [opening] = useState(() => ({ center, zoom }));\n  const [gate, setGate] = useState<SkiGate>({ state: \"checking\" });\n  const [attempt, setAttempt] = useState(0);\n  const [text, setText] = useState(\"\");\n  const [q, setQ] = useState(\"\");\n  const [province, setProvince] = useState(\"all\");\n  const [activity, setActivity] = useState<SkiActivityFilter>(\"all\");\n  const [list, setList] = useState<SkiList>({ areas: [], pending: true, error: null });\n  const [selected, setSelected] = useState<SkiArea | null>(null);\n  const [liftsFailure, setLiftsFailure] = useState<unknown>(null);\n  const [liftsAttempt, setLiftsAttempt] = useState(0);\n  const [liftPicker, setLiftPickerState] = useState<SkiLiftPicker | null>(null);\n  // A picker is a function: wrapped, so setState stores it rather than calling it.\n  const setLiftPicker = useCallback((next: SkiLiftPicker | null) => setLiftPickerState(() => next), []);\n  const panelRef = useRef<HTMLDivElement | null>(null);\n  // Every area listed so far, by id: what an area's slug is kept unique among.\n  const knownRef = useRef(new globalThis.Map<string, SkiArea>());\n  const listRef = useRef<HTMLUListElement | null>(null);\n  const uid = useId();\n  const searchId = `${uid}-search`;\n  const provinceId = `${uid}-province`;\n  const listId = `${uid}-list`;\n\n  // A new key starts over: its add-on answer is its own.\n  useEffect(() => {\n    setGate({ state: \"checking\" });\n    setSelected(null);\n  }, [apiKey, gateway]);\n\n  // The search waits for a pause in typing; the filters apply at once.\n  useEffect(() => {\n    const timer = setTimeout(() => setQ(text), SEARCH_DEBOUNCE_MS);\n    return () => clearTimeout(timer);\n  }, [text]);\n\n  // One /ski/areas per key and per filter set. The first answer is also the add-on gate.\n  useEffect(() => {\n    if (!hasKey) return;\n    const controller = new AbortController();\n    setList((current) => ({ ...current, pending: true }));\n    ski.areas({ ...areaQuery(q, province, activity), signal: controller.signal }).then(\n      ({ data }) => {\n        if (controller.signal.aborted) return;\n        setGate((current) => (current.state === \"granted\" ? current : { state: \"granted\" }));\n        setList({ areas: data, pending: false, error: null });\n      },\n      (caught: unknown) => {\n        if (controller.signal.aborted) return;\n        setGate((current) => (current.state === \"checking\" ? gateFrom(caught) : current));\n        setList((current) => ({ ...current, pending: false, error: caught ?? new Error(\"failed\") }));\n      },\n    );\n    return () => controller.abort();\n  }, [ski, hasKey, q, province, activity, attempt]);\n\n  // Remember every area listed, before the page's area is looked up among them.\n  useEffect(() => {\n    for (const entry of list.areas) knownRef.current.set(entry.id, entry);\n  }, [list.areas]);\n\n  // The page's area, once the list holds it: a shared link, or the page's back button.\n  useEffect(() => {\n    if (area === undefined) return;\n    if (area === null) {\n      setSelected(null);\n      return;\n    }\n    const found = findArea(area, list.areas, [...knownRef.current.values()]);\n    if (found) setSelected((current) => (current?.id === found.id ? current : found));\n  }, [area, list.areas]);\n\n  // A new area, or lifts turned off, clears the last lifts failure.\n  useEffect(() => {\n    setLiftsFailure(null);\n  }, [selected?.id, lifts3d]);\n\n  function pick(next: SkiArea | null) {\n    setSelected(next);\n    if (next) knownRef.current.set(next.id, next);\n    onAreaChange?.(next ? areaSlug(next, [...knownRef.current.values()]) : null, next);\n  }\n\n  function retry() {\n    setGate({ state: \"checking\" });\n    setAttempt((current) => current + 1);\n  }\n\n  function retryLifts() {\n    setLiftsFailure(null);\n    setLiftsAttempt((current) => current + 1);\n  }\n\n  // Arrow keys, Home and End move between rows; Tab leaves the list.\n  function onListKey(event: KeyboardEvent<HTMLUListElement>) {\n    const rows = [...(listRef.current?.querySelectorAll<HTMLElement>(\"[data-ski-area-id]\") ?? [])];\n    const index = rows.indexOf(document.activeElement as HTMLElement);\n    const next = nextRow(event.key, index < 0 ? -1 : index, rows.length);\n    if (next === null) return;\n    event.preventDefault();\n    rows[index < 0 && event.key === \"ArrowDown\" ? 0 : next]?.focus();\n  }\n\n  if (!hasKey) {\n    // Where the map would be: the block's gutter, the Alert at a readable width, the first action first.\n    return (\n      <div\n        data-unmap-block=\"ski-resort-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=\"ski-resort-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\n  const granted = gate.state === \"granted\";\n  const said = selected ? `${selected.name}. ${placeLine(selected, lang)}` : list.pending ? \"\" : copy.count(list.areas.length);\n  const aspect = selected ? aspectReadout(selected.aspect, lang) : null;\n\n  const figures = selected ? areaFigures(selected, lang) : null;\n  const websites = selected ? safeWebsites(selected.websites) : [];\n  const largestShare = aspect ? Math.max(...ASPECTS.map((direction) => selected?.aspect[direction] ?? 0)) : 0;\n\n  return (\n    <div data-unmap-block=\"ski-resort-explorer\" className={[\"relative h-full min-h-[480px]\", className].filter(Boolean).join(\" \")}>\n      <Map\n        id={SKI_MAP_ID}\n        apiKey={apiKey}\n        gateway={gateway}\n        lang={lang}\n        style=\"winter\"\n        terrain\n        {...(exaggeration != null ? { exaggeration } : {})}\n        {...(granted ? { overlays: [\"ski\"] } : {})}\n        maplibre={skiMapOptions(lifts3d)}\n        center={opening.center}\n        zoom={opening.zoom}\n        className=\"h-full\"\n      >\n        <FlyToArea area={selected} panelRef={panelRef} />\n        {lifts3d && granted && selected ? (\n          <Lifts3d key={liftsAttempt} areaId={selected.id} apiKey={apiKey} gateway={gateway} onError={setLiftsFailure} onPicker={setLiftPicker} />\n        ) : null}\n        {granted ? (\n          <SkiFeaturePopup\n            lang={lang}\n            liftPicker={lifts3d ? liftPicker : null}\n            areaKey={selected?.id ?? \"\"}\n            onArea={(id) => {\n              const found = list.areas.find((entry) => entry.id === id);\n              if (found) pick(found);\n            }}\n          />\n        ) : null}\n        {granted ? <SkiLegend lang={lang} lifts3d={lifts3d} /> : null}\n        <p data-testid=\"ski-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-[23rem] motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-4 motion-safe:duration-300 sm:motion-safe:slide-in-from-bottom-0 sm:motion-safe:slide-in-from-left-4\"\n        >\n          <Card className=\"flex min-h-0 flex-1 flex-col gap-0 py-0\">\n            {granted && selected && aspect && figures ? (\n              <section\n                key={selected.id}\n                aria-label={copy.selected}\n                data-testid=\"ski-area\"\n                className=\"flex min-h-0 flex-1 flex-col motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200\"\n              >\n                <CardHeader className=\"gap-3 border-b pt-4 pb-4\">\n                  <Button type=\"button\" variant=\"ghost\" size=\"sm\" className=\"-ml-2 w-fit text-muted-foreground\" onClick={() => pick(null)}>\n                    <span aria-hidden=\"true\">‹</span>\n                    {copy.back}\n                  </Button>\n                  <div className=\"flex min-w-0 flex-col gap-1\">\n                    <h3 className=\"text-xl leading-tight font-semibold text-balance\">{selected.name}</h3>\n                    <p className=\"text-sm text-muted-foreground\">{placeLine(selected, lang) || copy.noPlace}</p>\n                  </div>\n                  <div className=\"flex flex-wrap gap-1.5\">\n                    {selected.activities.map((name) => (\n                      <Badge key={name} variant=\"secondary\">\n                        {copy.activities[name]}\n                      </Badge>\n                    ))}\n                    {selected.status && selected.status !== \"operating\" ? <Badge variant=\"outline\">{selected.status}</Badge> : null}\n                  </div>\n                </CardHeader>\n                <CardContent className=\"flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto overscroll-contain py-4\">\n                  {lifts3d && liftsFailure ? (\n                    <Alert variant=\"destructive\" role=\"alert\" data-unmap-lifts-error=\"\">\n                      <AlertDescription className=\"flex flex-col items-start gap-2\">\n                        <span>{liftsError(liftsFailure, lang)}</span>\n                        <Button type=\"button\" variant=\"outline\" size=\"sm\" onClick={retryLifts}>\n                          {copy.retry}\n                        </Button>\n                      </AlertDescription>\n                    </Alert>\n                  ) : null}\n                  <dl className=\"grid grid-cols-3 gap-2\">\n                    {figures.tiles.map(([label, value]) => (\n                      <div key={label} className=\"flex flex-col gap-0.5 rounded-lg bg-muted/60 px-3 py-2.5\">\n                        <dt className=\"text-xs text-muted-foreground\">{label}</dt>\n                        <dd className=\"text-lg leading-tight font-semibold tabular-nums\">{value}</dd>\n                      </div>\n                    ))}\n                  </dl>\n                  <dl className=\"grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 text-sm\">\n                    {figures.rows.map(([label, value]) => (\n                      <div key={label} className=\"contents\">\n                        <dt className=\"text-muted-foreground\">{label}</dt>\n                        <dd className=\"text-right font-medium tabular-nums\">{value}</dd>\n                      </div>\n                    ))}\n                  </dl>\n                  <div className=\"flex flex-col gap-2\">\n                    <p className=\"text-sm font-medium\">{copy.aspect}</p>\n                    <Table aria-describedby={`${uid}-aspect`} className=\"table-fixed\">\n                      <TableHeader>\n                        <TableRow className=\"border-0 hover:bg-transparent\">\n                          {aspect.cells.map((cell) => (\n                            <TableHead key={cell.direction} scope=\"col\" className=\"h-auto px-0 pb-1 text-center align-bottom\">\n                              <span aria-hidden=\"true\" className=\"flex h-8 items-end justify-center\">\n                                <span\n                                  data-dominant={cell.dominant ? \"true\" : undefined}\n                                  className={`block w-3 rounded-sm bg-muted-foreground/35 data-[dominant=true]:bg-primary ${barHeight(selected.aspect[cell.direction] ?? 0, largestShare)}`}\n                                />\n                              </span>\n                              <abbr title={copy.directionNames[cell.direction]} className=\"mt-1 block text-xs font-medium no-underline\">\n                                {cell.label}\n                              </abbr>\n                            </TableHead>\n                          ))}\n                        </TableRow>\n                      </TableHeader>\n                      <TableBody>\n                        <TableRow className=\"border-0 hover:bg-transparent\">\n                          {aspect.cells.map((cell) => (\n                            <TableCell\n                              key={cell.direction}\n                              data-dominant={cell.dominant ? \"true\" : undefined}\n                              className=\"px-0 py-0.5 text-center text-xs text-muted-foreground tabular-nums data-[dominant=true]:font-semibold data-[dominant=true]:text-foreground\"\n                            >\n                              {cell.share}\n                            </TableCell>\n                          ))}\n                        </TableRow>\n                      </TableBody>\n                    </Table>\n                    <p id={`${uid}-aspect`} className=\"text-xs text-muted-foreground\">\n                      {aspect.summary} {copy.aspectCaption}\n                    </p>\n                  </div>\n                  {websites.length > 0 ? (\n                    <p className=\"flex flex-wrap gap-x-3 gap-y-1 text-sm\">\n                      <span className=\"text-muted-foreground\">{copy.websites}</span>\n                      {websites.map((url) => (\n                        <a key={url} className=\"font-medium text-primary underline underline-offset-4\" href={url} target=\"_blank\" rel=\"noreferrer\">\n                          {siteLabel(url)}\n                        </a>\n                      ))}\n                    </p>\n                  ) : null}\n                </CardContent>\n              </section>\n            ) : (\n              <>\n                <CardHeader className=\"gap-1 pt-4 pb-3\">\n                  <CardTitle role=\"heading\" aria-level={2}>\n                    {copy.title}\n                  </CardTitle>\n                  <CardDescription className=\"max-sm:hidden\">{copy.summary}</CardDescription>\n                </CardHeader>\n                {gate.state === \"denied\" || gate.state === \"unknown\" ? (\n                  <CardContent className=\"pb-4\">\n                    <Alert data-unmap-addon-gate={gate.state === \"denied\" ? gate.code : \"unknown\"}>\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                        {gate.state === \"denied\" ? (\n                          <a className=\"font-medium text-primary underline underline-offset-4\" href={copy.pricingUrl} target=\"_blank\" rel=\"noreferrer\">\n                            {copy.pricing}\n                          </a>\n                        ) : (\n                          <Button type=\"button\" variant=\"outline\" size=\"sm\" onClick={retry}>\n                            {copy.retry}\n                          </Button>\n                        )}\n                      </AlertDescription>\n                    </Alert>\n                  </CardContent>\n                ) : null}\n                {granted ? (\n                  <>\n                    <div data-testid=\"ski-tools\" className=\"flex shrink-0 flex-col gap-3 border-b px-6 pb-4\">\n                      <Field>\n                        <FieldLabel htmlFor={searchId} className=\"sr-only\">\n                          {copy.search}\n                        </FieldLabel>\n                        <Input\n                          id={searchId}\n                          type=\"search\"\n                          autoComplete=\"off\"\n                          placeholder={copy.searchPlaceholder}\n                          value={text}\n                          aria-controls={listId}\n                          onChange={(event) => setText(event.target.value)}\n                        />\n                      </Field>\n                      <div className=\"flex flex-wrap items-center gap-2\">\n                        <Field className=\"min-w-0 flex-1\">\n                          <FieldLabel htmlFor={provinceId} className=\"sr-only\">\n                            {copy.province}\n                          </FieldLabel>\n                          <Select\n                            items={[\n                              { value: \"all\", label: copy.allProvinces },\n                              ...PROVINCES.map((code) => ({ value: code, label: copy.provinceNames[code] ?? code })),\n                            ]}\n                            value={province}\n                            onValueChange={(next: unknown) => setProvince(typeof next === \"string\" && next ? next : \"all\")}\n                          >\n                            <SelectTrigger id={provinceId} size=\"sm\" className=\"w-full\">\n                              <SelectValue />\n                            </SelectTrigger>\n                            <SelectContent>\n                              <SelectItem value=\"all\">{copy.allProvinces}</SelectItem>\n                              {PROVINCES.map((code) => (\n                                <SelectItem key={code} value={code}>\n                                  {copy.provinceNames[code] ?? code}\n                                </SelectItem>\n                              ))}\n                            </SelectContent>\n                          </Select>\n                        </Field>\n                        <ToggleGroup\n                          variant=\"outline\"\n                          size=\"sm\"\n                          aria-label={copy.activity}\n                          value={[activity]}\n                          // Pressing the pressed item empties the group; the filter keeps its value.\n                          onValueChange={(next: unknown[]) => {\n                            const value = next[0];\n                            if (value === \"all\" || value === \"downhill\" || value === \"nordic\") setActivity(value);\n                          }}\n                        >\n                          {([\"all\", \"downhill\", \"nordic\"] as const).map((value) => (\n                            <ToggleGroupItem key={value} value={value} className=\"px-2.5 aria-pressed:font-semibold\">\n                              {copy.activities[value]}\n                            </ToggleGroupItem>\n                          ))}\n                        </ToggleGroup>\n                      </div>\n                    </div>\n                    <CardContent className=\"flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overscroll-contain py-3\">\n                      <p data-testid=\"ski-count\" className=\"flex items-center gap-2 text-xs font-medium text-muted-foreground\">\n                        {list.pending ? <Spinner className=\"size-3.5\" /> : null}\n                        {list.pending ? copy.loading : copy.count(list.areas.length)}\n                      </p>\n                      {list.error ? (\n                        <Alert variant=\"destructive\" role=\"alert\">\n                          <AlertDescription className=\"flex flex-col items-start gap-2\">\n                            <span>{requestError(list.error, lang)}</span>\n                            <Button type=\"button\" variant=\"outline\" size=\"sm\" onClick={() => setAttempt((current) => current + 1)}>\n                              {copy.retry}\n                            </Button>\n                          </AlertDescription>\n                        </Alert>\n                      ) : null}\n                      {!list.pending && !list.error && list.areas.length === 0 ? <p className=\"text-sm\">{copy.empty}</p> : null}\n                      <ul ref={listRef} id={listId} aria-label={copy.list} className=\"-mx-3 flex flex-col\" onKeyDown={onListKey}>\n                        {list.areas.map((area) => (\n                          <li key={area.id}>\n                            <Button\n                              type=\"button\"\n                              variant=\"ghost\"\n                              data-ski-area-id={area.id}\n                              className=\"h-auto w-full justify-between gap-3 rounded-md px-3 py-2 text-left whitespace-normal\"\n                              onClick={() => pick(area)}\n                            >\n                              <span className=\"flex min-w-0 flex-col items-start gap-0.5\">\n                                <span className=\"font-medium\">{area.name}</span>\n                                <span className=\"text-xs font-normal text-muted-foreground\">{placeLine(area, lang)}</span>\n                              </span>\n                              {area.elevation ? (\n                                <span className=\"shrink-0 text-xs font-normal text-muted-foreground tabular-nums\">\n                                  {formatMetres(area.elevation.vertical_m, lang)}\n                                </span>\n                              ) : null}\n                            </Button>\n                          </li>\n                        ))}\n                      </ul>\n                    </CardContent>\n                  </>\n                ) : null}\n              </>\n            )}\n            <div data-testid=\"ski-credits\" className=\"flex shrink-0 flex-col gap-1 border-t px-6 py-3 text-xs text-muted-foreground\">\n              <p>\n                <a className=\"underline underline-offset-2\" href={copy.attributionUrl} target=\"_blank\" rel=\"noreferrer\">\n                  {copy.attribution}\n                </a>\n                ,{\" \"}\n                <a className=\"underline underline-offset-2\" href={copy.licenceUrl} target=\"_blank\" rel=\"noreferrer\">\n                  {copy.licence}\n                </a>\n                .\n              </p>\n              <p>{copy.disclaimer}</p>\n            </div>\n          </Card>\n        </div>\n      </Map>\n    </div>\n  );\n}\n\n/** Flies to the picked area. It renders inside <Map>, where useMap() can see the map. */\nfunction FlyToArea({ area, panelRef }: { area: SkiArea | null; panelRef: RefObject<HTMLDivElement | null> }) {\n  const map = useMap(SKI_MAP_ID);\n  useEffect(() => {\n    if (!map || !area) return;\n    flyToArea(map as unknown as SkiCameraMap, area, panelRef.current?.getBoundingClientRect() ?? null);\n  }, [map, area, panelRef]);\n  return null;\n}\n\n/** The picked area's lifts in 3D, while it renders: one overlay, taken off when it unmounts. */\nfunction Lifts3d({\n  areaId,\n  apiKey,\n  gateway,\n  onError,\n  onPicker,\n}: {\n  areaId: string;\n  apiKey: string;\n  gateway?: string;\n  onError: (caught: unknown) => void;\n  onPicker: (pick: SkiLiftPicker | null) => void;\n}) {\n  const map = useMap(SKI_MAP_ID);\n  // Ready once the style has loaded: the terrain, and its exaggeration, are on the map. A style\n  // swap (a new exaggeration, light or dark) clears it, so the cables are drawn again on the new one.\n  const ready = useMapReady(SKI_MAP_ID);\n  useEffect(() => {\n    if (!map || !ready) return;\n    return showLifts3d(map as unknown as SkiLiftsMap, { key: apiKey, areaId, gateway, onError, onPicker });\n  }, [map, ready, areaId, apiKey, gateway, onError, onPicker]);\n  return null;\n}\n\n/** The part of a MapLibre map the popup reads. */\ninterface SkiPopupMap {\n  getLayer(id: string): unknown;\n  queryRenderedFeatures(geometry: [[number, number], [number, number]], options: { layers: string[] }): Array<{ layer?: { id?: string }; properties?: Record<string, unknown> }>;\n  project(lngLat: [number, number]): { x: number; y: number };\n  getCanvas(): { style: { cursor: string } };\n  on(type: string, listener: (event: { point: { x: number; y: number }; lngLat: { lng: number; lat: number } }) => void): unknown;\n  off(type: string, listener: (event: { point: { x: number; y: number }; lngLat: { lng: number; lat: number } }) => void): unknown;\n}\n\n/** How near, in pixels, the pointer must be to a run or a lift to open it. */\nconst HIT = 5;\n\n/** What is under a point: a 3D lift first, then a 2D lift, a run, or an area's label. */\nfunction featureAt(map: SkiPopupMap, point: { x: number; y: number }, liftPicker: SkiLiftPicker | null): SkiFeature | { kind: \"area\"; id: string } | null {\n  const lift = liftPicker?.(point.x, point.y);\n  if (lift) return liftFeature(lift);\n  const layers = [SKI_LAYERS.lifts, SKI_LAYERS.downhill, SKI_LAYERS.nordic, SKI_LAYERS.labels].filter((id) => map.getLayer(id));\n  if (layers.length === 0) return null;\n  const hits = map.queryRenderedFeatures(\n    [\n      [point.x - HIT, point.y - HIT],\n      [point.x + HIT, point.y + HIT],\n    ],\n    { layers },\n  );\n  for (const id of layers) {\n    const hit = hits.find((feature) => feature.layer?.id === id);\n    if (!hit) continue;\n    const properties = hit.properties ?? {};\n    if (id === SKI_LAYERS.labels) return typeof properties.id === \"string\" ? { kind: \"area\", id: properties.id } : null;\n    return id === SKI_LAYERS.lifts ? liftFeature(properties) : runFeature(properties);\n  }\n  return null;\n}\n\n/**\n * Hover a run or a lift for its name, kind and numbers, in a card that follows the pointer; on a\n * touch screen, with no hover, a tap opens it and a tap elsewhere closes it. Clicking an area's\n * name on the map picks that area. It reads what the map already drew, so it makes no request.\n */\nfunction SkiFeaturePopup({\n  lang,\n  liftPicker,\n  areaKey,\n  onArea,\n}: {\n  lang: SkiLang;\n  liftPicker: SkiLiftPicker | null;\n  areaKey: string;\n  onArea: (id: string) => void;\n}) {\n  const map = useMap(SKI_MAP_ID) as unknown as SkiPopupMap | null;\n  const [open, setOpen] = useState<{ feature: SkiFeature; key: string } | null>(null);\n  const cardRef = useRef<HTMLDivElement | null>(null);\n  const atRef = useRef<[number, number] | null>(null);\n  const pickerRef = useRef(liftPicker);\n  const areaRef = useRef(onArea);\n  pickerRef.current = liftPicker;\n  areaRef.current = onArea;\n\n  // Pins the card above its point; hidden while the point is off the map.\n  const place = useCallback(() => {\n    const card = cardRef.current;\n    const at = atRef.current;\n    if (!card || !at || !map) return;\n    const { x, y } = map.project(at);\n    const parent = card.parentElement?.getBoundingClientRect();\n    const inside = parent && parent.width > 0 ? x >= 0 && y >= 0 && x <= parent.width && y <= parent.height : true;\n    card.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px) translate(-50%, calc(-100% - 14px))`;\n    card.style.visibility = inside ? \"visible\" : \"hidden\";\n  }, [map]);\n\n  useEffect(() => {\n    if (!map || typeof map.on !== \"function\") return;\n    let frame = 0;\n    const show = (event: { point: { x: number; y: number }; lngLat: { lng: number; lat: number } }, found: SkiFeature | { kind: \"area\"; id: string } | null) => {\n      if (!found || found.kind === \"area\") {\n        atRef.current = null;\n        setOpen(null);\n        return;\n      }\n      atRef.current = [event.lngLat.lng, event.lngLat.lat];\n      const key = featureKey(found);\n      setOpen((current) => (current?.key === key ? current : { feature: found, key }));\n      place();\n    };\n    const onClick = (event: { point: { x: number; y: number }; lngLat: { lng: number; lat: number } }) => {\n      const found = featureAt(map, event.point, pickerRef.current);\n      if (found && found.kind === \"area\") {\n        show(event, null);\n        areaRef.current(found.id);\n        return;\n      }\n      show(event, found);\n    };\n    const onMove = (event: { point: { x: number; y: number }; lngLat: { lng: number; lat: number } }) => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(() => {\n        const found = featureAt(map, event.point, pickerRef.current);\n        map.getCanvas().style.cursor = found ? \"pointer\" : \"\";\n        show(event, found);\n      });\n    };\n    const onOut = () => {\n      cancelAnimationFrame(frame);\n      atRef.current = null;\n      setOpen(null);\n    };\n    map.on(\"click\", onClick);\n    map.on(\"mousemove\", onMove);\n    map.on(\"mouseout\", onOut);\n    return () => {\n      cancelAnimationFrame(frame);\n      map.off(\"click\", onClick);\n      map.off(\"mousemove\", onMove);\n      map.off(\"mouseout\", onOut);\n      map.getCanvas().style.cursor = \"\";\n    };\n  }, [map, place]);\n\n  // A new area, or lifts drawn another way, closes the card.\n  useEffect(() => setOpen(null), [areaKey, liftPicker]);\n\n  // Place the card as it opens, and keep it on its point as the camera moves.\n  useEffect(() => {\n    if (!map || !open) return;\n    place();\n    map.on(\"move\", place as never);\n    return () => {\n      map.off(\"move\", place as never);\n    };\n  }, [map, open, place]);\n\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (event: globalThis.KeyboardEvent) => {\n      if (event.key === \"Escape\") setOpen(null);\n    };\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  }, [open]);\n\n  if (!open) return null;\n  const details = featureDetails(open.feature, lang);\n  const symbol = open.feature.kind === \"run\" && open.feature.pisteType !== \"nordic\" ? difficultyKey(open.feature.difficulty) : null;\n  return (\n    <div\n      ref={cardRef}\n      role=\"tooltip\"\n      aria-label={details.title}\n      data-testid=\"ski-feature\"\n      className=\"pointer-events-none absolute top-0 left-0 z-20 w-60 rounded-lg border bg-popover p-3 text-popover-foreground shadow-lg motion-safe:animate-in motion-safe:fade-in-0 motion-safe:zoom-in-95\"\n    >\n      <div className=\"flex min-w-0 flex-col gap-0.5\">\n        <p className=\"text-sm leading-tight font-semibold\">{details.title}</p>\n        <p className=\"flex items-center gap-1.5 text-xs text-muted-foreground\">\n          {symbol && symbol !== \"unrated\" ? <DifficultySymbol kind={symbol} /> : null}\n          {details.kind}\n        </p>\n      </div>\n      {details.rows.length > 0 ? (\n        <dl className=\"mt-2 grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 border-t pt-2 text-xs\">\n          {details.rows.map(([label, value]) => (\n            <div key={label} className=\"contents\">\n              <dt className=\"text-muted-foreground\">{label}</dt>\n              <dd className=\"text-right font-medium tabular-nums\">{value}</dd>\n            </div>\n          ))}\n        </dl>\n      ) : null}\n    </div>\n  );\n}\n\n/** The trail-map symbol for a difficulty, in the colour the map draws it (or the text colour). */\nfunction DifficultySymbol({ kind, color }: { kind: \"easy\" | \"intermediate\" | \"advanced\" | \"expert\" | \"park\"; color?: string | null }) {\n  const paint = color ?? \"currentColor\";\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" className=\"size-3.5 shrink-0\">\n      {kind === \"easy\" ? <circle cx=\"8\" cy=\"8\" r=\"6\" fill={paint} /> : null}\n      {kind === \"intermediate\" ? <rect x=\"2.5\" y=\"2.5\" width=\"11\" height=\"11\" fill={paint} /> : null}\n      {kind === \"advanced\" ? <path d=\"M8 1.5 14.5 8 8 14.5 1.5 8Z\" fill={paint} /> : null}\n      {kind === \"expert\" ? <path d=\"M4.5 3 8.5 8 4.5 13 0.5 8ZM11.5 3 15.5 8 11.5 13 7.5 8Z\" fill={paint} /> : null}\n      {kind === \"park\" ? <rect x=\"1\" y=\"5.5\" width=\"14\" height=\"5\" rx=\"2.5\" fill={paint} /> : null}\n    </svg>\n  );\n}\n\n/** The part of a MapLibre map the legend reads. */\ninterface SkiLegendMap {\n  getLayer(id: string): unknown;\n  getPaintProperty(id: string, name: string): unknown;\n  getZoom(): number;\n  on(type: string, listener: () => void): unknown;\n  off(type: string, listener: () => void): unknown;\n}\n\n/** The zoom the runs and lifts appear at (overlays.ts), and so the legend with them. */\nconst RUNS_MINZOOM = 11;\n\n/**\n * What the run colours, the trail symbols and the lines mean, in the colours the style draws them.\n * Shown from the zoom the runs appear at; open by default on a wide screen, folded on a phone.\n */\nfunction SkiLegend({ lang, lifts3d }: { lang: SkiLang; lifts3d: boolean }) {\n  const map = useMap(SKI_MAP_ID) as unknown as SkiLegendMap | null;\n  const copy = SKI_COPY[lang];\n  const [state, setState] = useState<{ shown: boolean; colors: Record<SkiLegendKey, string | null>; core: string | null } | null>(null);\n  const [wide] = useState(() => typeof window === \"undefined\" || window.matchMedia?.(\"(min-width: 640px)\")?.matches !== false);\n\n  useEffect(() => {\n    if (!map || typeof map.on !== \"function\") return;\n    const read = () => {\n      const paint = (id: string) => (map.getLayer(id) ? map.getPaintProperty(id, \"line-color\") : undefined);\n      setState({\n        shown: map.getZoom() >= RUNS_MINZOOM - 0.5,\n        colors: legendColors({ downhill: paint(SKI_LAYERS.downhill), nordic: paint(SKI_LAYERS.nordic), lifts: paint(SKI_LAYERS.lifts) }),\n        core: ((core) => (typeof core === \"string\" ? core : null))(paint(SKI_LAYERS.expertCore)),\n      });\n    };\n    read();\n    map.on(\"zoomend\", read);\n    map.on(\"styledata\", read);\n    return () => {\n      map.off(\"zoomend\", read);\n      map.off(\"styledata\", read);\n    };\n  }, [map]);\n\n  if (!state?.shown) return null;\n  const { colors, core } = state;\n  const sample = (key: SkiLegendKey, kind: \"solid\" | \"double\" | \"dashed\" | \"lift\" = \"solid\") => (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 24 10\" className=\"h-2.5 w-6 shrink-0\">\n      {kind === \"lift\" ? <line x1=\"1\" y1=\"5\" x2=\"23\" y2=\"5\" stroke=\"var(--popover)\" strokeWidth={4.5} strokeLinecap=\"round\" /> : null}\n      <line x1=\"1\" y1=\"5\" x2=\"23\" y2=\"5\" stroke={colors[key] ?? \"currentColor\"} strokeWidth={kind === \"double\" ? 5 : 2.5} strokeLinecap=\"round\" strokeDasharray={kind === \"dashed\" ? \"3 2.5\" : undefined} />\n      {kind === \"double\" ? <line x1=\"1\" y1=\"5\" x2=\"23\" y2=\"5\" stroke={core ?? \"var(--popover)\"} strokeWidth={1.6} strokeLinecap=\"round\" /> : null}\n      {kind === \"lift\" ? <path d=\"M6 1.5v7M12 1.5v7M18 1.5v7\" stroke={colors[key] ?? \"currentColor\"} strokeWidth={1.4} /> : null}\n    </svg>\n  );\n  return (\n    <details\n      open={wide}\n      data-testid=\"ski-legend\"\n      className=\"group absolute top-3 left-3 z-10 rounded-lg border bg-popover/95 text-popover-foreground shadow-md backdrop-blur sm:top-auto sm:right-3 sm:bottom-10 sm:left-auto\"\n    >\n      <summary className=\"cursor-pointer list-none px-3 py-2 text-xs font-semibold select-none [&::-webkit-details-marker]:hidden\">\n        {copy.legend}\n      </summary>\n      <ul className=\"flex flex-col gap-1.5 px-3 pb-3 text-xs\">\n        {([\"easy\", \"intermediate\", \"advanced\", \"expert\"] as const).map((key) => (\n          <li key={key} className=\"flex items-center gap-2\">\n            <span className=\"flex w-4 justify-center\">\n              <DifficultySymbol kind={key} color={colors[key]} />\n            </span>\n            {sample(key, key === \"expert\" ? \"double\" : \"solid\")}\n            {copy.legendKeys[key]}\n          </li>\n        ))}\n        <li className=\"flex items-center gap-2\">\n          <span className=\"w-4\" />\n          {sample(\"park\")}\n          {copy.legendKeys.park}\n        </li>\n        <li className=\"flex items-center gap-2\">\n          <span className=\"w-4\" />\n          {sample(\"nordic\", \"dashed\")}\n          {copy.legendKeys.nordic}\n        </li>\n        <li className=\"flex items-center gap-2\">\n          <span className=\"w-4\" />\n          {sample(\"lift\", \"lift\")}\n          {lifts3d ? copy.legendKeys.lift3d : copy.legendKeys.lift}\n        </li>\n      </ul>\n    </details>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "envVars": {
    "NEXT_PUBLIC_UNMAP_KEY": ""
  },
  "docs": "Paste your unmap key into NEXT_PUBLIC_UNMAP_KEY in .env.local, then open /ski-resort-explorer. Needs the Ski add-on; without it the page says why. Guide: https://unmap.dev/docs/frontend/blocks#explore-canadas-ski-areas",
  "categories": [
    "blocks"
  ],
  "type": "registry:block"
}