{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "view-explorer",
  "title": "View Explorer",
  "description": "A page that answers whether you can see the Rockies from a home: Calgary in 3D, click any building and the view from its window opens west as a surface in the air, wedges that leave the window at its height and run out until they meet the ground, those that reach the mountains in your --primary with a rim where they strike the slopes and those a nearer hill cuts short in your --muted-foreground, with a score, the skyline you would see from that window and fifteen named summits marked in sight or hidden. Downtown asks the same of the downtown towers, with one sight line to their tops and the ground between drawn as a cross-section. The surface is drawn by deck.gl through @unmap/deck, loaded on the first pick. Calls /terrain for the map's own terrain tiles, read in the browser, fine near the home and coarse in the mountains, and accounts for the Earth's curve.",
  "dependencies": [
    "@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-controls.json",
    "https://registry.unmap.dev/r/map-context.json",
    "https://registry.unmap.dev/r/map-layers.json",
    "https://registry.unmap.dev/r/marker-popup.json",
    "card",
    "toggle-group",
    "button",
    "alert",
    "spinner"
  ],
  "files": [
    {
      "path": "registry/blocks/view-explorer/page.tsx",
      "content": "/**\n * /view-explorer: the view explorer block as a Next.js App Router page, full-bleed (the block\n * keeps its own gutter). The key comes from NEXT_PUBLIC_UNMAP_KEY in .env.local; installing the\n * block added the empty line, paste your key.\n */\nimport { ViewExplorer } from \"@/components/ui/view-explorer\";\n\nexport const metadata = { title: \"Find the view\" };\n\nexport default function ViewExplorerPage() {\n  return (\n    <main className=\"h-dvh\">\n      <ViewExplorer apiKey={process.env.NEXT_PUBLIC_UNMAP_KEY ?? \"\"} className=\"h-full\" />\n    </main>\n  );\n}\n",
      "type": "registry:page",
      "target": "app/view-explorer/page.tsx"
    },
    {
      "path": "registry/blocks/view-explorer/view-explorer.tsx",
      "content": "\"use client\";\n\n/**\n * <ViewExplorer> - can you see the Rockies from this home? Calgary in 3D, looking west. Click any\n * building and the view from its window opens across the western horizon as one surface in the\n * air, in your --primary: it leaves the window at its height and runs out until it meets the\n * ground. Its far edge is drawn bright where it strikes the mountains and in your\n * --muted-foreground where a nearer hill cuts it short. The panel scores the view\n * and draws the skyline you would see from that window, with the named peaks marked visible or\n * hidden. Downtown asks the same of the downtown towers, with one sight line to their tops.\n *\n * Every height comes from the terrain tiles the map already draws in 3D, read in the browser:\n * fine tiles near the home, where a neighbour's rise decides the view, coarse ones in the\n * mountains. The sight lines account for the curve of the Earth (with standard refraction), which\n * hides about 600 m of mountain at 100 km. The terrain is bare ground: it has the hills, not the\n * houses or the trees, and the panel says so.\n *\n * The surface is drawn by deck.gl through @unmap/deck, loaded on the first pick, so the map alone\n * never loads it. Composes the registry's map, map-controls and marker-popup items on your shadcn\n * Card, ToggleGroup, Button, Alert and Spinner, in your tokens. Each tile is a billed /terrain\n * request, so each is fetched once and kept: the first home reads about 25 tiles (the map has\n * usually loaded many of them already, and the browser answers those), the next home nearby a\n * few, and a home looked at again nothing.\n *\n * Installed by `npx shadcn add` with its page at app/view-explorer/page.tsx. Edit freely.\n */\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\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 { Map } from \"@/components/ui/map\";\nimport { MapControls } from \"@/components/ui/map-controls\";\nimport { Marker, MarkerLabel } from \"@/components/ui/marker-popup\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { ToggleGroup, ToggleGroupItem } from \"@/components/ui/toggle-group\";\nimport { useMap, useMapGeneration, useMapReady } from \"@/lib/map-context\";\nimport { tokenColor } from \"@/lib/map-layers\";\n\n// --- view-explorer helpers (kept identical across both lanes) ---\nexport type ViewLang = \"en\" | \"fr\";\nexport type ViewTarget = \"mountains\" | \"downtown\";\n\nexport const VIEW_TARGETS: ViewTarget[] = [\"mountains\", \"downtown\"];\n\n/** A home to look from: where it is, and how far above the ground the window is, in metres. */\nexport interface ViewHome {\n  lngLat: [number, number];\n  eye: number;\n  /** The building's height when the click landed on one that has it. */\n  building: number | null;\n}\n\n/** A named summit to test. */\nexport interface ViewPeak {\n  id: string;\n  name: string;\n  lngLat: [number, number];\n}\n\n/** One sight line, sampled: metres from the home, and the ground there (null where the model has none). */\nexport interface ViewRay {\n  azimuth: number;\n  distances: number[];\n  points: [number, number][];\n}\n\n/** What one sight line of the fan sees. */\nexport interface RayResult {\n  azimuth: number;\n  /** True when the skyline in this direction is mountains, not a nearer hill. */\n  clear: boolean;\n  /** The skyline point: where the line from the window grazes the ground last. */\n  horizon: { lngLat: [number, number]; distance: number; height: number; angle: number } | null;\n  /** The steepest angle up to any mountain on this line, seen or not, for the skyline's ghost. */\n  mountainAngle: number | null;\n}\n\n/** Whether a single target (a summit, the downtown towers) can be seen, and what is in the way. */\nexport interface TargetResult {\n  visible: boolean;\n  /** The angle up to the target, in degrees above the level line from the window. */\n  angle: number;\n  distance: number;\n  /** The nearer ground that rises highest in the view, and so blocks the target when it is hidden. */\n  blocker: { lngLat: [number, number]; distance: number; height: number } | null;\n}\n\n/** The block's own map id, so a map elsewhere on the page never answers for it. */\nexport const VIEW_MAP_ID = \"view-explorer\";\n/** Ids of what the block draws: the buildings in 3D on the map, the view in deck.gl over it. */\nexport const VIEW_LAYERS = {\n  buildings: \"view-explorer-buildings\",\n  surface: \"view-explorer-surface\",\n  edge: \"view-explorer-edge\",\n  line: \"view-explorer-line\",\n};\n\n/** Downtown Calgary, where the map opens and what the Downtown view looks at. */\nexport const CALGARY: [number, number] = [-114.0665, 51.0475];\n\n/**\n * The top of the downtown skyline, in metres above the street. The tallest towers (Brookfield\n * Place East, The Bow) stand about 240 m; 200 m is the skyline most of them make.\n */\nexport const DOWNTOWN_HEIGHT = 200;\n\n/** Where the camera stands before anything is picked: over the west side, looking west to the Rockies. */\nexport const OPENING_VIEW = { center: [-114.155, 51.045] as [number, number], zoom: 13.4, pitch: 72, bearing: 262 };\n\n/**\n * Steeper than MapLibre's default 60 degrees, so the camera can look along the ground to the\n * mountains, and antialiased, as @unmap/deck asks, so the surface's edges draw smooth.\n */\nexport const MAP_OPTIONS = { maxPitch: 80, canvasContextAttributes: { antialias: true } };\n/** The terrain drawn half again as tall, so the front ranges stand up 80 km off. The sight lines use true heights. */\nexport const EXAGGERATION = 1.5;\n\n/**\n * Four places to try, each a point in a real Calgary community: two on the west hills, one on the\n * bluff over the Bow, one down on the river flats.\n */\nexport const VIEW_SPOTS: Array<{ id: string; lngLat: [number, number] }> = [\n  { id: \"patterson\", lngLat: [-114.2005, 51.0575] },\n  { id: \"signal-hill\", lngLat: [-114.1685, 51.0195] },\n  { id: \"crescent-heights\", lngLat: [-114.0632, 51.0632] },\n  { id: \"inglewood\", lngLat: [-114.0255, 51.0375] },\n];\n\n/**\n * Front-range summits seen from Calgary. Coordinates from the Canadian Geographical Names\n * Database (Natural Resources Canada), concise code MTN, Alberta: Moose Mountain IALHD, Prairie\n * Mountain IACLY, Mount Burns IAJMA, Mount Rae IAEFV, Banded Peak IACYD, Mount Glasgow IAGNM,\n * Fisher Peak IAERI, Mount Kidd IABDJ, Mount Bogart IAHAM, Mount Lougheed IAFIB, Mount Assiniboine\n * IACKC, Mount Inglismaldie IALBQ, Mount Aylmer IACLS, Black Rock Mountain IAGXU, Devils Head\n * IABBC. Open Government Licence, Canada.\n */\nexport const VIEW_PEAKS: ViewPeak[] = [\n  { id: \"IALHD\", name: \"Moose Mountain\", lngLat: [-114.83806, 50.93889] },\n  { id: \"IACLY\", name: \"Prairie Mountain\", lngLat: [-114.80639, 50.88806] },\n  { id: \"IAJMA\", name: \"Mount Burns\", lngLat: [-114.86111, 50.64111] },\n  { id: \"IAEFV\", name: \"Mount Rae\", lngLat: [-114.97472, 50.62278] },\n  { id: \"IACYD\", name: \"Banded Peak\", lngLat: [-114.91806, 50.71833] },\n  { id: \"IAGNM\", name: \"Mount Glasgow\", lngLat: [-114.93194, 50.75556] },\n  { id: \"IAERI\", name: \"Fisher Peak\", lngLat: [-115.03, 50.81444] },\n  { id: \"IABDJ\", name: \"Mount Kidd\", lngLat: [-115.18972, 50.89361] },\n  { id: \"IAHAM\", name: \"Mount Bogart\", lngLat: [-115.24333, 50.91139] },\n  { id: \"IAFIB\", name: \"Mount Lougheed\", lngLat: [-115.2625, 50.96556] },\n  { id: \"IACKC\", name: \"Mount Assiniboine\", lngLat: [-115.65111, 50.86972] },\n  { id: \"IALBQ\", name: \"Mount Inglismaldie\", lngLat: [-115.42194, 51.24167] },\n  { id: \"IACLS\", name: \"Mount Aylmer\", lngLat: [-115.43333, 51.32389] },\n  { id: \"IAGXU\", name: \"Black Rock Mountain\", lngLat: [-115.16833, 51.33444] },\n  { id: \"IABBC\", name: \"Devils Head\", lngLat: [-115.26528, 51.34472] },\n];\n\n/** The fan: from south-southwest to west-northwest, the arc the Rockies fill from Calgary, every three degrees. */\nexport const FAN = { from: 200, to: 305, step: 3 };\n/** How far each line of the fan reaches, in metres: past the front ranges into the main ranges. */\nexport const FAN_REACH = 130_000;\n/** Samples per sight line, spaced closer near the home, where what blocks a view usually is. */\nexport const FAN_SAMPLES = 72;\nexport const PEAK_SAMPLES = 60;\nexport const DOWNTOWN_SAMPLES = 200;\n/** The first sample out from the window, in metres: the next lot over. */\nexport const NEAREST = 40;\n\n/** Ground this far above the home's own counts as mountain. Calgary's highest hills stop well short of it. */\nexport const MOUNTAIN_RELIEF = 600;\n\n/** How far above the ground the window is when the click lands on no building: a second storey. */\nexport const DEFAULT_EYE = 4.5;\n/** The window sits this far under the roof of a building the click landed on. */\nexport const ROOF_TO_EYE = 1.5;\n\n/**\n * The terrain zoom each sample is read at, by its distance from the home: the finest tiles where a\n * neighbour's rise matters, coarser ones in the mountains, where only the summits do. A z9 tile is\n * about 100 m a pixel at Calgary's latitude, plenty for a peak 100 km off, and twelve of them\n * cover the whole fan. The map loads the same tiles to draw its terrain.\n */\nexport const ZOOM_BANDS: Array<{ within: number; zoom: number }> = [\n  { within: 4_000, zoom: 12 },\n  { within: 12_000, zoom: 11 },\n  { within: 35_000, zoom: 10 },\n  { within: Infinity, zoom: 9 },\n];\n\n/** Decoded terrain tiles the block keeps, oldest dropped first: about a megabyte each. */\nexport const TILE_MEMORY = 48;\n\nconst EARTH_RADIUS_M = 6_371_008.8;\n/** Standard atmospheric refraction: light bends a little with the curve, so a little less is hidden. */\nexport const REFRACTION = 0.13;\n\n/** Camera moves, in milliseconds. Reduced motion jumps. */\nexport const CAMERA_MS = 1400;\n\n/** Where a reader gets an unmap key: the no-key state's first action. */\nexport const KEY_URL = \"https://unmap.dev/dashboard\";\n\n/** Where each credit links, per language. */\nexport const VIEW_CREDITS = {\n  elevation: { en: \"https://unmap.dev/attribution\", fr: \"https://unmap.dev/fr/attribution\" },\n  licence: {\n    en: \"https://open.canada.ca/en/open-government-licence-canada\",\n    fr: \"https://ouvert.canada.ca/fr/licence-du-gouvernement-ouvert-canada\",\n  },\n  names: {\n    en: \"https://open.canada.ca/data/en/dataset/e27c6eba-3c5d-4051-9db2-082dc6411c2c\",\n    fr: \"https://ouvert.canada.ca/data/fr/dataset/e27c6eba-3c5d-4051-9db2-082dc6411c2c\",\n  },\n};\n\nexport const VIEW_COPY = {\n  en: {\n    title: \"Find the view\",\n    intro: \"Click any building in Calgary to see whether the Rockies, or the downtown towers, can be seen from its windows.\",\n    target: \"Look toward\",\n    targets: { mountains: \"Mountains\", downtown: \"Downtown\" },\n    spotsLabel: \"Or try\",\n    spots: { patterson: \"Patterson\", \"signal-hill\": \"Signal Hill\", \"crescent-heights\": \"Crescent Heights\", inglewood: \"Inglewood\" } as Record<string, string>,\n    pick: \"Click a building on the map.\",\n    loading: \"Tracing the sight lines...\",\n    score: (percent: string) => `Mountain view: ${percent} of the western horizon`,\n    noMountains: \"No mountain view: nearer ground fills the whole western horizon.\",\n    allMountains: \"Mountains across the whole western horizon.\",\n    blockedBy: (direction: string, distance: string) => `Cut off to the ${direction} by higher ground ${distance} away.`,\n    peaksSeen: (count: number, total: number) => `${count} of ${total} named summits in sight.`,\n    downtownSeen: (distance: string) => `The downtown towers are in sight, ${distance} away.`,\n    downtownHidden: (distance: string, blocker: string) => `The downtown towers, ${distance} away, are hidden by higher ground ${blocker} from here.`,\n    downtownHere: \"This home is downtown: pick one farther out.\",\n    from: (eye: string) => `From a window ${eye} up`,\n    fromTop: (eye: string) => `From the top floor, ${eye} up`,\n    skyline: \"The skyline from this window\",\n    skylineHint: \"Facing west, heights stretched to fit. Mountains in sight are filled; a dashed line traces mountains hidden behind nearer ground, and their names are grey.\",\n    crossSection: \"The ground between here and downtown\",\n    crossHint: \"The line runs from the window to the top of the towers. Where the ground rises above it, the view is cut.\",\n    visible: \"in sight\",\n    hidden: \"hidden\",\n    south: \"S\",\n    west: \"W\",\n    north: \"N\",\n    home: \"This home\",\n    downtown: \"Downtown\",\n    caveat: \"Terrain only: the elevation model is bare ground, so houses and trees in the way are not counted. Includes the Earth's curve.\",\n    elevationCredit: \"Elevation:\",\n    elevationSource: \"DEM suppliers and licences\",\n    namesCredit: \"Summit names:\",\n    namesSource: \"Canadian Geographical Names Database\",\n    licence: \"Open Government Licence – Canada\",\n    retry: \"Try again\",\n    noKeyTitle: \"Add your unmap key to load the map.\",\n    getKey: \"Get a key\",\n    noKey:\n      '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    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    failed: \"The request failed. Try again.\",\n    noData: \"The terrain has no heights here.\",\n    noTerrain: \"The map has no terrain to read. Check that your key includes terrain.\",\n  },\n  fr: {\n    title: \"Trouver la vue\",\n    intro: \"Cliquez sur un bâtiment de Calgary pour savoir si l'on voit les Rocheuses, ou les tours du centre-ville, de ses fenêtres.\",\n    target: \"Regarder vers\",\n    targets: { mountains: \"Montagnes\", downtown: \"Centre-ville\" },\n    spotsLabel: \"Ou essayez\",\n    spots: { patterson: \"Patterson\", \"signal-hill\": \"Signal Hill\", \"crescent-heights\": \"Crescent Heights\", inglewood: \"Inglewood\" } as Record<string, string>,\n    pick: \"Cliquez sur un bâtiment de la carte.\",\n    loading: \"Tracé des lignes de visée...\",\n    score: (percent: string) => `Vue sur les montagnes : ${percent} de l'horizon ouest`,\n    noMountains: \"Aucune vue sur les montagnes : un relief plus proche occupe tout l'horizon ouest.\",\n    allMountains: \"Des montagnes sur tout l'horizon ouest.\",\n    blockedBy: (direction: string, distance: string) => `Coupée vers le ${direction} par un relief plus haut, à ${distance}.`,\n    peaksSeen: (count: number, total: number) => `${count} sommets nommés sur ${total} en vue.`,\n    downtownSeen: (distance: string) => `Les tours du centre-ville sont en vue, à ${distance}.`,\n    downtownHidden: (distance: string, blocker: string) => `Les tours du centre-ville, à ${distance}, sont cachées par un relief plus haut à ${blocker} d'ici.`,\n    downtownHere: \"Ce logement est au centre-ville : choisissez-en un plus loin.\",\n    from: (eye: string) => `D'une fenêtre à ${eye} du sol`,\n    fromTop: (eye: string) => `Du dernier étage, à ${eye} du sol`,\n    skyline: \"La ligne d'horizon depuis cette fenêtre\",\n    skylineHint: \"Face à l'ouest, hauteurs étirées. Les montagnes en vue sont pleines ; un tireté suit les montagnes cachées derrière un relief plus proche, et leur nom est en gris.\",\n    crossSection: \"Le terrain d'ici au centre-ville\",\n    crossHint: \"La ligne va de la fenêtre au sommet des tours. Là où le terrain la dépasse, la vue est coupée.\",\n    visible: \"en vue\",\n    hidden: \"caché\",\n    south: \"S\",\n    west: \"O\",\n    north: \"N\",\n    home: \"Ce logement\",\n    downtown: \"Centre-ville\",\n    caveat: \"Relief seulement : le modèle d'altitude est le sol nu, les maisons et les arbres qui gênent ne sont pas comptés. La courbure de la Terre est comprise.\",\n    elevationCredit: \"Altitude :\",\n    elevationSource: \"Fournisseurs du MNA et licences\",\n    namesCredit: \"Noms des sommets :\",\n    namesSource: \"Base de données toponymiques du Canada\",\n    licence: \"Licence du gouvernement ouvert – Canada\",\n    retry: \"Réessayer\",\n    noKeyTitle: \"Ajoutez votre clé unmap pour charger la carte.\",\n    getKey: \"Obtenir une clé\",\n    noKey:\n      '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    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    failed: \"La requête a échoué. Réessayez.\",\n    noData: \"Le relief n'a pas de hauteur ici.\",\n    noTerrain: \"La carte n'a pas de relief à lire. Vérifiez que votre clé comprend le relief.\",\n  },\n};\n\nexport function isTarget(value: unknown): value is ViewTarget {\n  return VIEW_TARGETS.includes(value as ViewTarget);\n}\n\nconst toRadians = (degrees: number) => (degrees * Math.PI) / 180;\nconst toDegrees = (radians: number) => (radians * 180) / Math.PI;\n\n/** Great-circle distance in metres. */\nexport function distanceMetres(a: [number, number], b: [number, number]): number {\n  const dLat = toRadians(b[1] - a[1]);\n  const dLng = toRadians(b[0] - a[0]);\n  const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRadians(a[1])) * Math.cos(toRadians(b[1])) * Math.sin(dLng / 2) ** 2;\n  return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(h)));\n}\n\n/** The initial bearing from `a` to `b`, in degrees clockwise from north, 0 to 360. */\nexport function bearingDegrees(a: [number, number], b: [number, number]): number {\n  const φ1 = toRadians(a[1]);\n  const φ2 = toRadians(b[1]);\n  const Δλ = toRadians(b[0] - a[0]);\n  const y = Math.sin(Δλ) * Math.cos(φ2);\n  const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);\n  return (toDegrees(Math.atan2(y, x)) + 360) % 360;\n}\n\n/** The point `metres` from `origin` along `azimuth`, on the sphere, rounded to five decimals (about a metre). */\nexport function destination(origin: [number, number], azimuth: number, metres: number): [number, number] {\n  const δ = metres / EARTH_RADIUS_M;\n  const θ = toRadians(azimuth);\n  const φ1 = toRadians(origin[1]);\n  const λ1 = toRadians(origin[0]);\n  const φ2 = Math.asin(Math.sin(φ1) * Math.cos(δ) + Math.cos(φ1) * Math.sin(δ) * Math.cos(θ));\n  const λ2 = λ1 + Math.atan2(Math.sin(θ) * Math.sin(δ) * Math.cos(φ1), Math.cos(δ) - Math.sin(φ1) * Math.sin(φ2));\n  return [round5(toDegrees(λ2)), round5(toDegrees(φ2))];\n}\n\nfunction round5(value: number): number {\n  return Math.round(value * 1e5) / 1e5;\n}\n\n/**\n * `count` distances from `near` to `far`, each a fixed ratio past the last: about 100 m apart a\n * kilometre out, a few kilometres apart in the mountains, where only the summits matter.\n */\nexport function sampleDistances(count: number, far: number, near = NEAREST): number[] {\n  if (count <= 1) return [far];\n  const ratio = (far / near) ** (1 / (count - 1));\n  return Array.from({ length: count }, (_value, index) => (index === count - 1 ? far : near * ratio ** index));\n}\n\n/** A sight line from `origin` along `azimuth`, sampled at `distances`. */\nexport function sightRay(origin: [number, number], azimuth: number, distances: number[]): ViewRay {\n  return { azimuth, distances, points: distances.map((distance) => destination(origin, azimuth, distance)) };\n}\n\n/** The fan's azimuths, inclusive of both ends. */\nexport function fanAzimuths(fan = FAN): number[] {\n  const out: number[] = [];\n  for (let azimuth = fan.from; azimuth <= fan.to + 1e-9; azimuth += fan.step) out.push(azimuth);\n  return out;\n}\n\n/** The fan's sight lines, every one the same reach and spacing. */\nexport function fanRays(origin: [number, number], fan = FAN, reach = FAN_REACH, samples = FAN_SAMPLES): ViewRay[] {\n  const distances = sampleDistances(samples, reach);\n  return fanAzimuths(fan).map((azimuth) => sightRay(origin, azimuth, distances));\n}\n\n/** A sight line ending exactly on `target`. */\nexport function targetRay(origin: [number, number], target: [number, number], samples: number): ViewRay {\n  const ray = sightRay(origin, bearingDegrees(origin, target), sampleDistances(samples, distanceMetres(origin, target), Math.min(NEAREST, distanceMetres(origin, target) / samples)));\n  ray.points[ray.points.length - 1] = [round5(target[0]), round5(target[1])];\n  return ray;\n}\n\n/**\n * How far the ground at `metres` sits below the level line from the window, from the Earth's\n * curve less the share refraction gives back: about 680 m at 100 km.\n */\nexport function curveDrop(metres: number, refraction = REFRACTION): number {\n  return ((metres * metres) / (2 * EARTH_RADIUS_M)) * (1 - refraction);\n}\n\n/** The angle up (or down) from the window to ground of `height` at `metres`, in degrees. */\nexport function sightAngle(eyeHeight: number, metres: number, height: number): number {\n  return toDegrees(Math.atan2(height - curveDrop(metres) - eyeHeight, metres));\n}\n\n/**\n * What a sight line of the fan sees from a window at `eyeHeight` (metres above sea level) over\n * ground at `groundHeight`. The skyline point is the sample at the steepest angle up; the line is\n * clear when that sample is mountain, at least MOUNTAIN_RELIEF above the home's own ground.\n */\nexport function readRay(ray: ViewRay, heights: (number | null)[], groundHeight: number, eyeHeight: number, relief = MOUNTAIN_RELIEF): RayResult {\n  let best = -Infinity;\n  let at = -1;\n  let mountainAngle: number | null = null;\n  for (let index = 0; index < ray.distances.length; index++) {\n    const height = heights[index];\n    if (height === null || height === undefined) continue;\n    const angle = sightAngle(eyeHeight, ray.distances[index]!, height);\n    if (angle > best) {\n      best = angle;\n      at = index;\n    }\n    if (height >= groundHeight + relief && (mountainAngle === null || angle > mountainAngle)) mountainAngle = angle;\n  }\n  if (at < 0) return { azimuth: ray.azimuth, clear: false, horizon: null, mountainAngle: null };\n  const height = heights[at]!;\n  return {\n    azimuth: ray.azimuth,\n    clear: height >= groundHeight + relief,\n    horizon: { lngLat: ray.points[at]!, distance: ray.distances[at]!, height, angle: best },\n    mountainAngle,\n  };\n}\n\n/**\n * Whether the last sample of `ray` (a summit, or the towers `targetLift` metres above their\n * street) can be seen: nothing nearer rises to a steeper angle. Within a hundredth of a degree\n * counts as seen, the model's own half-metre steps.\n */\nexport function readTarget(ray: ViewRay, heights: (number | null)[], eyeHeight: number, targetLift = 0): TargetResult | null {\n  const last = ray.distances.length - 1;\n  const ground = heights[last];\n  if (ground === null || ground === undefined) return null;\n  const distance = ray.distances[last]!;\n  const angle = sightAngle(eyeHeight, distance, ground + targetLift);\n  let best = -Infinity;\n  let blocker: TargetResult[\"blocker\"] = null;\n  for (let index = 0; index < last; index++) {\n    const height = heights[index];\n    if (height === null || height === undefined) continue;\n    const nearer = sightAngle(eyeHeight, ray.distances[index]!, height);\n    if (nearer > best) {\n      best = nearer;\n      blocker = { lngLat: ray.points[index]!, distance: ray.distances[index]!, height };\n    }\n  }\n  return { visible: angle >= best - 0.01, angle, distance, blocker };\n}\n\n/** The terrain zoom to read a sample at, by its distance from the home. */\nexport function zoomFor(metres: number): number {\n  return ZOOM_BANDS.find((band) => metres < band.within)!.zoom;\n}\n\n/** A terrain tile, decoded: its RGBA pixels, row by row, `size` on a side. */\nexport interface TerrainTile {\n  size: number;\n  data: ArrayLike<number>;\n}\n\n/** Terrarium: `elevation = R * 256 + G + B / 256 - 32768`, the encoding the map's terrain uses. */\nexport function terrarium(r: number, g: number, b: number): number {\n  return r * 256 + g + b / 256 - 32768;\n}\n\n/** The tile a coordinate falls in at zoom `z`, and its fractional pixel inside a `size` tile. */\nexport function tilePixel(lng: number, lat: number, z: number, size: number): { x: number; y: number; px: number; py: number } {\n  const n = 2 ** z;\n  const gx = ((lng + 180) / 360) * n;\n  const gy = ((1 - Math.asinh(Math.tan(toRadians(lat))) / Math.PI) / 2) * n;\n  const x = Math.floor(gx);\n  const y = Math.floor(gy);\n  return { x, y, px: (gx - x) * size, py: (gy - y) * size };\n}\n\n/**\n * The height at a pixel position, bilinear between the four pixel centres around it. At a tile's\n * edge the nearest pixels stand in for the neighbour's: a few metres of error, never a hole.\n */\nexport function sampleTile(tile: TerrainTile, px: number, py: number): number {\n  const size = tile.size;\n  const fx = Math.min(Math.max(px - 0.5, 0), size - 1);\n  const fy = Math.min(Math.max(py - 0.5, 0), size - 1);\n  const x0 = Math.floor(fx);\n  const y0 = Math.floor(fy);\n  const x1 = Math.min(x0 + 1, size - 1);\n  const y1 = Math.min(y0 + 1, size - 1);\n  const at = (x: number, y: number) => {\n    const i = (y * size + x) * 4;\n    return terrarium(tile.data[i]!, tile.data[i + 1]!, tile.data[i + 2]!);\n  };\n  const tx = fx - x0;\n  const ty = fy - y0;\n  const top = at(x0, y0) * (1 - tx) + at(x1, y0) * tx;\n  const bottom = at(x0, y1) * (1 - tx) + at(x1, y1) * tx;\n  return top * (1 - ty) + bottom * ty;\n}\n\n/** A tile's URL from the map's terrain source template. */\nexport function tileUrl(template: string, z: number, x: number, y: number): string {\n  return template.replace(\"{z}\", String(z)).replace(\"{x}\", String(x)).replace(\"{y}\", String(y));\n}\n\n/** Loads one terrain tile, null where the terrain has none. */\nexport type TileLoader = (z: number, x: number, y: number) => Promise<TerrainTile | null>;\n\n/**\n * The ground under every sample of every ray, each read at its distance's zoom. Each tile is\n * loaded once however many samples fall in it, all of them at once. `home` is read at the finest\n * zoom and answered first.\n */\nexport async function groundHeights(home: [number, number], rays: ViewRay[], load: TileLoader): Promise<{ ground: number | null; perRay: (number | null)[][] }> {\n  const wanted = new globalThis.Map<string, Promise<TerrainTile | null>>();\n  const read = (lngLat: [number, number], metres: number) => {\n    const z = zoomFor(metres);\n    const at = tilePixel(lngLat[0], lngLat[1], z, 512);\n    const id = `${z}/${at.x}/${at.y}`;\n    if (!wanted.has(id)) wanted.set(id, load(z, at.x, at.y));\n    return { id, at };\n  };\n  const homeRead = read(home, 0);\n  const rayReads = rays.map((ray) => ray.points.map((point, index) => read(point, ray.distances[index]!)));\n  const ids = [...wanted.keys()];\n  const loaded = await Promise.all(ids.map((id) => wanted.get(id)!));\n  const tiles = new globalThis.Map(ids.map((id, index) => [id, loaded[index]!]));\n  const height = ({ id, at }: { id: string; at: { px: number; py: number } }) => {\n    const tile = tiles.get(id);\n    if (!tile) return null;\n    const scale = tile.size / 512;\n    return sampleTile(tile, at.px * scale, at.py * scale);\n  };\n  return { ground: height(homeRead), perRay: rayReads.map((reads) => reads.map(height)) };\n}\n\n/** The share of the fan that reaches the mountains, 0 to 1. */\nexport function viewShare(results: RayResult[]): number {\n  const read = results.filter((result) => result.horizon !== null);\n  return read.length === 0 ? 0 : read.filter((result) => result.clear).length / read.length;\n}\n\n/**\n * The widest run of blocked lines in the fan: its middle azimuth, and the median distance to\n * what blocks it. Null when nothing is blocked.\n */\nexport function widestBlock(results: RayResult[]): { azimuth: number; distance: number } | null {\n  let best: RayResult[] = [];\n  let run: RayResult[] = [];\n  for (const result of results) {\n    if (result.horizon && !result.clear) run.push(result);\n    else run = [];\n    if (run.length > best.length) best = [...run];\n  }\n  if (best.length === 0) return null;\n  const distances = best.map((result) => result.horizon!.distance).sort((a, b) => a - b);\n  return { azimuth: (best[0]!.azimuth + best[best.length - 1]!.azimuth) / 2, distance: distances[Math.floor(distances.length / 2)]! };\n}\n\n/** A point as the scene draws it: longitude, latitude, and metres up on the map's drawn terrain. */\nexport type ScenePoint = [number, number, number];\n\n/**\n * Where a height sits in the drawn scene. The map draws the terrain EXAGGERATION times as tall,\n * and the buildings at their own heights on top of it, so ground scales and what stands on it\n * (a window, a tower) does not: a top-floor window sits on its drawn building's roof.\n */\nexport function sceneZ(ground: number, above = 0, exaggeration = EXAGGERATION): number {\n  return ground * exaggeration + above;\n}\n\n/** Steps each sight line is drawn in, closer together near the window, where the line skims the ground. */\nexport const SIGHT_STEPS = 24;\n\n/**\n * One sight line as the scene draws it: from the window out `distance` metres along `azimuth`,\n * climbing at `angle` degrees as the true line does. The map is drawn flat, so the line is\n * lifted by the Earth's curve as it goes (it skims the city near the window and bends up to meet\n * the mountains far out, which is why it is drawn in steps, not straight), and set on the drawn\n * terrain: its height above sea level scales with the exaggeration, and its height above the\n * ground at either end (the window's `eye`, the target's `lift`) does not, so it leaves the\n * window on its roof and lands on the drawn summit or tower top.\n */\nexport function sightPath(\n  home: [number, number],\n  azimuth: number,\n  distance: number,\n  eyeHeight: number,\n  eye: number,\n  angle: number,\n  lift = 0,\n  steps = SIGHT_STEPS,\n  exaggeration = EXAGGERATION,\n): ScenePoint[] {\n  const slope = Math.tan(toRadians(angle));\n  return Array.from({ length: steps + 1 }, (_value, step) => {\n    const along = distance * (step / steps) ** 2;\n    const [lng, lat] = destination(home, azimuth, along);\n    const height = eyeHeight + slope * along + curveDrop(along);\n    const standing = eye + (lift - eye) * (along / distance);\n    return [lng, lat, height * exaggeration - standing * (exaggeration - 1)];\n  });\n}\n\n/** The far edge of the view, in stretches: where it lies on the mountains, and where a nearer hill stops it. */\nexport interface ViewEdge {\n  clear: boolean;\n  path: ScenePoint[];\n}\n\n/** The view as one surface: triangles between neighbouring sight lines, step by step, edge to edge. */\nexport interface ViewSurface {\n  triangles: ScenePoint[][];\n  edges: ViewEdge[];\n}\n\n/**\n * The view as a single surface in the air: every line of the fan drawn as it runs from the window\n * to its skyline point, south to north, and the surface stretched between neighbouring lines, so it\n * skims the ground near the window and its far edge traces where the view meets the ground. Null\n * with fewer than two lines read.\n */\nexport function viewSurface(home: [number, number], groundHeight: number, eye: number, results: RayResult[]): ViewSurface | null {\n  const lines = results.flatMap((result) =>\n    result.horizon ? [{ clear: result.clear, path: sightPath(home, result.azimuth, result.horizon.distance, groundHeight + eye, eye, result.horizon.angle) }] : [],\n  );\n  if (lines.length < 2) return null;\n  const edges: ViewEdge[] = [];\n  const triangles: ScenePoint[][] = [];\n  for (let index = 1; index < lines.length; index++) {\n    const left = lines[index - 1]!;\n    const right = lines[index]!;\n    const clear = left.clear && right.clear;\n    const last = edges[edges.length - 1];\n    if (last && last.clear === clear) last.path.push(right.path[right.path.length - 1]!);\n    else edges.push({ clear, path: [left.path[left.path.length - 1]!, right.path[right.path.length - 1]!] });\n    for (let step = 1; step < left.path.length; step++) {\n      const [a, b, c, d] = [left.path[step - 1]!, left.path[step]!, right.path[step]!, right.path[step - 1]!];\n      // The first step starts at the window on both lines: one triangle, not two.\n      if (step === 1) triangles.push([a, b, c]);\n      else triangles.push([a, b, c], [a, c, d]);\n    }\n  }\n  return { triangles, edges };\n}\n\n/**\n * The downtown view in the scene: the sight line from the window toward the top of the towers,\n * drawn as the line runs; when nearer ground hides them, it stops where it runs into that ground.\n */\nexport function downtownScene(home: [number, number], groundHeight: number, eye: number, result: TargetResult): { line: ScenePoint[] } {\n  const azimuth = bearingDegrees(home, CALGARY);\n  const distance = !result.visible && result.blocker ? result.blocker.distance : result.distance;\n  return { line: sightPath(home, azimuth, distance, groundHeight + eye, eye, result.angle, distance === result.distance ? DOWNTOWN_HEIGHT : 0) };\n}\n\nconst COMPASS = {\n  en: [\"north\", \"north-northeast\", \"northeast\", \"east-northeast\", \"east\", \"east-southeast\", \"southeast\", \"south-southeast\", \"south\", \"south-southwest\", \"southwest\", \"west-southwest\", \"west\", \"west-northwest\", \"northwest\", \"north-northwest\"],\n  fr: [\"nord\", \"nord-nord-est\", \"nord-est\", \"est-nord-est\", \"est\", \"est-sud-est\", \"sud-est\", \"sud-sud-est\", \"sud\", \"sud-sud-ouest\", \"sud-ouest\", \"ouest-sud-ouest\", \"ouest\", \"ouest-nord-ouest\", \"nord-ouest\", \"nord-nord-ouest\"],\n};\n\n/** An azimuth as one of the sixteen compass points, spelled out. */\nexport function compass(azimuth: number, lang: ViewLang): string {\n  return COMPASS[lang][Math.round((((azimuth % 360) + 360) % 360) / 22.5) % 16]!;\n}\n\n/**\n * The camera that looks from a home toward what it is asked about: low and steep, facing the\n * middle of the fan (or downtown, closer in), the home held just under the middle (by `offset`,\n * which unlike padding does not stick), above the skyline, so the sight lines run up the map to\n * the horizon. On a phone the sheet covers the bottom half, so the home sits in the top third\n * instead.\n */\nexport function homeCamera(home: [number, number], toward: ViewTarget, height: number, wide: boolean, fan = FAN): { center: [number, number]; zoom: number; pitch: number; bearing: number; offset: [number, number] } {\n  const offset: [number, number] = [0, Math.round(height * (wide ? 0.06 : -0.2))];\n  if (toward === \"downtown\") return { center: home, zoom: 13, pitch: 72, bearing: bearingDegrees(home, CALGARY), offset };\n  return { center: home, zoom: 11.8, pitch: 78, bearing: (fan.from + fan.to) / 2, offset };\n}\n\n/** True from Tailwind's `sm` up, where the panels float apart; below it they are one sheet. True when it cannot tell. */\nexport function isWide(): boolean {\n  return typeof window === \"undefined\" || window.matchMedia?.(\"(min-width: 40rem)\")?.matches !== false;\n}\n\n/** A label laid out above the skyline: its row, counted down from the top, or null when no row has room. */\nexport interface SkylineLabel {\n  x: number;\n  width: number;\n  row: number | null;\n}\n\n/**\n * Rows for labels at `x` of `width` (centred), so none overlaps another in its row: each takes\n * the highest row whose last label ends `gap` before it starts, the order given. Visible summits\n * go first, so a hidden one gives way.\n */\nexport function labelRows(labels: Array<{ x: number; width: number }>, rows: number, gap = 8): Array<number | null> {\n  const spans: Array<Array<[number, number]>> = Array.from({ length: rows }, () => []);\n  return labels.map(({ x, width }) => {\n    const left = x - width / 2;\n    const right = x + width / 2;\n    for (let row = 0; row < rows; row++) {\n      if (spans[row]!.every(([start, end]) => right + gap <= start || left - gap >= end)) {\n        spans[row]!.push([left, right]);\n        return row;\n      }\n    }\n    return null;\n  });\n}\n\n/**\n * What to tell the reader about a failed request: the key or the rate when the gateway says so; a\n * service that did not answer for a 5xx or a request that never got an HTTP answer.\n */\nexport function requestError(caught: unknown, lang: ViewLang): string {\n  const copy = VIEW_COPY[lang];\n  const failure = typeof caught === \"object\" && caught !== null ? (caught as { status?: unknown }) : {};\n  const status = typeof failure.status === \"number\" ? failure.status : null;\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\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. */\nexport function prefersReducedMotion(): boolean {\n  return typeof window !== \"undefined\" && window.matchMedia?.(\"(prefers-reduced-motion: reduce)\")?.matches === true;\n}\n\nfunction numberFormat(lang: ViewLang, digits: number): Intl.NumberFormat {\n  return new Intl.NumberFormat(lang === \"fr\" ? \"fr-CA\" : \"en-CA\", { minimumFractionDigits: digits, maximumFractionDigits: digits });\n}\n\n/** `850 m` under a kilometre, `12.3 km` (fr: `12,3 km`) from there. */\nexport function formatDistance(metres: number, lang: ViewLang): string {\n  return metres < 1000 ? `${numberFormat(lang, 0).format(metres)} m` : `${numberFormat(lang, metres < 10_000 ? 1 : 0).format(metres / 1000)} km`;\n}\n\n/** Whole metres, or one decimal under ten. */\nexport function formatHeight(metres: number, lang: ViewLang): string {\n  return `${numberFormat(lang, metres < 10 ? 1 : 0).format(metres)} m`;\n}\n\n/** A share as a whole percent: \"71%\" (fr: \"71 %\"). */\nexport function formatShare(share: number, lang: ViewLang): string {\n  return new Intl.NumberFormat(lang === \"fr\" ? \"fr-CA\" : \"en-CA\", { style: \"percent\", maximumFractionDigits: 0 }).format(share);\n}\n\n/** A home as a primitive, so the same home clicked again is the same home. */\nexport function homeKey(home: ViewHome): string {\n  return `${round5(home.lngLat[0])},${round5(home.lngLat[1])},${home.eye}`;\n}\n\n/** The window's height from the building under the click: just under its roof, or a second storey when it has no height. */\nexport function eyeFor(building: number | null): number {\n  return building !== null && building > DEFAULT_EYE + ROOF_TO_EYE ? building - ROOF_TO_EYE : DEFAULT_EYE;\n}\n// --- end view-explorer helpers ---\n\nexport interface ViewExplorerProps {\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 and the map. Defaults to \"en\". */\n  lang?: ViewLang;\n  className?: string;\n}\n\nexport function ViewExplorer({ apiKey, gateway, lang = \"en\", className }: ViewExplorerProps) {\n  if (!apiKey.trim()) {\n    const copy = VIEW_COPY[lang];\n    return (\n      <div\n        data-unmap-block=\"view-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  return (\n    <div data-unmap-block=\"view-explorer\" className={[\"relative h-full min-h-[480px]\", className].filter(Boolean).join(\" \")}>\n      <Map\n        id={VIEW_MAP_ID}\n        apiKey={apiKey}\n        gateway={gateway}\n        center={OPENING_VIEW.center}\n        zoom={OPENING_VIEW.zoom}\n        pitch={OPENING_VIEW.pitch}\n        bearing={OPENING_VIEW.bearing}\n        terrain\n        exaggeration={EXAGGERATION}\n        // Flat, not the default globe: deck.gl places the view on the drawn terrain only in Mercator.\n        projection=\"mercator\"\n        maplibre={MAP_OPTIONS}\n        lang={lang}\n        controls={false}\n        className=\"h-full\"\n      >\n        <MapControls mapId={VIEW_MAP_ID} lang={lang} />\n        <Explorer apiKey={apiKey} gateway={gateway} lang={lang} />\n      </Map>\n    </div>\n  );\n}\n\n/** A mountain view, read: the fan and the summits. */\ninterface MountainView {\n  rays: RayResult[];\n  peaks: Array<{ peak: ViewPeak; result: TargetResult }>;\n}\n/** A downtown view, read, with the ground under the line for the cross-section. */\ninterface DowntownView {\n  result: TargetResult;\n  distances: number[];\n  heights: (number | null)[];\n  groundHeight: number;\n  eyeHeight: number;\n}\ntype Asked<T> = { status: \"loading\" } | { status: \"ready\"; value: T; ground: number } | { status: \"error\"; message: string };\n\n/** The map's part of a MapLibre map this block reads, beyond what the items already handle. */\ninterface ViewMap {\n  getStyle():\n    | { layers?: Array<{ id: string; type: string; source?: string; \"source-layer\"?: string }>; sources?: Record<string, { type?: string; tiles?: string[] }> }\n    | undefined;\n  getLayer(id: string): unknown;\n  addLayer(layer: Record<string, unknown>, beforeId?: string): void;\n  removeLayer(id: string): void;\n  getPaintProperty(layer: string, name: string): unknown;\n  queryRenderedFeatures(point: { x: number; y: number }, options: { layers: string[] }): Array<{ properties?: Record<string, unknown> }>;\n  easeTo(options: Record<string, unknown>): void;\n  on(type: string, listener: (event: { point: { x: number; y: number }; lngLat: { lng: number; lat: number } }) => void): void;\n  off(type: string, listener: (event: { point: { x: number; y: number }; lngLat: { lng: number; lat: number } }) => void): void;\n  getCanvas(): { style: { cursor: string } };\n}\n\n/** The building extrusion: the style's own buildings, raised to their heights, in the style's own building colour. */\nfunction addBuildings(map: ViewMap): void {\n  if (map.getLayer(VIEW_LAYERS.buildings)) return;\n  const layers = map.getStyle()?.layers ?? [];\n  const flat = layers.find((layer) => layer[\"source-layer\"] === \"buildings\" && layer.type === \"fill\");\n  if (!flat?.source) return;\n  const color = map.getPaintProperty(flat.id, \"fill-color\");\n  const labels = layers.find((layer) => layer.type === \"symbol\")?.id;\n  map.addLayer(\n    {\n      id: VIEW_LAYERS.buildings,\n      type: \"fill-extrusion\",\n      source: flat.source,\n      \"source-layer\": \"buildings\",\n      minzoom: 13,\n      paint: {\n        ...(color === undefined ? {} : { \"fill-extrusion-color\": color }),\n        \"fill-extrusion-height\": [\"coalesce\", [\"get\", \"height\"], 6],\n        \"fill-extrusion-base\": [\"coalesce\", [\"get\", \"min_height\"], 0],\n        \"fill-extrusion-opacity\": 0.9,\n      },\n    },\n    labels,\n  );\n}\n\n/** The URL template of the map's own terrain tiles, carrying the key the map was given. */\nfunction terrainTemplate(map: ViewMap): string | null {\n  for (const source of Object.values(map.getStyle()?.sources ?? {})) {\n    if (source.type === \"raster-dem\" && source.tiles?.[0]) return source.tiles[0];\n  }\n  return null;\n}\n\n/** A terrain tile's pixels, exactly as encoded: no premultiplying, no colour management. */\nasync function decodeTile(response: Response): Promise<TerrainTile> {\n  const bitmap = await createImageBitmap(await response.blob(), { premultiplyAlpha: \"none\", colorSpaceConversion: \"none\" });\n  const size = bitmap.width;\n  const canvas = typeof OffscreenCanvas !== \"undefined\" ? new OffscreenCanvas(size, bitmap.height) : Object.assign(document.createElement(\"canvas\"), { width: size, height: bitmap.height });\n  const context = canvas.getContext(\"2d\", { willReadFrequently: true }) as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null;\n  if (!context) throw new Error(\"no 2d canvas\");\n  context.drawImage(bitmap, 0, 0);\n  bitmap.close();\n  return { size, data: context.getImageData(0, 0, size, size).data };\n}\n\n/** What the scene draws: the view's surface, or the sight line and sheet to downtown. */\ntype Scene = { surface: ViewSurface; downtown?: never } | { downtown: ReturnType<typeof downtownScene> & { visible: boolean }; surface?: never };\ntype RGBA = [number, number, number, number];\n\n/** The renderer, loaded on demand: @unmap/deck's overlay and deck.gl's two layers the scene needs. */\nasync function loadDeck() {\n  const [{ createDeckOverlay }, { PathLayer, SolidPolygonLayer }] = await Promise.all([import(\"@unmap/deck\"), import(\"@deck.gl/layers\")]);\n  return { createDeckOverlay, PathLayer, SolidPolygonLayer };\n}\ntype DeckKit = Awaited<ReturnType<typeof loadDeck>>;\n\n/** A CSS colour (hex, rgb() or oklch(), as your tokens hold it) as the RGB deck.gl takes, through a one-pixel canvas. */\nfunction rgba(css: string | null): (alpha: number) => RGBA {\n  let rgb: [number, number, number] = [128, 128, 128];\n  const context = css && typeof document !== \"undefined\" ? document.createElement(\"canvas\").getContext(\"2d\", { willReadFrequently: true }) : null;\n  if (css && context) {\n    context.fillStyle = css;\n    context.fillRect(0, 0, 1, 1);\n    const [r, g, b] = context.getImageData(0, 0, 1, 1).data;\n    rgb = [r ?? 128, g ?? 128, b ?? 128];\n  }\n  return (alpha) => [...rgb, Math.round(alpha * 255)];\n}\n\n/**\n * The scene's layers. The surface is see-through and writes no depth, so the map shows through it;\n * the terrain still hides what lies behind a ridge. Polygons are triangulated on the map's plane and\n * keep each corner's height, which is what a fan from the window wants.\n */\nfunction sceneLayers(kit: DeckKit, scene: Scene, colours: { primary: (alpha: number) => RGBA; muted: (alpha: number) => RGBA }): unknown[] {\n  const { primary, muted } = colours;\n  const sheet = { filled: true, parameters: { depthWriteEnabled: false } };\n  const line = { widthUnits: \"pixels\" as const, capRounded: true, jointRounded: true };\n  if (scene.surface) {\n    return [\n      new kit.SolidPolygonLayer<ScenePoint[]>({\n        id: VIEW_LAYERS.surface,\n        data: scene.surface.triangles,\n        getPolygon: (points) => points,\n        getFillColor: primary(0.38),\n        ...sheet,\n      }),\n      new kit.PathLayer<ViewEdge>({\n        id: VIEW_LAYERS.edge,\n        data: scene.surface.edges,\n        getPath: (edge) => edge.path,\n        getColor: (edge) => (edge.clear ? primary(1) : muted(0.9)),\n        getWidth: (edge) => (edge.clear ? 4 : 2),\n        ...line,\n      }),\n    ];\n  }\n  const { downtown } = scene;\n  return [\n    new kit.PathLayer<ScenePoint[]>({\n      id: VIEW_LAYERS.line,\n      data: [downtown.line],\n      getPath: (points) => points,\n      getColor: (downtown.visible ? primary : muted)(1),\n      getWidth: 4,\n      ...line,\n    }),\n  ];\n}\n\n/** Everything that needs the map. It renders inside <Map>, where useMap() can see it. */\nfunction Explorer({ apiKey, gateway, lang }: { apiKey: string; gateway?: string; lang: ViewLang }) {\n  const copy = VIEW_COPY[lang];\n  const map = useMap(VIEW_MAP_ID) as unknown as (ViewMap & Parameters<typeof tokenColor>[0]) | null;\n  const ready = useMapReady(VIEW_MAP_ID);\n  const generation = useMapGeneration(VIEW_MAP_ID);\n  // Decoded terrain tiles, by z/x/y, oldest dropped first. A new key or gateway gets a new store.\n  const tiles = useMemo(() => {\n    void apiKey;\n    void gateway;\n    return new globalThis.Map<string, Promise<TerrainTile | null>>();\n  }, [apiKey, gateway]);\n  const [target, setTarget] = useState<ViewTarget>(\"mountains\");\n  const [home, setHome] = useState<ViewHome | null>(null);\n  // Every view asked for, by home and target, remembered for the life of the block.\n  const [mountains, setMountains] = useState<Record<string, Asked<MountainView>>>({});\n  const [downtown, setDowntown] = useState<Record<string, Asked<DowntownView>>>({});\n  const flight = useRef<{ key: string; controller: AbortController } | null>(null);\n  // The memory as of the last render, read by a click handler bound before it changed.\n  const memory = useRef({ mountains, downtown });\n  memory.current = { mountains, downtown };\n\n  const key = home ? `${target}:${homeKey(home)}` : null;\n  const current: Asked<MountainView> | Asked<DowntownView> | undefined = key ? (target === \"mountains\" ? mountains[key] : downtown[key]) : undefined;\n  const mountainView = target === \"mountains\" && current?.status === \"ready\" ? (current.value as MountainView) : null;\n  const downtownView = target === \"downtown\" && current?.status === \"ready\" ? (current.value as DowntownView) : null;\n  const tooClose = home !== null && target === \"downtown\" && distanceMetres(home.lngLat, CALGARY) < 600;\n\n  // A new key or gateway is a new account: forget what the old one was told.\n  const askedWith = useRef(tiles);\n  useEffect(() => {\n    if (askedWith.current === tiles) return;\n    askedWith.current = tiles;\n    flight.current?.controller.abort();\n    flight.current = null;\n    setMountains({});\n    setDowntown({});\n  }, [tiles]);\n\n  useEffect(() => () => flight.current?.controller.abort(), []);\n\n  // The buildings in 3D, now and after every style swap.\n  useEffect(() => {\n    if (!map || !ready) return;\n    addBuildings(map);\n    const again = () => addBuildings(map);\n    map.on(\"styledata\", again as never);\n    return () => {\n      map.off(\"styledata\", again as never);\n    };\n  }, [map, ready, generation]);\n\n  // A click picks a home: the building under it if there is one, for its height.\n  useEffect(() => {\n    if (!map || !ready) return;\n    const onClick = (event: { point: { x: number; y: number }; lngLat: { lng: number; lat: number } }) => {\n      const hit = map.getLayer(VIEW_LAYERS.buildings) ? map.queryRenderedFeatures(event.point, { layers: [VIEW_LAYERS.buildings] })[0] : undefined;\n      const height = typeof hit?.properties?.height === \"number\" ? hit.properties.height : null;\n      pick({ lngLat: [event.lngLat.lng, event.lngLat.lat], eye: eyeFor(height), building: height });\n    };\n    map.on(\"click\", onClick);\n    map.getCanvas().style.cursor = \"crosshair\";\n    return () => {\n      map.off(\"click\", onClick);\n      map.getCanvas().style.cursor = \"\";\n    };\n    // pick reads the latest state through its arguments and refs\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [map, ready, target, tiles]);\n\n  function look(next: ViewHome, toward: ViewTarget) {\n    if (!map) return;\n    const height = (map as unknown as { getContainer(): HTMLElement }).getContainer().clientHeight;\n    map.easeTo({ ...homeCamera(next.lngLat, toward, height, isWide()), duration: prefersReducedMotion() ? 0 : CAMERA_MS });\n  }\n\n  function pick(next: ViewHome, toward: ViewTarget = target, force = false) {\n    setHome(next);\n    if (toward === \"mountains\" || distanceMetres(next.lngLat, CALGARY) >= 600) look(next, toward);\n    ask(next, toward, force);\n  }\n\n  /** The view for one home and target: remembered, or asked once. A newer pick overtakes one in flight. */\n  function ask(next: ViewHome, toward: ViewTarget, force: boolean) {\n    const askKey = `${toward}:${homeKey(next)}`;\n    const known = toward === \"mountains\" ? memory.current.mountains[askKey] : memory.current.downtown[askKey];\n    if (!force && known && known.status !== \"error\") return;\n    if (toward === \"downtown\" && distanceMetres(next.lngLat, CALGARY) < 600) return;\n    flight.current?.controller.abort();\n    const controller = new AbortController();\n    flight.current = { key: askKey, controller };\n    const mine = () => flight.current?.controller === controller;\n    // Either memory, written the same way: only the states that carry no view are set through it.\n    const set = (update: (all: Record<string, Asked<never>>) => Record<string, Asked<never>>) =>\n      (toward === \"mountains\" ? setMountains : setDowntown)(update as never);\n    set((all) => ({ ...all, [askKey]: { status: \"loading\" } }));\n\n    const fan = toward === \"mountains\" ? fanRays(next.lngLat) : [];\n    const peakRays = toward === \"mountains\" ? VIEW_PEAKS.map((peak) => targetRay(next.lngLat, peak.lngLat, PEAK_SAMPLES)) : [];\n    const rays = toward === \"mountains\" ? [...fan, ...peakRays] : [targetRay(next.lngLat, CALGARY, DOWNTOWN_SAMPLES)];\n    const template = map ? terrainTemplate(map) : null;\n    if (!template) {\n      set((all) => ({ ...all, [askKey]: { status: \"error\", message: copy.noTerrain } }));\n      return;\n    }\n    const load: TileLoader = (z, x, y) => {\n      const id = `${z}/${x}/${y}`;\n      const known = tiles.get(id);\n      if (known) {\n        // Most recently used goes to the back of the queue.\n        tiles.delete(id);\n        tiles.set(id, known);\n        return known;\n      }\n      const loading = fetch(tileUrl(template, z, x, y)).then((response) => {\n        if (response.status === 404 || response.status === 204) return null;\n        if (!response.ok) throw Object.assign(new Error(`terrain ${response.status}`), { status: response.status });\n        return decodeTile(response);\n      });\n      loading.catch(() => tiles.delete(id));\n      tiles.set(id, loading);\n      while (tiles.size > TILE_MEMORY) tiles.delete(tiles.keys().next().value!);\n      return loading;\n    };\n    groundHeights(next.lngLat, rays, load).then(\n      ({ ground, perRay }) => {\n        if (!mine()) return;\n        flight.current = null;\n        if (ground === null) {\n          set((all) => ({ ...all, [askKey]: { status: \"error\", message: copy.noData } }));\n          return;\n        }\n        const eyeHeight = ground + next.eye;\n        if (toward === \"mountains\") {\n          const value: MountainView = {\n            rays: fan.map((ray, index) => readRay(ray, perRay[index]!, ground, eyeHeight)),\n            peaks: VIEW_PEAKS.flatMap((peak, index) => {\n              const result = readTarget(peakRays[index]!, perRay[fan.length + index]!, eyeHeight);\n              return result ? [{ peak, result }] : [];\n            }),\n          };\n          setMountains((all) => ({ ...all, [askKey]: { status: \"ready\", value, ground } }));\n        } else {\n          const result = readTarget(rays[0]!, perRay[0]!, eyeHeight, DOWNTOWN_HEIGHT);\n          if (!result) {\n            setDowntown((all) => ({ ...all, [askKey]: { status: \"error\", message: copy.noData } }));\n            return;\n          }\n          const value: DowntownView = { result, distances: rays[0]!.distances, heights: perRay[0]!, groundHeight: ground, eyeHeight };\n          setDowntown((all) => ({ ...all, [askKey]: { status: \"ready\", value, ground } }));\n        }\n      },\n      (caught: unknown) => {\n        if (!mine()) return;\n        flight.current = null;\n        set((all) => {\n          const rest = { ...all };\n          if (isAbort(caught)) delete rest[askKey];\n          else rest[askKey] = { status: \"error\", message: requestError(caught, lang) };\n          return rest;\n        });\n      },\n    );\n  }\n\n  // The sample spot the home is, if it is one and was not a building clicked on the map.\n  const spotId = home && home.building === null ? (VIEW_SPOTS.find((spot) => spot.lngLat[0] === home.lngLat[0] && spot.lngLat[1] === home.lngLat[1])?.id ?? null) : null;\n\n  function pickSpot(id: unknown) {\n    // Pressing the pressed spot empties the group; the home stays.\n    const spot = VIEW_SPOTS.find((entry) => entry.id === id);\n    if (spot) pick({ lngLat: spot.lngLat, eye: DEFAULT_EYE, building: null });\n  }\n\n  function switchTarget(next: unknown) {\n    // Pressing the pressed item empties a single ToggleGroup; the target stays.\n    if (!isTarget(next) || next === target) return;\n    setTarget(next);\n    if (home) pick(home, next);\n  }\n\n  // The view in the air, as plain data: one surface, or the line to downtown.\n  const scene = useMemo((): Scene | null => {\n    if (!home || !current || current.status !== \"ready\") return null;\n    if (mountainView) {\n      const surface = viewSurface(home.lngLat, current.ground, home.eye, mountainView.rays);\n      return surface ? { surface } : null;\n    }\n    if (downtownView) return { downtown: { ...downtownScene(home.lngLat, current.ground, home.eye, downtownView.result), visible: downtownView.result.visible } };\n    return null;\n  }, [home, current, mountainView, downtownView]);\n\n  // deck.gl, loaded on the first view and drawn into the map's own WebGL, kept for the block's life.\n  const deck = useRef<Promise<DeckKit> | null>(null);\n  const overlay = useRef<{ setLayers(layers: unknown[]): void; remove(): void } | null>(null);\n  useEffect(\n    () => () => {\n      overlay.current?.remove();\n      overlay.current = null;\n    },\n    [map],\n  );\n  useEffect(() => {\n    if (!map || !ready || (!scene && !overlay.current)) return;\n    let live = true;\n    const colours = { primary: rgba(tokenColor(map, \"--primary\")), muted: rgba(tokenColor(map, \"--muted-foreground\")) };\n    (deck.current ??= loadDeck()).then(\n      (kit) => {\n        if (!live) return;\n        overlay.current ??= kit.createDeckOverlay(map as never, {});\n        overlay.current.setLayers(scene ? sceneLayers(kit, scene, colours) : []);\n      },\n      // The answer is in the panel either way; a renderer that will not load leaves the map plain.\n      () => {\n        deck.current = null;\n      },\n    );\n    return () => {\n      live = false;\n    };\n  }, [map, ready, scene]);\n\n  const share = mountainView ? viewShare(mountainView.rays) : null;\n  const block = mountainView ? widestBlock(mountainView.rays) : null;\n  const seen = mountainView ? mountainView.peaks.filter((item) => item.result.visible) : [];\n  const eyeText = home ? (home.building !== null && home.eye > DEFAULT_EYE ? copy.fromTop(formatHeight(home.eye, lang)) : copy.from(formatHeight(home.eye, lang))) : \"\";\n\n  const headline = mountainView\n    ? share === 0\n      ? copy.noMountains\n      : share === 1\n        ? copy.allMountains\n        : copy.score(formatShare(share!, lang))\n    : downtownView\n      ? downtownView.result.visible\n        ? copy.downtownSeen(formatDistance(downtownView.result.distance, lang))\n        : copy.downtownHidden(formatDistance(downtownView.result.distance, lang), formatDistance(downtownView.result.blocker?.distance ?? 0, lang))\n      : \"\";\n  const detail = mountainView\n    ? [block && share !== 0 ? copy.blockedBy(compass(block.azimuth, lang), formatDistance(block.distance, lang)) : \"\", copy.peaksSeen(seen.length, mountainView.peaks.length)].filter(Boolean).join(\" \")\n    : \"\";\n\n  return (\n    <>\n      {home ? (\n        // A ring centred on the window, where the sight lines start: a pin would stand above it.\n        <Marker\n          key={`home:${homeKey(home)}`}\n          mapId={VIEW_MAP_ID}\n          lngLat={home.lngLat}\n          markerContent={<span className=\"block size-4 rounded-full border-[3px] border-foreground bg-background\" />}\n        >\n          <MarkerLabel position=\"top\">{copy.home}</MarkerLabel>\n        </Marker>\n      ) : null}\n      {mountainView\n        ? mountainView.peaks.map(({ peak, result }) => (\n            <Marker\n              key={`peak:${peak.id}`}\n              mapId={VIEW_MAP_ID}\n              lngLat={peak.lngLat}\n              markerContent={\n                <span\n                  data-visible={result.visible}\n                  className=\"block size-2.5 rounded-full border-2 border-background bg-primary data-[visible=false]:bg-muted-foreground\"\n                />\n              }\n            >\n              <MarkerLabel position=\"top\" className={result.visible ? \"font-medium\" : \"text-muted-foreground\"}>\n                {peak.name}\n              </MarkerLabel>\n            </Marker>\n          ))\n        : null}\n      {target === \"downtown\" && home ? (\n        <Marker key=\"downtown\" mapId={VIEW_MAP_ID} lngLat={CALGARY} markerContent={<span className=\"block size-3 bg-foreground\" />}>\n          <MarkerLabel position=\"top\">{copy.downtown}</MarkerLabel>\n        </Marker>\n      ) : null}\n      <p data-unmap-view-announce=\"\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {current?.status === \"loading\" ? copy.loading : headline}\n      </p>\n      {/*\n        Wide: the controls float at the top left, clear of the mountains on the horizon, and the\n        skyline runs along the bottom, under the home and its fan. Below `sm`: one sheet across\n        the bottom, at most half the map, that scrolls.\n      */}\n      <div className=\"absolute inset-x-4 bottom-10 z-10 flex max-h-[50%] flex-col gap-3 overflow-y-auto overscroll-contain sm:pointer-events-none sm:inset-x-6 sm:top-6 sm:bottom-10 sm:max-h-none sm:justify-between sm:gap-4 sm:overflow-visible motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-4 motion-safe:duration-300\">\n        <Card className=\"shrink-0 sm:pointer-events-auto sm:w-[22rem]\">\n          <CardHeader>\n            <CardTitle role=\"heading\" aria-level={2}>\n              {copy.title}\n            </CardTitle>\n            <CardDescription className=\"max-sm:hidden\">{copy.intro}</CardDescription>\n          </CardHeader>\n          <CardContent className=\"flex flex-col gap-4\">\n            <ToggleGroup\n              variant=\"outline\"\n              aria-label={copy.target}\n              value={[target]}\n              onValueChange={(value: unknown[]) => switchTarget(value[0])}\n              className=\"grid w-full grid-cols-2\"\n            >\n              {VIEW_TARGETS.map((item) => (\n                <ToggleGroupItem key={item} value={item} className=\"aria-pressed:font-semibold\">\n                  {copy.targets[item]}\n                </ToggleGroupItem>\n              ))}\n            </ToggleGroup>\n            <div className=\"flex flex-col gap-2\">\n              <p className=\"text-xs text-muted-foreground\">{copy.spotsLabel}</p>\n              {/* The same toggle as the target above, so the spot picked shows the same way; a click on the map clears it. */}\n              <ToggleGroup\n                variant=\"outline\"\n                aria-label={copy.spotsLabel}\n                value={spotId ? [spotId] : []}\n                onValueChange={(value: unknown[]) => pickSpot(value[0])}\n                className=\"grid w-full grid-cols-2\"\n              >\n                {VIEW_SPOTS.map((spot) => (\n                  <ToggleGroupItem key={spot.id} value={spot.id} className=\"aria-pressed:font-semibold\">\n                    {copy.spots[spot.id]}\n                  </ToggleGroupItem>\n                ))}\n              </ToggleGroup>\n            </div>\n            {/* Where the answer lands, always there and tall enough for an error and its retry, so nothing jumps. */}\n            <div data-unmap-view-answer=\"\" className=\"flex min-h-20 flex-col justify-center gap-1 text-sm\">\n              {tooClose ? (\n                <p className=\"text-muted-foreground\">{copy.downtownHere}</p>\n              ) : current?.status === \"error\" ? (\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\">{current.message}</span>\n                    <Button type=\"button\" variant=\"outline\" size=\"sm\" className=\"shrink-0\" onClick={() => home && pick(home, target, true)}>\n                      {copy.retry}\n                    </Button>\n                  </AlertDescription>\n                </Alert>\n              ) : current?.status === \"loading\" ? (\n                <p className=\"flex items-center gap-2 text-muted-foreground\">\n                  <Spinner /> <span>{copy.loading}</span>\n                </p>\n              ) : headline ? (\n                <div className=\"flex flex-col gap-1 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200\">\n                  <p data-unmap-view-headline=\"\" className=\"font-semibold tabular-nums\">\n                    {headline}\n                  </p>\n                  {mountainView && share !== null ? (\n                    <div aria-hidden=\"true\" className=\"h-2 w-full bg-muted-foreground/30\">\n                      <div className=\"h-full bg-primary\" style={{ width: `${Math.round(share * 100)}%` }} />\n                    </div>\n                  ) : null}\n                  {detail ? <p className=\"text-muted-foreground\">{detail}</p> : null}\n                  <p className=\"text-xs text-muted-foreground\">{eyeText}</p>\n                </div>\n              ) : (\n                <p className=\"text-muted-foreground\">{copy.pick}</p>\n              )}\n            </div>\n            <div className=\"flex flex-col gap-1 text-xs text-muted-foreground\">\n              <p>{copy.caveat}</p>\n              <p>\n                {copy.elevationCredit}{\" \"}\n                <a className=\"underline underline-offset-2\" href={VIEW_CREDITS.elevation[lang]} target=\"_blank\" rel=\"noreferrer\">\n                  {copy.elevationSource}\n                </a>\n                .{\" \"}\n                {copy.namesCredit}{\" \"}\n                <a className=\"underline underline-offset-2\" href={VIEW_CREDITS.names[lang]} target=\"_blank\" rel=\"noreferrer\">\n                  {copy.namesSource}\n                </a>\n                ,{\" \"}\n                <a className=\"underline underline-offset-2\" href={VIEW_CREDITS.licence[lang]} target=\"_blank\" rel=\"noreferrer\">\n                  {copy.licence}\n                </a>\n                .\n              </p>\n            </div>\n          </CardContent>\n        </Card>\n        {mountainView || downtownView ? (\n          <Card className=\"shrink-0 sm:pointer-events-auto sm:w-full sm:max-w-3xl sm:self-center motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-4 motion-safe:duration-300\">\n            <CardHeader>\n              <CardTitle role=\"heading\" aria-level={2}>\n                {mountainView ? copy.skyline : copy.crossSection}\n              </CardTitle>\n              <CardDescription className=\"text-xs max-sm:hidden\">{mountainView ? copy.skylineHint : copy.crossHint}</CardDescription>\n            </CardHeader>\n            <CardContent>\n              {mountainView && home ? <Skyline view={mountainView} home={home} lang={lang} /> : null}\n              {downtownView ? <CrossSection view={downtownView} lang={lang} /> : null}\n            </CardContent>\n          </Card>\n        ) : null}\n      </div>\n    </>\n  );\n}\n\n/** The skyline's drawing box, in SVG units; it scales to the card. Labels take the top rows. */\nconst SKY = { width: 1000, height: 230, ground: 206, rows: 3, row: 19, char: 7.4 };\n\n/**\n * The horizon from the window, facing west: south on the left, north on the right. The skyline is\n * the steepest angle up in each direction, filled; where that skyline is mountains it takes your\n * --primary, where a nearer hill makes it, your --muted-foreground. Mountains hidden behind a\n * nearer hill are traced dashed above it. The angles are tiny (a mountain 100 km off stands about\n * a degree high), so the vertical is stretched to fit the card: it shows the order, not the size.\n */\nfunction Skyline({ view, home, lang }: { view: MountainView; home: ViewHome; lang: ViewLang }) {\n  const copy = VIEW_COPY[lang];\n  const read = view.rays.filter((result) => result.horizon);\n  if (read.length < 2) return null;\n  const from = view.rays[0]!.azimuth;\n  const to = view.rays[view.rays.length - 1]!.azimuth;\n  const peaks = view.peaks\n    .map(({ peak, result }) => ({ peak, result, azimuth: bearingDegrees(home.lngLat, peak.lngLat) }))\n    .filter((item) => item.azimuth >= from && item.azimuth <= to);\n  const angles = [...read.flatMap((result) => [result.horizon!.angle, result.mountainAngle ?? result.horizon!.angle]), ...peaks.map((item) => item.result.angle)];\n  const low = Math.min(...angles);\n  const high = Math.max(...angles);\n  const span = Math.max(high - low, 0.2);\n  const top = SKY.rows * SKY.row + 14;\n  const x = (azimuth: number) => ((azimuth - from) / (to - from)) * SKY.width;\n  const y = (angle: number) => SKY.ground - 10 - ((angle - low) / span) * (SKY.ground - 10 - top);\n  const outline = read.map((result) => `${x(result.azimuth).toFixed(1)},${y(result.horizon!.angle).toFixed(1)}`);\n  const silhouette = `M0,${SKY.ground} L${outline.join(\" L\")} L${SKY.width},${SKY.ground} Z`;\n  // The mountain stretches: consecutive clear lines, each a polygon of its own.\n  const runs: RayResult[][] = [];\n  for (const result of read) {\n    if (!result.clear) continue;\n    const last = runs[runs.length - 1];\n    const previous = last?.[last.length - 1];\n    if (last && previous && Math.abs(result.azimuth - previous.azimuth - FAN.step) < 1e-6) last.push(result);\n    else runs.push([result]);\n  }\n  const half = (FAN.step / 2 / (to - from)) * SKY.width;\n  const mountainPaths = runs.map((run) => {\n    const left = Math.max(0, x(run[0]!.azimuth) - half);\n    const right = Math.min(SKY.width, x(run[run.length - 1]!.azimuth) + half);\n    const edge = run.map((result) => `${x(result.azimuth).toFixed(1)},${y(result.horizon!.angle).toFixed(1)}`);\n    return `M${left.toFixed(1)},${SKY.ground} L${left.toFixed(1)},${y(run[0]!.horizon!.angle).toFixed(1)} L${edge.join(\" L\")} L${right.toFixed(1)},${y(run[run.length - 1]!.horizon!.angle).toFixed(1)} L${right.toFixed(1)},${SKY.ground} Z`;\n  });\n  // Hidden mountains: the steepest mountain on each blocked line, which the nearer ground outranks.\n  const ghost = read\n    .filter((result) => !result.clear && result.mountainAngle !== null)\n    .map((result) => `${x(result.azimuth).toFixed(1)},${y(result.mountainAngle!).toFixed(1)}`);\n  // Visible summits claim label rows first; a hidden one takes what is left, or goes unnamed.\n  const ordered = [...peaks].sort((a, b) => Number(b.result.visible) - Number(a.result.visible) || a.azimuth - b.azimuth);\n  const rows = labelRows(\n    ordered.map((item) => ({ x: Math.min(Math.max(x(item.azimuth), 4 + (item.peak.name.length * SKY.char) / 2), SKY.width - 4 - (item.peak.name.length * SKY.char) / 2), width: item.peak.name.length * SKY.char })),\n    SKY.rows,\n  );\n  return (\n    <svg data-unmap-view-skyline=\"\" viewBox={`0 0 ${SKY.width} ${SKY.height}`} className=\"h-auto w-full\" role=\"img\" aria-label={copy.skyline}>\n      <rect x={0} y={0} width={SKY.width} height={SKY.ground} className=\"fill-muted\" />\n      <path d={silhouette} className=\"fill-muted-foreground/45\" />\n      {mountainPaths.map((d, index) => (\n        <path key={index} d={d} className=\"fill-primary motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-500\" />\n      ))}\n      {ghost.length > 1 ? <polyline points={ghost.join(\" \")} fill=\"none\" strokeDasharray=\"6 5\" strokeWidth={2} className=\"stroke-muted-foreground\" /> : null}\n      <rect x={0} y={SKY.ground} width={SKY.width} height={SKY.height - SKY.ground} className=\"fill-foreground/80\" />\n      {ordered.map(({ peak, result, azimuth }, index) => {\n        const row = rows[index];\n        if (row === null || row === undefined) return null;\n        const px = x(azimuth);\n        const label = Math.min(Math.max(px, 4 + (peak.name.length * SKY.char) / 2), SKY.width - 4 - (peak.name.length * SKY.char) / 2);\n        const baseline = 14 + row * SKY.row;\n        return (\n          <g key={peak.id} data-visible={result.visible} className=\"text-[13px]\">\n            <line x1={px} y1={baseline + 4} x2={px} y2={y(result.angle) - 3} strokeWidth={1} strokeDasharray={result.visible ? undefined : \"3 3\"} className=\"stroke-foreground/60\" />\n            <text x={label} y={baseline} textAnchor=\"middle\" className={result.visible ? \"fill-foreground font-medium\" : \"fill-muted-foreground\"}>\n              {peak.name}\n            </text>\n          </g>\n        );\n      })}\n      <g className=\"fill-background text-[13px] font-medium\">\n        <text x={8} y={SKY.height - 7}>{copy.south}</text>\n        <text x={x(270)} y={SKY.height - 7} textAnchor=\"middle\">\n          {copy.west}\n        </text>\n        <text x={SKY.width - 8} y={SKY.height - 7} textAnchor=\"end\">\n          {copy.north}\n        </text>\n      </g>\n    </svg>\n  );\n}\n\n/** The cross-section's drawing box, in SVG units. */\nconst SECTION = { width: 1000, height: 180, pad: 12 };\n\n/** The ground from the window to downtown, as the window sees it (the curve taken off), and the line to the top of the towers. */\nfunction CrossSection({ view, lang }: { view: DowntownView; lang: ViewLang }) {\n  const copy = VIEW_COPY[lang];\n  const { distances, heights, groundHeight, eyeHeight, result } = view;\n  const last = distances.length - 1;\n  const seenHeights = heights.map((height, index) => (height === null ? null : height - curveDrop(distances[index]!)));\n  const towerTop = (seenHeights[last] ?? eyeHeight) + DOWNTOWN_HEIGHT;\n  const known = seenHeights.filter((height): height is number => height !== null);\n  const low = Math.min(...known, eyeHeight) - 10;\n  const high = Math.max(...known, eyeHeight, towerTop) + 10;\n  const x = (metres: number) => SECTION.pad + (metres / distances[last]!) * (SECTION.width - 2 * SECTION.pad);\n  const y = (metres: number) => SECTION.height - SECTION.pad - ((metres - low) / (high - low)) * (SECTION.height - 2 * SECTION.pad);\n  const ground = [`${x(0)},${y(groundHeight).toFixed(1)}`]\n    .concat(seenHeights.flatMap((height, index) => (height === null ? [] : [`${x(distances[index]!).toFixed(1)},${y(height).toFixed(1)}`])))\n    .join(\" L\");\n  const area = `M${x(0)},${SECTION.height} L${ground} L${x(distances[last]!)},${SECTION.height} Z`;\n  return (\n    <svg data-unmap-view-section=\"\" viewBox={`0 0 ${SECTION.width} ${SECTION.height}`} className=\"h-auto w-full\" role=\"img\" aria-label={copy.crossSection}>\n      <path d={area} className=\"fill-muted-foreground/45\" />\n      <rect x={x(distances[last]!) - 10} y={y(towerTop)} width={20} height={y(seenHeights[last] ?? low) - y(towerTop)} className=\"fill-foreground/80\" />\n      <line\n        x1={x(0)}\n        y1={y(eyeHeight)}\n        x2={x(distances[last]!)}\n        y2={y(towerTop)}\n        strokeWidth={3}\n        strokeDasharray={result.visible ? undefined : \"8 6\"}\n        className={result.visible ? \"stroke-primary\" : \"stroke-muted-foreground\"}\n      />\n      {!result.visible && result.blocker ? <circle cx={x(result.blocker.distance)} cy={y(result.blocker.height - curveDrop(result.blocker.distance))} r={7} className=\"fill-destructive\" /> : null}\n      <circle cx={x(0)} cy={y(eyeHeight)} r={7} className=\"fill-primary stroke-background\" strokeWidth={2} />\n      <g className=\"fill-foreground text-[13px] font-medium\">\n        <text x={x(0) + 12} y={y(eyeHeight) - 10}>{copy.home}</text>\n        <text x={x(distances[last]!) - 16} y={y(towerTop) - 8} textAnchor=\"end\">\n          {`${copy.downtown}, ${formatDistance(distances[last]!, lang)}`}\n        </text>\n      </g>\n    </svg>\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 /view-explorer. Guide: https://unmap.dev/docs/frontend/blocks#find-the-view",
  "categories": [
    "blocks"
  ],
  "type": "registry:block"
}