{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "elevation-explorer",
  "title": "Elevation Explorer",
  "description": "A page that profiles a line from the unmap elevation API: pick a mountain trip, route your own by bike or draw one, and read every climb on a chart that answers the map. Hover either and the other follows; the line is coloured by grade. Calls /route, /geocode/autocomplete (and /geocode/reverse for typed coordinates) and /elevation.",
  "dependencies": [
    "@unmap/routing"
  ],
  "registryDependencies": [
    "https://registry.unmap.dev/r/map.json",
    "https://registry.unmap.dev/r/map-context.json",
    "https://registry.unmap.dev/r/geocoder.json",
    "https://registry.unmap.dev/r/draw.json",
    "https://registry.unmap.dev/r/elevation-profile.json",
    "https://registry.unmap.dev/r/route-line.json",
    "https://registry.unmap.dev/r/marker-popup.json",
    "card",
    "toggle-group",
    "button",
    "alert",
    "spinner"
  ],
  "files": [
    {
      "path": "registry/blocks/elevation-explorer/page.tsx",
      "content": "/**\n * /elevation-explorer: the elevation 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.\n */\nimport { ElevationExplorer } from \"@/components/ui/elevation-explorer\";\n\nexport const metadata = { title: \"See the climb\" };\n\nexport default function ElevationExplorerPage() {\n  return (\n    <main className=\"h-dvh\">\n      <ElevationExplorer apiKey={process.env.NEXT_PUBLIC_UNMAP_KEY ?? \"\"} className=\"h-full\" />\n    </main>\n  );\n}\n",
      "type": "registry:page",
      "target": "app/elevation-explorer/page.tsx"
    },
    {
      "path": "registry/blocks/elevation-explorer/elevation-explorer.tsx",
      "content": "\"use client\";\n\n/**\n * <ElevationExplorer> - an elevation page in one component. The chart is the second half of the\n * map: pick a mountain trip, route your own by bike, or draw a line, and its profile from unmap's\n * elevation API runs along the bottom while the line on the map takes the colour of its\n * grade. Hover either half and the other answers: the chart follows the line, and the map shows\n * the height and the grade at the point the chart is on.\n *\n * Composes the registry's map, geocoder, draw, elevation-profile, route-line and marker-popup\n * items on your shadcn Card, ToggleGroup, Button, Alert and Spinner, in your tokens. Each request\n * is billed, so each answer is asked for once and remembered for the life of the block: a trip\n * calls /route once; the search boxes call /geocode/autocomplete as you type, and\n * /geocode/reverse when you type coordinates; drawing calls nothing; each new line's profile is\n * one /elevation request for up to 500 samples (elevation-profile's default spacing), and a line\n * shown again comes from the block's profile cache. The grade colours come from the profile's own\n * samples, so they cost nothing extra.\n *\n * Installed by `npx shadcn add` with its page at app/elevation-explorer/page.tsx. Edit freely.\n */\n\nimport { useCallback, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { Router } from \"@unmap/routing\";\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 { Draw, type DrawValue } from \"@/components/ui/draw\";\nimport { ElevationProfile, type ProfilePoint } from \"@/components/ui/elevation-profile\";\nimport { Geocoder } from \"@/components/ui/geocoder\";\nimport { Map } from \"@/components/ui/map\";\nimport { Marker, MarkerLabel } from \"@/components/ui/marker-popup\";\nimport { RouteLine } from \"@/components/ui/route-line\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { ToggleGroup, ToggleGroupItem } from \"@/components/ui/toggle-group\";\nimport { useMap, useMapGeneration } from \"@/lib/map-context\";\n\n// --- elevation-explorer helpers (kept identical across both lanes) ---\nexport type ExplorerLang = \"en\" | \"fr\";\nexport type ExplorerSource = \"preset\" | \"route\" | \"draw\";\n\n/** A named place: a preset's end, or one chosen in a search box. */\nexport interface ExplorerPlace {\n  name: string;\n  lngLat: [number, number];\n}\n\nexport interface ExplorerPreset {\n  id: string;\n  from: ExplorerPlace;\n  to: ExplorerPlace;\n}\n\n/** A profile sample as elevation-profile reports it: metres along the line, the height there, and where it is. */\nexport interface ExplorerPoint {\n  distance: number;\n  elevation: number | null;\n  lng?: number;\n  lat?: number;\n}\n\n/** The sample under the pointer, on the chart or on the line, as elevation-profile reports it. */\nexport interface ExplorerHover extends ExplorerPoint {\n  index: number;\n  lng: number;\n  lat: number;\n}\n\n/** The part of a drawing this block reads. */\nexport interface DrawnShapes {\n  features: Array<{ geometry: { type: string; coordinates: unknown } | null }>;\n}\n\n/** A routed line, as /route answers it. */\nexport interface ExplorerTrip {\n  coordinates: [number, number][];\n  distanceMeters: number;\n  durationSeconds: number;\n}\n\n/** A box on screen, as getBoundingClientRect() gives it. */\nexport interface ExplorerRect {\n  left: number;\n  top: number;\n  right: number;\n  bottom: number;\n  width: number;\n  height: number;\n}\n\n/** Camera padding in pixels, as fitBounds takes it. */\nexport interface ExplorerPadding {\n  top: number;\n  right: number;\n  bottom: number;\n  left: number;\n}\n\nexport const EXPLORER_SOURCES: ExplorerSource[] = [\"preset\", \"route\", \"draw\"];\n\n/**\n * Three trips through the mountains. Endpoints from the Canadian Geographical Names Database\n * (Natural Resources Canada): Lake Louise IACFT, Jasper IAMCJ, Squamish JCVCJ, Whistler JCJHI,\n * Calgary IAKID, Banff IACYE. Open Government Licence, Canada.\n */\nexport const EXPLORER_PRESETS: ExplorerPreset[] = [\n  {\n    id: \"icefields-parkway\",\n    from: { name: \"Lake Louise\", lngLat: [-116.18056, 51.42528] },\n    to: { name: \"Jasper\", lngLat: [-118.08306, 52.87778] },\n  },\n  {\n    id: \"sea-to-sky\",\n    from: { name: \"Squamish\", lngLat: [-123.15583, 49.69806] },\n    to: { name: \"Whistler\", lngLat: [-122.95472, 50.11722] },\n  },\n  {\n    id: \"calgary-banff\",\n    from: { name: \"Calgary\", lngLat: [-114.0575, 51.04583] },\n    to: { name: \"Banff\", lngLat: [-115.57361, 51.17778] },\n  },\n];\n\n/** The block's own map id, so a map elsewhere on the page never answers for it. */\nexport const EXPLORER_MAP_ID = \"elevation-explorer\";\n/** Source ids (and layer-id prefixes) of what the block draws. */\nexport const EXPLORER_LAYERS = { line: \"elevation-explorer-line\", draw: \"elevation-explorer-draw\", profile: \"elevation-explorer-profile\" };\n\n/** The trips are drives; your own route is by bike, where the grade is what you feel. */\nexport const PRESET_MODE = \"auto\";\nexport const ROUTE_MODE = \"bicycle\";\n\n/** The drawing tool offers lines only: a profile needs a line. */\nexport const LINE_ONLY: [\"linestring\"] = [\"linestring\"];\n\n/** The grade range the line's colours span, in percent; steeper stretches take the end colours. */\nexport const GRADE_DOMAIN: [number, number] = [-10, 10];\n\n/** A sample-to-sample grade past this is elevation-model noise (a bridge deck, a cliff edge), not road. */\nexport const MAX_GRADE = 25;\n\n/** The shortest run a grade is read over, in metres: a single 30 m cell of noise cannot spike it. */\nexport const GRADE_RUN = 200;\n\n/**\n * The grade's ramp, in your own tokens: descents in --primary, flat in --muted-foreground, climbs\n * in --destructive, mixed in between. route-line spreads a gradient over --chart-1 to --chart-5,\n * which in a stock theme are categorical colours in no order; this remaps them on the map (the\n * block's <Map className>), so the line and the legend inside the map read the same ordered ramp\n * and your charts elsewhere keep theirs.\n */\nexport const GRADE_TOKENS =\n  \"[--chart-1:var(--primary)] [--chart-2:color-mix(in_oklab,var(--primary),var(--muted-foreground))] [--chart-3:var(--muted-foreground)] [--chart-4:color-mix(in_oklab,var(--destructive),var(--muted-foreground))] [--chart-5:var(--destructive)]\";\n\n/** The legend's bar: the same five tokens, as GRADE_TOKENS sets them inside the map. */\nexport const GRADE_RAMP =\n  \"linear-gradient(to right, var(--chart-1), var(--chart-2), var(--chart-3), var(--chart-4), var(--chart-5))\";\n\n/** Banff, between the three presets. */\nexport const BANFF: [number, number] = [-115.5708, 51.1784];\n\n/** Where each credit links, per language. */\nexport const EXPLORER_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  routes: { source: \"https://www.openstreetmap.org/copyright\", licence: \"https://opendatacommons.org/licenses/odbl/\" },\n};\n\n/** The panels' inset from the map edge, and the gutter a framed line keeps (room for the names beside its pins). */\nexport const GUTTER = 24;\nexport const FRAME_GUTTER = 48;\n/** Camera moves, in milliseconds. Reduced motion jumps. */\nexport const CAMERA_MS = 800;\n/** The closest a line is framed. */\nexport const ROUTE_MAX_ZOOM = 14;\n/** The least map, in pixels, a padded camera must have before it tightens its 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/** Where a reader gets an unmap key: the no-key state's first action. */\nexport const KEY_URL = \"https://unmap.dev/dashboard\";\n\nexport const EXPLORER_COPY = {\n  en: {\n    title: \"See the climb\",\n    intro: \"Pick a trip, route your own or draw a line. The chart and the line show every climb.\",\n    source: \"Line from\",\n    sources: { preset: \"Trips\", route: \"Route\", draw: \"Draw\" },\n    between: (from: string, to: string) => `${from} to ${to}`,\n    roads: { \"icefields-parkway\": \"Icefields Parkway\", \"sea-to-sky\": \"Sea-to-Sky Highway\", \"calgary-banff\": \"Trans-Canada Highway\" } as Record<string, string>,\n    pickTrip: \"Pick a trip to see its climb.\",\n    from: \"Choose a start\",\n    to: \"Choose a destination\",\n    byBike: \"Routed by bike, where the grade counts. Routing covers Canada.\",\n    needEnds: \"Choose a start and a destination.\",\n    drawHint: \"Pick Line in the drawing tools at the top right, click along your path, then double-click or press Enter to finish.\",\n    drawn: \"Your line\",\n    loading: \"Finding the route...\",\n    by: { auto: \"by car\", bicycle: \"by bike\" },\n    profile: \"Elevation profile\",\n    hoverHint: \"Hover the chart or the line to read the grade.\",\n    grade: \"Grade\",\n    down: \"down\",\n    up: \"up\",\n    at: (distance: string, height: string, grade: string) => `At ${distance}: ${height}${grade ? `, grade ${grade}` : \"\"}`,\n    steepest: (grade: string, distance: string) => `Steepest climb: ${grade} at ${distance}`,\n    noClimb: \"No climb along this line.\",\n    noHeight: \"no data\",\n    climb: (unit: string) => `${unit} of climb`,\n    announce: (name: string, distance: string, gain: string, steepest: string) =>\n      `${name}: ${distance}, ${gain} of climb${steepest ? `, steepest climb ${steepest}` : \"\"}.`,\n    fold: \"Hide details\",\n    unfold: \"Show details\",\n    retry: \"Try again\",\n    namesCredit: \"Trip ends:\",\n    namesSource: \"Canadian Geographical Names Database\",\n    elevationCredit: \"Elevation:\",\n    elevationSource: \"DEM suppliers and licences\",\n    licence: \"Open Government Licence – Canada\",\n    routesCredit: \"Routes:\",\n    routesSource: \"OpenStreetMap contributors\",\n    routesLicence: \"ODbL\",\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    outside: \"That trip leaves Canada. Routing covers Canada.\",\n    noRoute: \"No road connects those places.\",\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  },\n  fr: {\n    title: \"Voir le dénivelé\",\n    intro: \"Choisissez un trajet, tracez votre itinéraire ou dessinez une ligne. Le graphique et la ligne montrent chaque montée.\",\n    source: \"Ligne tirée de\",\n    sources: { preset: \"Trajets\", route: \"Itinéraire\", draw: \"Dessin\" },\n    between: (from: string, to: string) => `De ${from} à ${to}`,\n    roads: { \"icefields-parkway\": \"Promenade des Glaciers\", \"sea-to-sky\": \"Autoroute Sea-to-Sky\", \"calgary-banff\": \"Route transcanadienne\" } as Record<string, string>,\n    pickTrip: \"Choisissez un trajet pour voir son dénivelé.\",\n    from: \"Choisir un départ\",\n    to: \"Choisir une destination\",\n    byBike: \"Itinéraire à vélo, là où la pente compte. Les itinéraires couvrent le Canada.\",\n    needEnds: \"Choisissez un départ et une destination.\",\n    drawHint: \"Choisissez Ligne dans les outils de dessin en haut à droite, cliquez le long du tracé, puis double-cliquez ou appuyez sur Entrée pour terminer.\",\n    drawn: \"Votre ligne\",\n    loading: \"Calcul de l'itinéraire...\",\n    by: { auto: \"en voiture\", bicycle: \"à vélo\" },\n    profile: \"Profil d'altitude\",\n    hoverHint: \"Survolez le graphique ou la ligne pour lire la pente.\",\n    grade: \"Pente\",\n    down: \"descente\",\n    up: \"montée\",\n    at: (distance: string, height: string, grade: string) => `À ${distance} : ${height}${grade ? `, pente ${grade}` : \"\"}`,\n    steepest: (grade: string, distance: string) => `Montée la plus raide : ${grade} à ${distance}`,\n    noClimb: \"Aucune montée sur cette ligne.\",\n    noHeight: \"aucune donnée\",\n    climb: (unit: string) => `${unit} de montée`,\n    announce: (name: string, distance: string, gain: string, steepest: string) =>\n      `${name} : ${distance}, ${gain} de montée${steepest ? `, montée la plus raide ${steepest}` : \"\"}.`,\n    fold: \"Masquer les détails\",\n    unfold: \"Afficher les détails\",\n    retry: \"Réessayer\",\n    namesCredit: \"Extrémités des trajets :\",\n    namesSource: \"Base de données toponymiques du Canada\",\n    elevationCredit: \"Altitude :\",\n    elevationSource: \"Fournisseurs du MNA et licences\",\n    licence: \"Licence du gouvernement ouvert – Canada\",\n    routesCredit: \"Itinéraires :\",\n    routesSource: \"contributeurs d'OpenStreetMap\",\n    routesLicence: \"ODbL\",\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    outside: \"Ce trajet sort du Canada. Les itinéraires couvrent le Canada.\",\n    noRoute: \"Aucune route ne relie ces lieux.\",\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  },\n};\n\nexport function isSource(value: unknown): value is ExplorerSource {\n  return EXPLORER_SOURCES.includes(value as ExplorerSource);\n}\n\n/** A place as a primitive, so the same place chosen again is the same place. */\nexport function placeKey(place: ExplorerPlace): string {\n  return `${place.lngLat[0]},${place.lngLat[1]}`;\n}\n\n/** The last line in a drawing, or null when it has none. */\nexport function lastLine(shapes: DrawnShapes | null | undefined): [number, number][] | null {\n  const lines = (shapes?.features ?? []).filter((feature) => feature.geometry?.type === \"LineString\");\n  const last = lines[lines.length - 1];\n  return last?.geometry ? (last.geometry.coordinates as [number, number][]) : null;\n}\n\nconst EARTH_RADIUS_M = 6_371_008.8;\n\nfunction haversineMetres(a: [number, number], b: [number, number]): number {\n  const toRadians = (degrees: number) => (degrees * Math.PI) / 180;\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/** Length of a line in metres. */\nexport function lineLength(coordinates: [number, number][]): number {\n  let total = 0;\n  for (let index = 1; index < coordinates.length; index++) total += haversineMetres(coordinates[index - 1]!, coordinates[index]!);\n  return total;\n}\n\nfunction clampGrade(grade: number): number {\n  return Math.max(-MAX_GRADE, Math.min(MAX_GRADE, grade));\n}\n\n/**\n * The grade from sample `low` to sample `high`, widened both ways until it spans at least `run`\n * metres. Null where either end has no height, or the samples do not advance.\n */\nfunction windowGrade(points: ExplorerPoint[], low: number, high: number, run: number): number | null {\n  const last = points.length - 1;\n  while (points[high]!.distance - points[low]!.distance < run && (low > 0 || high < last)) {\n    if (low > 0) low -= 1;\n    if (high < last && points[high]!.distance - points[low]!.distance < run) high += 1;\n  }\n  const start = points[low]!;\n  const end = points[high]!;\n  const span = end.distance - start.distance;\n  if (span <= 0 || start.elevation === null || end.elevation === null) return null;\n  return clampGrade(((end.elevation - start.elevation) / span) * 100);\n}\n\n/**\n * Grade in percent at each vertex of `coordinates`, from the profile's samples: each vertex takes\n * the grade of the sample-to-sample stretch its distance along the line falls in (half a metre of\n * slack at a boundary). With a `run`, each stretch is read over at least that many metres around\n * it, as the hover readout is. A stretch with a missing height counts as flat, and one steeper than\n * MAX_GRADE is clamped. Null when no stretch has two heights, so the line draws plain.\n */\nexport function gradesAlong(coordinates: [number, number][], points: ExplorerPoint[], run = 0): number[] | null {\n  const grades = points.map((_point, index): number | null => (index > 0 ? windowGrade(points, index - 1, index, run) : null));\n  if (!grades.some((grade) => grade !== null)) return null;\n  let travelled = 0;\n  let stretch = 1;\n  return coordinates.map((vertex, index) => {\n    if (index > 0) travelled += haversineMetres(coordinates[index - 1]!, vertex);\n    while (stretch < points.length - 1 && points[stretch]!.distance < travelled - 0.5) stretch += 1;\n    return grades[stretch] ?? 0;\n  });\n}\n\n/**\n * The line to colour, and a grade for each of its points. The profile's samples (which lie on the\n * line) are merged in between its vertices, so a drawn line of four clicks still changes colour\n * wherever the ground does, and a route keeps every bend. Samples without a position leave the\n * line as it is. Null when there is no grade to draw.\n */\nexport function gradedLine(coordinates: [number, number][], points: ExplorerPoint[]): { coordinates: [number, number][]; values: number[] } | null {\n  let merged = coordinates;\n  if (points.length > 0 && points.every((point) => typeof point.lng === \"number\" && typeof point.lat === \"number\")) {\n    merged = [];\n    let travelled = 0;\n    let next = 0;\n    coordinates.forEach((vertex, index) => {\n      if (index > 0) travelled += haversineMetres(coordinates[index - 1]!, vertex);\n      while (next < points.length && points[next]!.distance < travelled - 0.5) {\n        merged.push([points[next]!.lng!, points[next]!.lat!]);\n        next += 1;\n      }\n      merged.push(vertex);\n      // A sample on the vertex itself adds nothing.\n      while (next < points.length && Math.abs(points[next]!.distance - travelled) <= 0.5) next += 1;\n    });\n  }\n  // Over the same run as the hover readout, so the colour under the pointer says what the readout does.\n  const values = gradesAlong(merged, points, GRADE_RUN);\n  return values ? { coordinates: merged, values } : null;\n}\n\n/**\n * The grade at one sample, read over at least `run` metres around it (widening both ways), so a\n * reading is never one 30 m cell of noise. Null where either end of that run has no height.\n */\nexport function gradeAt(points: ExplorerPoint[], index: number, run = GRADE_RUN): number | null {\n  if (!points[index] || points[index]!.elevation === null) return null;\n  return windowGrade(points, index, index, run);\n}\n\n/**\n * Whether a profile belongs to `line`: its first sample sits on the line's start, within the\n * couple of metres the profile's rounding allows. Samples without a position are taken.\n */\nexport function profileBelongs(points: ExplorerPoint[], line: [number, number][]): boolean {\n  const first = points[0];\n  const start = line[0];\n  if (!first || !start || typeof first.lng !== \"number\" || typeof first.lat !== \"number\") return true;\n  return haversineMetres([first.lng, first.lat], start) <= 2;\n}\n\n/** The steepest climb over any run of at least `run` metres, and the distance at its middle. Null when nothing climbs. */\nexport function steepestClimb(points: ExplorerPoint[], run = GRADE_RUN): { grade: number; distance: number } | null {\n  let best: { grade: number; distance: number } | null = null;\n  let high = 0;\n  for (let low = 0; low < points.length - 1; low++) {\n    if (high <= low) high = low + 1;\n    while (high < points.length - 1 && points[high]!.distance - points[low]!.distance < run) high += 1;\n    const start = points[low]!;\n    const end = points[high]!;\n    const span = end.distance - start.distance;\n    if (span <= 0 || start.elevation === null || end.elevation === null) continue;\n    const grade = clampGrade(((end.elevation - start.elevation) / span) * 100);\n    if (grade > 0 && (!best || grade > best.grade)) best = { grade, distance: (start.distance + end.distance) / 2 };\n  }\n  return best;\n}\n\n/** The profile's length and total climb, from its samples. Null when no sample has a height. */\nexport function profileSummary(points: ExplorerPoint[]): { distance: number; gain: number } | null {\n  let gain = 0;\n  let previous: number | null = null;\n  let any = false;\n  for (const point of points) {\n    if (point.elevation === null) continue;\n    if (previous !== null && point.elevation > previous) gain += point.elevation - previous;\n    previous = point.elevation;\n    any = true;\n  }\n  return any ? { distance: points[points.length - 1]!.distance, gain } : null;\n}\n\n/** [[west, south], [east, north]] around a line, for fitBounds. A loop: spreading a long route into Math.min overflows the stack. */\nexport function lineBounds(coordinates: [number, number][]): [[number, number], [number, number]] {\n  let west = Infinity;\n  let south = Infinity;\n  let east = -Infinity;\n  let north = -Infinity;\n  for (const [lng, lat] of coordinates) {\n    if (lng < west) west = lng;\n    if (lng > east) east = lng;\n    if (lat < south) south = lat;\n    if (lat > north) north = lat;\n  }\n  return [\n    [west, south],\n    [east, north],\n  ];\n}\n\n/** The map, as far as `keepOrder` reads and moves its layers. */\nexport interface OrderedMap {\n  getStyle(): { layers?: Array<{ id: string }> } | undefined;\n  moveLayer(id: string, beforeId?: string): void;\n}\n\n/**\n * Puts the block's layers back in their order when something else added its own after them (the\n * drawing re-adds its layers on its own schedule, and a style swap re-adds everyone's in whatever\n * order): the grade line just under the drawing's handles, so they stay in reach, and the\n * profile's hover dot on top of everything. Moves only what is out of place.\n */\nexport function keepOrder(map: OrderedMap): void {\n  const ids = () => map.getStyle()?.layers?.map((layer) => layer.id) ?? [];\n  let order = ids();\n  const handles = `${EXPLORER_LAYERS.draw}-point`;\n  const line = [`${EXPLORER_LAYERS.line}-casing`, `${EXPLORER_LAYERS.line}-line`].filter((id) => order.includes(id));\n  const at = order.indexOf(handles);\n  if (line.length > 0 && at >= 0 && order.slice(at - line.length, at).join(\"|\") !== line.join(\"|\")) {\n    for (const id of line) map.moveLayer(id, handles);\n    order = ids();\n  }\n  const dot = `${EXPLORER_LAYERS.profile}-point`;\n  if (order.includes(dot) && order[order.length - 1] !== dot) map.moveLayer(dot);\n}\n\n/**\n * Which side of each pin its name sits: toward the other end, so a framed line keeps both names\n * inside the frame.\n */\nexport function labelSides(from: ExplorerPlace | null, to: ExplorerPlace | null): { from: \"left\" | \"right\"; to: \"left\" | \"right\" } {\n  if (!from || !to) return { from: \"right\", to: \"right\" };\n  return from.lngLat[0] <= to.lngLat[0] ? { from: \"right\", to: \"left\" } : { from: \"left\", to: \"right\" };\n}\n\n/**\n * Camera padding that keeps a framed line clear of the panels floating on the map. A panel as\n * wide as most of the map (the profile along the bottom, or the whole sheet on a phone) covers it\n * from the bottom up; a narrower one (the controls) covers it from the left. When the full gutter\n * would leave too little map, it tightens and the frame stays in the strip the panels leave,\n * never behind them.\n */\nexport function framePadding(map: ExplorerRect, panels: Array<ExplorerRect | null>, gutter = GUTTER): ExplorerPadding {\n  const padding = { top: gutter, right: gutter, bottom: gutter, left: gutter };\n  if (map.width <= 0 || map.height <= 0) return padding;\n  let bottom = 0;\n  let left = 0;\n  for (const panel of panels) {\n    if (!panel || panel.width <= 0 || panel.height <= 0) continue;\n    if (panel.width >= map.width * 0.6) bottom = Math.max(bottom, Math.round(map.bottom - panel.top));\n    else left = Math.max(left, Math.round(panel.right - map.left));\n  }\n  if (bottom > 0) {\n    if (map.height - 2 * gutter - bottom >= MIN_FRAME) padding.bottom = bottom + gutter;\n    else {\n      padding.top = TIGHT;\n      padding.bottom = Math.min(bottom + TIGHT, map.height - TIGHT - MIN_STRIP);\n    }\n  }\n  if (left > 0) {\n    if (map.width - 2 * gutter - left >= MIN_FRAME) padding.left = left + gutter;\n    else {\n      padding.right = TIGHT;\n      padding.left = Math.min(left + TIGHT, map.width - TIGHT - MIN_STRIP);\n    }\n  }\n  return padding;\n}\n\n/** The pixel offset that centres a point in the padded area: easeTo's `offset`, which, unlike its `padding`, does not stick. */\nexport function paddingOffset(padding: ExplorerPadding): [number, number] {\n  return [(padding.left - padding.right) / 2, (padding.top - padding.bottom) / 2];\n}\n\n/**\n * What to tell the reader about a failed route: the place, the key or the rate when the gateway\n * says so; a service that did not answer for a 5xx or a request that never got an HTTP answer.\n */\nexport function requestError(caught: unknown, lang: ExplorerLang): string {\n  const copy = EXPLORER_COPY[lang];\n  const failure = typeof caught === \"object\" && caught !== null ? (caught as { code?: unknown; status?: unknown }) : {};\n  const status = typeof failure.status === \"number\" ? failure.status : null;\n  if (failure.code === \"outside_coverage\") return copy.outside;\n  if (failure.code === \"no_route\") return copy.noRoute;\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\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\nfunction numberFormat(lang: ExplorerLang, 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: the profile's own units. */\nexport function formatDistance(metres: number, lang: ExplorerLang): string {\n  return metres < 1000 ? `${numberFormat(lang, 0).format(metres)} m` : `${numberFormat(lang, 1).format(metres / 1000)} km`;\n}\n\n/** Whole metres: a 30 m elevation model has no decimals worth printing. */\nexport function formatElevation(metres: number, lang: ExplorerLang): string {\n  return `${numberFormat(lang, 0).format(metres)} m`;\n}\n\n/** \"3 h 3 min\", \"8 min\". Never under a minute. */\nexport function formatDuration(seconds: number): string {\n  const minutes = Math.max(1, Math.round(seconds / 60));\n  if (minutes < 60) return `${minutes} min`;\n  const rest = minutes % 60;\n  return rest ? `${Math.floor(minutes / 60)} h ${rest} min` : `${Math.floor(minutes / 60)} h`;\n}\n\n/** A grade with its sign: \"+4.1%\", \"-10%\" (fr: \"+4,1 %\"). */\nexport function formatGrade(grade: number, lang: ExplorerLang, digits = 1): string {\n  return new Intl.NumberFormat(lang === \"fr\" ? \"fr-CA\" : \"en-CA\", {\n    style: \"percent\",\n    minimumFractionDigits: digits,\n    maximumFractionDigits: digits,\n    signDisplay: \"exceptZero\",\n  }).format(grade / 100);\n}\n\n/** One sentence for a screen reader when a profile arrives: what, how long, how much climb, how steep. */\nexport function describeProfile(name: string, points: ExplorerPoint[], lang: ExplorerLang): string {\n  const summary = profileSummary(points);\n  if (!summary) return \"\";\n  const steepest = steepestClimb(points);\n  return EXPLORER_COPY[lang].announce(\n    name,\n    formatDistance(summary.distance, lang),\n    formatElevation(summary.gain, lang),\n    steepest ? formatGrade(steepest.grade, lang) : \"\",\n  );\n}\n// --- end elevation-explorer helpers ---\n\nexport interface ElevationExplorerProps {\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  /** Where the map opens, [longitude, latitude]. Defaults to Banff. */\n  center?: [number, number];\n  /** Opening zoom. Defaults to 8. */\n  zoom?: number;\n  /** Language of the copy, the chart and the tools. Defaults to \"en\". */\n  lang?: ExplorerLang;\n  className?: string;\n}\n\nexport function ElevationExplorer({ apiKey, gateway, center = BANFF, zoom = 8, lang = \"en\", className }: ElevationExplorerProps) {\n  if (!apiKey.trim()) {\n    const copy = EXPLORER_COPY[lang];\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=\"elevation-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=\"elevation-explorer\" className={[\"relative h-full min-h-[480px]\", className].filter(Boolean).join(\" \")}>\n      <Map id={EXPLORER_MAP_ID} apiKey={apiKey} gateway={gateway} center={center} zoom={zoom} lang={lang} className={`h-full ${GRADE_TOKENS}`}>\n        <Explorer apiKey={apiKey} gateway={gateway} lang={lang} />\n      </Map>\n    </div>\n  );\n}\n\n/** A route asked for: loading, answered, or why it failed. */\ntype Asked = { status: \"loading\" } | { status: \"ready\"; trip: ExplorerTrip } | { status: \"error\"; message: string };\ntype Line = [number, number][];\n/** The two sources that route: a trip, and your own. */\ntype Routed = \"preset\" | \"route\";\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: ExplorerLang }) {\n  const copy = EXPLORER_COPY[lang];\n  const map = useMap(EXPLORER_MAP_ID);\n  const generation = useMapGeneration(EXPLORER_MAP_ID);\n  const router = useMemo(() => new Router({ key: apiKey, gateway }), [apiKey, gateway]);\n  const [source, setSource] = useState<ExplorerSource>(\"preset\");\n  // Each source keeps its own line, so switching back shows it again without asking again.\n  const [presetId, setPresetId] = useState<string | null>(null);\n  const [from, setFrom] = useState<ExplorerPlace | null>(null);\n  const [to, setTo] = useState<ExplorerPlace | null>(null);\n  const [drawn, setDrawn] = useState<DrawValue | null>(null);\n  // Every route asked for, by key, remembered for the life of the block.\n  const [asked, setAsked] = useState<Record<string, Asked>>({});\n  // Every profile fetched, by its samples, so a line shown again is not billed again. A new key or\n  // gateway gets a new cache in the same render, before any child effect can read the old one.\n  const cache = useMemo(() => {\n    void router; // the dependency is the point: a new client means a new cache\n    return new globalThis.Map<string, ProfilePoint[]>();\n  }, [router]);\n  // The profile's samples, with the line they were taken on.\n  const [profile, setProfile] = useState<{ line: Line; points: ExplorerPoint[] } | null>(null);\n  const [hovered, setHovered] = useState<ExplorerHover | null>(null);\n  const [said, setSaid] = useState(\"\");\n  // A routed line to frame once it is on screen: after the render that shows it, so the panels have their final size.\n  const [framing, setFraming] = useState<{ line: Line } | null>(null);\n  // On a phone the sheet can fold to the profile's headline, to give the map back.\n  const [folded, setFolded] = useState(false);\n  const flights = useRef<Record<Routed, { key: string; controller: AbortController } | null>>({ preset: null, route: null });\n  const panelsRef = useRef<HTMLDivElement | null>(null);\n  const controlsRef = useRef<HTMLDivElement | null>(null);\n  const profileRef = useRef<HTMLDivElement | null>(null);\n  const controlsId = useId();\n  const bodyId = useId();\n  const roadIds = useId();\n\n  const chosen = EXPLORER_PRESETS.find((item) => item.id === presetId) ?? null;\n  const presetKey = chosen ? `preset:${chosen.id}` : null;\n  const routeKey = from && to ? `route:${placeKey(from)}>${placeKey(to)}` : null;\n  const key = source === \"preset\" ? presetKey : source === \"route\" ? routeKey : null;\n  const current = key ? asked[key] : undefined;\n  const drawnLine = useMemo(() => lastLine(drawn), [drawn]);\n  const line: Line | null = source === \"draw\" ? drawnLine : current?.status === \"ready\" ? current.trip.coordinates : null;\n  const ends = source === \"preset\" && chosen ? { from: chosen.from, to: chosen.to } : source === \"route\" ? { from, to } : { from: null, to: null };\n  const name =\n    source === \"draw\"\n      ? line\n        ? copy.drawn\n        : null\n      : ends.from && ends.to && line\n        ? copy.between(ends.from.name, ends.to.name)\n        : null;\n  const points = profile && profile.line === line ? profile.points : null;\n  const isFolded = folded && line !== null;\n\n  // What the reader is looking at now, read when an answer lands after they moved on.\n  const now = useRef({ key, line, name });\n  now.current = { key, line, name };\n\n  // The line as drawn on the map: with the profile's samples merged in once they arrive, and coloured by grade.\n  const graded = useMemo(() => (line && points ? gradedLine(line, points) : null), [line, points]);\n  const gradient = useMemo(() => (graded ? { values: graded.values, domain: GRADE_DOMAIN } : undefined), [graded]);\n  const steepest = useMemo(() => (points ? steepestClimb(points) : null), [points]);\n  const summary = useMemo(() => (points ? profileSummary(points) : null), [points]);\n\n  function padding(gutter = GUTTER): ExplorerPadding {\n    const container = map?.getContainer();\n    const none = { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 };\n    if (!container) return framePadding(none, [], gutter);\n    const rect = (element: HTMLElement | null) => element?.getBoundingClientRect() ?? null;\n    // Wide, the controls sit at the left and the profile along the bottom; on a phone they are one sheet.\n    const panels = isWide() ? [rect(controlsRef.current), rect(profileRef.current)] : [rect(panelsRef.current)];\n    return framePadding(container.getBoundingClientRect(), panels, gutter);\n  }\n  const duration = () => (prefersReducedMotion() ? 0 : CAMERA_MS);\n  function frame(next: Line) {\n    map?.fitBounds(lineBounds(next), { padding: padding(FRAME_GUTTER), maxZoom: ROUTE_MAX_ZOOM, duration: duration() });\n  }\n\n  useEffect(() => {\n    if (framing && framing.line === line) frame(framing.line);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [framing]);\n\n  useEffect(\n    () => () => {\n      flights.current.preset?.controller.abort();\n      flights.current.route?.controller.abort();\n    },\n    [],\n  );\n\n  // A new line starts quiet: its samples, its hover and its sentence belong to the old one.\n  useEffect(() => {\n    setHovered(null);\n    setSaid(\"\");\n    // On a phone, bring the profile into view in the sheet (the sheet scrolls, never the page).\n    const sheet = panelsRef.current;\n    const card = profileRef.current;\n    if (line && sheet && card && !isWide() && sheet.scrollHeight > sheet.clientHeight) sheet.scrollTop = card.offsetTop;\n  }, [line]);\n\n  // Folding or unfolding the sheet changes how much map there is: frame the line again.\n  const wasFolded = useRef(isFolded);\n  useEffect(() => {\n    if (wasFolded.current === isFolded) return;\n    wasFolded.current = isFolded;\n    if (line && source !== \"draw\") frame(line);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [isFolded]);\n\n  // One end chosen in the search boxes: the map eases to it, clear of the panels. This runs after\n  // the search box's own fly to the result, and takes over from it.\n  const fromKey = from ? placeKey(from) : \"\";\n  const toKey = to ? placeKey(to) : \"\";\n  useEffect(() => {\n    if (!map || source !== \"route\") return;\n    if (from && to) {\n      if (routeKey && asked[routeKey]?.status === \"ready\") return;\n      frame([from.lngLat, to.lngLat]);\n      return;\n    }\n    const place = from ?? to;\n    if (!place) return;\n    map.easeTo({ center: place.lngLat, zoom: Math.max(map.getZoom(), 12), offset: paddingOffset(padding()), duration: duration() });\n    // Only a newly chosen end moves the camera.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [map, fromKey, toKey]);\n\n  // A new key or gateway is a new account: forget what the old one was told.\n  const askedWith = useRef(router);\n  useEffect(() => {\n    if (askedWith.current === router) return;\n    askedWith.current = router;\n    flights.current.preset?.controller.abort();\n    flights.current.route?.controller.abort();\n    flights.current = { preset: null, route: null };\n    setAsked({});\n    setPresetId(null);\n  }, [router]);\n\n  // The grade line under the drawing's handles, and the profile's dot on top: now, after every\n  // swap, and whenever the drawing adds its layers while it is out.\n  useEffect(() => {\n    if (!map) return;\n    keepOrder(map);\n    if (source !== \"draw\") return;\n    const hold = () => keepOrder(map);\n    map.on(\"styledata\", hold);\n    return () => {\n      map.off(\"styledata\", hold);\n    };\n  }, [map, generation, source, gradient]);\n\n  /**\n   * The route for one key: remembered, or asked once. A newer request from the same source\n   * overtakes one in flight (its answer would be for ends no longer chosen); the other source's\n   * request lands in the memory, since it is billed either way.\n   */\n  function ask(kind: Routed, routeFor: string, start: ExplorerPlace, end: ExplorerPlace, remembered = asked) {\n    const known = remembered[routeFor];\n    if (known?.status === \"ready\") {\n      setFraming({ line: known.trip.coordinates });\n      return;\n    }\n    const flying = flights.current[kind];\n    if (flying?.key === routeFor) return;\n    if (flying) {\n      flying.controller.abort();\n      setAsked((all) => {\n        const rest = { ...all };\n        delete rest[flying.key];\n        return rest;\n      });\n    }\n    const controller = new AbortController();\n    flights.current[kind] = { key: routeFor, controller };\n    const mine = () => flights.current[kind]?.controller === controller;\n    setAsked((all) => ({ ...all, [routeFor]: { status: \"loading\" } }));\n    const mode = kind === \"preset\" ? PRESET_MODE : ROUTE_MODE;\n    router.route(start.lngLat, end.lngLat, { mode, signal: controller.signal }).then(\n      (result) => {\n        if (!mine()) return;\n        flights.current[kind] = null;\n        const trip = { coordinates: result.geometry.coordinates, distanceMeters: result.distanceMeters, durationSeconds: result.durationSeconds };\n        setAsked((all) => ({ ...all, [routeFor]: { status: \"ready\", trip } }));\n        // Framed only if the reader is still looking at it; otherwise it waits in the memory.\n        if (now.current.key === routeFor) setFraming({ line: trip.coordinates });\n      },\n      (caught: unknown) => {\n        if (!mine()) return;\n        flights.current[kind] = null;\n        setAsked((all) => {\n          const rest = { ...all };\n          if (isAbort(caught)) delete rest[routeFor];\n          else rest[routeFor] = { status: \"error\", message: requestError(caught, lang) };\n          return rest;\n        });\n      },\n    );\n  }\n  function pickPreset(item: ExplorerPreset) {\n    setPresetId(item.id);\n    setFolded(false);\n    ask(\"preset\", `preset:${item.id}`, item.from, item.to);\n  }\n\n  function retry() {\n    if (source === \"preset\" && chosen && presetKey) {\n      const rest = { ...asked };\n      delete rest[presetKey];\n      setAsked(rest);\n      ask(\"preset\", presetKey, chosen.from, chosen.to, rest);\n    }\n    if (source === \"route\" && from && to && routeKey) {\n      const rest = { ...asked };\n      delete rest[routeKey];\n      setAsked(rest);\n      ask(\"route\", routeKey, from, to, rest);\n    }\n  }\n\n  function choose(end: \"from\" | \"to\", result: { name: string; lng: number; lat: number }) {\n    const place: ExplorerPlace = { name: result.name, lngLat: [result.lng, result.lat] };\n    const nextFrom = end === \"from\" ? place : from;\n    const nextTo = end === \"to\" ? place : to;\n    if (end === \"from\") setFrom(place);\n    else setTo(place);\n    setFolded(false);\n    if (nextFrom && nextTo) ask(\"route\", `route:${placeKey(nextFrom)}>${placeKey(nextTo)}`, nextFrom, nextTo);\n  }\n\n  function switchSource(next: unknown) {\n    // Pressing the pressed item empties a single ToggleGroup; the source stays.\n    if (!isSource(next) || next === source) return;\n    setSource(next);\n    // A routed line already known is framed again when its source comes back.\n    const knownKey = next === \"preset\" ? presetKey : next === \"route\" ? routeKey : null;\n    const known = knownKey ? asked[knownKey] : undefined;\n    if (known?.status === \"ready\") setFraming({ line: known.trip.coordinates });\n  }\n\n  const onProfile = useCallback((next: { points: ExplorerPoint[] } | null) => {\n    const shown = now.current;\n    if (!next || !shown.line) {\n      setProfile(null);\n      return;\n    }\n    // An answer for a line no longer on screen is not this line's profile.\n    if (!profileBelongs(next.points, shown.line)) return;\n    setProfile({ line: shown.line, points: next.points });\n    setSaid(shown.name ? describeProfile(shown.name, next.points, lang) : \"\");\n  }, [lang]);\n  const onHover = useCallback((point: ExplorerHover | null) => setHovered(point), []);\n  const onDraw = useCallback((value: DrawValue) => setDrawn(value), []);\n\n  const hoverGrade = hovered && points ? gradeAt(points, hovered.index) : null;\n  const hoverHeight = hovered ? (hovered.elevation === null ? copy.noHeight : formatElevation(hovered.elevation, lang)) : \"\";\n  const readout = !line || !points\n    ? \"\"\n    : hovered\n      ? copy.at(formatDistance(hovered.distance, lang), hoverHeight, hoverGrade === null ? \"\" : formatGrade(hoverGrade, lang))\n      : steepest\n        ? copy.steepest(formatGrade(steepest.grade, lang), formatDistance(steepest.distance, lang))\n        : copy.noClimb;\n  const sides = labelSides(ends.from, ends.to);\n  // The folded headline: the climb, set large, its unit and the length quieter.\n  const climbText = summary ? formatElevation(summary.gain, lang) : \"\";\n  const climb = summary\n    ? { amount: climbText.slice(0, climbText.lastIndexOf(\" \")), unit: climbText.slice(climbText.lastIndexOf(\" \") + 1), distance: summary.distance }\n    : null;\n  const routed = source !== \"draw\" && line !== null;\n\n  return (\n    <>\n      {source === \"draw\" ? (\n        <Draw\n          mapId={EXPLORER_MAP_ID}\n          id={EXPLORER_LAYERS.draw}\n          modes={LINE_ONLY}\n          value={drawn ?? undefined}\n          onValueChange={onDraw}\n          lang={lang}\n          position=\"top-right\"\n          // Beside the map's zoom buttons, which hold the top-right corner.\n          className=\"right-12\"\n        />\n      ) : null}\n      {line ? (\n        <RouteLine\n          mapId={EXPLORER_MAP_ID}\n          id={EXPLORER_LAYERS.line}\n          coordinates={graded?.coordinates ?? line}\n          gradient={gradient}\n          // A routed line sits below the labels; a drawn one over the drawing's own line, so its grades show, and under its handles.\n          beforeId={source === \"draw\" ? `${EXPLORER_LAYERS.draw}-point` : undefined}\n        />\n      ) : null}\n      {/* The start: a ring in --foreground. The end: your pin in --primary. Each is named. */}\n      {source !== \"draw\" && ends.from ? (\n        <Marker\n          key={`from:${placeKey(ends.from)}`}\n          mapId={EXPLORER_MAP_ID}\n          lngLat={ends.from.lngLat}\n          markerContent={<span className=\"block size-4 rounded-full border-[3px] border-foreground bg-background\" />}\n        >\n          <MarkerLabel position={sides.from}>{ends.from.name}</MarkerLabel>\n        </Marker>\n      ) : null}\n      {source !== \"draw\" && ends.to ? (\n        <Marker key={`to:${placeKey(ends.to)}`} mapId={EXPLORER_MAP_ID} lngLat={ends.to.lngLat}>\n          <MarkerLabel position={sides.to}>{ends.to.name}</MarkerLabel>\n        </Marker>\n      ) : null}\n      {/* The map's answer to a hover: the height and grade where the chart is, above the profile's own dot. */}\n      {hovered && points ? (\n        <Marker mapId={EXPLORER_MAP_ID} lngLat={[hovered.lng, hovered.lat]} markerContent={<span className=\"block size-4\" />}>\n          <MarkerLabel position=\"top\" className=\"tabular-nums\">\n            {hoverGrade === null ? hoverHeight : `${hoverHeight}, ${formatGrade(hoverGrade, lang)}`}\n          </MarkerLabel>\n        </Marker>\n      ) : null}\n      {/* While the line on screen is being routed, the wait is said; then the profile's sentence. */}\n      <p data-unmap-explorer-announce=\"\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {current?.status === \"loading\" ? copy.loading : said}\n      </p>\n      {/*\n        Wide: the controls float at the top left and the profile runs along the bottom, the chart\n        as the map's second half. Below `sm`: one sheet across the bottom, at most half the map,\n        clear of the attribution, that scrolls and folds to the profile's headline.\n      */}\n      <div\n        ref={panelsRef}\n        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      >\n        <Card\n          ref={controlsRef}\n          id={controlsId}\n          data-folded={isFolded}\n          className=\"shrink-0 max-sm:data-[folded=true]:hidden sm:pointer-events-auto sm:min-h-0 sm:w-[22rem] sm:shrink\"\n        >\n          <CardHeader>\n            <CardTitle role=\"heading\" aria-level={2}>\n              {copy.title}\n            </CardTitle>\n            {/* On a phone, or a short window, the controls say it with their labels alone. */}\n            <CardDescription className=\"max-sm:hidden [@media(max-height:45rem)]:hidden\">{copy.intro}</CardDescription>\n          </CardHeader>\n          <CardContent className=\"flex min-h-0 flex-col gap-4 sm:overflow-y-auto sm:overscroll-contain\">\n            <ToggleGroup\n              variant=\"outline\"\n              aria-label={copy.source}\n              value={[source]}\n              onValueChange={(value: unknown[]) => switchSource(value[0])}\n              className=\"grid w-full shrink-0 grid-cols-3\"\n            >\n              {EXPLORER_SOURCES.map((item) => (\n                <ToggleGroupItem key={item} value={item} className=\"aria-pressed:font-semibold\">\n                  {copy.sources[item]}\n                </ToggleGroupItem>\n              ))}\n            </ToggleGroup>\n            {source === \"preset\" ? (\n              <div className=\"flex flex-col gap-2\">\n                {EXPLORER_PRESETS.map((item) => {\n                  const pressed = item.id === presetId;\n                  const roadId = `${roadIds}-${item.id}`;\n                  return (\n                    <Button\n                      key={item.id}\n                      type=\"button\"\n                      variant={pressed ? \"secondary\" : \"outline\"}\n                      aria-pressed={pressed}\n                      aria-describedby={roadId}\n                      onClick={() => pickPreset(item)}\n                      className=\"h-auto w-full flex-col items-start gap-0.5 py-2 text-left aria-pressed:font-semibold\"\n                    >\n                      <span className=\"flex items-center gap-1.5\">\n                        {/* Picked is marked by a check as well as the fill. */}\n                        {pressed ? (\n                          <span aria-hidden=\"true\" data-pressed-mark=\"\">\n                            ✓\n                          </span>\n                        ) : null}\n                        {copy.between(item.from.name, item.to.name)}\n                      </span>\n                      <span id={roadId} aria-hidden=\"true\" className=\"text-xs font-normal text-muted-foreground\">\n                        {copy.roads[item.id]}\n                      </span>\n                    </Button>\n                  );\n                })}\n                <p className=\"text-xs text-muted-foreground\">\n                  {copy.namesCredit}{\" \"}\n                  <a className=\"underline underline-offset-2\" href={EXPLORER_CREDITS.names[lang]} target=\"_blank\" rel=\"noreferrer\">\n                    {copy.namesSource}\n                  </a>\n                  ,{\" \"}\n                  <a className=\"underline underline-offset-2\" href={EXPLORER_CREDITS.licence[lang]} target=\"_blank\" rel=\"noreferrer\">\n                    {copy.licence}\n                  </a>\n                  .\n                </p>\n              </div>\n            ) : null}\n            {source === \"route\" ? (\n              <div className=\"flex flex-col gap-2\">\n                <div className=\"relative flex flex-col gap-2\">\n                  {/* The rail from the start's ring to the end's dot, the shapes the map pins use. */}\n                  <span aria-hidden=\"true\" className=\"absolute top-1/4 bottom-1/4 left-1.5 -translate-x-1/2 border-l border-dashed border-border\" />\n                  <div className=\"flex items-center gap-3\">\n                    <span aria-hidden=\"true\" className=\"relative size-3 shrink-0 rounded-full border-2 border-foreground bg-background\" />\n                    {/* `[&>p]:hidden` hides the <p> coverage line the geocoder renders after its combobox; the line below both boxes says it once. */}\n                    <Geocoder\n                      mapId={EXPLORER_MAP_ID}\n                      apiKey={apiKey}\n                      gateway={gateway}\n                      lang={lang}\n                      placeholder={copy.from}\n                      proximity=\"map\"\n                      flyToZoom={12}\n                      onSelect={(result) => choose(\"from\", result)}\n                      className=\"[&>p]:hidden\"\n                    />\n                  </div>\n                  <div className=\"flex items-center gap-3\">\n                    <span aria-hidden=\"true\" className=\"relative size-3 shrink-0 rounded-full bg-primary\" />\n                    <Geocoder\n                      mapId={EXPLORER_MAP_ID}\n                      apiKey={apiKey}\n                      gateway={gateway}\n                      lang={lang}\n                      placeholder={copy.to}\n                      proximity=\"map\"\n                      flyToZoom={12}\n                      onSelect={(result) => choose(\"to\", result)}\n                      className=\"[&>p]:hidden\"\n                    />\n                  </div>\n                </div>\n                <p className=\"pl-6 text-xs text-muted-foreground\">{copy.byBike}</p>\n              </div>\n            ) : null}\n            {/* Where the route lands, always there and tall enough for an error and its retry, so nothing jumps. */}\n            <div data-unmap-explorer-answer=\"\" className=\"flex min-h-16 shrink-0 flex-col justify-center gap-1 text-sm\">\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={retry}>\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              ) : current?.status === \"ready\" ? (\n                <p className=\"text-muted-foreground tabular-nums motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200\">\n                  {`${formatDistance(current.trip.distanceMeters, lang)} ${copy.by[source === \"route\" ? ROUTE_MODE : PRESET_MODE]}, ${formatDuration(current.trip.durationSeconds)}`}\n                </p>\n              ) : source === \"draw\" ? (\n                <p className=\"text-muted-foreground tabular-nums\">{line ? `${copy.drawn}, ${formatDistance(lineLength(line), lang)}` : copy.drawHint}</p>\n              ) : (\n                <p className=\"text-muted-foreground\">{source === \"route\" ? copy.needEnds : copy.pickTrip}</p>\n              )}\n            </div>\n          </CardContent>\n        </Card>\n        <Card ref={profileRef} className=\"shrink-0 sm:pointer-events-auto sm:w-full sm:max-w-5xl\">\n          <CardHeader>\n            <div className=\"flex items-center justify-between gap-2\">\n              <CardTitle role=\"heading\" aria-level={2} className=\"min-w-0 truncate\">\n                {name ?? copy.profile}\n              </CardTitle>\n              {line ? (\n                <Button\n                  type=\"button\"\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  className=\"shrink-0 sm:hidden\"\n                  aria-expanded={!isFolded}\n                  aria-controls={`${controlsId} ${bodyId}`}\n                  onClick={() => setFolded((was) => !was)}\n                >\n                  {isFolded ? copy.unfold : copy.fold}\n                </Button>\n              ) : null}\n            </div>\n            {isFolded && climb ? (\n              <p data-unmap-explorer-folded=\"\" className=\"flex items-baseline gap-1 tabular-nums sm:hidden\">\n                <span className=\"text-2xl leading-none font-semibold\">{climb.amount}</span>{\" \"}\n                <span className=\"text-sm text-muted-foreground\">{`${copy.climb(climb.unit)}, ${formatDistance(climb.distance, lang)}`}</span>\n              </p>\n            ) : null}\n          </CardHeader>\n          <CardContent id={bodyId} data-folded={isFolded} className=\"flex flex-col gap-3 max-sm:data-[folded=true]:hidden\">\n            {/*\n              The chart's place, held at the result's height in every state so the camera frames\n              once and nothing jumps; dashed while it waits for a line. The block speaks for the\n              profile and credits it with links, so the profile's own live region and plain\n              source line step aside.\n            */}\n            <div\n              data-empty={line === null}\n              className=\"flex min-h-64 flex-col justify-center sm:min-h-[13.5rem] data-[empty=true]:rounded-md data-[empty=true]:border data-[empty=true]:border-dashed data-[empty=true]:p-4 data-[empty=true]:text-center\"\n            >\n              <ElevationProfile\n                apiKey={apiKey}\n                gateway={gateway}\n                mapId={EXPLORER_MAP_ID}\n                id={EXPLORER_LAYERS.profile}\n                line={line}\n                lang={lang}\n                cache={cache}\n                onProfile={onProfile}\n                onHover={onHover}\n                className=\"[&_[data-unmap-elevation-announce]]:hidden [&_[data-unmap-elevation-source]]:hidden\"\n              />\n            </div>\n            {/* The grade: its ramp, the same one the line is drawn in, and what the pointer or the steepest stretch says. Reserved, so nothing jumps. */}\n            <div className=\"flex min-h-10 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-6\">\n              {gradient ? (\n                <div data-unmap-grade-legend=\"\" className=\"flex min-w-0 flex-col gap-1 sm:w-64\">\n                  <div aria-hidden=\"true\" className=\"relative h-2 rounded-sm\" style={{ background: GRADE_RAMP }}>\n                    <span className=\"absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-background\" />\n                  </div>\n                  <div className=\"flex justify-between text-xs text-muted-foreground tabular-nums\">\n                    <span>{`${formatGrade(GRADE_DOMAIN[0], lang, 0)} ${copy.down}`}</span>\n                    <span className=\"font-medium text-foreground\">{copy.grade}</span>\n                    <span>{`${formatGrade(GRADE_DOMAIN[1], lang, 0)} ${copy.up}`}</span>\n                  </div>\n                </div>\n              ) : null}\n              <div className=\"flex min-w-0 flex-col gap-0.5 sm:items-end\">\n                <p data-unmap-explorer-readout=\"\" className=\"text-sm font-medium tabular-nums\">\n                  {readout}\n                </p>\n                {points ? <p className=\"text-xs text-muted-foreground max-sm:hidden\">{copy.hoverHint}</p> : null}\n              </div>\n            </div>\n            <p className=\"text-xs text-muted-foreground\">\n              {copy.elevationCredit}{\" \"}\n              <a className=\"underline underline-offset-2\" href={EXPLORER_CREDITS.elevation[lang]} target=\"_blank\" rel=\"noreferrer\">\n                {copy.elevationSource}\n              </a>\n              .\n              {routed ? (\n                <>\n                  {\" \"}\n                  {copy.routesCredit}{\" \"}\n                  <a className=\"underline underline-offset-2\" href={EXPLORER_CREDITS.routes.source} target=\"_blank\" rel=\"noreferrer\">\n                    {copy.routesSource}\n                  </a>\n                  ,{\" \"}\n                  <a className=\"underline underline-offset-2\" href={EXPLORER_CREDITS.routes.licence} target=\"_blank\" rel=\"noreferrer\">\n                    {copy.routesLicence}\n                  </a>\n                  .\n                </>\n              ) : null}\n            </p>\n          </CardContent>\n        </Card>\n      </div>\n    </>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "envVars": {
    "NEXT_PUBLIC_UNMAP_KEY": ""
  },
  "docs": "Paste your unmap key into NEXT_PUBLIC_UNMAP_KEY in .env.local, then open /elevation-explorer. Guide: https://unmap.dev/docs/frontend/blocks#see-the-climb",
  "categories": [
    "blocks"
  ],
  "type": "registry:block"
}