{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "directions",
  "title": "Directions",
  "description": "A page that gets directions: choose a start and a destination and the route draws in with its travel time as the headline. A drive, cycle or walk switch redraws the route for that mode and keeps each mode's time on it, and a reachable-area toggle shades how far you can go from the start. Calls /route, /isochrone and the geocoder.",
  "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/geojson-layer.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/directions/page.tsx",
      "content": "/**\n * /directions: the directions block as a Next.js App Router page, full-bleed (the block keeps its\n * own gutter). The key comes from NEXT_PUBLIC_UNMAP_KEY in .env.local; installing the block added\n * the empty line, paste your key.\n */\nimport { Directions } from \"@/components/ui/directions\";\n\nexport const metadata = { title: \"Get directions\" };\n\nexport default function DirectionsPage() {\n  return (\n    <main className=\"h-dvh\">\n      <Directions apiKey={process.env.NEXT_PUBLIC_UNMAP_KEY ?? \"\"} className=\"h-full\" />\n    </main>\n  );\n}\n",
      "type": "registry:page",
      "target": "app/directions/page.tsx"
    },
    {
      "path": "registry/blocks/directions/directions.tsx",
      "content": "\"use client\";\n\n/**\n * <Directions> - a directions page in one component. A full-bleed map with a floating panel (a\n * bottom sheet on a phone): choose a start and a destination and the route draws in, with its\n * travel time as the headline. The travel-mode switch is the point: drive, cycle or walk, and the\n * route redraws for that mode while each mode keeps its time on the switch, so they compare at a\n * glance. \"Reachable area\" shades how far you can go from the start in the same mode.\n *\n * Composes the registry's map, geocoder, route-line, geojson-layer and marker-popup items on your\n * shadcn Card, ToggleGroup, Button, Alert and Spinner, in your tokens. Each answer is one request\n * to the unmap API, asked once and remembered: the search boxes call /geocode/autocomplete, a\n * route calls /route once per mode, the reachable area calls /isochrone once per start and mode.\n * The routing API returns one route per request, so there are no alternative routes to show.\n *\n * Installed by `npx shadcn add` with its page at app/directions/page.tsx. Edit freely.\n */\n\nimport { Fragment, 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 { Geocoder } from \"@/components/ui/geocoder\";\nimport { GeoJSONLayer } from \"@/components/ui/geojson-layer\";\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 } from \"@/lib/map-context\";\n\n// --- directions helpers (kept identical across both lanes) ---\nexport type DirectionsLang = \"en\" | \"fr\";\nexport type DirectionsMode = \"auto\" | \"bicycle\" | \"pedestrian\";\n\n/** A place chosen in one of the search boxes. */\nexport interface DirectionsStop {\n  name: string;\n  lng: number;\n  lat: number;\n}\n\n/** A route for one travel mode, as /route answers it. */\nexport interface DirectionsTrip {\n  coordinates: [number, number][];\n  distanceMeters: number;\n  durationSeconds: number;\n}\n\n/** What /isochrone returns: nested polygons, one per band, each tagged with its `contour` in minutes. */\nexport interface TravelArea {\n  type: \"FeatureCollection\";\n  features: Array<{ properties?: { contour?: number } | null; geometry: { type: string; coordinates: unknown } | null }>;\n}\n\n/** A box on screen, as getBoundingClientRect() gives it. */\nexport interface DirectionsRect {\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 DirectionsPadding {\n  top: number;\n  right: number;\n  bottom: number;\n  left: number;\n}\n\nexport const DIRECTIONS_MODES: DirectionsMode[] = [\"auto\", \"bicycle\", \"pedestrian\"];\n\n/** The reachable-area bands per mode, inside the API's limit of 4 bands of at most 120 minutes. */\nexport const AREA_MINUTES: Record<DirectionsMode, number[]> = {\n  auto: [10, 20, 30],\n  bicycle: [10, 20, 30],\n  pedestrian: [5, 10, 15],\n};\n\n/** Downtown Calgary. */\nexport const CALGARY: [number, number] = [-114.0719, 51.0447];\n\n/** The block's own map id, so a map elsewhere on the page never answers for it. */\nexport const DIRECTIONS_MAP_ID = \"directions\";\n/** Source ids (and layer-id prefixes) of what the block draws. */\nexport const DIRECTIONS_LAYERS = { route: \"directions-route\", area: \"directions-area\" };\n\n/**\n * The bands are nested, so one light wash per band stacks: the nearest band, under all of them, is\n * the strongest. A dashed edge marks where each band ends, so the bands differ by more than shade.\n */\nexport const AREA_FILL = { \"fill-opacity\": 0.08 };\nexport const AREA_LINE = { \"line-width\": 1, \"line-opacity\": 0.6, \"line-dasharray\": [2, 2] };\n/** Legend swatches, nearest band first, matching the stacked washes (about 22, 15 and 8 %). */\nexport const AREA_SWATCHES = [\"bg-primary/20\", \"bg-primary/15\", \"bg-primary/8\"];\n\n/** Who the routes and travel times come from, and the licence they carry. */\nexport const ROUTING_CREDIT = {\n  sourceUrl: \"https://www.openstreetmap.org/copyright\",\n  licenceUrl: \"https://opendatacommons.org/licenses/odbl/\",\n};\n\n/** The panel's inset from the map edge, and the gutter the camera keeps. */\nexport const GUTTER = 24;\n/** The gutter a framed route or pair of ends keeps: room for the names beside the pins. */\nexport const FRAME_GUTTER = 48;\n/** Camera moves and the route draw-in, in milliseconds. Reduced motion jumps. */\nexport const CAMERA_MS = 800;\nexport const DRAW_MS = 900;\n/** The closest a route or a pair of ends is framed. */\nexport const ROUTE_MAX_ZOOM = 15;\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 DIRECTIONS_COPY = {\n  en: {\n    title: \"Get directions\",\n    intro: \"Choose a start and a destination, then switch how you travel.\",\n    from: \"Choose a start\",\n    to: \"Choose a destination\",\n    coverage: \"Routing covers Canada.\",\n    mode: \"Travel mode\",\n    modes: { auto: \"Drive\", bicycle: \"Cycle\", pedestrian: \"Walk\" },\n    by: { auto: \"by car\", bicycle: \"by bike\", pedestrian: \"on foot\" },\n    needStops: \"Choose a start and a destination to see the trip.\",\n    loadingRoute: \"Finding the route...\",\n    announceTrip: (mode: string, duration: string, distance: string) => `${mode}: ${duration}, ${distance}.`,\n    area: \"Reachable area\",\n    needStart: \"Choose a start to see how far you can go from it.\",\n    legend: \"Travel time\",\n    areaCaption: (by: string, minutes: string, place: string) => `Reachable ${by} within ${minutes} of ${place}.`,\n    loadingArea: \"Drawing the reachable area...\",\n    fold: \"Hide details\",\n    unfold: \"Show details\",\n    retry: \"Try again\",\n    credit: \"Routes and travel times:\",\n    source: \"OpenStreetMap contributors\",\n    licence: \"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 place is outside Canada. Routing covers Canada.\",\n    noRoute: \"No road or path connects those places.\",\n    areaNoRoute: \"No road or path starts near there. Choose a start closer to one.\",\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: \"Obtenir un itinéraire\",\n    intro: \"Choisissez un départ et une destination, puis changez de mode de déplacement.\",\n    from: \"Choisir un départ\",\n    to: \"Choisir une destination\",\n    coverage: \"Les itinéraires couvrent le Canada.\",\n    mode: \"Mode de déplacement\",\n    modes: { auto: \"Voiture\", bicycle: \"Vélo\", pedestrian: \"À pied\" },\n    by: { auto: \"en voiture\", bicycle: \"à vélo\", pedestrian: \"à pied\" },\n    needStops: \"Choisissez un départ et une destination pour voir le trajet.\",\n    loadingRoute: \"Calcul de l'itinéraire...\",\n    announceTrip: (mode: string, duration: string, distance: string) => `${mode} : ${duration}, ${distance}.`,\n    area: \"Zone accessible\",\n    needStart: \"Choisissez un départ pour voir jusqu'où vous pouvez aller.\",\n    legend: \"Temps de trajet\",\n    areaCaption: (by: string, minutes: string, place: string) => `Accessible ${by} en moins de ${minutes} depuis ${place}.`,\n    loadingArea: \"Tracé de la zone accessible...\",\n    fold: \"Masquer les détails\",\n    unfold: \"Afficher les détails\",\n    retry: \"Réessayer\",\n    credit: \"Itinéraires et temps de trajet :\",\n    source: \"contributeurs d'OpenStreetMap\",\n    licence: \"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 lieu est hors du Canada. Les itinéraires couvrent le Canada.\",\n    noRoute: \"Aucune route ni aucun chemin ne relie ces lieux.\",\n    areaNoRoute: \"Aucune route ni aucun chemin ne part de ce point. Choisissez un départ plus près d'une route.\",\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 isMode(value: unknown): value is DirectionsMode {\n  return DIRECTIONS_MODES.includes(value as DirectionsMode);\n}\n\n/** A stop as a primitive, so the same place chosen again is the same stop. */\nexport function stopKey(stop: DirectionsStop): string {\n  return `${stop.lng},${stop.lat}`;\n}\n\n/**\n * Which side of each pin its name sits: toward the other end, so a framed trip keeps both names\n * inside the frame. With one end chosen, its name sits on the right.\n */\nexport function labelSides(from: DirectionsStop | null, to: DirectionsStop | null): { from: \"left\" | \"right\"; to: \"left\" | \"right\" } {\n  if (!from || !to) return { from: \"right\", to: \"right\" };\n  return from.lng <= to.lng ? { from: \"right\", to: \"left\" } : { from: \"left\", to: \"right\" };\n}\n\n/** \"10, 20 and 30 min\" (fr: \"10, 20 et 30 min\"). */\nexport function minutesList(minutes: number[], lang: DirectionsLang): string {\n  const last = minutes[minutes.length - 1];\n  const head = minutes.slice(0, -1).join(\", \");\n  return head ? `${head} ${lang === \"fr\" ? \"et\" : \"and\"} ${last} min` : `${last} min`;\n}\n\n/** A travel time as values and units, so the values can be set larger: 75 min is 1 h, 15 min. Never under a minute. */\nexport function durationParts(seconds: number): Array<{ value: string; unit: string }> {\n  const minutes = Math.max(1, Math.round(seconds / 60));\n  if (minutes < 60) return [{ value: String(minutes), unit: \"min\" }];\n  const hours = Math.floor(minutes / 60);\n  const rest = minutes % 60;\n  return rest ? [{ value: String(hours), unit: \"h\" }, { value: String(rest), unit: \"min\" }] : [{ value: String(hours), unit: \"h\" }];\n}\n\n/** \"13 min\", \"1 h 15 min\". */\nexport function formatDuration(seconds: number): string {\n  return durationParts(seconds)\n    .map((part) => `${part.value} ${part.unit}`)\n    .join(\" \");\n}\n\nexport function formatDistance(metres: number, lang: DirectionsLang): string {\n  if (metres < 1000) return `${Math.round(metres)} m`;\n  const km = new Intl.NumberFormat(lang === \"fr\" ? \"fr-CA\" : \"en-CA\", {\n    minimumFractionDigits: 1,\n    maximumFractionDigits: 1,\n  });\n  return `${km.format(metres / 1000)} km`;\n}\n\n/** [[west, south], [east, north]] around some points, for fitBounds. A loop: spreading a long route into Math.min overflows the stack. */\nexport function pointsBounds(points: [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 points) {\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 reachable area's outer bounds, or null when it holds no polygon. */\nexport function areaBounds(area: TravelArea): [[number, number], [number, number]] | null {\n  const points = area.features.flatMap(({ geometry }) => {\n    if (geometry?.type === \"Polygon\") return (geometry.coordinates as [number, number][][])[0] ?? [];\n    if (geometry?.type === \"MultiPolygon\") return (geometry.coordinates as [number, number][][][]).flatMap((polygon) => polygon[0] ?? []);\n    return [];\n  });\n  return points.length > 0 ? pointsBounds(points) : null;\n}\n\n/**\n * Where the reachable area goes: under the route's lowest layer when the route is on the map, so\n * the wash never covers the line, and below the labels otherwise. A GeoJSONLayer `beforeId`.\n */\nexport function belowRoute(map: { getStyle(): { layers?: Array<{ id: string }> } | undefined }): string | undefined {\n  return map.getStyle()?.layers?.find((layer) => layer.id.startsWith(`${DIRECTIONS_LAYERS.route}-`))?.id;\n}\n\n/**\n * Camera padding that keeps what the camera frames clear of the floating panel: its width on the\n * left when it floats beside the map, its height at the bottom when it is a sheet across it. An\n * even gutter when nothing is measured. When the full gutter would leave too little map (a tall\n * sheet on a small phone), the gutter tightens and the frame stays in the strip the panel leaves,\n * never behind the panel.\n */\nexport function panelPadding(map: DirectionsRect, panel: DirectionsRect | null, gutter = GUTTER): DirectionsPadding {\n  const even = { top: gutter, right: gutter, bottom: gutter, left: gutter };\n  if (!panel || panel.width <= 0 || panel.height <= 0 || map.width <= 0 || map.height <= 0) return even;\n  const sheet = panel.width >= map.width * 0.6;\n  const cover = Math.round(sheet ? map.bottom - panel.top : panel.right - map.left);\n  const size = sheet ? map.height : map.width;\n  if (size - 2 * gutter - cover >= MIN_FRAME) return sheet ? { ...even, bottom: cover + gutter } : { ...even, left: cover + gutter };\n  const near = Math.min(cover + TIGHT, size - TIGHT - MIN_STRIP);\n  return sheet ? { ...even, top: TIGHT, bottom: near } : { ...even, left: near, right: TIGHT };\n}\n\n/** The pixel offset that centres a point in the padded area: easeTo's `offset`, which, unlike its `padding`, does not stick. */\nexport function paddingOffset(padding: DirectionsPadding): [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 request: 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. An\n * area has only a start, so its `no_route` is about the start.\n */\nexport function requestError(caught: unknown, lang: DirectionsLang, kind: \"route\" | \"area\" = \"route\"): string {\n  const copy = DIRECTIONS_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 kind === \"area\" ? copy.areaNoRoute : 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 and the route appears whole. */\nexport function prefersReducedMotion(): boolean {\n  return typeof window !== \"undefined\" && window.matchMedia?.(\"(prefers-reduced-motion: reduce)\")?.matches === true;\n}\n\n/** Fast start, soft landing: the route's draw-in. */\nexport function easeOut(t: number): number {\n  return 1 - (1 - t) ** 3;\n}\n// --- end directions helpers ---\n\nexport interface DirectionsProps {\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 downtown Calgary. */\n  center?: [number, number];\n  /** Opening zoom. Defaults to 11. */\n  zoom?: number;\n  /** Language of the copy, the numbers and the search results. Defaults to \"en\". */\n  lang?: DirectionsLang;\n  className?: string;\n}\n\nexport function Directions({ apiKey, gateway, center = CALGARY, zoom = 11, lang = \"en\", className }: DirectionsProps) {\n  if (!apiKey.trim()) {\n    const copy = DIRECTIONS_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=\"directions\"\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=\"directions\" className={[\"relative h-full min-h-[480px]\", className].filter(Boolean).join(\" \")}>\n      <Map id={DIRECTIONS_MAP_ID} apiKey={apiKey} gateway={gateway} center={center} zoom={zoom} lang={lang} className=\"h-full\">\n        <Planner apiKey={apiKey} gateway={gateway} lang={lang} />\n      </Map>\n    </div>\n  );\n}\n\n/** What was asked for one pair of ends (routes) or one start (areas): answers, requests in flight and failures, by mode. */\ntype Asked<T> = {\n  key: string;\n  byMode: Partial<Record<DirectionsMode, T>>;\n  pending: DirectionsMode[];\n  failed: Partial<Record<DirectionsMode, string>>;\n};\n\nfunction nothingAsked<T>(key = \"\"): Asked<T> {\n  return { key, byMode: {}, pending: [], failed: {} };\n}\n\n/**\n * Requests in flight for one key, by mode. A new key (another end, another key) aborts them all;\n * another mode for the same key does not, since a request already sent is already billed.\n */\ntype Flights = { key: string; byMode: Partial<Record<DirectionsMode, AbortController>> };\n\nfunction abortAll(flights: Flights) {\n  Object.values(flights.byMode).forEach((controller) => controller?.abort());\n  flights.byMode = {};\n}\n\n/** 0 to 1 as a new line draws in; 1 at once under reduced motion. */\nfunction useDrawIn(line: unknown): number {\n  const [drawn, setDrawn] = useState<{ line: unknown; progress: number }>({ line: null, progress: 1 });\n  useEffect(() => {\n    if (!line || prefersReducedMotion()) return;\n    if (typeof requestAnimationFrame !== \"function\") {\n      setDrawn({ line, progress: 1 });\n      return;\n    }\n    const start = performance.now();\n    let frame = 0;\n    const step = () => {\n      const t = Math.min(1, (performance.now() - start) / DRAW_MS);\n      setDrawn({ line, progress: easeOut(t) });\n      if (t < 1) frame = requestAnimationFrame(step);\n    };\n    frame = requestAnimationFrame(step);\n    return () => cancelAnimationFrame(frame);\n  }, [line]);\n  if (prefersReducedMotion()) return 1;\n  return drawn.line === line ? drawn.progress : 0;\n}\n\n/**\n * The route on the map, drawing in. Its own component so the draw-in's frames re-render the line\n * alone, not the panel. A muted line (`active={false}`) is the last mode's, shown whole while the\n * next mode's route loads.\n */\nfunction DrawnRoute({ line, active }: { line: DirectionsTrip; active: boolean }) {\n  const progress = useDrawIn(active ? line : null);\n  return (\n    <RouteLine\n      mapId={DIRECTIONS_MAP_ID}\n      id={DIRECTIONS_LAYERS.route}\n      coordinates={line.coordinates}\n      active={active}\n      progress={active ? progress : undefined}\n    />\n  );\n}\n\n/** Everything that needs the map. It renders inside <Map>, where useMap() can see it. */\nfunction Planner({ apiKey, gateway, lang }: { apiKey: string; gateway?: string; lang: DirectionsLang }) {\n  const copy = DIRECTIONS_COPY[lang];\n  const map = useMap(DIRECTIONS_MAP_ID);\n  const router = useMemo(() => new Router({ key: apiKey, gateway }), [apiKey, gateway]);\n  const [from, setFrom] = useState<DirectionsStop | null>(null);\n  const [to, setTo] = useState<DirectionsStop | null>(null);\n  const [mode, setMode] = useState<DirectionsMode>(\"auto\");\n  const [trips, setTrips] = useState<Asked<DirectionsTrip>>(() => nothingAsked());\n  const [areas, setAreas] = useState<Asked<TravelArea>>(() => nothingAsked());\n  const [areaOn, setAreaOn] = useState(false);\n  // The route last on screen for these ends: it stays, muted, while the next mode's route loads.\n  const [shown, setShown] = useState<{ pair: string; trip: DirectionsTrip } | null>(null);\n  // One polite announcement: the trip's time first, then the area, whichever of them answered last.\n  const [said, setSaid] = useState({ trip: \"\", area: \"\" });\n  // On a phone the sheet can fold to the headline, to give the map back.\n  const [folded, setFolded] = useState(false);\n  const routeFlights = useRef<Flights>({ key: \"\", byMode: {} });\n  const areaFlights = useRef<Flights>({ key: \"\", byMode: {} });\n  const panelRef = useRef<HTMLDivElement | null>(null);\n  const hintId = useId();\n  const endsId = useId();\n  const areaId = useId();\n  const modesId = useId();\n  const creditId = useId();\n\n  const fromKey = from ? stopKey(from) : \"\";\n  const toKey = to ? stopKey(to) : \"\";\n  const pair = from && to ? `${fromKey}>${toKey}` : null;\n  const routeAsked = pair && trips.key === pair ? trips : null;\n  const trip = routeAsked?.byMode[mode] ?? null;\n  const routePending = routeAsked?.pending.includes(mode) ?? false;\n  const routeError = routeAsked?.failed[mode] ?? null;\n  const ghost = !trip && routePending && pair && shown?.pair === pair ? shown.trip : null;\n  const areaAsked = areaOn && from && areas.key === fromKey ? areas : null;\n  const area = areaAsked?.byMode[mode] ?? null;\n  const areaPending = areaAsked?.pending.includes(mode) ?? false;\n  const areaError = areaAsked?.failed[mode] ?? null;\n  const line = trip ?? ghost;\n  const isFolded = folded && line !== null;\n  // What the reader is looking at now, read when an answer lands after they moved on.\n  const now = useRef({ pair, mode, start: fromKey, areaOn, trip });\n  now.current = { pair, mode, start: fromKey, areaOn, trip };\n\n  function padding(gutter = GUTTER): DirectionsPadding {\n    const container = map?.getContainer();\n    if (!container) return panelPadding({ left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }, null, gutter);\n    return panelPadding(container.getBoundingClientRect(), panelRef.current?.getBoundingClientRect() ?? null, gutter);\n  }\n  const duration = () => (prefersReducedMotion() ? 0 : CAMERA_MS);\n  function frameRoute(next: DirectionsTrip) {\n    map?.fitBounds(pointsBounds(next.coordinates), { padding: padding(FRAME_GUTTER), maxZoom: ROUTE_MAX_ZOOM, duration: duration() });\n  }\n\n  useEffect(\n    () => () => {\n      abortAll(routeFlights.current);\n      abortAll(areaFlights.current);\n    },\n    [],\n  );\n\n  // A chosen end pulls the map to it, clear of the panel; two frame the trip while its route loads.\n  // This runs after the search box's own fly to the result, and takes over from it.\n  useEffect(() => {\n    if (!map || (!from && !to)) return;\n    if (from && to) {\n      // A route that already answered has framed itself.\n      if (trips.key === pair && Object.keys(trips.byMode).length > 0) return;\n      map.fitBounds(\n        pointsBounds([\n          [from.lng, from.lat],\n          [to.lng, to.lat],\n        ]),\n        { padding: padding(FRAME_GUTTER), maxZoom: ROUTE_MAX_ZOOM, duration: duration() },\n      );\n      return;\n    }\n    const stop = (from ?? to)!;\n    map.easeTo({ center: [stop.lng, stop.lat], zoom: Math.max(map.getZoom(), 13), 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  // Folding or unfolding the sheet changes how much map there is: frame the route again.\n  const wasFolded = useRef(isFolded);\n  useEffect(() => {\n    if (wasFolded.current === isFolded) return;\n    wasFolded.current = isFolded;\n    if (line) frameRoute(line);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [isFolded]);\n\n  // A new key or gateway is a new account: forget what the old one was told, and ask again.\n  const askedWith = useRef(router);\n  useEffect(() => {\n    if (askedWith.current === router) return;\n    askedWith.current = router;\n    abortAll(routeFlights.current);\n    abortAll(areaFlights.current);\n    routeFlights.current.key = \"\";\n    areaFlights.current.key = \"\";\n    setTrips(nothingAsked());\n    setAreas(nothingAsked());\n    askRoute(from, to, mode, nothingAsked());\n    askArea(from, mode, areaOn, nothingAsked());\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [router]);\n\n  /** Asks once per key and mode; the answer, or why it failed, is remembered under that key. */\n  function ask<T>(\n    flights: Flights,\n    setAsked: (change: (current: Asked<T>) => Asked<T>) => void,\n    key: string,\n    travel: DirectionsMode,\n    kind: \"route\" | \"area\",\n    call: (signal: AbortSignal) => Promise<T>,\n    landed: (value: T) => void,\n  ) {\n    if (flights.key !== key) {\n      abortAll(flights);\n      flights.key = key;\n    }\n    if (flights.byMode[travel]) return;\n    const controller = new AbortController();\n    flights.byMode[travel] = controller;\n    const mine = () => flights.byMode[travel] === controller;\n    const update = (change: (asked: Asked<T>) => Asked<T>) =>\n      setAsked((current) => change(current.key === key ? current : nothingAsked<T>(key)));\n    const settle = (asked: Asked<T>) => asked.pending.filter((item) => item !== travel);\n    update((asked) => {\n      const failed = { ...asked.failed };\n      delete failed[travel];\n      return { ...asked, pending: [...settle(asked), travel], failed };\n    });\n    call(controller.signal).then(\n      (value) => {\n        if (!mine()) return;\n        delete flights.byMode[travel];\n        update((asked) => ({ ...asked, byMode: { ...asked.byMode, [travel]: value }, pending: settle(asked) }));\n        landed(value);\n      },\n      (caught: unknown) => {\n        if (!mine()) return;\n        delete flights.byMode[travel];\n        update((asked) =>\n          isAbort(caught)\n            ? { ...asked, pending: settle(asked) }\n            : { ...asked, pending: settle(asked), failed: { ...asked.failed, [travel]: requestError(caught, lang, kind) } },\n        );\n      },\n    );\n  }\n\n  function showTrip(next: DirectionsTrip, key: string, travel: DirectionsMode) {\n    setShown({ pair: key, trip: next });\n    const sentence = copy.announceTrip(copy.modes[travel], formatDuration(next.durationSeconds), formatDistance(next.distanceMeters, lang));\n    setSaid((current) => ({ ...current, trip: sentence }));\n    frameRoute(next);\n  }\n\n  /** The route between two ends in one mode: remembered, or asked once. */\n  function askRoute(start: DirectionsStop | null, end: DirectionsStop | null, travel: DirectionsMode, remembered = trips) {\n    setSaid((current) => ({ ...current, trip: \"\" }));\n    if (!start || !end) {\n      abortAll(routeFlights.current);\n      routeFlights.current.key = \"\";\n      return;\n    }\n    const key = `${stopKey(start)}>${stopKey(end)}`;\n    const known = remembered.key === key ? remembered.byMode[travel] : undefined;\n    if (known) {\n      showTrip(known, key, travel);\n      return;\n    }\n    ask<DirectionsTrip>(\n      routeFlights.current,\n      setTrips,\n      key,\n      travel,\n      \"route\",\n      (signal) =>\n        router.route([start.lng, start.lat], [end.lng, end.lat], { mode: travel, signal }).then((result) => ({\n          coordinates: result.geometry.coordinates,\n          distanceMeters: result.distanceMeters,\n          durationSeconds: result.durationSeconds,\n        })),\n      (next) => {\n        // An earlier mode's route is kept for its switch, but only the chosen mode's is shown.\n        if (now.current.pair === key && now.current.mode === travel) showTrip(next, key, travel);\n      },\n    );\n  }\n\n  /** How far you can go from the start in one mode: remembered, or asked once. */\n  function askArea(start: DirectionsStop | null, travel: DirectionsMode, on: boolean, remembered = areas) {\n    setSaid((current) => ({ ...current, area: \"\" }));\n    if (!on || !start) return;\n    const key = stopKey(start);\n    const caption = copy.areaCaption(copy.by[travel], minutesList(AREA_MINUTES[travel], lang), start.name);\n    if (remembered.key === key && remembered.byMode[travel]) {\n      setSaid((current) => ({ ...current, area: caption }));\n      return;\n    }\n    ask<TravelArea>(\n      areaFlights.current,\n      setAreas,\n      key,\n      travel,\n      \"area\",\n      (signal) =>\n        router.isochrone([start.lng, start.lat], { minutes: AREA_MINUTES[travel], mode: travel, signal }) as Promise<TravelArea>,\n      (next) => {\n        const current = now.current;\n        if (current.start !== key || current.mode !== travel || !current.areaOn) return;\n        setSaid((said) => ({ ...said, area: caption }));\n        // A route on screen keeps the camera; with none, the camera frames the area.\n        const bounds = areaBounds(next);\n        if (bounds && !current.trip) map?.fitBounds(bounds, { padding: padding(), duration: duration() });\n      },\n    );\n  }\n\n  function chooseFrom(result: { name: string; lng: number; lat: number }) {\n    const next = { name: result.name, lng: result.lng, lat: result.lat };\n    setFrom(next);\n    setFolded(false);\n    askRoute(next, to, mode);\n    askArea(next, mode, areaOn);\n  }\n\n  function chooseTo(result: { name: string; lng: number; lat: number }) {\n    const next = { name: result.name, lng: result.lng, lat: result.lat };\n    setTo(next);\n    setFolded(false);\n    askRoute(from, next, mode);\n  }\n\n  function chooseMode(value: unknown) {\n    // Pressing the pressed item empties a single ToggleGroup; the mode stays.\n    if (!isMode(value) || value === mode) return;\n    setMode(value);\n    askRoute(from, to, value);\n    askArea(from, value, areaOn);\n  }\n\n  function toggleArea() {\n    const next = !areaOn;\n    setAreaOn(next);\n    askArea(from, mode, next);\n  }\n\n  const known = routeAsked?.byMode ?? {};\n  const sides = labelSides(from, to);\n  const announcement = [said.trip, said.area].filter(Boolean).join(\" \");\n\n  return (\n    <>\n      {area ? (\n        <GeoJSONLayer\n          mapId={DIRECTIONS_MAP_ID}\n          id={DIRECTIONS_LAYERS.area}\n          data={area}\n          fillPaint={AREA_FILL}\n          linePaint={AREA_LINE}\n          circlePaint={false}\n          beforeId={belowRoute}\n        />\n      ) : null}\n      {line ? <DrawnRoute line={line} active={trip !== null} /> : null}\n      {/* The start: a ring in --foreground. The destination: your pin in --primary. Each is named. */}\n      {from ? (\n        <Marker\n          key={fromKey}\n          mapId={DIRECTIONS_MAP_ID}\n          lngLat={[from.lng, from.lat]}\n          markerContent={<span className=\"block size-4 rounded-full border-[3px] border-foreground bg-background\" />}\n        >\n          <MarkerLabel position={sides.from}>{from.name}</MarkerLabel>\n        </Marker>\n      ) : null}\n      {to ? (\n        <Marker key={toKey} mapId={DIRECTIONS_MAP_ID} lngLat={[to.lng, to.lat]}>\n          <MarkerLabel position={sides.to}>{to.name}</MarkerLabel>\n        </Marker>\n      ) : null}\n      <p data-testid=\"directions-announce\" aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </p>\n      {/* A card floating on the map at 24 px; below `sm`, a sheet across the bottom (at most half the map) that clears the attribution. */}\n      <div\n        ref={panelRef}\n        className=\"absolute inset-x-4 bottom-10 z-10 flex max-h-[50%] flex-col sm:inset-x-auto sm:top-6 sm:bottom-auto sm:left-6 sm:max-h-[calc(100%-3rem)] sm:w-[22rem] motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-4 motion-safe:duration-300 sm:motion-safe:slide-in-from-bottom-0 sm:motion-safe:slide-in-from-left-4\"\n      >\n        <Card className=\"flex min-h-0 flex-1 flex-col\">\n          <CardHeader>\n            <div className=\"flex items-center justify-between gap-2\">\n              <CardTitle role=\"heading\" aria-level={2} className=\"min-w-0\">\n                {copy.title}\n              </CardTitle>\n              {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={`${endsId} ${modesId} ${areaId} ${creditId}`}\n                  onClick={() => setFolded((current) => !current)}\n                >\n                  {isFolded ? copy.unfold : copy.fold}\n                </Button>\n              ) : null}\n            </div>\n            <CardDescription className=\"max-sm:hidden\">{copy.intro}</CardDescription>\n          </CardHeader>\n          {/* The ends, the mode and the answer stay put; only the reachable area below them scrolls. On a phone the whole sheet scrolls. */}\n          <CardContent className=\"flex min-h-0 flex-1 flex-col gap-4 max-sm:overflow-y-auto max-sm:overscroll-contain\">\n            <div id={endsId} data-folded={isFolded} className=\"flex shrink-0 flex-col gap-2 max-sm:data-[folded=true]:hidden\">\n              <div className=\"relative flex flex-col gap-2\">\n                {/* The rail from the start's ring to the destination'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={DIRECTIONS_MAP_ID}\n                    apiKey={apiKey}\n                    gateway={gateway}\n                    lang={lang}\n                    placeholder={copy.from}\n                    proximity=\"map\"\n                    flyToZoom={13}\n                    onSelect={chooseFrom}\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={DIRECTIONS_MAP_ID}\n                    apiKey={apiKey}\n                    gateway={gateway}\n                    lang={lang}\n                    placeholder={copy.to}\n                    proximity=\"map\"\n                    flyToZoom={13}\n                    onSelect={chooseTo}\n                    className=\"[&>p]:hidden\"\n                  />\n                </div>\n              </div>\n              <p className=\"pl-6 text-xs text-muted-foreground max-sm:hidden\">{copy.coverage}</p>\n            </div>\n            <ToggleGroup\n              id={modesId}\n              data-folded={isFolded}\n              variant=\"outline\"\n              aria-label={copy.mode}\n              value={[mode]}\n              onValueChange={(value: unknown[]) => chooseMode(value[0])}\n              className=\"grid w-full shrink-0 grid-cols-3 max-sm:data-[folded=true]:hidden\"\n            >\n              {DIRECTIONS_MODES.map((item) => {\n                const time = known[item];\n                return (\n                  <ToggleGroupItem key={item} value={item} className=\"h-auto flex-col gap-0.5 py-2 aria-pressed:font-semibold\">\n                    <span>{copy.modes[item]}</span>{\" \"}\n                    <span className=\"min-h-4 text-xs font-normal text-muted-foreground tabular-nums\">{time ? formatDuration(time.durationSeconds) : \"\"}</span>\n                  </ToggleGroupItem>\n                );\n              })}\n            </ToggleGroup>\n            {/* Where the trip lands, always there and tall enough for an error and its retry, so nothing jumps when it arrives. */}\n            <div\n              data-testid=\"directions-answer\"\n              data-folded={isFolded}\n              className=\"flex min-h-24 shrink-0 flex-col justify-center gap-1 max-sm:data-[folded=true]:min-h-0\"\n            >\n              {routeError ? (\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\">{routeError}</span>\n                    <Button type=\"button\" variant=\"outline\" size=\"sm\" className=\"shrink-0\" onClick={() => askRoute(from, to, mode)}>\n                      {copy.retry}\n                    </Button>\n                  </AlertDescription>\n                </Alert>\n              ) : trip ? (\n                <div key={`${pair}|${mode}`} className=\"flex flex-col gap-1 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200\">\n                  <p data-testid=\"directions-eta\" className=\"flex items-baseline gap-1\">\n                    {durationParts(trip.durationSeconds).map((part, index) => (\n                      <Fragment key={part.unit}>\n                        {index > 0 ? \" \" : null}\n                        <span className=\"text-3xl leading-none font-semibold tracking-tight tabular-nums\">{part.value}</span>{\" \"}\n                        <span className=\"text-sm text-muted-foreground\">{part.unit}</span>\n                      </Fragment>\n                    ))}\n                  </p>\n                  <p data-testid=\"directions-distance\" className=\"text-sm text-muted-foreground tabular-nums\">\n                    {`${formatDistance(trip.distanceMeters, lang)} ${copy.by[mode]}`}\n                  </p>\n                </div>\n              ) : routePending ? (\n                <p className=\"flex items-center gap-2 text-sm text-muted-foreground\">\n                  <Spinner /> <span>{copy.loadingRoute}</span>\n                </p>\n              ) : (\n                <p className=\"text-sm text-muted-foreground\">{copy.needStops}</p>\n              )}\n            </div>\n            <div\n              id={areaId}\n              data-folded={isFolded}\n              className=\"-mx-2 flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto overscroll-contain border-t px-2 pt-4 max-sm:flex-none max-sm:overflow-visible max-sm:data-[folded=true]:hidden\"\n            >\n              <div className=\"flex flex-col items-start gap-2\">\n                <Button\n                  type=\"button\"\n                  variant={areaOn ? \"secondary\" : \"outline\"}\n                  aria-pressed={areaOn}\n                  disabled={!from}\n                  aria-describedby={from ? undefined : hintId}\n                  onClick={toggleArea}\n                >\n                  {/* On is marked by a check as well as the fill. */}\n                  {areaOn ? (\n                    <span aria-hidden=\"true\" data-pressed-mark=\"\">\n                      ✓\n                    </span>\n                  ) : null}\n                  {copy.area}\n                </Button>\n                {from ? null : (\n                  <p id={hintId} className=\"text-xs text-muted-foreground\">\n                    {copy.needStart}\n                  </p>\n                )}\n              </div>\n              {areaOn && from ? (\n                <div className=\"flex min-h-24 flex-col justify-center gap-2\">\n                  {areaError ? (\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\">{areaError}</span>\n                        <Button type=\"button\" variant=\"outline\" size=\"sm\" className=\"shrink-0\" onClick={() => askArea(from, mode, true)}>\n                          {copy.retry}\n                        </Button>\n                      </AlertDescription>\n                    </Alert>\n                  ) : area ? (\n                    <>\n                      <ul aria-label={copy.legend} className=\"flex flex-wrap gap-x-4 gap-y-1\">\n                        {AREA_MINUTES[mode].map((minutes, index) => (\n                          <li key={minutes} className=\"flex items-center gap-1.5 text-xs text-muted-foreground tabular-nums\">\n                            <span aria-hidden=\"true\" className={`size-3 rounded-sm border border-dashed border-primary ${AREA_SWATCHES[index] ?? \"\"}`} />\n                            {`${minutes} min`}\n                          </li>\n                        ))}\n                      </ul>\n                      <p data-testid=\"directions-area-caption\" className=\"text-sm\">\n                        {copy.areaCaption(copy.by[mode], minutesList(AREA_MINUTES[mode], lang), from.name)}\n                      </p>\n                    </>\n                  ) : areaPending ? (\n                    <p className=\"flex items-center gap-2 text-sm text-muted-foreground\">\n                      <Spinner /> <span>{copy.loadingArea}</span>\n                    </p>\n                  ) : null}\n                </div>\n              ) : null}\n            </div>\n            {/* Folded on a phone, the map's own attribution carries the credit until the sheet opens again. */}\n            <p id={creditId} data-folded={isFolded} className=\"shrink-0 text-xs text-muted-foreground max-sm:data-[folded=true]:hidden\">\n              {copy.credit}{\" \"}\n              <a className=\"underline underline-offset-2\" href={ROUTING_CREDIT.sourceUrl} target=\"_blank\" rel=\"noreferrer\">\n                {copy.source}\n              </a>\n              ,{\" \"}\n              <a className=\"underline underline-offset-2\" href={ROUTING_CREDIT.licenceUrl} target=\"_blank\" rel=\"noreferrer\">\n                {copy.licence}\n              </a>\n              .\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 /directions. Guide: https://unmap.dev/docs/frontend/blocks#get-directions",
  "categories": [
    "blocks"
  ],
  "type": "registry:block"
}