{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "alive-route-explorer",
  "title": "Alive Route Explorer",
  "description": "A page with walking directions that trade a minute for a nicer walk: start and destination search with three Calgary samples, the fastest walk and livelier detours past parks, the river, streets away from traffic, cafés and public art, five dials and an extra-time budget that re-rank without calling, the pick in your --primary with time bubbles, highlight cards and a why-this-route card, a bottom sheet on a phone. When places or the basemap tiles are unavailable it ranks without the signals they feed and says so. Calls /route, /match, POST /places/along and up to 24 basemap tiles for scoring, at most 40 per search. Needs @unmap/maps 0.30.0 or later, for @unmap/maps/tiles.",
  "dependencies": [
    "@unmap/sdk",
    "@unmap/maps@^0.30.0",
    "lucide-react"
  ],
  "registryDependencies": [
    "https://registry.unmap.dev/r/map.json",
    "https://registry.unmap.dev/r/map-context.json",
    "https://registry.unmap.dev/r/map-layers.json",
    "https://registry.unmap.dev/r/geocoder.json",
    "card",
    "button",
    "field",
    "slider",
    "toggle-group",
    "radio-group",
    "badge",
    "alert",
    "spinner",
    "separator",
    "sheet",
    "popover",
    "collapsible"
  ],
  "files": [
    {
      "path": "registry/blocks/alive-route-explorer/page.tsx",
      "content": "/**\n * /alive-route-explorer: the alive route explorer block as a Next.js App Router page, full-bleed\n * (the block keeps its own gutter). The key comes from NEXT_PUBLIC_UNMAP_KEY in .env.local;\n * installing the block added the empty line, paste your key.\n */\nimport { AliveRouteExplorer } from \"@/components/ui/alive-route-explorer\";\n\nexport const metadata = { title: \"Find a livelier walk\" };\n\nexport default function AliveRouteExplorerPage() {\n  return (\n    <main className=\"h-dvh\">\n      <AliveRouteExplorer apiKey={process.env.NEXT_PUBLIC_UNMAP_KEY ?? \"\"} className=\"h-full\" />\n    </main>\n  );\n}\n",
      "type": "registry:page",
      "target": "app/alive-route-explorer/page.tsx"
    },
    {
      "path": "registry/blocks/alive-route-explorer/alive-route-explorer.tsx",
      "content": "\"use client\";\n\n/**\n * <AliveRouteExplorer> - walking directions that trade a minute for a nicer walk. Pick a start and\n * a destination (or one of three Calgary samples), get directions, and the block finds the fastest\n * walk, then detours past parks, the river, streets away from traffic, cafés and public art, and scores them\n * all. Five dials say what makes a walk alive to you and how much extra time you will take; moving\n * them re-ranks the walks at once and calls nothing. The pick is drawn in your --primary over a\n * --card casing, the fastest walk dashed in your --muted-foreground, with a time bubble beside each,\n * up to three cards for what the pick passes, and a \"Why this route\" card with the five scores. A\n * full-bleed map with a floating panel, a bottom sheet on a phone.\n *\n * Props: `apiKey`, `lang` (\"en\" | \"fr\"), `className`, `initial` ({ from, to, label? }), `title`\n * (the panel heading, default \"Find a livelier walk\"), `badge` (a short tag beside it, such as\n * \"BETA\"), `pictures` (image URLs for the highlight cards, keyed by highlight kind or place\n * category id, each a list of variants; see `AlivePictures`), and `onEndsChange` (called with the\n * start and destination whenever the reader changes them, not at mount, so a page can keep them in\n * its URL). A card shows its places' most common category's picture, else its kind's, else the\n * drawn illustration; the variant is chosen from the place so a walk keeps its picture.\n *\n * Honest about its sources: the signals are parks, water and streets from the basemap tiles, and\n * cafés, shops and landmarks from unmap places. When `/places/along` is unavailable the ranking\n * uses parks, water and streets away from traffic only, and when the basemap tiles were not read\n * it leaves those three out; either way their dials are off, the Why card says \"Unavailable\" and\n * the panel says so. A walk found on the walking\n * graph says it was found in your browser and snapped by map matching. Every picture, drawn or\n * passed in, is tagged \"Illustration\", and a passed-in one has alt text saying it is AI-generated.\n *\n * Metering: every authenticated request is one call. \"Get directions\" spends at most 40: one\n * `/route` for the fastest walk, up to 24 basemap tiles read for scoring (not the map's own display\n * tiles), two `POST /places/along`, then `/route` pairs for detours and `/match` for walking-graph\n * paths, within the budget. The answer is kept per start and destination, so asking again costs\n * nothing, unless a source was unavailable, and the dials never call. The search box\n * calls `/geocode/autocomplete` as you type; the map makes its own style and tile requests.\n *\n * Composes the map, map-context, map-layers and geocoder items on your shadcn Card, Button, Field,\n * Slider, ToggleGroup, RadioGroup, Badge, Alert, Spinner, Separator, Sheet, Popover and Collapsible. Installed by\n * `npx shadcn add` with its page at app/alive-route-explorer/page.tsx. Edit freely.\n */\n\nimport { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { Unmap } from \"@unmap/sdk\";\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\";\nimport { AliveCamera, AliveLayers, AliveOverlay } from \"@/components/ui/alive-map\";\nimport { AliveControls, AliveGoButton, AliveRoutesList, AliveWhyCard } from \"@/components/ui/alive-panel\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card } from \"@/components/ui/card\";\nimport { Map } from \"@/components/ui/map\";\nimport { Sheet, SheetContent, SheetDescription, SheetTitle, SheetTrigger } from \"@/components/ui/sheet\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { DEFAULT_WEIGHTS, rank, type Weights } from \"@/lib/alive-rank\";\nimport type { LngLat } from \"@/lib/alive-signals\";\nimport {\n  ALIVE_COPY,\n  ALIVE_MAP_ID,\n  DEFAULT_BUDGET,\n  DEFAULT_MIN_GAIN,\n  KEY_URL,\n  PHONE_QUERY,\n  SAMPLE_COMMUTES,\n  NARROW_QUERY,\n  cacheable,\n  isAbort,\n  requestError,\n  usableWeights,\n  walkDeps,\n  walkNotes,\n  type AliveLang,\n  type AlivePictures,\n  type Endpoint,\n  type Method,\n  type Search,\n} from \"@/lib/alive-view\";\nimport { findWalks, type Candidate, type WalkResult } from \"@/lib/alive-walks\";\n\n// The block's words, samples and helpers live in alive-view.ts; re-exported here so the block is\n// one import.\nexport * from \"@/lib/alive-view\";\nexport { Illustration, type IllustrationKind } from \"@/components/ui/alive-illustrations\";\n\n/** True while a media query matches (false on the server and where matchMedia is missing). */\nfunction useMedia(query: string): boolean {\n  return useSyncExternalStore(\n    (notify) => {\n      const list = typeof window !== \"undefined\" ? window.matchMedia?.(query) : undefined;\n      list?.addEventListener?.(\"change\", notify);\n      return () => list?.removeEventListener?.(\"change\", notify);\n    },\n    () => (typeof window !== \"undefined\" && window.matchMedia?.(query)?.matches === true),\n    () => false,\n  );\n}\n\n// ---------------------------------------------------------------------------------------------\n// The block.\n\nexport interface AliveRouteExplorerProps {\n  /** Your unmap key. Without one the block shows how to get one and calls nothing. */\n  apiKey: string;\n  lang?: AliveLang;\n  className?: string;\n  /** Where the walk starts and ends. Default: the Bridgeland sample. */\n  initial?: { from: LngLat; to: LngLat; label?: string };\n  /** The panel's heading. Default: \"Find a livelier walk\" (fr \"Trouver une marche plus vivante\"). */\n  title?: string;\n  /** A short tag beside the heading, such as \"BETA\". None by default. */\n  badge?: string;\n  /**\n   * Pictures for the highlight cards, by highlight kind or place category id, each a list of URLs to\n   * vary between walks (see `AlivePictures`). Shown tagged \"Illustration\" with AI-generated alt\n   * text; without one a card draws its inline illustration.\n   */\n  pictures?: AlivePictures;\n  /**\n   * Called with the start and destination each time the reader changes them (a sample, a search,\n   * the swap), never at mount. A page can write them into its URL so a link opens the same walk.\n   */\n  onEndsChange?: (ends: { from: LngLat; to: LngLat }) => void;\n}\n\nconst same = (a: LngLat, b: LngLat) => a[0] === b[0] && a[1] === b[1];\nconst pairKey = (a: LngLat, b: LngLat) => `${a.join(\",\")}|${b.join(\",\")}`;\n\nfunction startingPoint(initial: AliveRouteExplorerProps[\"initial\"]) {\n  const sample = initial ? SAMPLE_COMMUTES.find((s) => same(s.from, initial.from) && same(s.to, initial.to)) : SAMPLE_COMMUTES[0]!;\n  return { sample: sample?.id ?? null, from: initial?.from ?? sample!.from, to: initial?.to ?? sample!.to, label: initial?.label ?? null };\n}\n\nexport function AliveRouteExplorer({ apiKey, lang = \"en\", className, initial, title, badge, pictures, onEndsChange }: AliveRouteExplorerProps) {\n  const copy = ALIVE_COPY[lang];\n  const hasKey = apiKey.trim() !== \"\";\n  const [opening] = useState(() => startingPoint(initial));\n  const [sampleId, setSampleId] = useState(opening.sample);\n  const [label] = useState(opening.label);\n  const [ends, setEnds] = useState<{ from: LngLat; to: LngLat; names: [string | null, string | null] }>({\n    from: opening.from,\n    to: opening.to,\n    names: [null, null],\n  });\n  const [weights, setWeights] = useState<Weights>(DEFAULT_WEIGHTS);\n  const [budget, setBudget] = useState(DEFAULT_BUDGET);\n  const [minGain, setMinGain] = useState(DEFAULT_MIN_GAIN);\n  const [method, setMethod] = useState<Method>(\"both\");\n  const [search, setSearch] = useState<Search>({ state: \"idle\" });\n  const [selectedId, setSelectedId] = useState<string | null>(null);\n  const [hoveredId, setHoveredId] = useState<string | null>(null);\n  /** The walk whose cards found no room at the first framing, so the camera gave it wider margins. */\n  const [roomyFor, setRoomyFor] = useState<string | null>(null);\n  /** Remounts the search boxes when the endpoints change by any other way than typing. */\n  const [boxes, setBoxes] = useState(0);\n  const cache = useRef(new globalThis.Map<string, WalkResult>());\n  const flight = useRef<AbortController | null>(null);\n  const panelRef = useRef<HTMLDivElement | null>(null);\n  const whyRef = useRef<HTMLDivElement | null>(null);\n  const phone = useMedia(PHONE_QUERY);\n  // The Why card opens by default on a wide screen (1280 px or more), where it has room.\n  const narrow = useMedia(NARROW_QUERY);\n\n  const deps = useMemo(\n    () => (hasKey ? walkDeps(new Unmap({ key: apiKey, fetch: (input, init) => globalThis.fetch(input, init) }), apiKey) : null),\n    [apiKey, hasKey],\n  );\n  useEffect(() => () => flight.current?.abort(), []);\n\n  // Tell the page when the ends move, once per change and not for the opening walk.\n  const announced = useRef(pairKey(opening.from, opening.to));\n  const onEnds = useRef(onEndsChange);\n  onEnds.current = onEndsChange;\n  useEffect(() => {\n    const key = pairKey(ends.from, ends.to);\n    if (key === announced.current) return;\n    announced.current = key;\n    onEnds.current?.({ from: ends.from, to: ends.to });\n  }, [ends.from, ends.to]);\n\n  const sample = SAMPLE_COMMUTES.find((s) => s.id === sampleId) ?? null;\n  const from: Endpoint = { at: ends.from, name: ends.names[0] ?? sample?.ends[lang][0] ?? null };\n  const to: Endpoint = { at: ends.to, name: ends.names[1] ?? sample?.ends[lang][1] ?? null };\n  const commute = sample ? sample.name[lang] : (label ?? copy.commute(from.name ?? copy.start, to.name ?? copy.destination));\n\n  const result = search.state === \"done\" ? search.result : null;\n  const placesUnavailable = result ? !result.placesAvailable : false;\n  const basemapUnavailable = result ? !result.basemapAvailable : false;\n  const ranked = useMemo(() => {\n    if (!result) return null;\n    const visible = result.candidates.filter((c) => method === \"both\" || c.method === \"fastest\" || c.method === method);\n    // A signal whose source could not be read is unknown, not zero: it leaves the ranking.\n    return rank(visible, usableWeights(weights, result), budget, minGain / 100);\n  }, [result, method, weights, budget, minGain]);\n  const fastest = ranked?.rows.find((row) => row.method === \"fastest\") ?? null;\n  const pick = ranked?.rows.find((row) => row.id === ranked.pickId) ?? null;\n  const shown = ranked?.rows.find((row) => row.id === selectedId) ?? pick;\n  // Once per walk: when a card cannot fit at the first framing, frame again with wider margins.\n  const walkKey = shown ? `${pairKey(ends.from, ends.to)}:${shown.id}` : null;\n  const crowded = useCallback(() => setRoomyFor(walkKey), [walkKey]);\n\n  function moveEnds(next: typeof ends, nextSample: typeof sampleId) {\n    flight.current?.abort();\n    setEnds(next);\n    setSampleId(nextSample);\n    setSelectedId(null);\n    setSearch({ state: \"idle\" });\n  }\n  function pickSample(id: string) {\n    const next = SAMPLE_COMMUTES.find((s) => s.id === id);\n    if (!next) return;\n    moveEnds({ from: next.from, to: next.to, names: [null, null] }, next.id);\n    setBoxes((n) => n + 1);\n  }\n  function swap() {\n    moveEnds({ from: ends.to, to: ends.from, names: [to.name, from.name] }, null);\n    setBoxes((n) => n + 1);\n  }\n  function retune(update: () => void) {\n    update();\n    setSelectedId(null);\n  }\n\n  function run() {\n    if (!deps) return;\n    flight.current?.abort();\n    setSelectedId(null);\n    const cached = cache.current.get(pairKey(ends.from, ends.to));\n    if (cached) {\n      setSearch({ state: \"done\", result: cached });\n      return;\n    }\n    const controller = new AbortController();\n    flight.current = controller;\n    setSearch({ state: \"loading\" });\n    const key = pairKey(ends.from, ends.to);\n    findWalks(deps, ends.from, ends.to, controller.signal).then(\n      (found) => {\n        if (controller.signal.aborted) return;\n        // A result an outage degraded is not kept, so asking again tries the sources again.\n        if (cacheable(found)) cache.current.set(key, found);\n        setSearch({ state: \"done\", result: found });\n      },\n      (caught: unknown) => {\n        if (controller.signal.aborted || isAbort(caught)) return;\n        setSearch({ state: \"error\", error: caught });\n      },\n    );\n  }\n\n  if (!hasKey) {\n    // Where the map would be: the block's gutter, the Alert at a readable width, the first action first.\n    return (\n      <div\n        data-unmap-block=\"alive-route-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\n  const titleOf = (row: Candidate) => (row.id === pick?.id && row.id !== fastest?.id ? copy.livelier : row.id === fastest?.id ? copy.fastest : copy.another);\n  const said = shown && fastest ? copy.said(titleOf(shown), copy.minutes(shown.durationS), shown.id === fastest.id ? null : copy.extra(shown.extraMin)) : \"\";\n  const busy = search.state === \"loading\";\n  const partial = result ? walkNotes(result, fastest?.distanceM ?? null, lang).partial : false;\n\n  const why = shown && fastest && ranked ? (\n    <AliveWhyCard\n      lang={lang}\n      shown={shown}\n      fastest={fastest}\n      shownScore={shown.score}\n      fastestScore={fastest.score}\n      placesUnavailable={placesUnavailable}\n      basemapUnavailable={basemapUnavailable}\n      defaultOpen={!narrow}\n    />\n  ) : null;\n  const controls = (\n    <AliveControls\n      lang={lang}\n      inSheet={phone}\n      apiKey={apiKey}\n      title={title}\n      badge={badge}\n      placesUnavailable={placesUnavailable}\n      basemapUnavailable={basemapUnavailable}\n      commute={commute}\n      isSample={sample !== null}\n      from={from}\n      to={to}\n      boxes={boxes}\n      sampleId={sampleId}\n      onSample={pickSample}\n      onSwap={swap}\n      onFrom={(at, name) => moveEnds({ ...ends, from: at, names: [name, to.name] }, null)}\n      onTo={(at, name) => moveEnds({ ...ends, to: at, names: [from.name, name] }, null)}\n      weights={weights}\n      onWeights={(next) => retune(() => setWeights(next))}\n      budget={budget}\n      onBudget={(next) => retune(() => setBudget(next))}\n      minGain={minGain}\n      onMinGain={(next) => retune(() => setMinGain(next))}\n      method={method}\n      onMethod={(next) => retune(() => setMethod(next))}\n      routes={\n        <>\n          <AliveRoutesList\n            lang={lang}\n            search={search}\n            rows={ranked?.rows ?? []}\n            pickId={pick?.id ?? null}\n            fastestId={fastest?.id ?? null}\n            shownId={shown?.id ?? null}\n            budget={budget}\n            pictures={pictures}\n            onSelect={setSelectedId}\n            onHover={setHoveredId}\n            onRetry={run}\n          />\n          {phone ? why : null}\n        </>\n      }\n      footer={<AliveGoButton lang={lang} busy={busy} calls={result?.calls ?? null} onGo={run} />}\n    />\n  );\n\n  return (\n    <div data-unmap-block=\"alive-route-explorer\" className={[\"relative h-full min-h-[480px]\", className].filter(Boolean).join(\" \")}>\n      <Map\n        id={ALIVE_MAP_ID}\n        apiKey={apiKey}\n        lang={lang}\n        style=\"tropic\"\n        center={[(opening.from[0] + opening.to[0]) / 2, (opening.from[1] + opening.to[1]) / 2]}\n        zoom={14}\n        className=\"h-full\"\n      >\n        <AliveLayers rows={ranked?.rows ?? []} shownId={shown?.id ?? null} fastestId={fastest?.id ?? null} hoveredId={hoveredId} onSelect={setSelectedId} />\n        <AliveCamera\n          roomy={!!walkKey && roomyFor === walkKey}\n          frame={shown && fastest ? [...shown.coords, ...fastest.coords] : [ends.from, ends.to]}\n          frameKey={shown ? `walks:${pairKey(ends.from, ends.to)}:${shown.id}` : `ends:${pairKey(ends.from, ends.to)}`}\n          panelRef={panelRef}\n          phone={phone}\n        />\n        <AliveOverlay\n          lang={lang}\n          shown={shown}\n          fastest={fastest}\n          isPick={!!shown && shown.id === pick?.id}\n          phone={phone}\n          from={{ at: ends.from, label: from.name ?? copy.start }}\n          to={{ at: ends.to, label: to.name ?? copy.destination }}\n          pictures={pictures}\n          panelRef={panelRef}\n          whyRef={whyRef}\n          onSelect={setSelectedId}\n          onCrowded={crowded}\n        />\n        <p aria-live=\"polite\" className=\"sr-only\">\n          {said}\n        </p>\n        {/* z-20: above the map item's loading overlay (z-10), which would otherwise take the first\n            clicks on the panel while the style loads; a search needs no map. */}\n        {phone ? (\n          <div ref={panelRef} className=\"absolute inset-x-4 bottom-10 z-20 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-4 motion-safe:duration-300\">\n            <Card className=\"gap-3 rounded-3xl p-4 shadow-xl\">\n              <div className=\"flex min-w-0 items-center gap-2\">\n                <p className=\"min-w-0 truncate text-sm font-medium\">{commute}</p>\n                {sample ? <Badge variant=\"secondary\">{copy.sample}</Badge> : null}\n              </div>\n              {search.state === \"error\" ? (\n                <p role=\"alert\" className=\"text-sm text-destructive\">\n                  {requestError(search.error, lang)}\n                </p>\n              ) : (\n                <p className=\"text-sm text-muted-foreground tabular-nums\">{said || (busy ? copy.going : copy.noResult)}</p>\n              )}\n              {partial ? <p className=\"text-xs text-muted-foreground\">{copy.partial}</p> : null}\n              <div className=\"flex gap-2\">\n                <Button type=\"button\" className=\"h-11 flex-1 rounded-full\" aria-busy={busy} onClick={run}>\n                  {busy ? <Spinner aria-hidden=\"true\" /> : null}\n                  {copy.go}\n                </Button>\n                <Sheet>\n                  <SheetTrigger\n                    render={\n                      <Button type=\"button\" variant=\"outline\" className=\"h-11 rounded-full\">\n                        {copy.options}\n                      </Button>\n                    }\n                  />\n                  <SheetContent side=\"bottom\" className=\"max-h-[85dvh] gap-0 overflow-y-auto p-0\">\n                    <SheetTitle className=\"sr-only\">{copy.options}</SheetTitle>\n                    <SheetDescription className=\"sr-only\">{copy.description}</SheetDescription>\n                    {controls}\n                  </SheetContent>\n                </Sheet>\n              </div>\n            </Card>\n          </div>\n        ) : (\n          <>\n            {/* A card floating on the map at the gutter, scrolling inside itself; the button stays in reach. */}\n            <div\n              ref={panelRef}\n              className=\"absolute top-6 bottom-6 left-6 z-20 flex w-[440px] max-w-[calc(100%-3rem)] flex-col motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-left-4 motion-safe:duration-300\"\n            >\n              <Card className=\"flex max-h-full min-h-0 flex-1 flex-col gap-0 overflow-hidden rounded-3xl py-0 shadow-2xl\">{controls}</Card>\n            </div>\n            {why ? (\n              <div ref={whyRef} className=\"absolute right-6 bottom-10 z-20 w-80 max-w-[calc(100%-32rem)]\">\n                {why}\n              </div>\n            ) : null}\n          </>\n        )}\n      </Map>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/blocks/alive-route-explorer/alive-panel.tsx",
      "content": "\"use client\";\n\n/**\n * The alive-route-explorer block's panel: the title, start and destination, the routes as radio\n * rows, the route options (five dials, the extra time, more options), the button and the call\n * counter, and the \"Why this route\" card. Drawn from the state <AliveRouteExplorer> holds;\n * installed beside it. Edit freely.\n */\n\nimport { useId, useState, type ReactNode } from \"react\";\nimport { ArrowUpDown, ChevronDown, Coffee, Landmark, Navigation, Trees, Waves, VolumeX, type LucideIcon } from \"lucide-react\";\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\";\nimport { EndPin, Illustration, StartDot } from \"@/components/ui/alive-illustrations\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card } from \"@/components/ui/card\";\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from \"@/components/ui/collapsible\";\nimport { FieldLabel, FieldLegend, FieldSet } from \"@/components/ui/field\";\nimport { Geocoder } from \"@/components/ui/geocoder\";\nimport { RadioGroup, RadioGroupItem } from \"@/components/ui/radio-group\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { Slider } from \"@/components/ui/slider\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { ToggleGroup, ToggleGroupItem } from \"@/components/ui/toggle-group\";\nimport { DEFAULT_WEIGHTS, type Weights } from \"@/lib/alive-rank\";\nimport { describe, type LngLat } from \"@/lib/alive-signals\";\nimport {\n  ALIVE_COPY,\n  ALIVE_MAP_ID,\n  MAX_BUDGET,\n  SAMPLE_COMMUTES,\n  WEIGHT_KEYS,\n  pictureFor,\n  requestError,\n  walkNotes,\n  type AliveLang,\n  type AlivePictures,\n  type Endpoint,\n  type Method,\n  type RankedWalk,\n  type Search,\n  type WeightKey,\n} from \"@/lib/alive-view\";\n\nconst WEIGHT_ICONS: Record<WeightKey, LucideIcon> = { green: Trees, water: Waves, quiet: VolumeX, life: Coffee, landmarks: Landmark };\n/** The signals that come from /places/along, off when it is unavailable. */\nconst PLACE_SIGNALS: readonly WeightKey[] = [\"life\", \"landmarks\"];\n/** The signals that come from the basemap tiles, off when they were not read. */\nconst BASEMAP_SIGNALS: readonly WeightKey[] = [\"green\", \"water\", \"quiet\"];\n/** Routes listed before \"Show N more\". */\nconst LISTED = 3;\n\n/** A search box for one end of the walk, 52 px tall, with the end's mark inside it. */\nfunction EndField(props: { lang: AliveLang; apiKey: string; mark: ReactNode; placeholder: string; onPick: (at: LngLat, name: string) => void }) {\n  return (\n    <div className=\"relative min-w-0 flex-1\">\n      <span className=\"pointer-events-none absolute top-[26px] left-4 z-10 flex -translate-y-1/2 items-center\">{props.mark}</span>\n      <Geocoder\n        apiKey={props.apiKey}\n        mapId={ALIVE_MAP_ID}\n        lang={props.lang}\n        proximity=\"map\"\n        placeholder={props.placeholder}\n        onSelect={(r) => props.onPick([r.lng, r.lat], r.name)}\n        className=\"[&_[data-slot=input-group]]:h-13 [&_[data-slot=input-group]]:rounded-xl [&_input]:pl-11 [&_input]:text-base [&>p]:sr-only\"\n      />\n    </div>\n  );\n}\n\nexport function AliveControls(props: {\n  lang: AliveLang;\n  apiKey: string;\n  title?: string;\n  badge?: string;\n  commute: string;\n  isSample: boolean;\n  from: Endpoint;\n  to: Endpoint;\n  boxes: number;\n  sampleId: string | null;\n  onSample: (id: string) => void;\n  onSwap: () => void;\n  onFrom: (at: LngLat, name: string) => void;\n  onTo: (at: LngLat, name: string) => void;\n  weights: Weights;\n  onWeights: (next: Weights) => void;\n  budget: number;\n  onBudget: (next: number) => void;\n  minGain: number;\n  onMinGain: (next: number) => void;\n  method: Method;\n  onMethod: (next: Method) => void;\n  /** Street life and landmarks could not be read: their dials are off. */\n  placesUnavailable: boolean;\n  /** The basemap tiles were not read: the parks, water and traffic dials are off. */\n  basemapUnavailable: boolean;\n  routes: ReactNode;\n  /** In the phone sheet, which scrolls as a whole: the button sticks to its bottom instead. */\n  inSheet?: boolean;\n  footer: ReactNode;\n}) {\n  const { lang } = props;\n  const copy = ALIVE_COPY[lang];\n  const uid = useId();\n  const [options, setOptions] = useState(true);\n  const [more, setMore] = useState(false);\n  return (\n    <>\n      <div className={[\"flex flex-col gap-6 p-7\", props.inSheet ? \"\" : \"min-h-0 flex-1 overflow-y-auto overscroll-contain\"].join(\" \")}>\n        <header className=\"flex flex-col gap-2\">\n          <div className=\"flex min-w-0 items-center gap-3\">\n            <span aria-hidden=\"true\" className=\"flex size-11 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground\">\n              <Navigation className=\"size-5 fill-current\" />\n            </span>\n            <h2 className=\"min-w-0 truncate text-3xl leading-tight font-extrabold tracking-tight\">{props.title ?? copy.title}</h2>\n            {props.badge ? (\n              <Badge variant=\"secondary\" className=\"shrink-0 rounded-full px-2.5 font-bold\">\n                {props.badge}\n              </Badge>\n            ) : null}\n          </div>\n          <p className=\"text-base text-muted-foreground\">{copy.subtitle}</p>\n        </header>\n\n        <section className=\"flex flex-col gap-3\" aria-label={`${copy.start}, ${copy.destination}`}>\n          <div className=\"flex items-center gap-2\">\n            <div key={props.boxes} className=\"flex min-w-0 flex-1 flex-col gap-2.5\">\n              <EndField lang={lang} apiKey={props.apiKey} mark={<StartDot className=\"size-3.5\" />} placeholder={props.from.name ? copy.from(props.from.name) : copy.start} onPick={props.onFrom} />\n              <EndField lang={lang} apiKey={props.apiKey} mark={<EndPin className=\"h-5\" />} placeholder={props.to.name ? copy.to(props.to.name) : copy.destination} onPick={props.onTo} />\n            </div>\n            <Button type=\"button\" variant=\"ghost\" size=\"icon-lg\" aria-label={copy.swap} onClick={props.onSwap}>\n              <ArrowUpDown />\n            </Button>\n          </div>\n          <div className=\"flex flex-wrap items-center gap-2\">\n            <span className=\"text-sm text-muted-foreground\">{copy.samples}</span>\n            <ToggleGroup\n              aria-label={copy.samples}\n              variant=\"outline\"\n              size=\"sm\"\n              spacing={1}\n              value={props.sampleId ? [props.sampleId] : []}\n              onValueChange={(next: unknown[]) => {\n                const id = next[0];\n                if (typeof id === \"string\") props.onSample(id);\n              }}\n            >\n              {SAMPLE_COMMUTES.map((s) => (\n                <ToggleGroupItem key={s.id} value={s.id} title={s.name[lang]} className=\"rounded-full px-3\">\n                  {s.ends[lang][0]}\n                </ToggleGroupItem>\n              ))}\n            </ToggleGroup>\n            {props.isSample ? <Badge variant=\"secondary\">{copy.sample}</Badge> : null}\n          </div>\n        </section>\n\n        <Separator />\n        {props.routes}\n        <Separator />\n\n        <Collapsible open={options} onOpenChange={setOptions} className=\"flex flex-col gap-4\">\n          <div className=\"flex items-baseline justify-between gap-3\">\n            <h3 className=\"text-lg font-bold\">{copy.routeOptions}</h3>\n            <CollapsibleTrigger\n              render={\n                <Button type=\"button\" variant=\"link\" className=\"h-auto px-0 text-base\">\n                  {options ? copy.hide : copy.show}\n                </Button>\n              }\n            />\n          </div>\n          <CollapsibleContent className=\"flex flex-col gap-5\">\n            <FieldSet className=\"gap-3\">\n              <div className=\"flex items-baseline justify-between gap-3\">\n                <FieldLegend className=\"mb-0 text-base font-semibold\">{copy.dials}</FieldLegend>\n                <Button type=\"button\" variant=\"link\" size=\"sm\" className=\"h-auto px-0\" onClick={() => props.onWeights(DEFAULT_WEIGHTS)}>\n                  {copy.reset}\n                </Button>\n              </div>\n              {WEIGHT_KEYS.map((key) => (\n                <Dial\n                  key={key}\n                  icon={WEIGHT_ICONS[key]}\n                  label={copy.weights[key]}\n                  value={props.weights[key]}\n                  shown={props.weights[key].toLocaleString(lang === \"fr\" ? \"fr-CA\" : \"en-CA\", { minimumFractionDigits: 1, maximumFractionDigits: 1 })}\n                  min={0}\n                  max={2}\n                  step={0.1}\n                  disabled={(props.placesUnavailable && PLACE_SIGNALS.includes(key)) || (props.basemapUnavailable && BASEMAP_SIGNALS.includes(key))}\n                  onChange={(v) => props.onWeights({ ...props.weights, [key]: v })}\n                />\n              ))}\n              {props.basemapUnavailable ? <p className=\"text-xs text-muted-foreground\">{copy.dialsBasemapUnavailable}</p> : null}\n              {props.placesUnavailable ? <p className=\"text-xs text-muted-foreground\">{copy.dialsUnavailable}</p> : null}\n            </FieldSet>\n\n            <Dial label={copy.budget} value={props.budget} shown={copy.budgetValue(props.budget)} min={0} max={MAX_BUDGET} step={0.5} onChange={props.onBudget} wide />\n\n            <Collapsible open={more} onOpenChange={setMore} className=\"flex flex-col gap-3\">\n              <CollapsibleTrigger\n                render={\n                  <Button type=\"button\" variant=\"ghost\" size=\"sm\" className=\"-ml-2 w-fit text-muted-foreground\">\n                    <ChevronDown className={[\"transition-transform motion-reduce:transition-none\", more ? \"rotate-180\" : \"\"].join(\" \")} />\n                    {more ? copy.fewer : copy.more}\n                  </Button>\n                }\n              />\n              <CollapsibleContent className=\"flex flex-col gap-4\">\n                <div className=\"flex flex-col gap-1\">\n                  <Dial label={copy.minGain} value={props.minGain} shown={copy.minGainValue(props.minGain)} min={0} max={20} step={1} onChange={props.onMinGain} wide />\n                  <p className=\"text-xs text-muted-foreground\">{copy.minGainHint}</p>\n                </div>\n                <div className=\"flex flex-col gap-2\">\n                  <span id={`${uid}-method`} className=\"text-sm font-medium\">\n                    {copy.method}\n                  </span>\n                  <ToggleGroup\n                    aria-labelledby={`${uid}-method`}\n                    variant=\"outline\"\n                    size=\"sm\"\n                    value={[props.method]}\n                    onValueChange={(next: unknown[]) => {\n                      const m = next[0];\n                      if (m === \"both\" || m === \"detour\" || m === \"graph\") props.onMethod(m);\n                    }}\n                  >\n                    {([\"both\", \"detour\", \"graph\"] as const).map((m) => (\n                      <ToggleGroupItem key={m} value={m}>\n                        {copy.methodFilter[m]}\n                      </ToggleGroupItem>\n                    ))}\n                  </ToggleGroup>\n                </div>\n              </CollapsibleContent>\n            </Collapsible>\n          </CollapsibleContent>\n        </Collapsible>\n      </div>\n      <div className={[\"flex flex-col gap-2 bg-card px-7 pt-4 pb-6\", props.inSheet ? \"sticky bottom-0 z-10 border-t\" : \"\"].join(\" \")}>\n        {props.footer}\n        <p className=\"text-center text-xs text-muted-foreground\">{copy.sources}</p>\n      </div>\n    </>\n  );\n}\n\n/** One labelled slider with its value beside it. */\nfunction Dial(props: {\n  label: string;\n  value: number;\n  shown: string;\n  min: number;\n  max: number;\n  step: number;\n  onChange: (next: number) => void;\n  icon?: LucideIcon;\n  wide?: boolean;\n  disabled?: boolean;\n}) {\n  const id = useId();\n  const Icon = props.icon;\n  const change = (v: number | readonly number[]) => props.onChange(typeof v === \"number\" ? v : (v[0] ?? props.value));\n  if (props.wide) {\n    return (\n      <div className=\"flex flex-col gap-2.5\">\n        <div className=\"flex items-baseline justify-between gap-3\">\n          <FieldLabel id={id} className=\"text-base font-bold\">\n            {props.label}\n          </FieldLabel>\n          <span className=\"text-sm font-semibold text-primary tabular-nums\">{props.shown}</span>\n        </div>\n        <Slider aria-labelledby={id} min={props.min} max={props.max} step={props.step} value={[props.value]} onValueChange={change} />\n      </div>\n    );\n  }\n  return (\n    <div className={[\"grid grid-cols-[1.25rem_8.5rem_minmax(0,1fr)_2rem] items-center gap-2.5\", props.disabled ? \"opacity-50\" : \"\"].join(\" \")}>\n      {Icon ? <Icon aria-hidden=\"true\" className=\"size-4 text-muted-foreground\" /> : <span />}\n      <FieldLabel id={id} className=\"text-sm font-normal\">\n        {props.label}\n      </FieldLabel>\n      <Slider aria-labelledby={id} disabled={props.disabled} min={props.min} max={props.max} step={props.step} value={[props.value]} onValueChange={change} />\n      <span className=\"text-right text-xs text-muted-foreground tabular-nums\">{props.shown}</span>\n    </div>\n  );\n}\n\nexport function AliveRoutesList(props: {\n  lang: AliveLang;\n  search: Search;\n  rows: RankedWalk[];\n  pickId: string | null;\n  fastestId: string | null;\n  shownId: string | null;\n  budget: number;\n  pictures?: AlivePictures;\n  onSelect: (id: string) => void;\n  /** A row under the pointer or focus, to draw that walk on the map; null when it leaves. */\n  onHover: (id: string | null) => void;\n  onRetry: () => void;\n}) {\n  const { lang, search } = props;\n  const copy = ALIVE_COPY[lang];\n  const id = useId();\n  const [all, setAll] = useState(false);\n  const result = search.state === \"done\" ? search.result : null;\n  const fastest = props.rows.find((row) => row.id === props.fastestId);\n  const notes = result ? walkNotes(result, fastest?.distanceM ?? null, lang).notes : [];\n  const known = { basemap: result?.basemapAvailable ?? true };\n  // The pick, the fastest, then the rest best first, those you would take before those over your\n  // extra time or short on gain.\n  const rank = (row: RankedWalk) => (row.id === props.pickId ? 0 : row.id === props.fastestId ? 1 : row.eligible ? 2 : 3);\n  const ordered = [...props.rows].sort((a, b) => rank(a) - rank(b));\n  // The shown walk always stays listed, even when it ranks past the first three.\n  const listed = all ? ordered : ordered.filter((row, index) => index < LISTED || row.id === props.shownId);\n  const hidden = ordered.length - listed.length;\n  return (\n    <section className=\"flex flex-col gap-3\" aria-labelledby={id}>\n      <h3 id={id} className=\"text-lg font-bold\">\n        {copy.routes}\n      </h3>\n      {search.state === \"idle\" ? (\n        <div className=\"flex items-center gap-3 rounded-xl border border-dashed p-3\">\n          <Illustration\n            kind=\"quiet\"\n            lang={lang}\n            src={pictureFor({ kind: \"quiet\", places: [] }, props.pictures, \"empty\")}\n            alt={copy.pictureAlt(\"quiet\")}\n            className=\"h-16 w-24 shrink-0 rounded-lg\"\n          />\n          <p className=\"text-sm text-muted-foreground\">{copy.empty}</p>\n        </div>\n      ) : null}\n      {search.state === \"loading\" ? (\n        <p aria-live=\"polite\" className=\"flex items-center gap-2 py-3 text-sm text-muted-foreground\">\n          <Spinner className=\"size-4\" />\n          {copy.going}\n        </p>\n      ) : null}\n      {search.state === \"error\" ? (\n        <Alert variant=\"destructive\" className=\"motion-safe:animate-in motion-safe:fade-in-0\">\n          <AlertTitle>{copy.errorTitle}</AlertTitle>\n          <AlertDescription className=\"flex flex-col items-start gap-2\">\n            <span>{requestError(search.error, lang)}</span>\n            <Button type=\"button\" variant=\"outline\" size=\"sm\" onClick={props.onRetry}>\n              {copy.retry}\n            </Button>\n          </AlertDescription>\n        </Alert>\n      ) : null}\n      {notes.map((note) => (\n        <Alert key={note} role=\"status\">\n          <AlertDescription>{note}</AlertDescription>\n        </Alert>\n      ))}\n      {result && props.pickId === props.fastestId && !result.tooShort ? (\n        <p className=\"text-sm text-muted-foreground\">{copy.alreadyLiveliest(props.budget)}</p>\n      ) : null}\n      {result ? (\n        <RadioGroup\n          aria-label={copy.routes}\n          value={props.shownId}\n          onValueChange={(v: unknown) => typeof v === \"string\" && props.onSelect(v)}\n          className=\"gap-2\"\n          onMouseLeave={() => props.onHover(null)}\n        >\n          {listed.map((row) => {\n            const isFastest = row.id === props.fastestId;\n            const isPick = row.id === props.pickId;\n            const title = isPick && !isFastest ? copy.livelier : isFastest ? copy.fastest : copy.another;\n            const reason = row.eligible || isFastest ? null : row.extraMin > props.budget + 1e-9 ? copy.overBudget : copy.underGain;\n            const shown = row.id === props.shownId;\n            return (\n              <label\n                key={row.id}\n                title={[row.method === \"detour\" || row.method === \"graph\" ? copy.methods[row.method] : null, reason ?? describe(row.scores, lang, known)].filter(Boolean).join(\". \")}\n                onMouseEnter={() => props.onHover(row.id)}\n                onFocus={() => props.onHover(row.id)}\n                onBlur={() => props.onHover(null)}\n                className={[\n                  \"flex h-12 cursor-pointer items-center gap-3 rounded-xl border px-3.5 text-base transition-colors motion-reduce:transition-none\",\n                  shown ? \"border-2 border-primary bg-primary/10\" : \"hover:bg-muted/60\",\n                  reason ? \"opacity-60\" : \"\",\n                ].join(\" \")}\n              >\n                <RadioGroupItem value={row.id} data-route-id={row.id} />\n                <span className=\"min-w-0 truncate font-semibold\">{title}</span>\n                {row.method === \"detour\" || row.method === \"graph\" ? (\n                  // Below 400 px the row keeps the name and the time; the method moves to the row's title.\n                  <Badge variant=\"outline\" className=\"h-5 shrink-0 px-1.5 text-[11px] max-[399px]:hidden\">\n                    {copy.methods[row.method]}\n                  </Badge>\n                ) : null}\n                {reason ? <span className=\"sr-only\">{reason}</span> : null}\n                <span className=\"ml-auto flex shrink-0 items-baseline gap-1.5 tabular-nums\">\n                  <span className={shown ? \"font-semibold text-primary\" : \"font-medium\"}>{copy.minutes(row.durationS)}</span>\n                  {isFastest ? null : <span className=\"text-sm text-muted-foreground\">{copy.extra(row.extraMin)}</span>}\n                </span>\n              </label>\n            );\n          })}\n        </RadioGroup>\n      ) : null}\n      {result && (hidden > 0 || all) && ordered.length > LISTED ? (\n        <Button type=\"button\" variant=\"link\" className=\"h-auto w-fit px-0\" onClick={() => setAll((a) => !a)}>\n          {all ? copy.showFewer : copy.showMore(hidden)}\n        </Button>\n      ) : null}\n    </section>\n  );\n}\n\nexport function AliveGoButton({ lang, busy, calls, onGo }: { lang: AliveLang; busy: boolean; calls: number | null; onGo: () => void }) {\n  const copy = ALIVE_COPY[lang];\n  return (\n    <>\n      <Button type=\"button\" size=\"lg\" className=\"h-14 w-full rounded-full text-lg font-semibold\" aria-busy={busy} onClick={onGo}>\n        {busy ? <Spinner aria-hidden=\"true\" /> : null}\n        {copy.go}\n      </Button>\n      <p className=\"text-center text-sm text-muted-foreground tabular-nums\">{calls === null ? copy.callsBefore : copy.calls(calls)}</p>\n    </>\n  );\n}\n\nexport function AliveWhyCard(props: {\n  lang: AliveLang;\n  shown: RankedWalk;\n  fastest: RankedWalk;\n  shownScore: number;\n  fastestScore: number;\n  /** Street life and landmarks could not be read: say so rather than draw an empty bar. */\n  placesUnavailable: boolean;\n  /** The basemap tiles were not read: parks, water and traffic say so rather than draw a guess. */\n  basemapUnavailable: boolean;\n  defaultOpen: boolean;\n}) {\n  const { lang, shown, fastest } = props;\n  const copy = ALIVE_COPY[lang];\n  const [open, setOpen] = useState(props.defaultOpen);\n  return (\n    <Card className=\"gap-0 overflow-hidden rounded-2xl py-0 shadow-lg motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-2 motion-safe:duration-300\">\n      <Collapsible open={open} onOpenChange={setOpen}>\n        <CollapsibleTrigger\n          render={\n            <Button type=\"button\" variant=\"ghost\" className=\"h-auto w-full justify-between gap-3 px-4 py-3 text-left whitespace-normal\">\n              <span className=\"flex min-w-0 flex-col\">\n                <span className=\"text-base font-bold\">{copy.why}</span>\n                <span className=\"text-xs font-normal text-muted-foreground tabular-nums\">\n                  {copy.aliveness(Math.round(props.shownScore * 100), Math.round(props.fastestScore * 100))}\n                </span>\n              </span>\n              <ChevronDown aria-hidden=\"true\" className={[\"transition-transform motion-reduce:transition-none\", open ? \"rotate-180\" : \"\"].join(\" \")} />\n            </Button>\n          }\n        />\n        <CollapsibleContent className=\"flex flex-col gap-3 px-4 pb-4\">\n          <p className=\"text-sm text-muted-foreground first-letter:uppercase\">{describe(shown.scores, lang, { basemap: !props.basemapUnavailable })}</p>\n          <dl className=\"grid grid-cols-[6rem_minmax(0,1fr)_2.75rem] items-center gap-x-2.5 gap-y-2 text-sm\">\n            {WEIGHT_KEYS.map((key) => {\n              const off = (props.placesUnavailable && PLACE_SIGNALS.includes(key)) || (props.basemapUnavailable && BASEMAP_SIGNALS.includes(key));\n              const value = shown.scores[key];\n              const base = fastest.scores[key];\n              return (\n                <div key={key} className=\"contents\">\n                  <dt>{copy.signals[key]}</dt>\n                  {off ? (\n                    <dd className=\"col-span-2 text-muted-foreground\">{copy.signalUnavailable}</dd>\n                  ) : (\n                    <>\n                      <dd aria-hidden=\"true\" className=\"relative h-2 rounded-full bg-muted\">\n                        <span className=\"absolute inset-y-0 left-0 rounded-full bg-primary transition-[width] duration-300 motion-reduce:transition-none\" style={{ width: `${Math.round(value * 100)}%` }} />\n                        <span className=\"absolute -inset-y-1 w-0.5 rounded-full bg-muted-foreground\" style={{ left: `calc(${Math.round(base * 100)}% - 1px)` }} />\n                      </dd>\n                      <dd className=\"text-right text-muted-foreground tabular-nums\">{Math.round(value * 100)}%</dd>\n                    </>\n                  )}\n                </div>\n              );\n            })}\n          </dl>\n          <p className=\"text-xs text-muted-foreground\">{copy.barsKey}</p>\n          {shown.method === \"graph\" ? <p className=\"text-xs text-muted-foreground\">{copy.graphNote}</p> : null}\n        </CollapsibleContent>\n      </Collapsible>\n    </Card>\n  );\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/blocks/alive-route-explorer/alive-map.tsx",
      "content": "\"use client\";\n\n/**\n * The alive-route-explorer block on the map: the walks as layers (the shown one in your --primary\n * over a --card casing, drawing itself in; the fastest dashed in your --muted-foreground; another\n * walk only while its row is hovered), the camera, and the HTML pinned to the map: the start and\n * destination with their names, a time bubble beside each walk, and up to three highlight cards\n * placed so they cover neither the walks, the panel nor each other. Each renders inside <Map>,\n * where useMap() can see it. Installed beside the block. Edit freely.\n */\n\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from \"react\";\nimport { Coffee, Landmark, Trees, type LucideIcon } from \"lucide-react\";\nimport { EndPin, Illustration, StartDot } from \"@/components/ui/alive-illustrations\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card } from \"@/components/ui/card\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/ui/popover\";\nimport type { LngLat } from \"@/lib/alive-signals\";\nimport {\n  ALIVE_COPY,\n  ALIVE_MAP_ID,\n  CAMERA_MS,\n  DRAW_MS,\n  apexOf,\n  boundsOf,\n  densify,\n  highlightCards,\n  pictureFor,\n  placeName,\n  placeBox,\n  placeCard,\n  prefersReducedMotion,\n  TAIL_INSET,\n  type AliveLang,\n  type AlivePictures,\n  type HighlightCard,\n  type Point,\n  type RankedWalk,\n  type Rect,\n  type Side,\n} from \"@/lib/alive-view\";\nimport { useMap, useMapReady } from \"@/lib/map-context\";\nimport { accentColor, tokenColor, useLayerPointer, useLayerSet, type LayerSpec } from \"@/lib/map-layers\";\n\nconst ROUTES_SOURCE = \"alive-routes\";\nconst SHOWN_SOURCE = \"alive-shown\";\nconst ROUTES_HIT = \"alive-routes-hit\";\nconst SHOWN_LINE = \"alive-shown-line\";\n\n/** The shown walk drawn up to `t` (0-1) of its length, for line-gradient. */\ntype LineGradient = NonNullable<NonNullable<Extract<LayerSpec, { type: \"line\" }>[\"paint\"]>[\"line-gradient\"]>;\nconst drawnTo = (colour: string, t: number): LineGradient => [\"step\", [\"line-progress\"], colour, Math.min(Math.max(t, 0.0001), 1.01), \"rgba(0, 0, 0, 0)\"];\n\nexport function AliveLayers(props: {\n  rows: RankedWalk[];\n  shownId: string | null;\n  fastestId: string | null;\n  /** A walk whose row is under the pointer: drawn faint while it is. */\n  hoveredId: string | null;\n  onSelect: (id: string) => void;\n}) {\n  const { rows, shownId, fastestId, hoveredId } = props;\n  const routes = useMemo(\n    () => ({\n      type: \"FeatureCollection\",\n      features: rows\n        .filter((row) => row.id !== shownId && (row.id === fastestId || row.id === hoveredId))\n        .map((row) => ({\n          type: \"Feature\",\n          properties: { id: row.id, role: row.id === fastestId ? \"fastest\" : \"other\" },\n          geometry: { type: \"LineString\", coordinates: row.coords },\n        })),\n    }),\n    [rows, shownId, fastestId, hoveredId],\n  );\n  const shownRow = rows.find((row) => row.id === shownId);\n  const shownLine = useMemo(\n    () => ({\n      type: \"FeatureCollection\",\n      features: shownRow ? [{ type: \"Feature\", properties: { id: shownRow.id }, geometry: { type: \"LineString\", coordinates: shownRow.coords } }] : [],\n    }),\n    [shownRow],\n  );\n\n  const { map, ready } = useLayerSet({\n    mapId: ALIVE_MAP_ID,\n    sourceId: ROUTES_SOURCE,\n    data: routes,\n    layers: (live): LayerSpec[] => {\n      const muted = tokenColor(live, \"--muted-foreground\") ?? accentColor(live, undefined, undefined);\n      const round = { \"line-cap\": \"round\", \"line-join\": \"round\" } as const;\n      return [\n        { id: \"alive-routes-other\", type: \"line\", source: ROUTES_SOURCE, filter: [\"==\", [\"get\", \"role\"], \"other\"], layout: round, paint: { \"line-color\": muted, \"line-width\": 5, \"line-opacity\": 0.55 } },\n        { id: \"alive-routes-fastest\", type: \"line\", source: ROUTES_SOURCE, filter: [\"==\", [\"get\", \"role\"], \"fastest\"], layout: round, paint: { \"line-color\": muted, \"line-width\": 5, \"line-dasharray\": [1.4, 1.6] } },\n        { id: ROUTES_HIT, type: \"line\", source: ROUTES_SOURCE, paint: { \"line-color\": muted, \"line-width\": 18, \"line-opacity\": 0 } },\n      ];\n    },\n  });\n  useLayerSet({\n    mapId: ALIVE_MAP_ID,\n    sourceId: SHOWN_SOURCE,\n    source: { lineMetrics: true },\n    data: shownLine,\n    layers: (live): LayerSpec[] => {\n      const primary = accentColor(live, undefined, undefined);\n      // --card, so the casing stays visible on a dark map as on a light one.\n      const casing = tokenColor(live, \"--card\") ?? tokenColor(live, \"--background\") ?? \"white\";\n      return [\n        { id: \"alive-shown-casing\", type: \"line\", source: SHOWN_SOURCE, layout: { \"line-cap\": \"round\", \"line-join\": \"round\" }, paint: { \"line-color\": casing, \"line-width\": 12 } },\n        { id: SHOWN_LINE, type: \"line\", source: SHOWN_SOURCE, layout: { \"line-cap\": \"round\", \"line-join\": \"round\" }, paint: { \"line-width\": 8, \"line-gradient\": drawnTo(primary, 1) } },\n      ];\n    },\n  });\n  useLayerPointer({\n    map,\n    layerIds: [ROUTES_HIT],\n    enabled: ready,\n    onClick: (event) => {\n      const id = event.feature.properties.id;\n      if (typeof id === \"string\") props.onSelect(id);\n    },\n  });\n\n  // The one orchestrated moment: a newly shown walk draws itself from start to finish.\n  const drawKey = shownRow ? `${shownRow.id}:${shownRow.coords.length}` : \"\";\n  useEffect(() => {\n    if (!map || !ready || !drawKey || !map.getLayer(SHOWN_LINE)) return;\n    const primary = accentColor(map, undefined, undefined);\n    if (prefersReducedMotion() || typeof requestAnimationFrame === \"undefined\") {\n      map.setPaintProperty(SHOWN_LINE, \"line-gradient\", drawnTo(primary, 1));\n      return;\n    }\n    let frame = 0;\n    const started = performance.now();\n    const step = (now: number) => {\n      const t = Math.min((now - started) / DRAW_MS, 1);\n      if (!map.getLayer(SHOWN_LINE)) return;\n      map.setPaintProperty(SHOWN_LINE, \"line-gradient\", drawnTo(primary, 1 - (1 - t) ** 3));\n      if (t < 1) frame = requestAnimationFrame(step);\n    };\n    frame = requestAnimationFrame(step);\n    return () => {\n      cancelAnimationFrame(frame);\n      if (map.getLayer(SHOWN_LINE)) map.setPaintProperty(SHOWN_LINE, \"line-gradient\", drawnTo(primary, 1));\n    };\n  }, [map, ready, drawKey]);\n  return null;\n}\n\n/**\n * Frames the endpoints, then the walks once they arrive, into the area right of the panel (above\n * the sheet on a phone), with room above and around for the cards and bubbles.\n */\nexport function AliveCamera({\n  frame,\n  frameKey,\n  panelRef,\n  phone,\n  roomy = false,\n}: {\n  frame: LngLat[];\n  frameKey: string;\n  panelRef: RefObject<HTMLDivElement | null>;\n  phone: boolean;\n  /** A card found no room: give the walk wider margins above and to the right, where cards go. */\n  roomy?: boolean;\n}) {\n  const map = useMap(ALIVE_MAP_ID);\n  const ready = useMapReady(ALIVE_MAP_ID);\n  const latest = useRef(frame);\n  latest.current = frame;\n  useEffect(() => {\n    if (!map || !ready || latest.current.length === 0) return;\n    const box = map.getContainer().getBoundingClientRect();\n    const panel = panelRef.current?.getBoundingClientRect();\n    let padding = phone ? { top: 120, right: 40, bottom: 40, left: 40 } : roomy ? { top: 240, right: 200, bottom: 120, left: 96 } : { top: 140, right: 96, bottom: 96, left: 96 };\n    if (panel && panel.width > 0 && box.width > 0) {\n      if (phone) padding.bottom = Math.max(padding.bottom, Math.round(box.bottom - panel.top + 40));\n      else padding.left = Math.round(panel.right - box.left + 48);\n    }\n    // A window too small for the reference padding gets an even one rather than an empty frame.\n    if (box.width - padding.left - padding.right < 160 || box.height - padding.top - padding.bottom < 160) padding = { top: 24, right: 24, bottom: 24, left: 24 };\n    map.fitBounds(boundsOf(latest.current), { padding, maxZoom: 17, duration: prefersReducedMotion() ? 0 : CAMERA_MS });\n  }, [map, ready, frameKey, panelRef, phone, roomy]);\n  return null;\n}\n\n/**\n * Re-renders whenever the map moves, so HTML pinned to coordinates follows it; `settled` counts the\n * moves that have ended (and resizes), for work that should wait until the map is still.\n */\nfunction useScreen(): { project: (at: LngLat) => Point; box: DOMRect; settled: number } | null {\n  const map = useMap(ALIVE_MAP_ID);\n  const ready = useMapReady(ALIVE_MAP_ID);\n  const [, setTick] = useState(0);\n  const [settled, setSettled] = useState(0);\n  useEffect(() => {\n    if (!map || !ready) return;\n    const bump = () => setTick((n) => n + 1);\n    const settle = () => setSettled((n) => n + 1);\n    map.on(\"move\", bump);\n    map.on(\"moveend\", settle);\n    map.on(\"resize\", settle);\n    return () => {\n      map.off(\"move\", bump);\n      map.off(\"moveend\", settle);\n      map.off(\"resize\", settle);\n    };\n  }, [map, ready]);\n  if (!map || !ready) return null;\n  return { project: ([lng, lat]) => map.project({ lng, lat }), box: map.getContainer().getBoundingClientRect(), settled };\n}\n\n/** A highlight card's size in pixels (w-55, a 140 px picture and a caption), for placing it. */\nconst CARD = { w: 220, h: 196 };\nconst BUBBLE = { pick: { w: 196, h: 70 }, fastest: { w: 150, h: 60 } };\nconst KIND_ICON: Record<HighlightCard[\"kind\"], LucideIcon> = { park: Trees, life: Coffee, landmark: Landmark };\n\n/** The tail on a box's edge facing the point it points at. */\nconst TAIL: Record<Side, string> = {\n  top: \"-bottom-[7px] left-1/2 -translate-x-1/2 border-r-2 border-b-2\",\n  bottom: \"-top-[7px] left-1/2 -translate-x-1/2 border-t-2 border-l-2\",\n  left: \"top-1/2 -right-[7px] -translate-y-1/2 border-t-2 border-r-2\",\n  right: \"top-1/2 -left-[7px] -translate-y-1/2 border-b-2 border-l-2\",\n  \"top-left\": \"-bottom-[7px] right-[22px] border-r-2 border-b-2\",\n  \"top-right\": \"-bottom-[7px] left-[22px] border-r-2 border-b-2\",\n  \"bottom-left\": \"-top-[7px] right-[22px] border-t-2 border-l-2\",\n  \"bottom-right\": \"-top-[7px] left-[22px] border-t-2 border-l-2\",\n};\n/** Every side, in the order a card tries them: above first, as in the reference. */\nconst CARD_SIDES: readonly Side[] = [\"top\", \"top-left\", \"top-right\", \"right\", \"left\", \"bottom\", \"bottom-left\", \"bottom-right\"];\nconst BUBBLE_SIDES: readonly Side[] = [\"top\", \"right\", \"left\", \"bottom\", \"top-left\", \"top-right\", \"bottom-left\", \"bottom-right\"];\n/** Where a placed box goes: at its anchor, offset by the tail's gap, so the tail meets the point whatever the box's real size. */\nfunction boxStyle(spot: { at: Point; side: Side }, gap: number) {\n  const { x, y } = spot.at;\n  switch (spot.side) {\n    case \"top\":\n      return { left: x, top: y - gap, transform: \"translate(-50%, -100%)\" };\n    case \"bottom\":\n      return { left: x, top: y + gap, transform: \"translate(-50%, 0)\" };\n    case \"left\":\n      return { left: x - gap, top: y, transform: \"translate(-100%, -50%)\" };\n    case \"right\":\n      return { left: x + gap, top: y, transform: \"translate(0, -50%)\" };\n    case \"top-left\":\n      return { left: x + TAIL_INSET, top: y - gap, transform: \"translate(-100%, -100%)\" };\n    case \"top-right\":\n      return { left: x - TAIL_INSET, top: y - gap, transform: \"translate(0, -100%)\" };\n    case \"bottom-left\":\n      return { left: x + TAIL_INSET, top: y + gap, transform: \"translate(-100%, 0)\" };\n    case \"bottom-right\":\n      return { left: x - TAIL_INSET, top: y + gap, transform: \"translate(0, 0)\" };\n  }\n}\n\nfunction Tail({ side, className }: { side: Side; className?: string }) {\n  return <span aria-hidden=\"true\" className={[\"absolute size-3 rotate-45 border-inherit bg-card\", TAIL[side], className].filter(Boolean).join(\" \")} />;\n}\n\n/** A rectangle of the page in the map's own pixels, or null when it is not on screen. */\nfunction within(box: DOMRect, ref?: RefObject<HTMLElement | null>): Rect | null {\n  const r = ref?.current?.getBoundingClientRect();\n  return r && r.width > 0 ? { x0: r.left - box.left, y0: r.top - box.top, x1: r.right - box.left, y1: r.bottom - box.top } : null;\n}\n\n/** Points of a walk ordered from its middle outwards: the bubble's preferred anchors. */\nfunction middleOut<T>(points: T[]): T[] {\n  const inner = points.slice(Math.floor(points.length * 0.15), Math.ceil(points.length * 0.85));\n  const step = Math.max(1, Math.floor(inner.length / 24));\n  const picked = inner.filter((_, i) => i % step === 0);\n  const mid = picked.length / 2;\n  return picked.map((p, i) => ({ p, d: Math.abs(i - mid) })).sort((a, b) => a.d - b.d).map(({ p }) => p);\n}\n\nfunction CardBody({ lang, card, picture }: { lang: AliveLang; card: HighlightCard; picture: string | undefined }) {\n  const copy = ALIVE_COPY[lang];\n  const Icon = KIND_ICON[card.kind];\n  const detail = card.kind === \"park\" && card.metres ? copy.through(card.metres) : card.extra > 0 ? copy.more_(card.extra) : null;\n  return (\n    <>\n      <Illustration kind={card.kind} lang={lang} src={picture} alt={copy.pictureAlt(card.kind)} className=\"h-35 w-full\" />\n      <div className=\"flex items-center gap-2.5 px-3 py-2.5\">\n        <span aria-hidden=\"true\" className=\"flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary\">\n          <Icon className=\"size-4\" />\n        </span>\n        <div className=\"flex min-w-0 flex-col\">\n          <span className=\"truncate text-sm leading-tight font-bold\">{copy.highlights[card.kind]}</span>\n          <span className=\"truncate text-xs leading-snug text-muted-foreground\">\n            {card.names.join(\", \")}\n            {detail ? `, ${detail}` : \"\"}\n          </span>\n        </div>\n      </div>\n    </>\n  );\n}\n\n/**\n * For the bubbles and the markers, Buttons whose left/top follow the map every frame: no\n * transition-all (they would trail the map by 150 ms), and no press nudge (it replaces the\n * centring translate).\n */\nconst TRACKS = \"transition-colors active:not-aria-[haspopup]:translate-y-0\";\n\n/** A marker whose card did not fit, or any marker on a phone: the card opens as a popover. */\nfunction MarkerPopover({ lang, card, picture, name, Icon }: { lang: AliveLang; card: HighlightCard; picture: string | undefined; name: string; Icon: LucideIcon }) {\n  return (\n    <Popover>\n      <PopoverTrigger\n        render={\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"icon\"\n            aria-label={name}\n            className={`pointer-events-auto size-9 rounded-full border bg-card text-primary shadow-md hover:bg-card hover:text-primary dark:hover:bg-card ${TRACKS}`}\n          >\n            <Icon aria-hidden=\"true\" />\n          </Button>\n        }\n      />\n      <PopoverContent side=\"top\" sideOffset={10} className=\"w-56 gap-0 overflow-hidden rounded-2xl p-0\">\n        <CardBody lang={lang} card={card} picture={picture} />\n      </PopoverContent>\n    </Popover>\n  );\n}\n\n/**\n * Measured sizes of the boxes this overlay places, by key. Every render reads them for placing and\n * then re-measures; a size that changed (a picture loaded, a font arrived, a longer name) re-renders\n * once with the true size. The first render places on estimates.\n */\nfunction useMeasured(): {\n  size: (key: string, fallback: { w: number; h: number }) => { w: number; h: number };\n  ref: (key: string) => (el: HTMLElement | null) => void;\n  version: number;\n} {\n  const sizes = useRef(new globalThis.Map<string, { w: number; h: number }>());\n  const nodes = useRef(new globalThis.Map<string, HTMLElement>());\n  const [version, setVersion] = useState(0);\n  useLayoutEffect(() => {\n    let changed = false;\n    for (const [key, el] of nodes.current) {\n      // offsetWidth/Height ignore transforms, so an entry animation never shrinks the measure.\n      const next = { w: el.offsetWidth, h: el.offsetHeight };\n      const was = sizes.current.get(key);\n      if (next.w > 0 && (!was || Math.abs(was.w - next.w) > 1 || Math.abs(was.h - next.h) > 1)) {\n        sizes.current.set(key, next);\n        changed = true;\n      }\n    }\n    if (changed) setVersion((v) => v + 1);\n  });\n  return {\n    version,\n    size: (key, fallback) => sizes.current.get(key) ?? fallback,\n    ref: (key) => (el) => {\n      if (el) nodes.current.set(key, el);\n      else nodes.current.delete(key);\n    },\n  };\n}\n\n/** The overlay's placements, made when the map is still and kept, relative to their anchors, while it moves. */\ninterface Layout {\n  bubbles: { key: \"pick\" | \"fastest\"; at: LngLat; side: Side }[];\n  /** By card index: null when closed by the reader, \"folded\" when nothing fits, else its spot relative to its marker. */\n  cards: (null | \"folded\" | { dx: number; dy: number; side?: Side; leader?: { x0: number; y0: number; x1: number; y1: number } })[];\n}\n\nexport function AliveOverlay(props: {\n  lang: AliveLang;\n  /** The shown walk and the fastest, or null before a search. */\n  shown: RankedWalk | null;\n  fastest: RankedWalk | null;\n  isPick: boolean;\n  phone: boolean;\n  from: { at: LngLat; label: string };\n  to: { at: LngLat; label: string };\n  pictures?: AlivePictures;\n  panelRef: RefObject<HTMLDivElement | null>;\n  whyRef: RefObject<HTMLDivElement | null>;\n  onSelect: (id: string) => void;\n  /** Some card found no room: the camera may give the walk more margin. */\n  onCrowded?: () => void;\n}) {\n  const { lang, shown, fastest, phone } = props;\n  const copy = ALIVE_COPY[lang];\n  const screen = useScreen();\n  const measured = useMeasured();\n  const cards = useMemo(() => (shown ? highlightCards(shown.coords, shown.scores, (place) => placeName(place, lang)) : []), [shown, lang]);\n  const [closed, setClosed] = useState<string[]>([]);\n  useEffect(() => setClosed([]), [shown?.id]);\n\n  // Each end's name sits to its right, or to its left when the right edge is too close.\n  const endsAt = (project: (at: LngLat) => Point, width: number) =>\n    [\n      { ...props.from, kind: \"start\" as const, p: project(props.from.at) },\n      { ...props.to, kind: \"end\" as const, p: project(props.to.at) },\n    ].map((end) => {\n      const pill = measured.size(`pill-${end.kind}`, { w: 28 + end.label.length * 9, h: 36 });\n      const flip = end.p.x + 20 + pill.w > width - 8;\n      const x0 = flip ? end.p.x - 20 - pill.w : end.p.x + 20;\n      const y0 = end.kind === \"start\" ? end.p.y - pill.h / 2 : end.p.y - 40;\n      return { ...end, flip, pill: { x0, y0, x1: x0 + pill.w, y1: y0 + pill.h } };\n    });\n\n  // Placing waits for the map to be still: a new walk, a size measured, a move or zoom ended.\n  const settled = screen?.settled ?? -1;\n  const layout = useMemo<Layout | null>(() => {\n    if (!screen || !shown || !fastest) return null;\n    const { project, box } = screen;\n    const bounds: Rect = { x0: 8, y0: 8, x1: box.width - 8, y1: box.height - 8 };\n    const obstacles: Rect[] = [];\n    for (const ref of [props.panelRef, props.whyRef]) {\n      const r = within(box, ref);\n      if (r) obstacles.push({ ...r, weight: 20 });\n    }\n    for (const end of endsAt(project, box.width)) {\n      obstacles.push(end.pill);\n      obstacles.push(end.kind === \"start\" ? { x0: end.p.x - 12, y0: end.p.y - 12, x1: end.p.x + 12, y1: end.p.y + 12 } : { x0: end.p.x - 14, y0: end.p.y - 42, x1: end.p.x + 14, y1: end.p.y + 2 });\n    }\n    const markers = cards.map((card) => project(card.at));\n    const markerRects: Rect[] = markers.map((m) => ({ x0: m.x - 18, y0: m.y - 18, x1: m.x + 18, y1: m.y + 18 }));\n    obstacles.push(...markerRects);\n    const shownLine = shown.coords.map(project);\n    const fastestLine = fastest.id !== shown.id ? fastest.coords.map(project) : [];\n    const lines = densify([shownLine, fastestLine], 12);\n    const pickPoints = densify([shownLine], 12);\n\n    // Bubbles first: they say the times, the reason the walks are there. Cards then avoid them.\n    const bubbles: Layout[\"bubbles\"] = [];\n    const bubble = (key: \"pick\" | \"fastest\", coords: LngLat[], first: LngLat[]) => {\n      const lngLats = [...first, ...middleOut(coords)];\n      const anchors = lngLats.map(project);\n      const size = measured.size(`bubble-${key}`, BUBBLE[key]);\n      const spot = placeBox(anchors, size, BUBBLE_SIDES, obstacles, lines, bounds, 14);\n      if (!spot) return;\n      obstacles.push(spot.rect);\n      bubbles.push({ key, at: lngLats[anchors.indexOf(spot.at)]!, side: spot.side });\n    };\n    if (fastest.id === shown.id) bubble(\"pick\", shown.coords, []);\n    else {\n      bubble(\"pick\", shown.coords, [apexOf(shown.coords, fastest.coords)]);\n      bubble(\"fastest\", fastest.coords, []);\n    }\n\n    // Cards: each on a ring round its marker, clear of everything above and of each other; a card\n    // with no such spot folds into its marker, which opens it as a popover.\n    const placed: Layout[\"cards\"] = cards.map((card, index) => {\n      if (phone || closed.includes(card.kind)) return null;\n      const m = markers[index]!;\n      const others = obstacles.filter((o) => o !== markerRects[index]);\n      const spot = placeCard(m, measured.size(`card-${card.kind}`, CARD), CARD_SIDES, others, pickPoints, bounds);\n      if (!spot) return \"folded\";\n      obstacles.push(spot.rect);\n      return {\n        dx: spot.rect.x0 - m.x,\n        dy: spot.rect.y0 - m.y,\n        ...(spot.side ? { side: spot.side } : {}),\n        ...(spot.leader ? { leader: { x0: spot.leader.x0 - m.x, y0: spot.leader.y0 - m.y, x1: spot.leader.x1 - m.x, y1: spot.leader.y1 - m.y } } : {}),\n      };\n    });\n    return { bubbles, cards: placed };\n    // `screen` is read at the moment the map settles, on purpose: placing on every frame would fold\n    // and unfold cards mid-gesture.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [settled, shown, fastest, cards, phone, closed, measured.version, props.from.label, props.to.label]);\n\n  // A walk is \"crowded\" only once the camera has framed it (a move has ended since it arrived): the\n  // first layout runs against the previous camera, where a card folding says nothing. Once per walk.\n  const walkKey = shown ? `${shown.id}|${shown.coords.length}|${shown.coords[0]?.join(\",\")}` : \"\";\n  const arrived = useRef({ walk: \"\", settled: -1 });\n  const reported = useRef(new Set<string>());\n  useEffect(() => {\n    if (arrived.current.walk !== walkKey) arrived.current = { walk: walkKey, settled };\n  }, [walkKey, settled]);\n  const crowded = !!layout && layout.cards.some((c) => c === \"folded\");\n  const onCrowded = props.onCrowded;\n  useEffect(() => {\n    if (!crowded || !walkKey || arrived.current.walk !== walkKey || settled <= arrived.current.settled || reported.current.has(walkKey)) return;\n    reported.current.add(walkKey);\n    onCrowded?.();\n  }, [crowded, walkKey, settled, onCrowded]);\n\n  if (!screen) return null;\n  const { project, box } = screen;\n  const place = (p: Point) => ({ left: p.x, top: p.y });\n  const ends = endsAt(project, box.width);\n  const markers = cards.map((card) => project(card.at));\n  const isFastest = !!shown && !!fastest && shown.id === fastest.id;\n\n  return (\n    // overflow-clip, not overflow-hidden: focusing a marker near the edge must not scroll this layer\n    // off the map underneath it.\n    <div className=\"pointer-events-none absolute inset-0 z-[5] overflow-clip\">\n      {ends.map((end) => (\n        <div key={end.kind} style={place(end.p)} className=\"absolute\">\n          {end.kind === \"start\" ? (\n            <StartDot className=\"absolute size-5 -translate-x-1/2 -translate-y-1/2\" />\n          ) : (\n            <EndPin className=\"absolute h-10 -translate-x-1/2 -translate-y-full\" />\n          )}\n          <span\n            ref={measured.ref(`pill-${end.kind}`)}\n            className={[\n              \"absolute rounded-xl bg-card px-3 py-1.5 text-base font-bold whitespace-nowrap text-card-foreground shadow-md\",\n              end.flip ? \"right-5\" : \"left-5\",\n              end.kind === \"start\" ? \"top-0 -translate-y-1/2\" : \"-top-10\",\n            ].join(\" \")}\n          >\n            {end.label}\n          </span>\n        </div>\n      ))}\n\n      {/* Leaders, from a marker's rim to its card, under both. */}\n      <svg aria-hidden=\"true\" className=\"absolute inset-0 size-full overflow-visible\">\n        {cards.map((card, index) => {\n          const spot = layout?.cards[index];\n          if (!spot || spot === \"folded\" || !spot.leader) return null;\n          const m = markers[index]!;\n          return (\n            <line\n              key={card.kind}\n              x1={m.x + spot.leader.x0}\n              y1={m.y + spot.leader.y0}\n              x2={m.x + spot.leader.x1}\n              y2={m.y + spot.leader.y1}\n              strokeWidth=\"2\"\n              strokeDasharray=\"3 4\"\n              strokeLinecap=\"round\"\n              className=\"stroke-foreground/50\"\n            />\n          );\n        })}\n      </svg>\n\n      {cards.map((card, index) => {\n        const Icon = KIND_ICON[card.kind];\n        const picture = pictureFor(card, props.pictures, shown?.id ?? \"\");\n        const name = `${copy.highlights[card.kind]}: ${card.names.join(\", \")}`;\n        const spot = layout?.cards[index];\n        return (\n          // The wrapper centres the marker, so the button's own press nudge never moves it off its point.\n          <div key={`${card.kind}-${card.names[0]}`} style={place(markers[index]!)} className=\"absolute\">\n            <div className=\"absolute -translate-x-1/2 -translate-y-1/2\">\n              {phone || spot === \"folded\" ? (\n                <MarkerPopover lang={lang} card={card} picture={picture} name={name} Icon={Icon} />\n              ) : (\n                <Button\n                  type=\"button\"\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  aria-label={name}\n                  aria-expanded={!!spot}\n                  onClick={() => setClosed((now) => (now.includes(card.kind) ? now.filter((k) => k !== card.kind) : [...now, card.kind]))}\n                  className={`pointer-events-auto size-9 rounded-full border bg-card text-primary shadow-md hover:bg-card hover:text-primary dark:hover:bg-card ${TRACKS}`}\n                >\n                  <Icon aria-hidden=\"true\" />\n                </Button>\n              )}\n            </div>\n          </div>\n        );\n      })}\n      {cards.map((card, index) => {\n        const spot = layout?.cards[index];\n        if (!spot || spot === \"folded\") return null;\n        const m = markers[index]!;\n        // Kept at its offset from the marker while the map moves, so card and marker move as one.\n        return (\n          <Card\n            key={`card-${card.kind}-${card.names[0]}`}\n            ref={measured.ref(`card-${card.kind}`)}\n            data-unmap-card={card.kind}\n            className=\"pointer-events-auto absolute w-55 gap-0 overflow-visible rounded-2xl py-0 shadow-lg motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-300\"\n            style={{ left: m.x + spot.dx, top: m.y + spot.dy }}\n          >\n            <div className=\"overflow-hidden rounded-2xl\">\n              <CardBody lang={lang} card={card} picture={pictureFor(card, props.pictures, shown?.id ?? \"\")} />\n            </div>\n            {spot.side ? <Tail side={spot.side} className=\"border-transparent\" /> : null}\n          </Card>\n        );\n      })}\n\n      {shown && fastest\n        ? (layout?.bubbles ?? []).map(({ key, at, side }) => {\n            const pick = key === \"pick\";\n            return (\n              <Button\n                key={key}\n                ref={measured.ref(`bubble-${key}`)}\n                type=\"button\"\n                variant=\"ghost\"\n                onClick={() => props.onSelect(pick ? shown.id : fastest.id)}\n                style={boxStyle({ at: project(at), side }, 14)}\n                className={[\n                  \"pointer-events-auto absolute h-auto flex-col items-start gap-0.5 overflow-visible rounded-2xl border-2 bg-card px-4 py-2 text-left whitespace-nowrap shadow-lg hover:bg-card dark:hover:bg-card\",\n                  TRACKS,\n                  pick ? \"border-primary text-primary hover:text-primary\" : \"border-border text-foreground hover:text-foreground\",\n                  pick ? \"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-300\" : \"\",\n                ].join(\" \")}\n              >\n                {pick ? (\n                  <>\n                    <span className=\"block text-lg leading-tight font-semibold\">{isFastest ? copy.fastest : props.isPick ? copy.livelier : copy.selected}</span>\n                    <span className=\"block leading-tight tabular-nums\">\n                      <span className=\"text-xl font-bold\">{copy.minutes(shown.durationS)}</span>\n                      {isFastest ? null : <span className=\"text-sm font-medium\"> ({copy.extra(shown.extraMin)})</span>}\n                    </span>\n                  </>\n                ) : (\n                  <>\n                    <span className=\"block text-base leading-tight font-semibold\">{copy.fastest}</span>\n                    <span className=\"block text-base leading-tight tabular-nums\">{copy.minutes(fastest.durationS)}</span>\n                  </>\n                )}\n                <Tail side={side} />\n              </Button>\n            );\n          })\n        : null}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/blocks/alive-route-explorer/alive-illustrations.tsx",
      "content": "\"use client\";\n\n/**\n * <Illustration> - five small scenes for what a walk passes: a park, the river, a street of cafés,\n * public art and a quiet residential street. Drawn, not photographed, and tagged \"Illustration\" on\n * the picture itself, so nobody takes one for the actual place. Every fill is one of your theme's\n * tokens (`primary`, `muted`, `muted-foreground`, `background`) at an opacity, so the scenes follow\n * your colours and your dark mode. Part of the alive-route-explorer block; edit freely.\n */\n\nimport { useState, type ReactElement } from \"react\";\n\nexport type IllustrationKind = \"park\" | \"water\" | \"life\" | \"landmark\" | \"quiet\";\n\nconst DESCRIPTIONS: Record<\"en\" | \"fr\", Record<IllustrationKind, string>> = {\n  en: {\n    park: \"Illustration: trees and a bench in a park\",\n    water: \"Illustration: a riverbank path beside the water\",\n    life: \"Illustration: a café terrace and a shop front\",\n    landmark: \"Illustration: a sculpture on a plaza\",\n    quiet: \"Illustration: houses on a quiet tree-lined street\",\n  },\n  fr: {\n    park: \"Illustration : des arbres et un banc dans un parc\",\n    water: \"Illustration : un sentier au bord de la rivière\",\n    life: \"Illustration : une terrasse de café et une vitrine\",\n    landmark: \"Illustration : une sculpture sur une place\",\n    quiet: \"Illustration : des maisons sur une rue calme bordée d'arbres\",\n  },\n};\n\nfunction Park() {\n  return (\n    <>\n      <rect y=\"78\" width=\"240\" height=\"34\" className=\"fill-primary/25\" />\n      <path d=\"M0 100 C60 88 110 112 240 92 L240 98 C120 116 64 94 0 106Z\" className=\"fill-background/70\" />\n      <rect x=\"45\" y=\"52\" width=\"6\" height=\"34\" className=\"fill-muted-foreground/50\" />\n      <circle cx=\"48\" cy=\"44\" r=\"26\" className=\"fill-primary/55\" />\n      <rect x=\"181\" y=\"50\" width=\"6\" height=\"34\" className=\"fill-muted-foreground/50\" />\n      <circle cx=\"184\" cy=\"38\" r=\"30\" className=\"fill-primary/45\" />\n      <rect x=\"117\" y=\"56\" width=\"5\" height=\"26\" className=\"fill-muted-foreground/50\" />\n      <circle cx=\"119\" cy=\"44\" r=\"17\" className=\"fill-primary/70\" />\n      <rect x=\"88\" y=\"78\" width=\"34\" height=\"4\" className=\"fill-muted-foreground/60\" />\n      <rect x=\"91\" y=\"82\" width=\"3\" height=\"8\" className=\"fill-muted-foreground/60\" />\n      <rect x=\"116\" y=\"82\" width=\"3\" height=\"8\" className=\"fill-muted-foreground/60\" />\n      <rect x=\"88\" y=\"70\" width=\"34\" height=\"3\" className=\"fill-muted-foreground/60\" />\n    </>\n  );\n}\n\nfunction Water() {\n  return (\n    <>\n      <rect y=\"40\" width=\"240\" height=\"22\" className=\"fill-primary/25\" />\n      <circle cx=\"30\" cy=\"38\" r=\"12\" className=\"fill-primary/45\" />\n      <circle cx=\"58\" cy=\"34\" r=\"16\" className=\"fill-primary/35\" />\n      <circle cx=\"200\" cy=\"36\" r=\"14\" className=\"fill-primary/45\" />\n      <rect y=\"60\" width=\"240\" height=\"52\" className=\"fill-primary/15\" />\n      <path d=\"M0 74c20 0 20-5 40-5s20 5 40 5 20-5 40-5 20 5 40 5 20-5 40-5 20 5 40 5\" fill=\"none\" strokeWidth=\"2.5\" className=\"stroke-primary/40\" />\n      <path d=\"M20 92c20 0 20-5 40-5s20 5 40 5 20-5 40-5 20 5 40 5 20-5 40-5\" fill=\"none\" strokeWidth=\"2.5\" className=\"stroke-primary/30\" />\n      <path d=\"M96 62 Q150 18 204 62\" fill=\"none\" strokeWidth=\"5\" className=\"stroke-muted-foreground/55\" />\n      <rect x=\"92\" y=\"58\" width=\"116\" height=\"5\" className=\"fill-muted-foreground/55\" />\n      <path d=\"M0 112 L0 98 C40 94 70 104 104 112Z\" className=\"fill-muted-foreground/25\" />\n    </>\n  );\n}\n\nfunction Life() {\n  return (\n    <>\n      <rect y=\"92\" width=\"240\" height=\"20\" className=\"fill-muted-foreground/20\" />\n      <rect x=\"18\" y=\"30\" width=\"112\" height=\"62\" className=\"fill-muted-foreground/30\" />\n      <rect x=\"28\" y=\"52\" width=\"40\" height=\"32\" className=\"fill-background/85\" />\n      <rect x=\"78\" y=\"52\" width=\"40\" height=\"32\" className=\"fill-background/85\" />\n      <path d=\"M12 30h124l-8 16H20Z\" className=\"fill-primary/80\" />\n      <path d=\"M32 30l-4 16M54 30l-2 16M74 30v16M94 30l2 16M114 30l4 16\" strokeWidth=\"5\" className=\"stroke-background/70\" />\n      <rect x=\"142\" y=\"38\" width=\"84\" height=\"54\" className=\"fill-muted-foreground/20\" />\n      <rect x=\"152\" y=\"54\" width=\"30\" height=\"38\" className=\"fill-background/85\" />\n      <rect x=\"190\" y=\"54\" width=\"26\" height=\"22\" className=\"fill-background/85\" />\n      <path d=\"M138 38h92l-6 10h-80Z\" className=\"fill-primary/45\" />\n      <circle cx=\"40\" cy=\"103\" r=\"6\" className=\"fill-muted-foreground/60\" />\n      <rect x=\"34\" y=\"95\" width=\"12\" height=\"3\" className=\"fill-muted-foreground/60\" />\n      <circle cx=\"96\" cy=\"103\" r=\"6\" className=\"fill-muted-foreground/60\" />\n      <rect x=\"90\" y=\"95\" width=\"12\" height=\"3\" className=\"fill-muted-foreground/60\" />\n      <circle cx=\"222\" cy=\"16\" r=\"8\" className=\"fill-primary/30\" />\n    </>\n  );\n}\n\nfunction Landmark() {\n  return (\n    <>\n      <rect y=\"84\" width=\"240\" height=\"28\" className=\"fill-muted-foreground/25\" />\n      <rect y=\"22\" width=\"56\" height=\"62\" className=\"fill-muted-foreground/15\" />\n      <rect x=\"164\" y=\"10\" width=\"76\" height=\"74\" className=\"fill-muted-foreground/20\" />\n      <rect x=\"176\" y=\"22\" width=\"10\" height=\"12\" className=\"fill-background/70\" />\n      <rect x=\"196\" y=\"22\" width=\"10\" height=\"12\" className=\"fill-background/70\" />\n      <rect x=\"216\" y=\"22\" width=\"10\" height=\"12\" className=\"fill-background/70\" />\n      <rect x=\"92\" y=\"70\" width=\"36\" height=\"16\" className=\"fill-muted-foreground/50\" />\n      <path d=\"M110 70C96 60 98 36 110 26c12 10 14 34 0 44Z\" className=\"fill-primary/70\" />\n      <path d=\"M110 62c-14-2-24-12-26-22 12 0 22 8 26 22Zm0 0c14-2 24-12 26-22-12 0-22 8-26 22Z\" className=\"fill-primary/45\" />\n      <circle cx=\"110\" cy=\"22\" r=\"6\" className=\"fill-primary/70\" />\n    </>\n  );\n}\n\nfunction Quiet() {\n  return (\n    <>\n      <rect y=\"82\" width=\"240\" height=\"30\" className=\"fill-muted-foreground/25\" />\n      <path d=\"M0 97h240\" fill=\"none\" strokeWidth=\"2\" strokeDasharray=\"10 8\" className=\"stroke-background/80\" />\n      <rect x=\"22\" y=\"48\" width=\"50\" height=\"34\" className=\"fill-muted-foreground/20\" />\n      <path d=\"M16 50 47 28 78 50Z\" className=\"fill-primary/35\" />\n      <rect x=\"40\" y=\"62\" width=\"12\" height=\"20\" className=\"fill-background/80\" />\n      <rect x=\"150\" y=\"44\" width=\"58\" height=\"38\" className=\"fill-muted-foreground/20\" />\n      <path d=\"M144 46 179 22 214 46Z\" className=\"fill-primary/35\" />\n      <rect x=\"160\" y=\"56\" width=\"12\" height=\"12\" className=\"fill-background/80\" />\n      <rect x=\"186\" y=\"56\" width=\"12\" height=\"12\" className=\"fill-background/80\" />\n      <rect x=\"104\" y=\"58\" width=\"5\" height=\"26\" className=\"fill-muted-foreground/50\" />\n      <circle cx=\"106\" cy=\"48\" r=\"16\" className=\"fill-primary/55\" />\n      <rect x=\"226\" y=\"60\" width=\"4\" height=\"24\" className=\"fill-muted-foreground/50\" />\n      <circle cx=\"228\" cy=\"52\" r=\"11\" className=\"fill-primary/45\" />\n    </>\n  );\n}\n\nconst SCENES: Record<IllustrationKind, () => ReactElement> = { park: Park, water: Water, life: Life, landmark: Landmark, quiet: Quiet };\n\n/**\n * A drawn scene, or the picture at `src` when you pass one (with `alt`). Either way it carries a\n * visible \"Illustration\" tag: pass only illustrations (drawn or AI-generated), never a photo\n * presented as the place.\n */\nexport function Illustration({\n  kind,\n  className,\n  lang = \"en\",\n  src,\n  alt,\n}: {\n  kind: IllustrationKind;\n  className?: string;\n  lang?: \"en\" | \"fr\";\n  src?: string;\n  alt?: string;\n}) {\n  const Scene = SCENES[kind];\n  // A picture that fails to load gives way to the drawn scene rather than a broken image.\n  const [failed, setFailed] = useState<string | null>(null);\n  const picture = src && failed !== src ? src : undefined;\n  return (\n    <div data-unmap-illustration={kind} className={[\"relative overflow-hidden bg-muted\", className].filter(Boolean).join(\" \")}>\n      {picture ? (\n        <img src={picture} alt={alt ?? DESCRIPTIONS[lang][kind]} loading=\"lazy\" decoding=\"async\" onError={() => setFailed(picture)} className=\"block size-full object-cover\" />\n      ) : (\n        <svg role=\"img\" aria-label={DESCRIPTIONS[lang][kind]} viewBox=\"0 0 240 112\" preserveAspectRatio=\"xMidYMid slice\" className=\"block size-full\">\n          <Scene />\n        </svg>\n      )}\n      {/* The tag is part of the picture: a scene, never a photo of the place. */}\n      <span aria-hidden=\"true\" className=\"absolute top-2 left-2 rounded-full bg-background/85 px-2 py-0.5 text-[11px] leading-tight font-medium text-foreground\">\n        Illustration\n      </span>\n    </div>\n  );\n}\n\n/** The start of a walk: a ring dot in your --primary with a card-coloured ring. */\nexport function StartDot({ className }: { className?: string }) {\n  return <span aria-hidden=\"true\" className={[\"block size-4 rounded-full bg-primary shadow-sm ring-4 ring-card\", className].filter(Boolean).join(\" \")} />;\n}\n\n/** The end of a walk: a map pin in your --destructive, outlined in --card. */\nexport function EndPin({ className }: { className?: string }) {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 36 46\" className={[\"block h-[1.4em] w-auto\", className].filter(Boolean).join(\" \")}>\n      <path d=\"M18 44C18 44 34 28 34 17A16 16 0 0 0 2 17C2 28 18 44 18 44Z\" strokeWidth=\"2.5\" className=\"fill-destructive stroke-card\" />\n      <circle cx=\"18\" cy=\"17\" r=\"6\" className=\"fill-card\" />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/blocks/alive-route-explorer/alive-signals.ts",
      "content": "// Pure scoring for Alive Maps: how green, watery, far from traffic, lively and notable a walking route is.\n// No React, no I/O. Everything is projected to local metres around one origin, which is exact\n// enough at walking scale (a few km) and keeps the maths to plain Euclid.\n\nexport type LngLat = [number, number];\nexport type XY = [number, number];\n\nexport interface Poi {\n  id: string;\n  name: string;\n  category: string;\n  at: LngLat;\n}\n\nexport interface AliveData {\n  parks: { name?: string; rings: LngLat[][] }[];\n  water: { lines: LngLat[][] }[];\n  roads: { kind: string; detail?: string; line: LngLat[] }[];\n  life: Poi[];\n  landmarks: Poi[];\n}\n\nexport interface Highlights {\n  /** `at`: where the route first enters the park, so a map can point at it. */\n  parks: { name: string; metres: number; at: LngLat }[];\n  life: Poi[];\n  landmarks: Poi[];\n}\n\nexport interface Scores {\n  lengthM: number;\n  green: number;\n  water: number;\n  quiet: number;\n  life: number;\n  landmarks: number;\n  highlights: Highlights;\n}\n\nexport interface Projector {\n  to: (p: LngLat) => XY;\n  from: (p: XY) => LngLat;\n}\n\n/** A line segment in projected metres. */\nexport interface Seg {\n  a: XY;\n  b: XY;\n}\n\nexport interface Grid<T> {\n  addBox(item: T, x0: number, y0: number, x1: number, y1: number): void;\n  near(p: XY, r: number): Set<T>;\n  /**\n   * Calls `fn` for every item in the cells within `r` of `p`, stopping when it returns true. An\n   * item spanning several cells is visited once per cell, so this suits a min or an any, not a\n   * count; it skips the Set `near` builds.\n   */\n  visit(p: XY, r: number, fn: (item: T) => boolean | void): void;\n}\n\nexport interface PreparedPark {\n  id: number;\n  name?: string;\n  rings: XY[][];\n  area: number;\n  /** [minX, minY, maxX, maxY] of the outer ring. */\n  box: [number, number, number, number];\n}\n\nexport interface PreparedPoi extends Poi {\n  xy: XY;\n}\n\n/**\n * The data, projected and indexed once per origin. The fields are internal: later modules (the\n * walking graph, candidate generation) read the projector and grids, nothing outside this\n * package should depend on their layout.\n */\nexport interface Prepared {\n  P: Projector;\n  segs: Grid<Seg & { quiet: number }>;\n  parks: PreparedPark[];\n  parkGrid: Grid<PreparedPark>;\n  waterGrid: Grid<Seg>;\n  life: PreparedPoi[];\n  lifeGrid: Grid<PreparedPoi>;\n  landmarks: PreparedPoi[];\n  landmarkGrid: Grid<PreparedPoi>;\n}\n\nexport const SCORE_RADII = { park: 20, water: 80, life: 35, landmark: 60, road: 18 };\n\n// ---------------------------------------------------------------------------------------------\n// Projection and geometry\n\nexport function projector([lng0, lat0]: LngLat): Projector {\n  const kx = Math.cos((lat0 * Math.PI) / 180) * 111320;\n  const ky = 110540;\n  return {\n    to: ([lng, lat]) => [(lng - lng0) * kx, (lat - lat0) * ky],\n    from: ([x, y]) => [x / kx + lng0, y / ky + lat0],\n  };\n}\n\nexport const dist = (a: XY, b: XY): number => Math.hypot(a[0] - b[0], a[1] - b[1]);\n\nexport function segDist(p: XY, a: XY, b: XY): number {\n  const dx = b[0] - a[0];\n  const dy = b[1] - a[1];\n  const l2 = dx * dx + dy * dy;\n  const t = l2 === 0 ? 0 : Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2));\n  return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));\n}\n\nexport function inRing(p: XY, ring: XY[]): boolean {\n  let inside = false;\n  for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {\n    const [xi, yi] = ring[i]!;\n    const [xj, yj] = ring[j]!;\n    if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) inside = !inside;\n  }\n  return inside;\n}\n\nexport function ringArea(ring: XY[]): number {\n  let a = 0;\n  for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) a += (ring[j]![0] + ring[i]![0]) * (ring[j]![1] - ring[i]![1]);\n  return Math.abs(a / 2);\n}\n\nexport function lineLength(xy: XY[]): number {\n  let d = 0;\n  for (let i = 1; i < xy.length; i++) d += dist(xy[i - 1]!, xy[i]!);\n  return d;\n}\n\n/** Points every `step` metres along a projected polyline. */\nexport function resample(xy: XY[], step: number): XY[] {\n  const out: XY[] = [xy[0]!];\n  let carry = 0;\n  for (let i = 1; i < xy.length; i++) {\n    const a = xy[i - 1]!;\n    const b = xy[i]!;\n    const seg = dist(a, b);\n    let t = step - carry;\n    while (t <= seg) {\n      out.push([a[0] + ((b[0] - a[0]) * t) / seg, a[1] + ((b[1] - a[1]) * t) / seg]);\n      t += step;\n    }\n    carry = (carry + seg) % step;\n  }\n  return out;\n}\n\n/** Packs a cell's two indices into one number key: exact for 2^20 cells either way of the origin. */\nconst GRID_OFFSET = 2 ** 20;\nconst GRID_SPAN = 2 ** 21;\nconst cellKey = (x: number, y: number): number => (x + GRID_OFFSET) * GRID_SPAN + (y + GRID_OFFSET);\n\n/** A spatial hash, so a 200-sample route against 20k road segments stays in the low milliseconds. */\nexport function grid<T>(cell: number): Grid<T> {\n  const m = new Map<number, T[]>();\n  return {\n    addBox(item, x0, y0, x1, y1) {\n      for (let x = Math.floor(x0 / cell); x <= Math.floor(x1 / cell); x++)\n        for (let y = Math.floor(y0 / cell); y <= Math.floor(y1 / cell); y++) {\n          const k = cellKey(x, y);\n          const list = m.get(k);\n          if (list) list.push(item);\n          else m.set(k, [item]);\n        }\n    },\n    near(p, r) {\n      const seen = new Set<T>();\n      for (let x = Math.floor((p[0] - r) / cell); x <= Math.floor((p[0] + r) / cell); x++)\n        for (let y = Math.floor((p[1] - r) / cell); y <= Math.floor((p[1] + r) / cell); y++) {\n          const list = m.get(cellKey(x, y));\n          if (list) for (const it of list) seen.add(it);\n        }\n      return seen;\n    },\n    visit(p, r, fn) {\n      for (let x = Math.floor((p[0] - r) / cell); x <= Math.floor((p[0] + r) / cell); x++)\n        for (let y = Math.floor((p[1] - r) / cell); y <= Math.floor((p[1] + r) / cell); y++) {\n          const list = m.get(cellKey(x, y));\n          if (list) for (const it of list) if (fn(it)) return;\n        }\n    },\n  };\n}\n\n// ---------------------------------------------------------------------------------------------\n// Preparing the data\n\n/**\n * How pleasant a street is to walk on, 0-1, from the Protomaps road classes. The weakest signal\n * here: road class stands in for traffic and noise, which it only roughly is.\n */\nexport function quietness(kind: string, detail?: string): number {\n  if (kind === \"path\") return detail === \"cycleway\" ? 0.8 : 1;\n  if (kind === \"minor_road\") return detail === \"service\" ? 0.6 : 0.75;\n  if (kind === \"medium_road\") return 0.35;\n  if (kind === \"major_road\") return 0.1;\n  if (kind === \"highway\") return 0;\n  return 0.6;\n}\n\nexport function prepare(data: AliveData, origin: LngLat): Prepared {\n  const P = projector(origin);\n  const segs = grid<Seg & { quiet: number }>(40);\n  const parkGrid = grid<PreparedPark>(80);\n  const waterGrid = grid<Seg>(60);\n  const lifeGrid = grid<PreparedPoi>(60);\n  const landmarkGrid = grid<PreparedPoi>(80);\n\n  for (const r of data.roads) {\n    const xy = r.line.map(P.to);\n    const quiet = quietness(r.kind, r.detail);\n    for (let i = 1; i < xy.length; i++) {\n      const a = xy[i - 1]!;\n      const b = xy[i]!;\n      segs.addBox({ a, b, quiet }, Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[0], b[0]), Math.max(a[1], b[1]));\n    }\n  }\n\n  const parks: PreparedPark[] = [];\n  data.parks.forEach((p, id) => {\n    const rings = p.rings.map((r) => r.map(P.to));\n    const outer = rings[0];\n    if (!outer || outer.length === 0) return;\n    const xs = outer.map((q) => q[0]);\n    const ys = outer.map((q) => q[1]);\n    const park: PreparedPark = {\n      id,\n      name: p.name,\n      rings,\n      area: ringArea(outer),\n      box: [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)],\n    };\n    parkGrid.addBox(park, ...park.box);\n    parks.push(park);\n  });\n\n  for (const w of data.water)\n    for (const line of w.lines) {\n      const xy = line.map(P.to);\n      for (let i = 1; i < xy.length; i++) {\n        const a = xy[i - 1]!;\n        const b = xy[i]!;\n        waterGrid.addBox({ a, b }, Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[0], b[0]), Math.max(a[1], b[1]));\n      }\n    }\n\n  const points = (list: Poi[], g: Grid<PreparedPoi>): PreparedPoi[] =>\n    list.map((p) => {\n      const xy = P.to(p.at);\n      const it = { ...p, xy };\n      g.addBox(it, xy[0], xy[1], xy[0], xy[1]);\n      return it;\n    });\n\n  return {\n    P,\n    segs,\n    parks,\n    parkGrid,\n    waterGrid,\n    life: points(data.life, lifeGrid),\n    lifeGrid,\n    landmarks: points(data.landmarks, landmarkGrid),\n    landmarkGrid,\n  };\n}\n\n// ---------------------------------------------------------------------------------------------\n// Scoring one route\n\n/** Inside the park's outer ring, or within the park radius of its edge. */\nexport function inPark(park: PreparedPark, s: XY): boolean {\n  const r = SCORE_RADII.park;\n  const [x0, y0, x1, y1] = park.box;\n  if (s[0] < x0 - r || s[0] > x1 + r || s[1] < y0 - r || s[1] > y1 + r) return false;\n  const outer = park.rings[0]!;\n  if (inRing(s, outer)) return true;\n  const [x, y] = s;\n  for (let i = 1; i < outer.length; i++) {\n    const a = outer[i - 1]!;\n    const b = outer[i]!;\n    // A side whose box is more than r away cannot be within r; skip its distance.\n    if ((x < a[0] - r && x < b[0] - r) || (x > a[0] + r && x > b[0] + r) || (y < a[1] - r && y < b[1] - r) || (y > a[1] + r && y > b[1] + r)) continue;\n    if (segDist(s, a, b) <= r) return true;\n  }\n  return false;\n}\n\n/** Whether `s` is in or beside any park: parksAt(...).length > 0 without building the list. */\nexport function inAnyPark(p: Prepared, s: XY): boolean {\n  let hit = false;\n  p.parkGrid.visit(s, SCORE_RADII.park, (park) => (hit = inPark(park, s)));\n  return hit;\n}\n\nexport function parksAt(p: Prepared, s: XY): PreparedPark[] {\n  const out: PreparedPark[] = [];\n  for (const park of p.parkGrid.near(s, SCORE_RADII.park)) if (inPark(park, s)) out.push(park);\n  return out;\n}\n\n/**\n * The loudest road within 18 m, not the way underfoot. Maps often draw a sidewalk as a separate\n * footway, so \"the nearest way\" would call a sidewalk beside a major road a quiet path. No road in\n * reach means off-street, which walks quiet.\n */\nexport function quietAt(p: Prepared, xy: XY): number {\n  let q = 1;\n  p.segs.visit(xy, SCORE_RADII.road, (seg) => {\n    if (seg.quiet < q && segDist(xy, seg.a, seg.b) <= SCORE_RADII.road) q = seg.quiet;\n    // Nothing is louder than a highway.\n    return q === 0;\n  });\n  return q;\n}\n\nexport function nearWater(p: Prepared, s: XY): boolean {\n  let hit = false;\n  p.waterGrid.visit(s, SCORE_RADII.water, (w) => (hit = segDist(s, w.a, w.b) <= SCORE_RADII.water));\n  return hit;\n}\n\nconst toPoi = ({ id, name, category, at }: PreparedPoi): Poi => ({ id, name, category, at });\n\n/**\n * Sub-scores, each 0-1, plus the named things the route passes, which is what makes a\n * recommendation explainable (\"+2 min, past Central Memorial Park and 11 cafes\").\n */\nexport function scoreRoute(p: Prepared, coords: LngLat[]): Scores {\n  const xy = coords.map(p.P.to);\n  const length = xy.length > 0 ? lineLength(xy) : 0;\n  const samples = xy.length > 0 ? resample(xy, 15) : [];\n  let green = 0;\n  let water = 0;\n  let quiet = 0;\n  const parksHit = new Map<string, number>();\n  const parkEntry = new Map<string, XY>();\n  const lifeHit = new Map<string, PreparedPoi>();\n  const landmarkHit = new Map<string, PreparedPoi>();\n\n  for (const s of samples) {\n    const here = parksAt(p, s);\n    if (here.length) green++;\n    for (const park of here)\n      if (park.name) {\n        parksHit.set(park.name, (parksHit.get(park.name) ?? 0) + 15);\n        if (!parkEntry.has(park.name)) parkEntry.set(park.name, s);\n      }\n    if (nearWater(p, s)) water++;\n    quiet += quietAt(p, s);\n    for (const q of p.lifeGrid.near(s, SCORE_RADII.life)) if (dist(s, q.xy) <= SCORE_RADII.life) lifeHit.set(q.id, q);\n    for (const q of p.landmarkGrid.near(s, SCORE_RADII.landmark)) if (dist(s, q.xy) <= SCORE_RADII.landmark) landmarkHit.set(q.id, q);\n  }\n\n  const n = Math.max(samples.length, 1);\n  const km = Math.max(length / 1000, 0.3);\n  return {\n    lengthM: Math.round(length),\n    green: green / n,\n    water: water / n,\n    quiet: samples.length ? quiet / n : 1,\n    // Per km, saturating: a street \"full of cafes\" is ~20 storefronts a km within 35 m. Per km\n    // rather than in total, so a long detour cannot win just for being long.\n    life: Math.min(lifeHit.size / km / 20, 1),\n    // Per km too: downtown is dense with public art, so a raw count saturated on every route.\n    landmarks: Math.min(landmarkHit.size / km / 5, 1),\n    highlights: {\n      // Named once the route covers 45 m of the park, so a corner graze is not a visit.\n      parks: [...parksHit.entries()]\n        .filter(([, m]) => m >= 45)\n        .sort((a, b) => b[1] - a[1])\n        .map(([name, metres]) => ({ name, metres, at: p.P.from(parkEntry.get(name)!) })),\n      life: [...lifeHit.values()].map(toPoi),\n      landmarks: [...landmarkHit.values()].map(toPoi),\n    },\n  };\n}\n\n// ---------------------------------------------------------------------------------------------\n// Words\n\nconst WORDS = {\n  en: {\n    past: \"past\",\n    and: \" and \",\n    river: \"the river\",\n    storefronts: \"storefronts\",\n    cafes: \"cafés\",\n    quiet: \"streets away from traffic\",\n    none: \"busy streets, little to see\",\n    unknown: \"no highlights found\",\n  },\n  fr: {\n    past: \"en passant par\",\n    and: \" et \",\n    river: \"la rivière\",\n    storefronts: \"commerces\",\n    cafes: \"cafés\",\n    quiet: \"rues loin de la circulation\",\n    none: \"rues passantes, peu à voir\",\n    unknown: \"aucun point marquant trouvé\",\n  },\n} as const;\n\n/**\n * A label from what the route actually passes, never from where it was aimed: a \"via\" label that\n * never enters the park would be a lie. With `basemap: false` the basemap tiles were not read, so\n * parks, the river and traffic are unknown: the label claims none of them, busy streets included.\n */\nexport function describe(s: Scores, lang: \"en\" | \"fr\", known: { basemap?: boolean } = {}): string {\n  const w = WORDS[lang];\n  const basemap = known.basemap ?? true;\n  const h = s.highlights;\n  const parts: string[] = [];\n  if (basemap && h.parks.length) parts.push(h.parks.slice(0, 2).map((p) => p.name).join(w.and));\n  if (basemap && s.water >= 0.25) parts.push(w.river);\n  const cafes = h.life.filter((p) => p.category === \"food.cafe\" || p.category === \"food.bakery\").length;\n  if (h.life.length >= 10) parts.push(`${h.life.length} ${w.storefronts}${cafes ? ` (${cafes} ${w.cafes})` : \"\"}`);\n  if (basemap && s.quiet >= 0.85) parts.push(w.quiet);\n  return parts.length ? `${w.past} ${parts.join(\", \")}` : basemap ? w.none : w.unknown;\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "registry/blocks/alive-route-explorer/alive-rank.ts",
      "content": "// Ranking: the only part the UI re-runs when a slider moves. Pure.\n\nimport type { Scores } from \"./alive-signals\";\n\nexport type Weights = { green: number; water: number; quiet: number; life: number; landmarks: number };\n\nexport const DEFAULT_WEIGHTS: Weights = { green: 1, water: 0.6, quiet: 0.8, life: 1, landmarks: 0.5 };\n\nexport function aliveness(s: Pick<Scores, keyof Weights>, w: Weights): number {\n  const total = w.green + w.water + w.quiet + w.life + w.landmarks || 1;\n  return (w.green * s.green + w.water * s.water + w.quiet * s.quiet + w.life * s.life + w.landmarks * s.landmarks) / total;\n}\n\nexport interface Ranked<C> {\n  rows: (C & { extraMin: number; score: number; eligible: boolean })[];\n  pickId: string;\n}\n\n/**\n * The best route within `budgetMin` extra minutes of the fastest. The fastest route is always\n * eligible, so with a zero budget, or when no detour beats it, the answer is the fastest route.\n * A detour must also beat the fastest by `minGain` (0-1): a minute's detour for a one-point gain\n * is not something anyone would notice on the street.\n *\n * \"The fastest\" is the candidate whose `method` is \"fastest\" (the route the engine called fastest),\n * when there is one; only without it is it the shortest duration. A detour that comes back a few\n * seconds quicker than the engine's fastest walk (a snapped spur, a rounding) must not take its\n * place as the baseline, or the extra minutes and the gain would be measured against a detour.\n */\nexport function rank<C extends { id: string; durationS: number; scores: Scores; method?: string }>(\n  candidates: C[],\n  w: Weights,\n  budgetMin: number,\n  minGain = 0.05,\n): Ranked<C> {\n  const fastest = candidates.find((c) => c.method === \"fastest\") ?? candidates.reduce((a, b) => (b.durationS < a.durationS ? b : a));\n  const base = aliveness(fastest.scores, w);\n  const rows = candidates.map((c) => {\n    const extraMin = (c.durationS - fastest.durationS) / 60;\n    const score = aliveness(c.scores, w);\n    const eligible = extraMin <= budgetMin + 1e-9 && (c === fastest || score - base >= minGain);\n    return { ...c, extraMin, score, eligible };\n  });\n  rows.sort((a, b) => b.score - a.score);\n  const pick = rows.find((r) => r.eligible) ?? rows[0]!;\n  return { rows, pickId: pick.id };\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "registry/blocks/alive-route-explorer/alive-data.ts",
      "content": "// Turning decoded basemap tiles and /places results into AliveData. Pure: the caller decodes the\n// tiles (for example with `tileFeatures` from @unmap/maps) and fetches the places.\n\nimport type { AliveData, LngLat, Poi } from \"./alive-signals\";\n\nexport const LIFE_CATEGORIES = [\"food.cafe\", \"food.bakery\", \"food.restaurant\", \"food.bar\", \"food.pub\", \"food.ice_cream\", \"shop.books\", \"shop.clothes\"] as const;\nexport const LANDMARK_CATEGORIES = [\"culture.museum\", \"culture.gallery\", \"culture.theatre\", \"culture.arts_centre\", \"culture.library\", \"tourism.attraction\", \"tourism.viewpoint\", \"tourism.artwork\"] as const;\n\nconst PARK_KINDS = new Set([\"park\", \"garden\", \"nature_reserve\", \"forest\", \"wood\", \"village_green\", \"dog_park\", \"playground\"]);\nconst NOT_WATER = new Set([\"swimming_pool\", \"fountain\"]);\nconst NOT_ROADS = new Set([\"rail\", \"ferry\"]);\n\n/** The part of a GeoJSON feature this module reads, declared here so the block imports no types. */\nexport interface TileFeature {\n  properties?: Record<string, unknown> | null;\n  geometry: { type: string; coordinates?: unknown } | null;\n}\n\ntype Pos = number[];\n\nconst str = (v: unknown): string | undefined => (typeof v === \"string\" && v ? v : undefined);\nconst asRing = (r: Pos[]): LngLat[] => r.map((p) => [p[0]!, p[1]!]);\n\nfunction inRingLngLat(p: LngLat, ring: LngLat[]): boolean {\n  let inside = false;\n  for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {\n    const [xi, yi] = ring[i]!;\n    const [xj, yj] = ring[j]!;\n    if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) inside = !inside;\n  }\n  return inside;\n}\n\nexport function dataFromTiles(features: Record<string, TileFeature[]>): Omit<AliveData, \"life\" | \"landmarks\"> {\n  const parks: AliveData[\"parks\"] = [];\n  const water: AliveData[\"water\"] = [];\n  const roads: AliveData[\"roads\"] = [];\n\n  for (const f of features.landuse ?? []) {\n    const kind = str(f.properties?.kind);\n    if (!kind || !PARK_KINDS.has(kind) || !f.geometry) continue;\n    const c = f.geometry.coordinates;\n    const polys = f.geometry.type === \"Polygon\" ? [c as Pos[][]] : f.geometry.type === \"MultiPolygon\" ? (c as Pos[][][]) : [];\n    for (const rings of polys) parks.push({ name: str(f.properties?.name), rings: rings.map(asRing) });\n  }\n\n  for (const f of features.water ?? []) {\n    const kind = str(f.properties?.kind);\n    if ((kind && NOT_WATER.has(kind)) || !f.geometry) continue;\n    const c = f.geometry.coordinates;\n    const t = f.geometry.type;\n    const lines =\n      t === \"Polygon\" ? (c as Pos[][]) : t === \"MultiPolygon\" ? (c as Pos[][][]).flat() : t === \"LineString\" ? [c as Pos[]] : t === \"MultiLineString\" ? (c as Pos[][]) : [];\n    if (lines.length) water.push({ lines: lines.map(asRing) });\n  }\n\n  for (const f of features.roads ?? []) {\n    const kind = str(f.properties?.kind);\n    if (!kind || NOT_ROADS.has(kind) || !f.geometry) continue;\n    const c = f.geometry.coordinates;\n    const lines = f.geometry.type === \"LineString\" ? [c as Pos[]] : f.geometry.type === \"MultiLineString\" ? (c as Pos[][]) : [];\n    for (const line of lines) roads.push({ kind, detail: str(f.properties?.kind_detail), line: asRing(line) });\n  }\n\n  // Protomaps landuse polygons carry no name; a park's name rides on a pois point inside it.\n  const named: { name: string; at: LngLat }[] = [];\n  for (const f of features.pois ?? []) {\n    const kind = str(f.properties?.kind);\n    const name = str(f.properties?.name);\n    if (kind && PARK_KINDS.has(kind) && name && f.geometry?.type === \"Point\") {\n      const [x, y] = f.geometry.coordinates as Pos;\n      named.push({ name, at: [x!, y!] });\n    }\n  }\n  for (const p of parks) {\n    const outer = p.rings[0];\n    if (!p.name && outer) p.name = named.find((n) => inRingLngLat(n.at, outer))?.name;\n  }\n\n  return { parks, water, roads };\n}\n\nexport function poisFromPlaces(places: { id: string; name: string; category: string | null; coordinates: [number, number] }[]): Poi[] {\n  return places.map((p) => ({ id: p.id, name: p.name, category: p.category ?? \"\", at: p.coordinates }));\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "registry/blocks/alive-route-explorer/alive-graph.ts",
      "content": "// An aliveness-weighted walking graph, built from the basemap's road lines. Pleasant streets\n// get cheaper, and a shortest-path search finds the route. Pure: no React, no I/O.\n//\n// The graph is good enough to find a path, not to time one; the engine re-routes through it so\n// durations and geometry stay the engine's.\n\nimport { aliveness, type Weights } from \"./alive-rank\";\nimport {\n  SCORE_RADII,\n  dist,\n  grid,\n  inAnyPark,\n  nearWater,\n  quietAt,\n  resample,\n  segDist,\n  type AliveData,\n  type Grid,\n  type LngLat,\n  type Prepared,\n  type PreparedPoi,\n  type XY,\n} from \"./alive-signals\";\n\n/** Snap tolerance for joining vertices, and the reach for joining a dangling end to a segment. */\nconst SNAP_M = 1.5;\nconst DANGLE_M = 4;\n/** Signal samples along an edge, every this many metres from its first end. */\nconst SAMPLE_M = 10;\n\n/** Gamma ladder: how hard each candidate route leans toward pleasant streets. */\nexport const GAMMAS = [0.5, 1, 1.5, 2, 3] as const;\n\ninterface Signals {\n  green: number;\n  water: number;\n  quiet: number;\n  life: number;\n  landmarks: number;\n}\n\ninterface Edge {\n  a: number;\n  b: number;\n  len: number;\n  dead: boolean;\n  sig: Signals;\n}\n\nexport interface WalkGraph {\n  nodes: number;\n  edges: number;\n  joined: number;\n}\n\ninterface GraphInternals extends WalkGraph {\n  xy: XY[];\n  adj: Edge[][];\n  nodeGrid: ReturnType<typeof grid<number>>;\n}\n\nexport function buildWalkGraph(p: Prepared, roads: AliveData[\"roads\"]): GraphInternals {\n  const xy: XY[] = [];\n  const adj: Edge[][] = [];\n  const nodeGrid = grid<number>(10);\n  const noSig: Signals = { green: 0, water: 0, quiet: 1, life: 0, landmarks: 0 };\n\n  const addNode = (q: XY): number => {\n    const id = xy.length;\n    xy.push(q);\n    adj.push([]);\n    nodeGrid.addBox(id, q[0], q[1], q[0], q[1]);\n    return id;\n  };\n  const nodeAt = (q: XY): number => {\n    let found = -1;\n    nodeGrid.visit(q, SNAP_M, (id) => {\n      if (dist(xy[id]!, q) > SNAP_M) return false;\n      found = id;\n      return true;\n    });\n    return found >= 0 ? found : addNode(q);\n  };\n\n  const edges: Edge[] = [];\n  const link = (a: number, b: number): Edge | null => {\n    if (a === b) return null;\n    const e: Edge = { a, b, len: dist(xy[a]!, xy[b]!), dead: false, sig: noSig };\n    edges.push(e);\n    adj[a]!.push(e);\n    adj[b]!.push(e);\n    return e;\n  };\n\n  // Highways are not walkable, so they never become edges (they still count as loud roads in\n  // quietAt, which reads the prepared segments).\n  for (const r of roads) {\n    if (r.kind === \"highway\") continue;\n    const line = r.line.map(p.P.to);\n    for (let i = 1; i < line.length; i++) link(nodeAt(line[i - 1]!), nodeAt(line[i]!));\n  }\n\n  // Tile clipping and line simplification leave T-junctions and tile seams unjoined: a way's end\n  // sits a metre or two off the street it meets. Split that street and join them.\n  const boxOf = (e: Edge): [number, number, number, number] => {\n    const a = xy[e.a]!;\n    const b = xy[e.b]!;\n    return [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[0], b[0]), Math.max(a[1], b[1])];\n  };\n  const edgeGrid = grid<Edge>(30);\n  for (const e of edges) edgeGrid.addBox(e, ...boxOf(e));\n\n  let joined = 0;\n  const dangling: number[] = [];\n  adj.forEach((list, id) => {\n    if (list.length === 1) dangling.push(id);\n  });\n  for (const id of dangling) {\n    const q = xy[id]!;\n    // Typed by assertion: assigned only inside the visit callback, which flow analysis cannot see.\n    let best = null as Edge | null;\n    let bestD = DANGLE_M;\n    edgeGrid.visit(q, DANGLE_M, (e) => {\n      if (e.dead || e.a === id || e.b === id) return;\n      const d = segDist(q, xy[e.a]!, xy[e.b]!);\n      if (d < bestD) {\n        bestD = d;\n        best = e;\n      }\n    });\n    if (!best) continue;\n    const a = xy[best.a]!;\n    const b = xy[best.b]!;\n    const dx = b[0] - a[0];\n    const dy = b[1] - a[1];\n    const t = Math.max(0, Math.min(1, ((q[0] - a[0]) * dx + (q[1] - a[1]) * dy) / (dx * dx + dy * dy || 1)));\n    const foot: XY = [a[0] + t * dx, a[1] + t * dy];\n    const end = dist(foot, a) <= SNAP_M ? best.a : dist(foot, b) <= SNAP_M ? best.b : null;\n    if (end !== null) {\n      link(id, end);\n    } else {\n      best.dead = true;\n      adj[best.a] = adj[best.a]!.filter((e) => e !== best);\n      adj[best.b] = adj[best.b]!.filter((e) => e !== best);\n      const m = addNode(foot);\n      link(best.a, m);\n      link(m, best.b);\n      link(id, m);\n      for (const e of adj[m]!) edgeGrid.addBox(e, ...boxOf(e));\n    }\n    joined++;\n  }\n\n  // Per-edge signals, the same five as scoreRoute, computed once. Weights apply later. Every\n  // sample gets exactly what quietAt, parksAt, nearWater and the place grids would tell it (a\n  // test holds the build to that); what changed is how often the grids are asked. Places and\n  // water are gathered once per edge, and the per-sample lookups visit grid cells without\n  // building a Set. A place is a point, so it sits in one grid cell and a visit meets it once.\n  const lifeBuf: PreparedPoi[] = [];\n  const landmarkBuf: PreparedPoi[] = [];\n  const pointsNear = (g: Grid<PreparedPoi>, at: XY, r: number, into: PreparedPoi[]): PreparedPoi[] => {\n    into.length = 0;\n    g.visit(at, r, (q) => {\n      into.push(q);\n    });\n    return into;\n  };\n  /** Metres to the nearest water within `r` of `at`, or Infinity. */\n  const waterWithin = (at: XY, r: number): number => {\n    let d = Infinity;\n    p.waterGrid.visit(at, r, (w) => {\n      d = Math.min(d, segDist(at, w.a, w.b));\n    });\n    return d;\n  };\n  const live = edges.filter((e) => !e.dead);\n  for (const e of live) {\n    const a = xy[e.a]!;\n    const b = xy[e.b]!;\n    const pts = resample([a, b], SAMPLE_M);\n    const mid: XY = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];\n    const half = e.len / 2;\n    const lifeNear = pointsNear(p.lifeGrid, mid, half + SCORE_RADII.life, lifeBuf);\n    const landmarksNear = pointsNear(p.landmarkGrid, mid, half + SCORE_RADII.landmark, landmarkBuf);\n    // Every sample is within `half` of the midpoint, so the water nearest the midpoint settles\n    // the whole edge unless it lies within `half` of the 80 m reach.\n    const dw = waterWithin(mid, half + SCORE_RADII.water);\n    const wet = dw + half <= SCORE_RADII.water ? \"all\" : dw - half > SCORE_RADII.water ? \"none\" : \"some\";\n    let green = 0;\n    let water = 0;\n    let quiet = 0;\n    const life = new Set<string>();\n    const landmarks = new Set<string>();\n    for (const s of pts) {\n      quiet += quietAt(p, s);\n      if (inAnyPark(p, s)) green++;\n      if (wet === \"all\" || (wet === \"some\" && nearWater(p, s))) water++;\n      for (const q of lifeNear) if (dist(s, q.xy) <= SCORE_RADII.life) life.add(q.id);\n      for (const q of landmarksNear) if (dist(s, q.xy) <= SCORE_RADII.landmark) landmarks.add(q.id);\n    }\n    // Storefront density smoothed over 100 m, so a 5 m edge beside one cafe is not \"100%\".\n    const km = e.len / 1000 + 0.1;\n    e.sig = {\n      green: green / pts.length,\n      water: water / pts.length,\n      quiet: quiet / pts.length,\n      life: Math.min(life.size / km / 20, 1),\n      landmarks: Math.min(landmarks.size / km / 5, 1),\n    };\n  }\n  return { xy, adj, nodeGrid, nodes: xy.length, edges: live.length, joined };\n}\n\nfunction nearestNode(g: GraphInternals, q: XY): number {\n  for (let r = 10; r <= 640; r *= 2) {\n    let best = -1;\n    let bestD = Infinity;\n    for (const id of g.nodeGrid.near(q, r)) {\n      const d = dist(g.xy[id]!, q);\n      if (d < bestD && g.adj[id]!.length) {\n        bestD = d;\n        best = id;\n      }\n    }\n    if (best >= 0) return best;\n  }\n  return -1;\n}\n\n/**\n * Cheapest path where an edge costs `len * exp(-gamma * aliveness)`. gamma 0 is the plain\n * shortest walk; gamma 2 makes a perfect street cost 14% of its length and an average one (0.3)\n * 55%. A linear `len * (1 - beta * p)` barely moved the path: a 25% longer detour needs a\n * pleasantness gap the data rarely shows. Null if the graph does not connect the two ends.\n */\nexport function alivePath(p: Prepared, g: GraphInternals, from: LngLat, to: LngLat, w: Weights, gamma: number): LngLat[] | null {\n  const s = nearestNode(g, p.P.to(from));\n  const t = nearestNode(g, p.P.to(to));\n  if (s < 0 || t < 0) return null;\n\n  const cost = new Float64Array(g.nodes).fill(Infinity);\n  const prev = new Int32Array(g.nodes).fill(-1);\n  cost[s] = 0;\n\n  // Binary min-heap on [cost, node].\n  const heap: [number, number][] = [[0, s]];\n  const push = (item: [number, number]) => {\n    heap.push(item);\n    let i = heap.length - 1;\n    while (i > 0) {\n      const parent = (i - 1) >> 1;\n      if (heap[parent]![0] <= heap[i]![0]) break;\n      [heap[parent], heap[i]] = [heap[i]!, heap[parent]!];\n      i = parent;\n    }\n  };\n  const pop = (): [number, number] => {\n    const top = heap[0]!;\n    const last = heap.pop()!;\n    if (heap.length) {\n      heap[0] = last;\n      let i = 0;\n      for (;;) {\n        const l = 2 * i + 1;\n        const r = l + 1;\n        let m = i;\n        if (l < heap.length && heap[l]![0] < heap[m]![0]) m = l;\n        if (r < heap.length && heap[r]![0] < heap[m]![0]) m = r;\n        if (m === i) break;\n        [heap[m], heap[i]] = [heap[i]!, heap[m]!];\n        i = m;\n      }\n    }\n    return top;\n  };\n\n  while (heap.length) {\n    const [c, u] = pop();\n    if (c > cost[u]!) continue;\n    if (u === t) break;\n    for (const e of g.adj[u]!) {\n      const v = e.a === u ? e.b : e.a;\n      const nc = c + e.len * Math.exp(-gamma * aliveness(e.sig, w));\n      if (nc < cost[v]!) {\n        cost[v] = nc;\n        prev[v] = u;\n        push([nc, v]);\n      }\n    }\n  }\n  if (cost[t] === Infinity) return null;\n  const ids: number[] = [];\n  for (let u = t; u !== -1; u = prev[u]!) ids.push(u);\n  return ids.reverse().map((id) => p.P.from(g.xy[id]!));\n}\n\n/** At most `maxPoints` points, evenly spaced by index, always keeping both ends. */\nexport function resampleTo(coords: LngLat[], maxPoints: number): LngLat[] {\n  if (coords.length <= maxPoints || maxPoints < 2) return coords.length <= maxPoints ? coords : [coords[0]!, coords[coords.length - 1]!];\n  const out: LngLat[] = [];\n  for (let i = 0; i < maxPoints; i++) out.push(coords[Math.round((i * (coords.length - 1)) / (maxPoints - 1))]!);\n  return out;\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "registry/blocks/alive-route-explorer/alive-candidates.ts",
      "content": "// Candidate detours for Alive Maps: where a walk could go out of its way, how two routing legs\n// join into one walk, and how much two walks share. Pure: no React, no I/O.\n\nimport { dist, grid, lineLength, resample, segDist, type LngLat, type Prepared, type Seg, type XY } from \"./alive-signals\";\n\nexport type ViaKind = \"park\" | \"life\" | \"landmark\" | \"water\";\n\nexport interface Via {\n  kind: ViaKind;\n  label: string;\n  via: LngLat;\n}\n\n/** A via this close to either end is not a detour, just a slightly different start. */\nconst END_CLEARANCE_M = 120;\n/** Parks smaller than this (about a city lot) are not worth walking to. */\nconst MIN_PARK_M2 = 4000;\n/** How far a park via is pulled from its nearest edge toward its centroid, so the walk enters it. */\nconst PARK_PULL = 0.35;\nconst LIFE_CELL_M = 120;\nconst LIFE_MIN_PLACES = 5;\nconst RIVER_CELL_M = 300;\n/** Two vias closer than this produce nearly the same walk, so only the better one is routed. */\nconst VIA_DEDUPE_M = 150;\n/** Joining legs: consecutive points this close on both legs are one out-and-back spur. */\nconst SPUR_M = 12;\n/** Overlap: a point of one walk within this of the other is shared. */\nconst SHARED_M = 15;\n\nfunction centroid(pts: XY[]): XY {\n  let x = 0;\n  let y = 0;\n  for (const p of pts) {\n    x += p[0];\n    y += p[1];\n  }\n  return [x / pts.length, y / pts.length];\n}\n\n/**\n * Places worth walking past, as via points, inside the ellipse a walker might accept\n * (|PA| + |PB| <= stretch * |AB|). Each becomes one A to via to B walk. Ordered most promising\n * first and capped at `max`, because every via costs two routing calls.\n */\nexport function candidateVias(p: Prepared, from: LngLat, to: LngLat, opts: { stretch?: number; max?: number } = {}): Via[] {\n  const stretch = opts.stretch ?? 1.45;\n  const max = opts.max ?? 6;\n  const A = p.P.to(from);\n  const B = p.P.to(to);\n  const D = dist(A, B);\n  const inEllipse = (q: XY): boolean => dist(q, A) + dist(q, B) <= D * stretch && dist(q, A) > END_CLEARANCE_M && dist(q, B) > END_CLEARANCE_M;\n  const out: { kind: ViaKind; label: string; xy: XY; value: number }[] = [];\n\n  // Parks: the park vertex nearest the straight line, pulled toward the centroid, so the route\n  // goes into the park rather than grazing its fence.\n  for (const park of p.parks) {\n    const outer = park.rings[0];\n    if (park.area < MIN_PARK_M2 || !outer || outer.length === 0) continue;\n    const c = centroid(outer);\n    let nearest = c;\n    let bestD = Infinity;\n    for (const v of outer) {\n      const d = segDist(v, A, B);\n      if (d < bestD) {\n        bestD = d;\n        nearest = v;\n      }\n    }\n    const xy: XY = [nearest[0] + (c[0] - nearest[0]) * PARK_PULL, nearest[1] + (c[1] - nearest[1]) * PARK_PULL];\n    if (!inEllipse(xy)) continue;\n    out.push({ kind: \"park\", label: park.name ?? \"unnamed park\", xy, value: Math.min(park.area / 40000, 1.5) });\n  }\n\n  // Lively blocks: cells dense with cafes and shops. A lone cafe is not a reason to detour.\n  const cells = new Map<string, XY[]>();\n  for (const q of p.life) {\n    if (!inEllipse(q.xy)) continue;\n    const k = `${Math.floor(q.xy[0] / LIFE_CELL_M)},${Math.floor(q.xy[1] / LIFE_CELL_M)}`;\n    const list = cells.get(k);\n    if (list) list.push(q.xy);\n    else cells.set(k, [q.xy]);\n  }\n  for (const pts of cells.values()) {\n    if (pts.length < LIFE_MIN_PLACES) continue;\n    out.push({ kind: \"life\", label: `${pts.length} cafés and shops`, xy: centroid(pts), value: Math.min(pts.length / 10, 1.5) });\n  }\n\n  for (const q of p.landmarks) if (inEllipse(q.xy)) out.push({ kind: \"landmark\", label: q.name, xy: q.xy, value: 0.6 });\n\n  // Riverside: water edge midpoints inside the ellipse, at most one per cell, so a long river\n  // does not crowd out everything else.\n  const riverCells = new Map<string, XY>();\n  for (const w of p.waterGrid.near([(A[0] + B[0]) / 2, (A[1] + B[1]) / 2], D * stretch)) {\n    const m: XY = [(w.a[0] + w.b[0]) / 2, (w.a[1] + w.b[1]) / 2];\n    if (!inEllipse(m)) continue;\n    const k = `${Math.floor(m[0] / RIVER_CELL_M)},${Math.floor(m[1] / RIVER_CELL_M)}`;\n    if (!riverCells.has(k)) riverCells.set(k, m);\n  }\n  for (const m of riverCells.values()) out.push({ kind: \"water\", label: \"riverside\", xy: m, value: 0.8 });\n\n  // Prefer high value and a small detour, then drop anything near a better pick.\n  const ranked = out\n    .map((c) => ({ ...c, rank: c.value / ((dist(c.xy, A) + dist(c.xy, B)) / (D || 1)) }))\n    .sort((a, b) => b.rank - a.rank);\n  const kept: typeof ranked = [];\n  for (const c of ranked) {\n    if (kept.length >= max) break;\n    if (kept.some((k) => dist(k.xy, c.xy) < VIA_DEDUPE_M)) continue;\n    kept.push(c);\n  }\n  return kept.map((c) => ({ kind: c.kind, label: c.label, via: p.P.from(c.xy) }));\n}\n\n/**\n * Join A to via and via to B, trimming the out-and-back spur the router adds when the via snaps\n * to a dead end or the far side of a block. Returns the joined line and the metres trimmed.\n */\nexport function joinLegs(p: Prepared, a: LngLat[], b: LngLat[]): { coords: LngLat[]; trimmedM: number } {\n  if (a.length === 0) return { coords: b, trimmedM: 0 };\n  if (b.length === 0) return { coords: a, trimmedM: 0 };\n  const ax = a.map(p.P.to);\n  const bx = b.map(p.P.to);\n  let i = ax.length - 1;\n  let j = 0;\n  while (i > 0 && j < bx.length - 1 && dist(ax[i - 1]!, bx[j + 1]!) < SPUR_M) {\n    i--;\n    j++;\n  }\n  const trimmedM = lineLength(ax.slice(i)) + lineLength(bx.slice(0, j + 1));\n  return { coords: [...a.slice(0, i + 1), ...b.slice(j + 1)], trimmedM };\n}\n\n/** Share of `a` within 15 m of `b`: 1 means `a` walks entirely along `b`. Not symmetric. */\nexport function overlap(p: Prepared, a: LngLat[], b: LngLat[]): number {\n  if (a.length === 0 || b.length === 0) return 0;\n  const r = grid<Seg>(30);\n  const bx = b.map(p.P.to);\n  // A one-point reference is a zero-length segment, so a lone point still counts as shared.\n  if (bx.length === 1) bx.push(bx[0]!);\n  for (let i = 1; i < bx.length; i++) {\n    const s = bx[i - 1]!;\n    const e = bx[i]!;\n    r.addBox({ a: s, b: e }, Math.min(s[0], e[0]), Math.min(s[1], e[1]), Math.max(s[0], e[0]), Math.max(s[1], e[1]));\n  }\n  const samples = resample(a.map(p.P.to), 20);\n  let hit = 0;\n  for (const q of samples)\n    for (const seg of r.near(q, SHARED_M))\n      if (segDist(q, seg.a, seg.b) <= SHARED_M) {\n        hit++;\n        break;\n      }\n  return hit / samples.length;\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "registry/blocks/alive-route-explorer/alive-walks.ts",
      "content": "// Finding candidate walks for Alive Maps. The orchestrator: it asks injected deps for the fastest\n// walk, the basemap and the places between the two ends, makes detours two ways (a via routed by the engine,\n// and a path from the aliveness-weighted graph snapped by map matching), and scores them all.\n// Pure apart from the deps, so the UI adapts the SDK to WalkDeps and tests pass fakes.\n\nimport { candidateVias, joinLegs, overlap } from \"./alive-candidates\";\nimport { LANDMARK_CATEGORIES, LIFE_CATEGORIES, dataFromTiles, type TileFeature } from \"./alive-data\";\nimport { GAMMAS, alivePath, buildWalkGraph, resampleTo } from \"./alive-graph\";\nimport { DEFAULT_WEIGHTS } from \"./alive-rank\";\nimport { dist, prepare, projector, resample, scoreRoute, segDist, type AliveData, type LngLat, type Poi, type Scores, type XY } from \"./alive-signals\";\n\nexport type Method = \"detour\" | \"graph\";\n\nexport interface Candidate {\n  id: string;\n  method: Method | \"fastest\";\n  coords: LngLat[];\n  durationS: number;\n  distanceM: number;\n  /** Graph paths only: the share of the graph path the matched walk actually follows. */\n  fidelity?: number;\n  scores: Scores;\n}\n\n/** What `/route` and `/match` answer, as far as this module reads it. */\nexport interface WalkLeg {\n  geometry: { coordinates: LngLat[] };\n  durationSeconds: number;\n  distanceMeters: number;\n}\n\nexport interface WalkDeps {\n  route(from: LngLat, to: LngLat, signal: AbortSignal): Promise<WalkLeg>;\n  match(coords: LngLat[], signal: AbortSignal): Promise<WalkLeg>;\n  /** Throws an object with a numeric `status` on an HTTP error. */\n  placesAlong(line: LngLat[], categories: readonly string[], radius: number, signal: AbortSignal): Promise<Poi[]>;\n  /**\n   * Decoded basemap features by layer, and how many tile requests that took. Rejects with a\n   * `RangeError` when the box needs more tiles than the reader allows, before fetching anything.\n   * Any other rejection may carry a numeric `requests` (the tile fetches it made); without one,\n   * every tile in the box at z14 is assumed fetched, because the reader issues them all at once\n   * and fails on the first bad answer. GeoJSON features satisfy\n   * `TileFeature`, which is declared in the block so it imports no types.\n   */\n  tiles(bbox: [number, number, number, number], signal: AbortSignal): Promise<{ features: Record<string, TileFeature[]>; requests: number }>;\n}\n\nexport interface WalkResult {\n  /** Fastest first, then detours, then graph paths, with repeats of the same walk dropped. */\n  candidates: Candidate[];\n  /** Every route, match, places and tile request made. */\n  calls: number;\n  /** False after a 503 from placesAlong: street life and landmarks then score 0 everywhere. */\n  placesAvailable: boolean;\n  /**\n   * False when the basemap tiles were refused (`graphSkipped` \"too-big\") or failed (\"tiles-failed\").\n   * Parks, water and roads are then empty, so every walk scores green 0, water 0 and quiet 1 as if\n   * measured; those three scores are unknown, not measured, and a ranking must leave them out.\n   */\n  basemapAvailable: boolean;\n  graphSkipped?: \"too-big\" | \"tiles-failed\";\n  /** Candidates whose route or match call failed (not aborted), each counted once. Stated, not filled. */\n  skipped: number;\n  tooShort?: true;\n}\n\nexport const CALL_BUDGET = 40;\n\n/**\n * The most basemap tiles one search reads. The first wave (the fastest route, the tiles and the\n * two corridor places calls) goes out with no budget check, so this must leave room in\n * CALL_BUDGET for it and at least three detours (six route calls): 1 + 24 + 2 + 6 = 33. Detours,\n * matches and top-ups check the budget as each is sent, so whatever the tiles leave is all they\n * spend.\n */\nexport const MAX_TILES = 24;\n\n/** Under this straight-line distance any detour would be most of the walk. */\nconst TOO_SHORT_M = 200;\n/** The ellipse a walker might accept, as in candidateVias. */\nconst STRETCH = 1.45;\n/** Tiles reach past the ellipse so a via on its rim still has streets around it. */\nconst TILE_MARGIN_M = 150;\n/** Corridor places: the widest radius /places/along allows, so most detours stay inside it. */\nconst CORRIDOR_M = 500;\n/** A candidate this far from the corridor line leaves the corridor (500 m less the 35-60 m scoring reach). */\nconst STRAY_M = 450;\n/** Top-up radius: scoring looks at most 60 m off a route, so this is ample. */\nconst TOP_UP_M = 100;\n/** Two walks that each share this much of the other are the same walk. */\nconst SAME_WALK = 0.92;\n/** Two graph paths this alike would match to the same walk, so the second is not worth a call. */\nconst SAME_PATH = 0.95;\n/** /match accepts at most this many points. */\nconst MAX_MATCH_POINTS = 100;\n\nconst abortError = (): DOMException => new DOMException(\"Aborted\", \"AbortError\");\nconst isAbort = (e: unknown): boolean => typeof e === \"object\" && e !== null && (e as { name?: unknown }).name === \"AbortError\";\nconst requestsOf = (e: unknown): number | undefined => {\n  const n = typeof e === \"object\" && e !== null && \"requests\" in e ? (e as { requests: unknown }).requests : undefined;\n  return typeof n === \"number\" && Number.isFinite(n) && n >= 0 ? n : undefined;\n};\nconst statusOf = (e: unknown): unknown => (typeof e === \"object\" && e !== null && \"status\" in e ? (e as { status: unknown }).status : undefined);\n\n/** The lng/lat box of the A-B ellipse plus a margin, computed in local metres. */\nfunction ellipseBox(P: ReturnType<typeof projector>, from: LngLat, to: LngLat): [number, number, number, number] {\n  const A = P.to(from);\n  const B = P.to(to);\n  const D = dist(A, B);\n  const a = (STRETCH * D) / 2;\n  const b = Math.sqrt(Math.max(a * a - (D / 2) * (D / 2), 0));\n  const th = Math.atan2(B[1] - A[1], B[0] - A[0]);\n  const hx = Math.hypot(a * Math.cos(th), b * Math.sin(th)) + TILE_MARGIN_M;\n  const hy = Math.hypot(a * Math.sin(th), b * Math.cos(th)) + TILE_MARGIN_M;\n  const mx = (A[0] + B[0]) / 2;\n  const my = (A[1] + B[1]) / 2;\n  const [w, s] = P.from([mx - hx, my - hy]);\n  const [e, n] = P.from([mx + hx, my + hy]);\n  return [w, s, e, n];\n}\n\n/** The zoom the tile reader reads at (the deepest the gateway serves). */\nconst TILE_ZOOM = 14;\n\n/**\n * How many z14 tiles a box touches, by the same slippy-map formula the tile reader uses. The\n * fallback count for a failed read that did not say how many fetches it made.\n */\nfunction tilesInBox([west, south, east, north]: [number, number, number, number]): number {\n  const n = 2 ** TILE_ZOOM;\n  const clamp = (v: number) => Math.min(Math.max(v, 0), n - 1);\n  const tx = (lng: number) => clamp(Math.floor(((lng + 180) / 360) * n));\n  const ty = (lat: number) => clamp(Math.floor(((1 - Math.asinh(Math.tan((lat * Math.PI) / 180)) / Math.PI) / 2) * n));\n  return (tx(east) - tx(west) + 1) * (ty(south) - ty(north) + 1);\n}\n\n/** The farthest any part of `coords` gets from the polyline `ref`, sampled every 50 m. */\nfunction strayM(P: ReturnType<typeof projector>, coords: LngLat[], ref: XY[]): number {\n  if (coords.length === 0 || ref.length === 0) return 0;\n  const segs = ref.length === 1 ? [[ref[0]!, ref[0]!] as const] : ref.slice(1).map((q, i) => [ref[i]!, q] as const);\n  let worst = 0;\n  for (const s of resample(coords.map(P.to), 50)) {\n    let near = Infinity;\n    for (const [a, b] of segs) near = Math.min(near, segDist(s, a, b));\n    worst = Math.max(worst, near);\n  }\n  return worst;\n}\n\nfunction mergePois(into: Poi[], more: Poi[]): number {\n  const seen = new Set(into.map((q) => q.id));\n  let added = 0;\n  for (const q of more)\n    if (!seen.has(q.id)) {\n      seen.add(q.id);\n      into.push(q);\n      added++;\n    }\n  return added;\n}\n\n/** A call's outcome, so a call nobody ends up waiting for cannot surface as an unhandled rejection. */\ntype Settled<T> = { ok: true; value: T } | { ok: false; error: unknown };\n\n/** Sends now (a synchronous throw counts as a rejection) and never rejects. */\nfunction settle<T>(send: () => Promise<T>): Promise<Settled<T>> {\n  let sent: Promise<T>;\n  try {\n    sent = send();\n  } catch (e) {\n    sent = Promise.reject(e);\n  }\n  return sent.then(\n    (value) => ({ ok: true as const, value }),\n    (error: unknown) => ({ ok: false as const, error }),\n  );\n}\n\n/**\n * A turn of the event loop, so the requests just sent leave before the graph build holds the\n * thread (a fetch can need one to go out) and answers already in can be read. Not a timer when\n * anything else will do: a hidden tab holds a 0 ms timer back for up to a second.\n */\nfunction yieldToEvents(): Promise<void> {\n  const scheduler = (globalThis as { scheduler?: { yield?: () => Promise<void> } }).scheduler;\n  if (typeof scheduler?.yield === \"function\") return scheduler.yield();\n  if (typeof MessageChannel === \"function\") {\n    return new Promise((resolve) => {\n      const channel = new MessageChannel();\n      channel.port1.onmessage = () => {\n        channel.port1.close();\n        resolve();\n      };\n      channel.port2.postMessage(null);\n    });\n  }\n  return new Promise((resolve) => setTimeout(resolve, 0));\n}\n\ntype Draft = Omit<Candidate, \"scores\">;\n\n/**\n * Every independent call goes out as soon as what it needs is known, in three waves: the fastest\n * route, the basemap and the corridor places together; then every detour leg at once, with the\n * graph built while they fly and every match sent right after; then a places top-up for each\n * kept candidate that strays, sent as soon as it and every candidate before it have answered (so\n * dedupe has settled whether it is kept). Counting at issue is exact\n * (JS runs one thing at a time), so the budget holds, and it is spent in the same priority as\n * ever: detours, then matches, then top-ups.\n */\nexport async function findWalks(deps: WalkDeps, from: LngLat, to: LngLat, signal: AbortSignal): Promise<WalkResult> {\n  const run = { done: false };\n  try {\n    return await search(deps, from, to, signal, run);\n  } finally {\n    // Matches still out when the search settles (a rejection included) must send nothing.\n    run.done = true;\n  }\n}\n\nasync function search(deps: WalkDeps, from: LngLat, to: LngLat, signal: AbortSignal, run: { done: boolean }): Promise<WalkResult> {\n  let calls = 0;\n  let skipped = 0;\n  const guard = (): void => {\n    if (signal.aborted) throw abortError();\n  };\n  /** A failure from a dep: an abort always propagates; anything else is the caller's to judge. */\n  const rethrowAbort = (e: unknown): void => {\n    if (signal.aborted) throw abortError();\n    if (isAbort(e)) throw e;\n  };\n  const fits = (n: number): boolean => calls + n <= CALL_BUDGET;\n  /** One call, guarded, counted and sent now. */\n  const issue = <T>(send: () => Promise<T>): Promise<Settled<T>> => {\n    guard();\n    calls++;\n    return settle(send);\n  };\n\n  const P = projector(from);\n  guard();\n  if (dist(P.to(from), P.to(to)) < TOO_SHORT_M) {\n    calls++;\n    const fastest = await deps.route(from, to, signal);\n    const coords = fastest.geometry.coordinates;\n    const empty = prepare({ parks: [], water: [], roads: [], life: [], landmarks: [] }, from);\n    return {\n      candidates: [{ id: \"fastest\", method: \"fastest\", coords, durationS: fastest.durationSeconds, distanceM: fastest.distanceMeters, scores: scoreRoute(empty, coords) }],\n      calls,\n      placesAvailable: true,\n      basemapAvailable: true,\n      skipped: 0,\n      tooShort: true,\n    };\n  }\n\n  // Wave 1, all at once. The corridor places run along the straight line rather than the fastest\n  // walk so they need not wait for it; at 500 m either way they cover the same neighbourhood, and\n  // strays are measured from this same line, so whatever leaves it is topped up below.\n  // No budget checks: 1 + MAX_TILES + 2 always fits.\n  const corridor: LngLat[] = [from, to];\n  const bbox = ellipseBox(P, from, to);\n  const fastestP = issue(() => deps.route(from, to, signal));\n  guard();\n  // Counted when it answers: only the reader knows how many tiles it fetched.\n  const tilesP = settle(() => deps.tiles(bbox, signal));\n  const lifeP = issue(() => deps.placesAlong(corridor, LIFE_CATEGORIES, CORRIDOR_M, signal));\n  const landmarksP = issue(() => deps.placesAlong(corridor, LANDMARK_CATEGORIES, CORRIDOR_M, signal));\n  const [fastestR, tilesR, lifeR, landmarksR] = await Promise.all([fastestP, tilesP, lifeP, landmarksP]);\n\n  if (!fastestR.ok) {\n    rethrowAbort(fastestR.error);\n    throw fastestR.error;\n  }\n  const fastest = fastestR.value;\n  const fastestCandidate: Draft = {\n    id: \"fastest\",\n    method: \"fastest\",\n    coords: fastest.geometry.coordinates,\n    durationS: fastest.durationSeconds,\n    distanceM: fastest.distanceMeters,\n  };\n\n  // Basemap. A failure here costs the graph method and the parks, water and quiet scores (the\n  // result says so with basemapAvailable); detours need no tiles. The reader's MAX_TILES cap\n  // means this lands at MAX_TILES calls at most. A RangeError is refused before any fetch and\n  // counts 0; any other failure counts the fetches it made, so the cap still holds.\n  let base: Omit<AliveData, \"life\" | \"landmarks\"> = { parks: [], water: [], roads: [] };\n  let graphSkipped: WalkResult[\"graphSkipped\"];\n  if (tilesR.ok) {\n    calls += tilesR.value.requests;\n    base = dataFromTiles(tilesR.value.features);\n  } else {\n    rethrowAbort(tilesR.error);\n    if (tilesR.error instanceof RangeError) graphSkipped = \"too-big\";\n    else {\n      graphSkipped = \"tiles-failed\";\n      calls += requestsOf(tilesR.error) ?? tilesInBox(bbox);\n    }\n  }\n\n  // Corridor places: cafe clusters and landmarks become vias.\n  let placesAvailable = true;\n  const life: Poi[] = [];\n  const landmarks: Poi[] = [];\n  const unavailable = (): void => {\n    placesAvailable = false;\n    life.length = 0;\n    landmarks.length = 0;\n  };\n  for (const r of [lifeR, landmarksR]) {\n    if (r.ok) continue;\n    rethrowAbort(r.error);\n    if (statusOf(r.error) !== 503) throw r.error;\n  }\n  if (lifeR.ok && landmarksR.ok) {\n    mergePois(life, lifeR.value);\n    mergePois(landmarks, landmarksR.value);\n  } else unavailable();\n  guard();\n\n  let p = prepare({ ...base, life, landmarks }, from);\n\n  // Candidates, in the order they were asked for: the fastest, every detour, every graph path.\n  // Each slot fills as its answer arrives; `settleUpTo` walks the filled run from the front,\n  // keeping a walk unless it repeats one kept before it, and tops up each one kept. So only a\n  // walk the result will show is topped up, the fastest included (the corridor is the straight\n  // line, which the fastest walk can bow far off), and in candidate order.\n  type Slot = { state: \"pending\" } | { state: \"ok\"; draft: Draft } | { state: \"failed\"; error: unknown };\n  const slots: Slot[] = [{ state: \"ok\", draft: fastestCandidate }];\n  const candidates: Draft[] = [];\n  let front = 0;\n  /** False until every detour and match is out, so a short budget goes to them before top-ups. */\n  let topUpsOpen = false;\n  const settleUpTo = (): void => {\n    if (!topUpsOpen) return;\n    while (front < slots.length && slots[front]!.state !== \"pending\") {\n      const slot = slots[front++]!;\n      if (slot.state !== \"ok\") continue;\n      const c = slot.draft;\n      if (candidates.some((k) => overlap(p, c.coords, k.coords) >= SAME_WALK && overlap(p, k.coords, c.coords) >= SAME_WALK)) continue;\n      candidates.push(c);\n      topUp(c.coords);\n    }\n  };\n  const fill = (at: number, slot: Slot): void => {\n    slots[at] = slot;\n    settleUpTo();\n  };\n\n  // Top-ups. A candidate that leaves the 500 m corridor would be scored as if its far half had no\n  // cafes or landmarks, so places are fetched along it too, once it is kept.\n  const corridorXY = corridor.map(P.to);\n  const topUps: Promise<[Settled<Poi[]>, Settled<Poi[]>]>[] = [];\n  let topUpRefused = false;\n  const topUp = (coords: LngLat[]): void => {\n    // Often called from an answer's callback: an abort, or a search already settled, stops it\n    // quietly (the main flow throws or has returned).\n    if (run.done || signal.aborted || !placesAvailable || topUpRefused || !fits(2)) return;\n    if (strayM(P, coords, corridorXY) <= STRAY_M) return;\n    calls += 2;\n    const pair = Promise.all([\n      settle(() => deps.placesAlong(coords, LIFE_CATEGORIES, TOP_UP_M, signal)),\n      settle(() => deps.placesAlong(coords, LANDMARK_CATEGORIES, TOP_UP_M, signal)),\n    ]);\n    topUps.push(\n      pair.then((both) => {\n        // Scoring some candidates with places and some without would rank them unfairly.\n        if (both.some((r) => !r.ok && statusOf(r.error) === 503)) topUpRefused = true;\n        return both;\n      }),\n    );\n  };\n\n  // Detours: one via each, two routing calls, every leg sent now. A via the engine cannot reach\n  // is just skipped.\n  const detours: Promise<void>[] = [];\n  for (const [i, v] of candidateVias(p, from, to).entries()) {\n    if (!fits(2)) break;\n    const at = slots.length;\n    slots.push({ state: \"pending\" });\n    const leg1 = issue(() => deps.route(from, v.via, signal));\n    const leg2 = issue(() => deps.route(v.via, to, signal));\n    const detour = Promise.all([leg1, leg2]).then(([r1, r2]): void => {\n      if (!r1.ok) return fill(at, { state: \"failed\", error: r1.error });\n      if (!r2.ok) return fill(at, { state: \"failed\", error: r2.error });\n      const [l1, l2] = [r1.value, r2.value];\n      const { coords, trimmedM } = joinLegs(p, l1.geometry.coordinates, l2.geometry.coordinates);\n      const rawM = l1.distanceMeters + l2.distanceMeters;\n      const distanceM = Math.max(rawM - trimmedM, 0);\n      // Walking time scales with distance (grade aside), so a trimmed spur takes its share of time.\n      const durationS = rawM > 0 ? ((l1.durationSeconds + l2.durationSeconds) * distanceM) / rawM : l1.durationSeconds + l2.durationSeconds;\n      fill(at, { state: \"ok\", draft: { id: `detour-${i + 1}`, method: \"detour\", coords, durationS, distanceM } });\n    });\n    // Awaited below; until then a throw elsewhere must not leave it unhandled.\n    detour.catch(() => undefined);\n    detours.push(detour);\n  }\n\n  // Graph paths, built while the detours fly: once, with the default weights; the dials re-rank,\n  // they do not re-route.\n  const matches: Promise<void>[] = [];\n  if (!graphSkipped) {\n    await yieldToEvents();\n    guard();\n    const g = buildWalkGraph(p, base.roads);\n    // The plain shortest path on the graph is the fastest walk again, not a candidate; seeding\n    // with it means a gamma that does not leave it spends no call.\n    const plain = alivePath(p, g, from, to, DEFAULT_WEIGHTS, 0);\n    const seen: LngLat[][] = plain ? [plain] : [];\n    for (const gamma of GAMMAS) {\n      const path = alivePath(p, g, from, to, DEFAULT_WEIGHTS, gamma);\n      if (!path || path.length < 2) continue;\n      if (seen.some((q) => overlap(p, path, q) >= SAME_PATH && overlap(p, q, path) >= SAME_PATH)) continue;\n      seen.push(path);\n      if (!fits(1)) break;\n      const at = slots.length;\n      slots.push({ state: \"pending\" });\n      const match = issue(() => deps.match(resampleTo(path, MAX_MATCH_POINTS), signal)).then((r): void => {\n        if (!r.ok) return fill(at, { state: \"failed\", error: r.error });\n        const coords = r.value.geometry.coordinates;\n        fill(at, { state: \"ok\", draft: { id: `graph-${gamma}`, method: \"graph\", coords, durationS: r.value.durationSeconds, distanceM: r.value.distanceMeters, fidelity: overlap(p, path, coords) } });\n      });\n      match.catch(() => undefined);\n      matches.push(match);\n    }\n  }\n\n  // Every detour and match is out: top-ups may spend what is left, from the fastest walk on.\n  topUpsOpen = true;\n  settleUpTo();\n\n  // Once every answer is in, every slot is filled, every kept walk is in `candidates` and every\n  // top-up that will ever be sent is in `topUps`.\n  await Promise.all(detours);\n  await Promise.all(matches);\n  for (const slot of slots)\n    if (slot.state === \"failed\") {\n      rethrowAbort(slot.error);\n      skipped++;\n    }\n\n  let toppedUp = false;\n  for (const [l, k] of await Promise.all(topUps)) {\n    if (!placesAvailable) break;\n    if (l.ok && k.ok) {\n      if (mergePois(life, l.value) + mergePois(landmarks, k.value) > 0) toppedUp = true;\n      continue;\n    }\n    const error = l.ok ? (k as { error: unknown }).error : l.error;\n    rethrowAbort(error);\n    if (statusOf(error) !== 503) throw error;\n    unavailable();\n    toppedUp = true;\n  }\n  // Once, so every candidate is scored against the same data.\n  if (toppedUp) p = prepare({ ...base, life, landmarks }, from);\n\n  guard();\n  return {\n    candidates: candidates.map((c) => ({ ...c, scores: scoreRoute(p, c.coords) })),\n    calls,\n    placesAvailable,\n    basemapAvailable: !graphSkipped,\n    skipped,\n    ...(graphSkipped ? { graphSkipped } : {}),\n  };\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "registry/blocks/alive-route-explorer/alive-view.ts",
      "content": "// What the alive-route-explorer block shows, framework-free: its words in English and French, the\n// sample walks, the SDK adapted to findWalks, and how a walk is read for the map (where its\n// highlights are, where a time bubble goes). The React and Vue files beside it only draw this.\n\nimport { tileFeatures, tilesCovering } from \"@unmap/maps/tiles\";\nimport { poisFromPlaces, type TileFeature } from \"./alive-data\";\nimport type { rank } from \"./alive-rank\";\nimport { projector, type LngLat, type Poi, type Scores } from \"./alive-signals\";\nimport { CALL_BUDGET, MAX_TILES, type Candidate, type WalkDeps, type WalkLeg, type WalkResult } from \"./alive-walks\";\n\nexport type AliveLang = \"en\" | \"fr\";\n\n/** The block's own map id, so a map elsewhere on the page never answers for it. */\nexport const ALIVE_MAP_ID = \"alive-route-explorer\";\n\n/** Where a reader gets an unmap key: the no-key state's first action. */\nexport const KEY_URL = \"https://unmap.dev/dashboard\";\n\n/** Three Calgary walks to start from, each labelled a sample wherever it shows. */\nexport const SAMPLE_COMMUTES: {\n  id: \"bridgeland\" | \"kensington\" | \"mission\";\n  from: LngLat;\n  to: LngLat;\n  name: { en: string; fr: string };\n  ends: { en: [string, string]; fr: [string, string] };\n}[] = [\n  {\n    id: \"bridgeland\",\n    from: [-114.0415, 51.0545],\n    to: [-114.0575, 51.0455],\n    name: { en: \"Bridgeland to City Hall\", fr: \"Bridgeland à l'hôtel de ville\" },\n    ends: { en: [\"Bridgeland\", \"City Hall\"], fr: [\"Bridgeland\", \"Hôtel de ville\"] },\n  },\n  {\n    id: \"kensington\",\n    from: [-114.087, 51.0525],\n    to: [-114.0614, 51.0478],\n    name: { en: \"Kensington to the Bow\", fr: \"Kensington à la rivière Bow\" },\n    ends: { en: [\"Kensington\", \"The Bow\"], fr: [\"Kensington\", \"Rivière Bow\"] },\n  },\n  {\n    id: \"mission\",\n    from: [-114.0705, 51.03],\n    to: [-114.0679, 51.0459],\n    name: { en: \"Mission to Stephen Avenue\", fr: \"Mission à l'avenue Stephen\" },\n    ends: { en: [\"Mission\", \"Stephen Avenue\"], fr: [\"Mission\", \"Avenue Stephen\"] },\n  },\n];\n\nexport const WEIGHT_KEYS = [\"green\", \"water\", \"quiet\", \"life\", \"landmarks\"] as const;\nexport type WeightKey = (typeof WEIGHT_KEYS)[number];\n\n/** Extra minutes the reader will take, by default and at most. */\nexport const DEFAULT_BUDGET = 3;\nexport const MAX_BUDGET = 15;\n/** A detour must beat the fastest walk by this many points of 100. */\nexport const DEFAULT_MIN_GAIN = 5;\n/** Past this the idea strains: few detours fit a long walk's budget. */\nexport const LONG_WALK_M = 5000;\nexport const PHONE_QUERY = \"(max-width: 639.98px)\";\n/** Below this the Why card starts closed. */\nexport const NARROW_QUERY = \"(max-width: 1279.98px)\";\nexport const GUTTER = 24;\nexport const CAMERA_MS = 700;\nexport const DRAW_MS = 700;\n\nexport type Method = \"both\" | \"detour\" | \"graph\";\n\n/** What a picture can show: a highlight's kind, also the empty state's quiet street. */\nexport type PictureKind = \"park\" | \"water\" | \"life\" | \"landmark\" | \"quiet\";\n\n/**\n * Pictures for the highlight cards, by key: a highlight kind (`park`, `water`, `life`, `landmark`,\n * `quiet`) or a place category id (`food.cafe`, `culture.museum`, `tourism.artwork`, ...). Each key\n * holds one or more image URLs, variants a walk picks from. Without an entry a card draws its\n * inline illustration. Every picture is shown tagged \"Illustration\", with alt text saying it is\n * AI-generated, so pass only illustrations, never photos presented as the place.\n */\nexport type AlivePictures = Partial<Record<string, readonly string[]>>;\n\nconst PICTURE_NOUNS: Record<AliveLang, Record<PictureKind, string>> = {\n  en: { park: \"a park\", water: \"a riverside\", life: \"a street with cafés\", landmark: \"public art\", quiet: \"a quiet street\" },\n  fr: { park: \"parc\", water: \"bord de rivière\", life: \"rue de cafés\", landmark: \"œuvre d'art public\", quiet: \"rue calme\" },\n};\n\nexport const ALIVE_COPY = {\n  en: {\n    title: \"Find a livelier walk\",\n    subtitle: \"Directions for a livelier walk\",\n    description: \"Walking directions that trade a minute for a nicer walk: past parks, the river, streets away from traffic, cafés and public art.\",\n    start: \"Start\",\n    destination: \"Destination\",\n    from: (name: string) => `From ${name}`,\n    to: (name: string) => `To ${name}`,\n    commute: (from: string, to: string) => `${from} to ${to}`,\n    swap: \"Swap start and destination\",\n    samples: \"Samples\",\n    sample: \"Sample\",\n    dials: \"What makes a walk alive\",\n    reset: \"Reset\",\n    routeOptions: \"Route options\",\n    show: \"Show\",\n    hide: \"Hide\",\n    showMore: (n: number) => `Show ${n} more`,\n    showFewer: \"Show fewer\",\n    signalUnavailable: \"Unavailable\",\n    dialsUnavailable: \"Street life and landmarks are unavailable right now, so these two dials are off.\",\n    dialsBasemapUnavailable: \"Parks, water and traffic are unavailable for this walk, so these three dials are off.\",\n    pictureAlt: (kind: PictureKind) => `AI-generated illustration of ${PICTURE_NOUNS.en[kind]}, not a photo of this place`,\n    weights: { green: \"Parks and green\", water: \"River and water\", quiet: \"Away from traffic\", life: \"Cafés and shops\", landmarks: \"Art and landmarks\" },\n    budget: \"Extra time I'll take\",\n    budgetValue: (minutes: number) => `up to +${formatNumber(minutes, \"en\")} min`,\n    more: \"More options\",\n    fewer: \"Fewer options\",\n    minGain: \"Minimum gain\",\n    minGainValue: (points: number) => `${points} points`,\n    minGainHint: \"A detour must beat the fastest walk by this much to be picked.\",\n    method: \"Walks found by\",\n    methodFilter: { both: \"Both\", detour: \"API detours\", graph: \"Walking graph\" },\n    methods: { detour: \"API detour\", graph: \"Walking graph\" },\n    routes: \"Routes\",\n    livelier: \"Livelier route\",\n    fastest: \"Fastest route\",\n    selected: \"Selected route\",\n    another: \"Another walk\",\n    minutes: (seconds: number) => `${Math.max(1, Math.round(seconds / 60))} min`,\n    extra: (minutes: number) => `+${minutes > 0.05 ? Math.max(1, Math.round(minutes)) : 0} min`,\n    overBudget: \"Over your extra time\",\n    underGain: \"Not enough gain to pick\",\n    graphNote: \"Path found in your browser from map tiles; snapped and timed by unmap map matching.\",\n    go: \"Get directions\",\n    going: \"Finding walks...\",\n    calls: (n: number) => `${n} of ${CALL_BUDGET} API calls`,\n    callsBefore: `Up to ${CALL_BUDGET} API calls per search`,\n    empty: \"Get directions to see the fastest walk and the livelier ones beside it. The dials decide what is worth a detour.\",\n    why: \"Why this route\",\n    aliveness: (route: number, fastest: number) => `aliveness ${route} vs ${fastest}`,\n    signals: { green: \"Green\", water: \"Water\", quiet: \"Away from traffic\", life: \"Street life\", landmarks: \"Landmarks\" },\n    barsKey: \"Bar: this walk. Line: the fastest walk.\",\n    placesUnavailable: \"Street life and landmarks are unavailable right now; this ranking uses parks, water and streets away from traffic only.\",\n    nothingAvailable: \"Neither the map tiles nor street life and landmarks could be read, so nothing can be scored and the fastest walk is the pick.\",\n    tooShort: \"These points are under 200 m apart, so the direct walk is the only one.\",\n    alreadyLiveliest: (minutes: number) => `Your fastest walk is already the liveliest one within +${formatNumber(minutes, \"en\")} min.`,\n    graphTooBig: \"This walk is too long to read its map tiles, so the routes are API detours only and the ranking leaves out parks, water and traffic.\",\n    graphFailed: \"The map tiles did not load, so the routes are API detours only and the ranking leaves out parks, water and traffic.\",\n    partial: \"Partial results: open Route options to see why.\",\n    skipped: (n: number) => (n === 1 ? \"1 route could not be computed.\" : `${n} routes could not be computed.`),\n    longWalk: \"This is built for walks; over 5 km few detours fit your extra time.\",\n    sources: \"Parks, water and streets come from the basemap tiles; cafés, shops and landmarks from unmap places.\",\n    highlights: { park: \"A park\", life: \"Cafés and street life\", landmark: \"Art and landmarks\" },\n    more_: (n: number) => `and ${n} more`,\n    through: (metres: number) => `${Math.round(metres / 10) * 10} m of the walk`,\n    options: \"Route options\",\n    noResult: \"No walk yet\",\n    said: (label: string, time: string, extra: string | null) => `${label}, ${time}${extra ? `, ${extra} over the fastest` : \"\"}.`,\n    noRoute: \"There is no walking route between these points. Try a start or destination on a street.\",\n    outside: \"Start and destination must both be in Canada.\",\n    busy: \"Too many requests. Try again in a moment.\",\n    keyRefused: \"The API key was refused. Check it and its allowed origins.\",\n    unavailable: \"The routing service did not answer. Try again in a moment.\",\n    failed: \"The directions did not load. Try again.\",\n    retry: \"Try again\",\n    errorTitle: \"No directions\",\n    noKeyTitle: \"Add your unmap key to load the map.\",\n    getKey: \"Get a key\",\n    noKey:\n      'No unmap key. Pass apiKey. On Next.js, set NEXT_PUBLIC_UNMAP_KEY in .env.local. On Nuxt, set NUXT_PUBLIC_UNMAP_KEY in .env. If you installed with unmap add, nuxt.config already declares runtimeConfig: { public: { unmapKey: \"\" } }; otherwise add it there. Then restart the dev server.',\n  },\n  fr: {\n    title: \"Trouver une marche plus vivante\",\n    subtitle: \"Un itinéraire plus vivant\",\n    description: \"Un itinéraire à pied qui échange une minute contre une plus belle marche : parcs, rivière, rues loin de la circulation, cafés et art public.\",\n    start: \"Départ\",\n    destination: \"Destination\",\n    from: (name: string) => `Depuis ${name}`,\n    to: (name: string) => `Vers ${name}`,\n    commute: (from: string, to: string) => `${from} à ${to}`,\n    swap: \"Inverser le départ et l'arrivée\",\n    samples: \"Exemples\",\n    sample: \"Exemple\",\n    dials: \"Ce qui rend une marche vivante\",\n    reset: \"Réinitialiser\",\n    routeOptions: \"Options de l'itinéraire\",\n    show: \"Afficher\",\n    hide: \"Masquer\",\n    showMore: (n: number) => `Afficher ${n} de plus`,\n    showFewer: \"Afficher moins\",\n    signalUnavailable: \"Indisponible\",\n    dialsUnavailable: \"La vie de quartier et les repères sont indisponibles pour le moment : ces deux curseurs sont désactivés.\",\n    dialsBasemapUnavailable: \"Les parcs, l'eau et la circulation sont indisponibles pour cette marche : ces trois curseurs sont désactivés.\",\n    pictureAlt: (kind: PictureKind) => `Illustration générée par IA (${PICTURE_NOUNS.fr[kind]}), pas une photo de ce lieu`,\n    weights: { green: \"Parcs et verdure\", water: \"Rivière et eau\", quiet: \"Loin de la circulation\", life: \"Cafés et commerces\", landmarks: \"Art et repères\" },\n    budget: \"Temps de plus que j'accepte\",\n    budgetValue: (minutes: number) => `jusqu'à +${formatNumber(minutes, \"fr\")} min`,\n    more: \"Plus d'options\",\n    fewer: \"Moins d'options\",\n    minGain: \"Gain minimal\",\n    minGainValue: (points: number) => `${points} points`,\n    minGainHint: \"Un détour doit battre la marche la plus rapide d'au moins cette valeur pour être choisi.\",\n    method: \"Marches trouvées par\",\n    methodFilter: { both: \"Les deux\", detour: \"Détours de l'API\", graph: \"Graphe piéton\" },\n    methods: { detour: \"Détour de l'API\", graph: \"Graphe piéton\" },\n    routes: \"Itinéraires\",\n    livelier: \"Itinéraire plus vivant\",\n    fastest: \"Itinéraire le plus rapide\",\n    selected: \"Itinéraire choisi\",\n    another: \"Autre marche\",\n    minutes: (seconds: number) => `${Math.max(1, Math.round(seconds / 60))} min`,\n    extra: (minutes: number) => `+${minutes > 0.05 ? Math.max(1, Math.round(minutes)) : 0} min`,\n    overBudget: \"Au-delà de votre temps de plus\",\n    underGain: \"Gain trop faible pour être choisi\",\n    graphNote: \"Chemin trouvé dans votre navigateur à partir des tuiles; ajusté et chronométré par l'appariement de trajets unmap.\",\n    go: \"Obtenir l'itinéraire\",\n    going: \"Recherche de marches...\",\n    calls: (n: number) => `${n} appels d'API sur ${CALL_BUDGET}`,\n    callsBefore: `Jusqu'à ${CALL_BUDGET} appels d'API par recherche`,\n    empty: \"Obtenez l'itinéraire pour voir la marche la plus rapide et les plus vivantes à côté. Les curseurs décident de ce qui vaut un détour.\",\n    why: \"Pourquoi cet itinéraire\",\n    aliveness: (route: number, fastest: number) => `vitalité ${route} contre ${fastest}`,\n    signals: { green: \"Verdure\", water: \"Eau\", quiet: \"Loin de la circulation\", life: \"Vie de quartier\", landmarks: \"Repères\" },\n    barsKey: \"Barre : cette marche. Trait : la marche la plus rapide.\",\n    placesUnavailable:\n      \"La vie de quartier et les repères sont indisponibles pour le moment; ce classement utilise seulement les parcs, l'eau et les rues loin de la circulation.\",\n    nothingAvailable:\n      \"Ni les tuiles de la carte ni la vie de quartier et les repères n'ont pu être lus : rien ne peut être noté, et la marche la plus rapide est le choix.\",\n    tooShort: \"Ces points sont à moins de 200 m l'un de l'autre : la marche directe est la seule.\",\n    alreadyLiveliest: (minutes: number) => `Votre marche la plus rapide est déjà la plus vivante à +${formatNumber(minutes, \"fr\")} min près.`,\n    graphTooBig:\n      \"Cette marche est trop longue pour lire ses tuiles de carte : les itinéraires sont seulement des détours de l'API, et le classement laisse de côté les parcs, l'eau et la circulation.\",\n    graphFailed:\n      \"Les tuiles de la carte n'ont pas chargé : les itinéraires sont seulement des détours de l'API, et le classement laisse de côté les parcs, l'eau et la circulation.\",\n    partial: \"Résultats partiels : ouvrez Options de l'itinéraire pour savoir pourquoi.\",\n    skipped: (n: number) => (n === 1 ? \"1 itinéraire n'a pas pu être calculé.\" : `${n} itinéraires n'ont pas pu être calculés.`),\n    longWalk: \"Conçu pour la marche : au-delà de 5 km, peu de détours tiennent dans votre temps de plus.\",\n    sources: \"Les parcs, l'eau et les rues viennent des tuiles de la carte; les cafés, commerces et repères, des lieux d'unmap.\",\n    highlights: { park: \"Un parc\", life: \"Cafés et vie de quartier\", landmark: \"Art et repères\" },\n    more_: (n: number) => `et ${n} de plus`,\n    through: (metres: number) => `${Math.round(metres / 10) * 10} m de la marche`,\n    options: \"Options de l'itinéraire\",\n    noResult: \"Pas encore de marche\",\n    said: (label: string, time: string, extra: string | null) => `${label}, ${time}${extra ? `, soit ${extra} par rapport à la plus rapide` : \"\"}.`,\n    noRoute: \"Aucun itinéraire à pied ne relie ces points. Essayez un départ ou une arrivée sur une rue.\",\n    outside: \"Le départ et l'arrivée doivent être au Canada.\",\n    busy: \"Trop de requêtes. Réessayez dans un instant.\",\n    keyRefused: \"La clé d'API a été refusée. Vérifiez-la, ainsi que ses origines autorisées.\",\n    unavailable: \"Le service d'itinéraires n'a pas répondu. Réessayez dans un instant.\",\n    failed: \"L'itinéraire n'a pas chargé. Réessayez.\",\n    retry: \"Réessayer\",\n    errorTitle: \"Aucun itinéraire\",\n    noKeyTitle: \"Ajoutez votre clé unmap pour charger la carte.\",\n    getKey: \"Obtenir une clé\",\n    noKey:\n      'Aucune clé unmap. Passez apiKey. Avec Next.js, définissez NEXT_PUBLIC_UNMAP_KEY dans .env.local. Avec Nuxt, définissez NUXT_PUBLIC_UNMAP_KEY dans .env. Si vous avez installé le bloc avec unmap add, nuxt.config déclare déjà runtimeConfig: { public: { unmapKey: \"\" } } ; sinon, ajoutez-le. Redémarrez ensuite le serveur de développement.',\n  },\n} as const;\n\nexport function formatNumber(value: number, lang: AliveLang): string {\n  return value.toLocaleString(lang === \"fr\" ? \"fr-CA\" : \"en-CA\", { maximumFractionDigits: 1 });\n}\n\n// ---------------------------------------------------------------------------------------------\n// The SDK, adapted to what findWalks asks for.\n\n/** The part of `new Unmap(...)` the walks need. */\nexport interface AliveClient {\n  router: {\n    route(from: LngLat, to: LngLat, opts: { mode: \"pedestrian\"; signal: AbortSignal }): Promise<WalkLeg>;\n    match(coordinates: LngLat[], opts: { mode: \"pedestrian\"; signal: AbortSignal }): Promise<WalkLeg>;\n  };\n  places: {\n    along(\n      route: { type: \"LineString\"; coordinates: LngLat[] },\n      category: readonly string[],\n      opts: { radius: number; limit: number; signal: AbortSignal },\n    ): Promise<{ data: { id: string; name: string; category: string | null; coordinates: [number, number] }[] }>;\n  };\n}\n\ntype TileReader = (opts: {\n  key: string;\n  bbox: [number, number, number, number];\n  layers: readonly string[];\n  maxTiles: number;\n  signal: AbortSignal;\n}) => Promise<{ features: Record<string, TileFeature[]>; requests: number }>;\n\n/** The basemap layers the signals and the walking graph read. */\nexport const TILE_LAYERS = [\"roads\", \"landuse\", \"water\", \"pois\"] as const;\n\n/**\n * findWalks' dependencies from one SDK client and the tile reader. A failed tile read says how many\n * tiles it covered, so the call counter stays exact; a RangeError was refused before any fetch.\n */\nexport function walkDeps(client: AliveClient, apiKey: string, read: TileReader = tileFeatures): WalkDeps {\n  return {\n    route: (from, to, signal) => client.router.route(from, to, { mode: \"pedestrian\", signal }),\n    match: (coordinates, signal) => client.router.match(coordinates, { mode: \"pedestrian\", signal }),\n    placesAlong: async (line, categories, radius, signal) => {\n      const { data } = await client.places.along({ type: \"LineString\", coordinates: line }, categories, { radius, limit: 200, signal });\n      return poisFromPlaces(data);\n    },\n    tiles: async (bbox, signal) => {\n      try {\n        return await read({ key: apiKey, bbox, layers: TILE_LAYERS, maxTiles: MAX_TILES, signal });\n      } catch (caught) {\n        if (!(caught instanceof RangeError) && typeof caught === \"object\" && caught !== null && !(\"requests\" in caught)) {\n          Object.assign(caught, { requests: tilesCovering(bbox, 14).length });\n        }\n        throw caught;\n      }\n    },\n  };\n}\n\n// ---------------------------------------------------------------------------------------------\n// Reading a walk: where its highlights are, where a bubble goes.\n\nexport interface HighlightCard {\n  kind: \"park\" | \"life\" | \"landmark\";\n  at: LngLat;\n  names: string[];\n  /** Places of this kind beyond `names`. */\n  extra: number;\n  /** The places named on the card (life and landmark cards), for choosing its picture. */\n  places: Poi[];\n  /** Parks only: metres of the walk inside. */\n  metres?: number;\n}\n\n/** Metres from the start of `coords` to the point on it nearest `p`. */\nfunction alongM(coords: LngLat[], p: LngLat): number {\n  if (coords.length < 2) return 0;\n  const P = projector(coords[0]!);\n  const q = P.to(p);\n  let walked = 0;\n  let best = { d: Infinity, at: 0 };\n  for (let i = 1; i < coords.length; i++) {\n    const a = P.to(coords[i - 1]!);\n    const b = P.to(coords[i]!);\n    const dx = b[0] - a[0];\n    const dy = b[1] - a[1];\n    const len = Math.hypot(dx, dy);\n    const t = len > 0 ? Math.max(0, Math.min(1, ((q[0] - a[0]) * dx + (q[1] - a[1]) * dy) / (len * len))) : 0;\n    const d = Math.hypot(a[0] + t * dx - q[0], a[1] + t * dy - q[1]);\n    if (d < best.d) best = { d, at: walked + t * len };\n    walked += len;\n  }\n  return best.at;\n}\n\nexport const metresBetween = (a: LngLat, b: LngLat): number => {\n  const P = projector(a);\n  const [x, y] = P.to(b);\n  return Math.hypot(x, y);\n};\n\n/** A street-life stretch: places within this of one another. */\nconst STRETCH_M = 120;\n\n/** What each searched category is, for a place with no name: a description, never an invented name. */\nexport const CATEGORY_LABELS: Record<AliveLang, Record<string, string>> = {\n  en: {\n    \"food.cafe\": \"Café\",\n    \"food.bakery\": \"Bakery\",\n    \"food.restaurant\": \"Restaurant\",\n    \"food.bar\": \"Bar\",\n    \"food.pub\": \"Pub\",\n    \"food.ice_cream\": \"Ice cream shop\",\n    \"shop.books\": \"Bookshop\",\n    \"shop.clothes\": \"Clothing store\",\n    \"culture.museum\": \"Museum\",\n    \"culture.gallery\": \"Art gallery\",\n    \"culture.theatre\": \"Theatre\",\n    \"culture.arts_centre\": \"Arts centre\",\n    \"culture.library\": \"Library\",\n    \"tourism.attraction\": \"Attraction\",\n    \"tourism.viewpoint\": \"Viewpoint\",\n    \"tourism.artwork\": \"Public art\",\n  },\n  fr: {\n    \"food.cafe\": \"Café\",\n    \"food.bakery\": \"Boulangerie\",\n    \"food.restaurant\": \"Restaurant\",\n    \"food.bar\": \"Bar\",\n    \"food.pub\": \"Pub\",\n    \"food.ice_cream\": \"Bar laitier\",\n    \"shop.books\": \"Librairie\",\n    \"shop.clothes\": \"Boutique de vêtements\",\n    \"culture.museum\": \"Musée\",\n    \"culture.gallery\": \"Galerie d'art\",\n    \"culture.theatre\": \"Théâtre\",\n    \"culture.arts_centre\": \"Centre des arts\",\n    \"culture.library\": \"Bibliothèque\",\n    \"tourism.attraction\": \"Attrait touristique\",\n    \"tourism.viewpoint\": \"Point de vue\",\n    \"tourism.artwork\": \"Art public\",\n  },\n};\n\n/** A place's name, or what it is when it has none (\"Public art\"), or \"Unnamed place\" past that. */\nexport function placeName(place: Pick<Poi, \"name\" | \"category\">, lang: AliveLang): string {\n  return place.name.trim() || CATEGORY_LABELS[lang][place.category] || (lang === \"fr\" ? \"Lieu sans nom\" : \"Unnamed place\");\n}\n\n/**\n * At most three cards for what a walk passes: the first park it enters, the stretch with the most\n * cafés and shops, and the first landmark, each with the real names it passes.\n */\nexport function highlightCards(coords: LngLat[], scores: Scores, nameOf: (place: Poi) => string = (place) => place.name): HighlightCard[] {\n  const cards: HighlightCard[] = [];\n  const first = <T,>(items: T[], at: (item: T) => LngLat) =>\n    [...items].sort((a, b) => alongM(coords, at(a)) - alongM(coords, at(b)))[0];\n\n  const park = first(scores.highlights.parks, (p) => p.at);\n  if (park) cards.push({ kind: \"park\", at: park.at, names: [park.name], extra: 0, places: [], metres: park.metres });\n\n  const life = scores.highlights.life;\n  if (life.length) {\n    const near = (a: Poi) => life.filter((b) => metresBetween(a.at, b.at) <= STRETCH_M);\n    const centre = [...life].sort((a, b) => near(b).length - near(a).length || alongM(coords, a.at) - alongM(coords, b.at))[0]!;\n    const stretch = near(centre).sort((a, b) => metresBetween(centre.at, a.at) - metresBetween(centre.at, b.at));\n    cards.push({ kind: \"life\", at: centre.at, names: [...new Set(stretch.map(nameOf))].slice(0, 2), extra: Math.max(0, stretch.length - 2), places: stretch });\n  }\n\n  const landmark = first(scores.highlights.landmarks, (p) => p.at);\n  if (landmark) cards.push({ kind: \"landmark\", at: landmark.at, names: [nameOf(landmark)], extra: 0, places: [landmark] });\n  return cards;\n}\n\n/** The point of `coords` farthest from `ref`: where a detour's bubble reads as the detour's. */\nexport function apexOf(coords: LngLat[], ref: LngLat[]): LngLat {\n  if (coords.length === 0) return [0, 0];\n  let best = { d: -1, at: coords[Math.floor(coords.length / 2)]! };\n  for (let i = 1; i < coords.length - 1; i++) {\n    const p = coords[i]!;\n    const d = Math.min(...ref.map((r) => metresBetween(p, r)));\n    if (d > best.d) best = { d, at: p };\n  }\n  return best.at;\n}\n\n/** [[west, south], [east, north]] around some points. A loop: spreading a long route into Math.min overflows the stack. */\nexport function boundsOf(points: LngLat[]): [[number, number], [number, number]] {\n  let w = Infinity;\n  let s = Infinity;\n  let e = -Infinity;\n  let n = -Infinity;\n  for (const [x, y] of points) {\n    w = Math.min(w, x);\n    s = Math.min(s, y);\n    e = Math.max(e, x);\n    n = Math.max(n, y);\n  }\n  return [\n    [w, s],\n    [e, n],\n  ];\n}\n\n/** What to tell the reader about a failed search. */\nexport function requestError(caught: unknown, lang: AliveLang): string {\n  const copy = ALIVE_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\" || status === 422) return copy.outside;\n  if (failure.code === \"no_route\" || status === 400) 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\n/**\n * What the panel says about a finished search, in order, and whether any of it means the results\n * are partial (a source unavailable or a route that failed), which the phone's summary card hints\n * at. `fastestM` is the fastest walk's length, for the long-walk note.\n */\nexport function walkNotes(result: WalkResult, fastestM: number | null, lang: AliveLang): { notes: string[]; partial: boolean } {\n  const copy = ALIVE_COPY[lang];\n  const notes: string[] = [];\n  if (!result.placesAvailable) notes.push(result.basemapAvailable ? copy.placesUnavailable : copy.nothingAvailable);\n  if (result.tooShort) notes.push(copy.tooShort);\n  if (result.graphSkipped === \"too-big\") notes.push(copy.graphTooBig);\n  if (result.graphSkipped === \"tiles-failed\") notes.push(copy.graphFailed);\n  if (result.skipped > 0) notes.push(copy.skipped(result.skipped));\n  if (fastestM !== null && fastestM > LONG_WALK_M) notes.push(copy.longWalk);\n  return { notes, partial: !result.placesAvailable || !result.basemapAvailable || result.skipped > 0 };\n}\n\n/**\n * The weights a ranking uses: a signal whose source could not be read is unknown, not zero, so its\n * weight is 0 and it leaves the ranking. Places feed street life and landmarks; the basemap tiles\n * feed parks, water and traffic.\n */\nexport function usableWeights<W extends Record<WeightKey, number>>(weights: W, result: Pick<WalkResult, \"placesAvailable\" | \"basemapAvailable\">): W {\n  return {\n    ...weights,\n    ...(result.placesAvailable ? {} : { life: 0, landmarks: 0 }),\n    ...(result.basemapAvailable ? {} : { green: 0, water: 0, quiet: 0 }),\n  };\n}\n\n/** Results worth keeping for the same start and destination: not ones a passing outage degraded. */\nexport const cacheable = (result: WalkResult): boolean => result.placesAvailable && result.graphSkipped !== \"tiles-failed\";\n\nexport const isAbort = (caught: unknown): boolean =>\n  typeof caught === \"object\" && caught !== null && (caught as { name?: unknown }).name === \"AbortError\";\n\nexport function prefersReducedMotion(): boolean {\n  return typeof window !== \"undefined\" && window.matchMedia?.(\"(prefers-reduced-motion: reduce)\")?.matches === true;\n}\n\n\n// ---------------------------------------------------------------------------------------------\n// The block's state, shared by its pieces.\n\n/** A walk as ranked for the current dials: its extra minutes, its score, whether it may be picked. */\nexport type RankedWalk = ReturnType<typeof rank<Candidate>>[\"rows\"][number];\n\nexport interface Endpoint {\n  at: LngLat;\n  /** A sample's or the search's name; null when the point came without one. */\n  name: string | null;\n}\n\nexport type Search =\n  | { state: \"idle\" }\n  | { state: \"loading\" }\n  | { state: \"done\"; result: WalkResult }\n  | { state: \"error\"; error: unknown };\n\n// ---------------------------------------------------------------------------------------------\n// Pictures.\n\n/** A small stable string hash (djb2), so a walk keeps its picture and different walks vary. */\nfunction hash(text: string): number {\n  let h = 5381;\n  for (let i = 0; i < text.length; i++) h = ((h * 33) ^ text.charCodeAt(i)) >>> 0;\n  return h;\n}\n\n/**\n * A card's picture: for a life or landmark card, its places' most common category when `pictures`\n * has that category, else the card's kind; a variant chosen by the first place's id (or the kind and\n * the walk's id), the same on every render. `undefined` means draw the inline illustration.\n */\nexport function pictureFor(card: { kind: PictureKind; places: readonly Poi[] }, pictures: AlivePictures | undefined, walkId: string): string | undefined {\n  if (!pictures) return undefined;\n  const counts = new globalThis.Map<string, number>();\n  for (const place of card.places) if (place.category) counts.set(place.category, (counts.get(place.category) ?? 0) + 1);\n  // The most common category; between equally common ones, one that has pictures.\n  let category: string | undefined;\n  const has = (key: string) => (pictures[key]?.length ?? 0) > 0;\n  for (const [key, n] of counts) {\n    const best = category === undefined ? -1 : counts.get(category)!;\n    if (n > best || (n === best && has(key) && !has(category!))) category = key;\n  }\n  const list = (category && pictures[category]?.length ? pictures[category] : pictures[card.kind]) ?? [];\n  if (list.length === 0) return undefined;\n  return list[hash(card.places[0]?.id ?? `${card.kind}:${walkId}`) % list.length];\n}\n\n// ---------------------------------------------------------------------------------------------\n// Placing HTML on the map without covering the walk, the panel or each other. Screen pixels.\n\nexport interface Rect {\n  x0: number;\n  y0: number;\n  x1: number;\n  y1: number;\n  /** How much covering it costs per pixel; 1 by default. The panel and the Why card weigh more. */\n  weight?: number;\n}\nexport interface Point {\n  x: number;\n  y: number;\n}\n/**\n * Where a box sits relative to the point it points at; its tail is on the facing edge. The corner\n * sides keep the tail near one end of that edge (`top-left`: above the point, reaching left).\n */\nexport type Side = \"top\" | \"bottom\" | \"left\" | \"right\" | \"top-left\" | \"top-right\" | \"bottom-left\" | \"bottom-right\";\n\n/** How far from a corner side's near edge its tail sits. */\nexport const TAIL_INSET = 28;\n\nexport function rectAt(at: Point, size: { w: number; h: number }, side: Side, gap: number): Rect {\n  switch (side) {\n    case \"top\":\n      return { x0: at.x - size.w / 2, x1: at.x + size.w / 2, y0: at.y - gap - size.h, y1: at.y - gap };\n    case \"bottom\":\n      return { x0: at.x - size.w / 2, x1: at.x + size.w / 2, y0: at.y + gap, y1: at.y + gap + size.h };\n    case \"left\":\n      return { x0: at.x - gap - size.w, x1: at.x - gap, y0: at.y - size.h / 2, y1: at.y + size.h / 2 };\n    case \"right\":\n      return { x0: at.x + gap, x1: at.x + gap + size.w, y0: at.y - size.h / 2, y1: at.y + size.h / 2 };\n    case \"top-left\":\n      return { x0: at.x + TAIL_INSET - size.w, x1: at.x + TAIL_INSET, y0: at.y - gap - size.h, y1: at.y - gap };\n    case \"top-right\":\n      return { x0: at.x - TAIL_INSET, x1: at.x - TAIL_INSET + size.w, y0: at.y - gap - size.h, y1: at.y - gap };\n    case \"bottom-left\":\n      return { x0: at.x + TAIL_INSET - size.w, x1: at.x + TAIL_INSET, y0: at.y + gap, y1: at.y + gap + size.h };\n    case \"bottom-right\":\n      return { x0: at.x - TAIL_INSET, x1: at.x - TAIL_INSET + size.w, y0: at.y + gap, y1: at.y + gap + size.h };\n  }\n}\n\nconst area = (r: Rect) => Math.max(0, r.x1 - r.x0) * Math.max(0, r.y1 - r.y0);\nconst meet = (a: Rect, b: Rect): Rect => ({ x0: Math.max(a.x0, b.x0), y0: Math.max(a.y0, b.y0), x1: Math.min(a.x1, b.x1), y1: Math.min(a.y1, b.y1) });\n\n/** Points every `step` px along screen polylines, so a box can tell whether it covers a line. */\nexport function densify(lines: Point[][], step = 10): Point[] {\n  const out: Point[] = [];\n  for (const line of lines) {\n    for (let i = 1; i < line.length; i++) {\n      const a = line[i - 1]!;\n      const b = line[i]!;\n      const n = Math.max(1, Math.ceil(Math.hypot(b.x - a.x, b.y - a.y) / step));\n      for (let k = 0; k < n; k++) out.push({ x: a.x + ((b.x - a.x) * k) / n, y: a.y + ((b.y - a.y) * k) / n });\n    }\n    if (line.length) out.push(line[line.length - 1]!);\n  }\n  return out;\n}\n\n/** A spot `placeBox` chose, with what it still covers: `overlap` in weighted px², `covered` line points. */\nexport interface Placed {\n  at: Point;\n  side: Side;\n  rect: Rect;\n  cost: number;\n  overlap: number;\n  covered: number;\n}\n\n/**\n * The best spot for a box among `anchors` (tried in order) and `sides`: the one covering the least\n * of `obstacles` and `line` points and staying inside `bounds`. Ties keep the earlier try, so the\n * caller's order is its preference. Null when there is no anchor to try.\n */\nexport function placeBox(\n  anchors: Point[],\n  size: { w: number; h: number },\n  sides: readonly Side[],\n  obstacles: readonly Rect[],\n  line: readonly Point[],\n  bounds: Rect,\n  gap: number,\n): Placed | null {\n  let best: Placed | null = null;\n  for (const at of anchors) {\n    for (const side of sides) {\n      const rect = rectAt(at, size, side, gap);\n      let overlap = (area(rect) - area(meet(rect, bounds))) * 4;\n      for (const o of obstacles) overlap += area(meet(rect, o)) * (o.weight ?? 1);\n      let covered = 0;\n      for (const p of line) if (p.x > rect.x0 && p.x < rect.x1 && p.y > rect.y0 && p.y < rect.y1) covered++;\n      const cost = overlap + covered * 400;\n      if (!best || cost < best.cost) best = { at, side, rect, cost, overlap, covered };\n      if (cost === 0) return best;\n    }\n  }\n  return best;\n}\n\n/** Where a highlight card stands relative to its marker, in pixels. */\nexport interface CardSpot {\n  /** The card's box, in screen pixels at the time of placing. */\n  rect: Rect;\n  /** Next to its marker: the side its tail is on. */\n  side?: Side;\n  /** Further out: a leader from the marker's rim to the card's edge, in screen pixels. */\n  leader?: { x0: number; y0: number; x1: number; y1: number };\n  cost: number;\n}\n\n/** The marker's radius, and how close to it the walk must stay visible (where the card points). */\nconst MARKER_R = 18;\nexport const MARKER_CLEAR = 60;\n\n/**\n * A spot for a highlight card around its marker: first right beside it on one of `sides` (a tail\n * points at the marker), then on a ring of distances and angles (a leader line points at it). A\n * spot must stay inside `bounds`, overlap none of `obstacles`, and leave the picked walk visible\n * within MARKER_CLEAR px of the marker; among those it prefers near, little of the picked walk\n * covered, and above the marker. The basemap and the fastest walk may be covered freely. Null\n * when no spot qualifies.\n */\nexport function placeCard(\n  marker: Point,\n  size: { w: number; h: number },\n  sides: readonly Side[],\n  obstacles: readonly Rect[],\n  pick: readonly Point[],\n  bounds: Rect,\n): CardSpot | null {\n  const near = pick.filter((p) => Math.hypot(p.x - marker.x, p.y - marker.y) <= MARKER_CLEAR);\n  const far = pick.filter((p) => Math.hypot(p.x - marker.x, p.y - marker.y) > MARKER_CLEAR);\n  const inside = (r: Rect, p: Point) => p.x > r.x0 && p.x < r.x1 && p.y > r.y0 && p.y < r.y1;\n  const fits = (r: Rect) =>\n    r.x0 >= bounds.x0 && r.y0 >= bounds.y0 && r.x1 <= bounds.x1 && r.y1 <= bounds.y1 && obstacles.every((o) => area(meet(r, o)) < 1) && !near.some((p) => inside(r, p));\n  const score = (r: Rect, distance: number) => distance + 200 * far.filter((p) => inside(r, p)).length + ((r.y0 + r.y1) / 2 > marker.y ? 40 : 0);\n  let best: CardSpot | null = null;\n  for (const side of sides) {\n    const rect = rectAt(marker, size, side, 16);\n    if (!fits(rect)) continue;\n    const cost = score(rect, 0);\n    if (!best || cost < best.cost) best = { rect, side, cost };\n  }\n  const hw = size.w / 2;\n  const hh = size.h / 2;\n  for (const distance of [48, 100, 160, 230]) {\n    for (let k = 0; k < 12; k++) {\n      // Starting straight up, the reference's favourite, then round the clock.\n      const angle = -Math.PI / 2 + (k * Math.PI) / 6;\n      const dx = Math.cos(angle);\n      const dy = Math.sin(angle);\n      // From the card's centre to its edge along the ray towards the marker.\n      const edge = Math.min(Math.abs(dx) > 1e-6 ? hw / Math.abs(dx) : Infinity, Math.abs(dy) > 1e-6 ? hh / Math.abs(dy) : Infinity);\n      const cx = marker.x + dx * (distance + edge);\n      const cy = marker.y + dy * (distance + edge);\n      const rect = { x0: cx - hw, y0: cy - hh, x1: cx + hw, y1: cy + hh };\n      if (!fits(rect)) continue;\n      const cost = score(rect, distance);\n      if (!best || cost < best.cost) {\n        best = { rect, cost, leader: { x0: marker.x + dx * MARKER_R, y0: marker.y + dy * MARKER_R, x1: cx - dx * edge, y1: cy - dy * edge } };\n      }\n    }\n  }\n  return best;\n}\n\n",
      "type": "registry:lib"
    }
  ],
  "envVars": {
    "NEXT_PUBLIC_UNMAP_KEY": ""
  },
  "docs": "Paste your unmap key into NEXT_PUBLIC_UNMAP_KEY in .env.local, then open /alive-route-explorer. Get directions spends at most 40 calls per start and destination; the dials re-rank without calling. It reads the basemap tiles through @unmap/maps/tiles, which needs @unmap/maps 0.30.0 or later. Guide: https://unmap.dev/docs/frontend/blocks#find-a-livelier-walk",
  "categories": [
    "blocks"
  ],
  "type": "registry:block"
}