{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "strip-station",
  "title": "Strip Station",
  "description": "Turn-by-turn directions unbent into one vertical straight-line diagram: row spacing is proportional to real ground distance, not step count, with a 44px floor that visibly breaks (equation-station zigzag, skipped chainage) rather than silently lying about distance.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/strip-station/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport type { KeyboardEvent } from \"react\";\n\n// ---------------------------------------------------------------------------\n// StripStation — a turn-by-turn directions panel drawn as a straight-line\n// diagram (a DOT \"SLD\"): the route is unbent into one vertical strip where\n// vertical space between maneuvers is proportional to real ground distance,\n// not to row count or elapsed time. A 200m hop and a 40km motorway leg get\n// visibly different heights instead of identical row heights.\n//\n// Geometry contract: row pitch = max(44px, distanceM * scalePxPerM). When\n// the true proportional pitch would fall under the 44px floor, the strip\n// does NOT silently clamp — it flags the row with the standard equation-\n// station break glyph (a zigzag) and the chainage ticks skip a number,\n// so the compression is visible rather than a quiet lie about distance.\n// ---------------------------------------------------------------------------\n\nexport type StripStationManeuver = {\n  /** stable id */\n  id: string;\n  /** e.g. \"Turn left onto Mill Rd\" */\n  instruction: string;\n  /** optional secondary road label appended after the instruction */\n  road?: string;\n  /** length, in meters, of the leg that STARTS at this maneuver (0 for arrival) */\n  distanceM: number;\n  /** signed exit angle in degrees: 0 = straight ahead, negative = left, positive = right, ±180 = U-turn */\n  turnAngleDeg: number;\n  /** absolute compass bearing (0-360) of the leg that follows this maneuver */\n  headingDeg: number;\n};\n\nexport interface StripStationProps {\n  /** the route, depart-to-arrive, oldest first */\n  steps: StripStationManeuver[];\n  /** route progress in meters from the start; drives the marker + auto-follow scroll */\n  positionM?: number;\n  /** pixels per meter of ground distance before the 44px floor applies */\n  scalePxPerM?: number;\n  /** accessible label for the directions region */\n  label?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst ROW_H = 44; // also the falsifiable minimum pitch, in px\nconst GLYPH = 28;\n\nfunction clamp(v: number, min: number, max: number) {\n  return Math.min(max, Math.max(min, v));\n}\n\nfunction formatDistance(m: number) {\n  if (m >= 1000) return `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`;\n  return `${Math.round(Math.max(0, m))} m`;\n}\n\n// DOT-style chainage: whole hundreds \"+\" remainder, e.g. 150m -> \"1+50\"\nfunction formatChainage(m: number) {\n  const safe = Math.max(0, m);\n  let whole = Math.floor(safe / 100);\n  let rem = Math.round(safe % 100);\n  if (rem === 100) {\n    rem = 0;\n    whole += 1;\n  }\n  return `${whole}+${String(rem).padStart(2, \"0\")}`;\n}\n\n// smallest step (in meters) on a 1-2-5 ladder whose pixel pitch lands in a\n// legible band, so the tick density adapts to the chosen scale instead of\n// being hardcoded.\nfunction niceStepMeters(scale: number) {\n  if (scale <= 0) return 100;\n  const [lo, hi] = [24, 56];\n  let mag = 1;\n  for (let guard = 0; guard < 60; guard++) {\n    for (const base of [1, 2, 5]) {\n      const step = base * mag;\n      const px = step * scale;\n      if (px >= lo && px <= hi) return step;\n    }\n    const midPx = 2 * mag * scale;\n    if (midPx < lo) mag *= 10;\n    else mag /= 10;\n  }\n  return 100;\n}\n\nfunction ticksInRange(fromM: number, toM: number, step: number) {\n  if (step <= 0) return [] as number[];\n  const start = Math.ceil((fromM + 1e-6) / step) * step;\n  const out: number[] = [];\n  for (let m = start; m < toM - 1e-6; m += step) out.push(m);\n  return out;\n}\n\ntype Rows = {\n  cumD: number[]; // meters at each maneuver, index-aligned to steps\n  y: number[]; // px at top of each row, index-aligned to steps\n  broke: boolean[]; // true when the segment INTO row i was floored\n  totalY: number;\n  totalD: number;\n};\n\nfunction buildRows(steps: StripStationManeuver[], scale: number): Rows {\n  const n = steps.length;\n  const cumD = new Array<number>(n).fill(0);\n  const y = new Array<number>(n).fill(0);\n  const broke = new Array<boolean>(n).fill(false);\n  for (let i = 1; i < n; i++) {\n    const legM = Math.max(0, steps[i - 1]?.distanceM ?? 0);\n    cumD[i] = (cumD[i - 1] ?? 0) + legM;\n    const natural = legM * scale;\n    broke[i] = natural < ROW_H - 0.01;\n    const pitch = Math.max(ROW_H, natural);\n    y[i] = (y[i - 1] ?? 0) + pitch;\n  }\n  return {\n    cumD,\n    y,\n    broke,\n    totalY: y[n - 1] ?? 0,\n    totalD: cumD[n - 1] ?? 0,\n  };\n}\n\nfunction yForPosition(pm: number, rows: Rows) {\n  const { cumD, y } = rows;\n  const n = cumD.length;\n  if (n === 0) return 0;\n  if (pm <= (cumD[0] ?? 0)) return y[0] ?? 0;\n  for (let i = 0; i < n - 1; i++) {\n    const next = cumD[i + 1] ?? 0;\n    if (pm < next) {\n      const start = cumD[i] ?? 0;\n      const span = next - start;\n      const t = span > 0 ? (pm - start) / span : 0;\n      return (y[i] ?? 0) + t * ((y[i + 1] ?? 0) - (y[i] ?? 0));\n    }\n  }\n  return y[n - 1] ?? 0;\n}\n\n// index of the leg the driver is currently ON (0..n-2), clamped\nfunction currentLegIndex(pm: number, cumD: number[]) {\n  const n = cumD.length;\n  if (n < 2) return 0;\n  for (let i = 0; i < n - 1; i++) {\n    if (pm < (cumD[i + 1] ?? 0)) return i;\n  }\n  return n - 2;\n}\n\nfunction TurnGlyph({\n  angleDeg,\n  kind,\n}: {\n  angleDeg: number;\n  kind: \"start\" | \"mid\" | \"end\";\n}) {\n  const c = GLYPH / 2;\n  const arm = c - 5;\n\n  if (kind === \"start\") {\n    return (\n      <svg width={GLYPH} height={GLYPH} viewBox={`0 0 ${GLYPH} ${GLYPH}`}>\n        <circle cx={c} cy={c} r={3} fill=\"currentColor\" />\n        <line\n          x1={c}\n          y1={c + 3}\n          x2={c}\n          y2={c + arm}\n          stroke=\"currentColor\"\n          strokeWidth={1.75}\n          strokeLinecap=\"round\"\n        />\n      </svg>\n    );\n  }\n  if (kind === \"end\") {\n    return (\n      <svg width={GLYPH} height={GLYPH} viewBox={`0 0 ${GLYPH} ${GLYPH}`}>\n        <line\n          x1={c}\n          y1={c - arm}\n          x2={c}\n          y2={c}\n          stroke=\"currentColor\"\n          strokeWidth={1.75}\n          strokeLinecap=\"round\"\n        />\n        <rect\n          x={c - 3}\n          y={c - 3}\n          width={6}\n          height={6}\n          fill=\"currentColor\"\n          transform={`rotate(45 ${c} ${c})`}\n        />\n      </svg>\n    );\n  }\n\n  // mid: one polyline, entry -> vertex -> exit-at-true-angle -> arrowhead.\n  // exit angle is the ACTUAL turnAngleDeg, not a snapped category — a 15°\n  // fork and an 80° hard left are literally different drawings.\n  const rad = (angleDeg * Math.PI) / 180;\n  const ex = c + Math.sin(rad) * arm;\n  const ey = c + Math.cos(rad) * arm;\n  const headLen = 4.2;\n  const a1 = rad + Math.PI - 0.55;\n  const a2 = rad + Math.PI + 0.55;\n  const h1x = ex + Math.sin(a1) * headLen;\n  const h1y = ey + Math.cos(a1) * headLen;\n  const h2x = ex + Math.sin(a2) * headLen;\n  const h2y = ey + Math.cos(a2) * headLen;\n  const points = [\n    `${c},${c - arm}`,\n    `${c},${c}`,\n    `${ex.toFixed(2)},${ey.toFixed(2)}`,\n    `${h1x.toFixed(2)},${h1y.toFixed(2)}`,\n    `${ex.toFixed(2)},${ey.toFixed(2)}`,\n    `${h2x.toFixed(2)},${h2y.toFixed(2)}`,\n  ].join(\" \");\n\n  return (\n    <svg width={GLYPH} height={GLYPH} viewBox={`0 0 ${GLYPH} ${GLYPH}`}>\n      <polyline\n        points={points}\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={1.75}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nfunction BreakZigzag() {\n  // the standard equation-station break symbol: renders in place of a\n  // connector when true spacing fell below the 44px floor, so the\n  // compression reads as a deliberate mark, not a silent clamp.\n  return (\n    <svg\n      width={20}\n      height={9}\n      viewBox=\"0 0 20 9\"\n      className=\"text-ns-muted\"\n    >\n      <polyline\n        points=\"0,4.5 4,0.5 8,8.5 12,0.5 16,8.5 20,4.5\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={1.25}\n      />\n    </svg>\n  );\n}\n\nexport function StripStation({\n  steps,\n  positionM = 0,\n  scalePxPerM = 0.09,\n  label = \"Turn-by-turn directions\",\n  className = \"h-[560px] w-full\",\n}: StripStationProps) {\n  const scrollerRef = useRef<HTMLDivElement>(null);\n  const markerRef = useRef<HTMLDivElement>(null);\n  const roseRef = useRef<SVGGElement>(null);\n  const headingChipRef = useRef<HTMLSpanElement>(null);\n  const btnRefs = useRef<(HTMLButtonElement | null)[]>([]);\n\n  const rows = useMemo(() => buildRows(steps, scalePxPerM), [steps, scalePxPerM]);\n  const minorStep = useMemo(() => niceStepMeters(scalePxPerM), [scalePxPerM]);\n  const majorStep = minorStep * 5;\n\n  const initialLeg = currentLegIndex(clamp(positionM, 0, rows.totalD), rows.cumD);\n  const [currentIndex, setCurrentIndex] = useState(() =>\n    Math.min(initialLeg + 1, Math.max(0, steps.length - 1))\n  );\n  const [focusedIndex, setFocusedIndex] = useState(currentIndex);\n  const [live, setLive] = useState(\"\");\n\n  const reducedRef = useRef(false);\n  const currentIndexRef = useRef(currentIndex);\n  const targetYRef = useRef(0);\n  const suspendUntilRef = useRef(0);\n  const listFocusedRef = useRef(false);\n  const lastSetScrollRef = useRef(0);\n  const wakeRef = useRef<() => void>(() => {});\n\n  // reduced-motion tracking\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    reducedRef.current = mq.matches;\n    const onChange = () => {\n      reducedRef.current = mq.matches;\n    };\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, []);\n\n  // the persistent scroll-follow loop: chases targetYRef with an ease-out-\n  // expo catch-up, never a hard lock — it yields the moment the user scrolls\n  // by hand, and it sleeps once caught up rather than spinning forever.\n  useEffect(() => {\n    let raf = 0;\n    let running = false;\n    let last = 0;\n    const EPS = 0.5;\n\n    const loop = (now: number) => {\n      const dt = last === 0 ? 1 / 60 : Math.min(0.1, (now - last) / 1000);\n      last = now;\n      const scroller = scrollerRef.current;\n      if (!scroller) {\n        running = false;\n        return;\n      }\n      const H = scroller.clientHeight;\n      const maxScroll = Math.max(0, scroller.scrollHeight - H);\n      const target = clamp(targetYRef.current - H / 3, 0, maxScroll);\n      const cur = scroller.scrollTop;\n      const suspended = now < suspendUntilRef.current || listFocusedRef.current;\n\n      if (!suspended) {\n        const next = reducedRef.current\n          ? target\n          : cur + (target - cur) * (1 - Math.pow(2, -10 * dt));\n        if (Math.abs(next - cur) > 0.05) {\n          scroller.scrollTop = next;\n          lastSetScrollRef.current = next;\n        }\n      }\n\n      if (!suspended && Math.abs(target - scroller.scrollTop) > EPS) {\n        raf = requestAnimationFrame(loop);\n      } else {\n        running = false;\n      }\n    };\n\n    wakeRef.current = () => {\n      if (!running) {\n        running = true;\n        last = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    };\n\n    return () => cancelAnimationFrame(raf);\n  }, []);\n\n  // manual scroll pauses auto-follow for a beat rather than fighting it\n  useEffect(() => {\n    const el = scrollerRef.current;\n    if (!el) return;\n    const onScroll = () => {\n      if (Math.abs(el.scrollTop - lastSetScrollRef.current) > 3) {\n        suspendUntilRef.current = performance.now() + 2500;\n      }\n    };\n    el.addEventListener(\"scroll\", onScroll, { passive: true });\n    return () => el.removeEventListener(\"scroll\", onScroll);\n  }, []);\n\n  // p (positionM) is the single scalar driving both the marker and the\n  // scroll-follow target. The marker itself is never smoothed — it always\n  // reads the honest position; only the viewport eases toward it.\n  useEffect(() => {\n    const pm = clamp(positionM, 0, rows.totalD);\n    const y = yForPosition(pm, rows);\n    if (markerRef.current) markerRef.current.style.transform = `translateY(${y}px)`;\n    targetYRef.current = y;\n\n    const legIdx = currentLegIndex(pm, rows.cumD);\n    const heading = steps[legIdx]?.headingDeg ?? 0;\n    if (roseRef.current) roseRef.current.style.transform = `rotate(${(-heading).toFixed(1)}deg)`;\n    if (headingChipRef.current) {\n      headingChipRef.current.textContent = `${Math.round(((heading % 360) + 360) % 360)}°`;\n    }\n\n    const upcoming = Math.min(legIdx + 1, Math.max(0, steps.length - 1));\n    if (upcoming !== currentIndexRef.current) {\n      currentIndexRef.current = upcoming;\n      setCurrentIndex(upcoming);\n      const step = steps[upcoming];\n      if (step) {\n        const remain = (rows.cumD[upcoming] ?? 0) - pm;\n        setLive(\n          `${step.instruction}${remain > 0.5 ? `, in ${formatDistance(remain)}` : \"\"}, step ${\n            upcoming + 1\n          } of ${steps.length}`\n        );\n      }\n    }\n    wakeRef.current();\n  }, [positionM, rows, steps]);\n\n  const focusRow = (i: number) => {\n    setFocusedIndex(i);\n    requestAnimationFrame(() => {\n      btnRefs.current[i]?.focus();\n      btnRefs.current[i]?.scrollIntoView({\n        block: \"nearest\",\n        behavior: reducedRef.current ? \"auto\" : \"smooth\",\n      });\n    });\n  };\n\n  const onListKeyDown = (e: KeyboardEvent<HTMLOListElement>) => {\n    if (![\"ArrowDown\", \"ArrowUp\", \"Home\", \"End\"].includes(e.key)) return;\n    e.preventDefault();\n    const n = steps.length;\n    let next = focusedIndex;\n    if (e.key === \"ArrowDown\") next = Math.min(n - 1, focusedIndex + 1);\n    else if (e.key === \"ArrowUp\") next = Math.max(0, focusedIndex - 1);\n    else if (e.key === \"Home\") next = 0;\n    else if (e.key === \"End\") next = n - 1;\n    focusRow(next);\n  };\n\n  return (\n    <div\n      role=\"region\"\n      aria-label={label}\n      className={`relative flex overflow-hidden rounded-md border border-border bg-background ${className}`}\n    >\n      {/* compass rose: pinned in its own non-scrolling gutter column (never\n          overlaps row content) — aria-hidden, it pays back the bearing the\n          strip sacrificed by keeping true heading always in view */}\n      <div\n        aria-hidden=\"true\"\n        className=\"flex w-12 shrink-0 flex-col items-center gap-1 border-r border-border py-2\"\n      >\n        <svg width={26} height={26} viewBox=\"0 0 26 26\" className=\"text-foreground\">\n          <circle cx={13} cy={13} r={11} fill=\"none\" stroke=\"currentColor\" strokeOpacity={0.35} />\n          <g\n            ref={roseRef}\n            style={{ transformOrigin: \"13px 13px\" }}\n            className=\"transition-transform duration-500 ease-out motion-reduce:transition-none motion-reduce:duration-0\"\n          >\n            <path d=\"M13 3L15.2 10.5L13 8.8L10.8 10.5Z\" fill=\"currentColor\" />\n            <circle cx={13} cy={13} r={1.2} fill=\"currentColor\" />\n          </g>\n        </svg>\n        <span\n          ref={headingChipRef}\n          className=\"font-mono text-[8px] leading-none text-ns-muted\"\n        >\n          0&deg;\n        </span>\n      </div>\n\n      <div\n        ref={scrollerRef}\n        className=\"h-full min-w-0 flex-1 overflow-y-auto overscroll-contain pr-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\n      >\n        <div className=\"relative\">\n          <ol\n            aria-label={label}\n            onKeyDown={onListKeyDown}\n            onFocusCapture={() => {\n              listFocusedRef.current = true;\n            }}\n            onBlurCapture={(e) => {\n              if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {\n                listFocusedRef.current = false;\n              }\n            }}\n            className=\"relative\"\n          >\n            {steps.map((s, i) => {\n              const passed = i <= currentLegIndex(clamp(positionM, 0, rows.totalD), rows.cumD);\n              const isCurrent = i === currentIndex;\n              const kind = i === 0 ? \"start\" : i === steps.length - 1 ? \"end\" : \"mid\";\n              const tone = passed && !isCurrent ? \"text-ns-muted\" : \"text-foreground\";\n              const ticks =\n                i > 0 && !rows.broke[i]\n                  ? ticksInRange(rows.cumD[i - 1] ?? 0, rows.cumD[i] ?? 0, minorStep).map((m) => ({\n                      m,\n                      offset: m - (rows.cumD[i - 1] ?? 0),\n                      major: Math.abs(m % majorStep) < 1e-6,\n                    }))\n                  : [];\n\n              return (\n                <li key={s.id}>\n                  {i > 0 && (rows.y[i] ?? 0) - (rows.y[i - 1] ?? 0) > 0.5 && (\n                    <div\n                      aria-hidden=\"true\"\n                      className=\"relative\"\n                      style={{ height: (rows.y[i] ?? 0) - (rows.y[i - 1] ?? 0) }}\n                    >\n                      {rows.broke[i] ? (\n                        <div className=\"absolute left-1 top-0 -translate-y-1/2\">\n                          <BreakZigzag />\n                        </div>\n                      ) : (\n                        ticks.map((t) => (\n                          <div\n                            key={t.m}\n                            className=\"absolute left-0 flex items-center gap-1\"\n                            style={{ top: t.offset }}\n                          >\n                            <span\n                              className={`h-px ${\n                                t.major ? \"w-3 bg-foreground/50\" : \"w-1.5 bg-border\"\n                              }`}\n                            />\n                            {t.major && (\n                              <span className=\"font-mono text-[8px] leading-none text-ns-muted\">\n                                {formatChainage(t.m)}\n                              </span>\n                            )}\n                          </div>\n                        ))\n                      )}\n                    </div>\n                  )}\n\n                  <div\n                    className=\"flex items-center gap-2\"\n                    style={{ height: ROW_H }}\n                  >\n                    <div\n                      aria-hidden=\"true\"\n                      className=\"w-10 shrink-0 text-right font-mono text-[9px] leading-none text-ns-muted\"\n                    >\n                      {formatChainage(rows.cumD[i] ?? 0)}\n                    </div>\n                    <div aria-hidden=\"true\" className={`shrink-0 ${tone}`}>\n                      <TurnGlyph angleDeg={s.turnAngleDeg} kind={kind} />\n                    </div>\n                    <div className=\"min-w-0 flex-1\">\n                      <button\n                        ref={(el) => {\n                          btnRefs.current[i] = el;\n                        }}\n                        type=\"button\"\n                        tabIndex={focusedIndex === i ? 0 : -1}\n                        aria-current={isCurrent ? \"step\" : undefined}\n                        aria-label={`${s.instruction}${\n                          s.road ? `, ${s.road}` : \"\"\n                        }${s.distanceM > 0 ? `, in ${formatDistance(s.distanceM)}` : \"\"}, step ${\n                          i + 1\n                        } of ${steps.length}`}\n                        title={`${s.instruction}${s.road ? ` · ${s.road}` : \"\"}`}\n                        onClick={() => focusRow(i)}\n                        onFocus={() => setFocusedIndex(i)}\n                        className=\"flex h-full w-full min-w-0 items-center justify-between gap-2 rounded-md px-2 text-left outline-none transition-colors duration-150 hover:bg-foreground/[0.04] focus-visible:ring-2 focus-visible:ring-ns-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n                      >\n                        <span className={`min-w-0 flex-1 truncate text-sm ${tone}`}>\n                          {s.instruction}\n                          {s.road ? (\n                            <span className=\"text-ns-muted\"> · {s.road}</span>\n                          ) : null}\n                        </span>\n                        {s.distanceM > 0 && (\n                          <span className=\"shrink-0 font-mono text-[11px] tabular-nums text-ns-muted\">\n                            {formatDistance(s.distanceM)}\n                          </span>\n                        )}\n                      </button>\n                    </div>\n                  </div>\n                </li>\n              );\n            })}\n          </ol>\n\n          {/* position marker: y is the honest, unsmoothed proportional\n              position — only the viewport (above) eases toward it. Foreground,\n              not accent — this is a persistent state readout, not an\n              interaction affordance. */}\n          <div\n            ref={markerRef}\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute left-[38px] top-0 -translate-y-1/2\"\n          >\n            <span className=\"relative flex h-[10px] w-[10px] items-center justify-center\">\n              <span className=\"absolute inline-flex h-full w-full rounded-full border border-foreground/50 motion-safe:animate-ping\" />\n              <svg width={10} height={10} viewBox=\"0 0 10 10\" className=\"relative text-foreground\">\n                <path d=\"M0 0L10 5L0 10Z\" fill=\"currentColor\" />\n              </svg>\n            </span>\n          </div>\n        </div>\n      </div>\n\n      <div role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {live}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/strip-station.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "directions",
      "navigation",
      "list",
      "svg",
      "scroll",
      "chainage",
      "route",
      "instrument"
    ],
    "instruction": "A directions panel rendered as a DOT-style straight-line diagram (SLD). Core data: an ordered array of maneuvers, each with instruction text, an optional road label, distanceM (length of the leg that STARTS at this maneuver), a signed turnAngleDeg (0 straight, negative left, positive right, ±180 U-turn) and an absolute headingDeg for the leg that follows it. LAYOUT: row pitch = max(44px, distanceM * scalePxPerM, default scale 0.09 px/m) — cumulative real distance drives vertical position directly, so a 34km highway leg renders as ~3000px of scrollable strip while a 90m downtown turn collapses toward the 44px floor. FALSIFIABLE FLOOR: when true proportional spacing would fall under 44px, the strip does not silently clamp — it renders the standard equation-station break glyph (a small zigzag polyline) in place of the connector and the chainage numbering visibly skips (no interstitial ladder ticks in that segment), so compression is a legible mark, never a quiet lie about distance. CHAINAGE: DOT-format station labels (e.g. 1+50 = 150m) sit at every maneuver plus a 1-2-5-ladder of intermediate ticks (auto-picked so pixel pitch lands 24-56px) inside any non-broken connector; minor ticks are unlabeled lines, major ticks (5x the minor step) carry the mono chainage label. TURN GLYPHS: each maneuver is a single SVG polyline per glyph tracing entry -> vertex -> exit at the maneuver's ACTUAL turnAngleDeg plus a small arrowhead, so a 15° fork and an 80° hard left are literally different drawings, not different icon names; depart renders a start dot, arrival a diamond waypoint marker. COMPASS ROSE: a small SVG rose lives in its own non-scrolling gutter column (a fixed-width flex column beside the scroller, border-separated, aria-hidden — never an overlay floating on top of row content) and counter-rotates its needle group to -headingDeg of the CURRENT leg (the leg containing positionM) with a 500ms ease-out CSS transition, plus a tiny mono degree readout — this is the bearing the strip gave up by straightening the road, paid back visibly rather than discarded. MOTION: one governing scalar, positionM (route progress in meters), drives both the marker and the scroll target. The marker's own y is never smoothed — it is always the honest proportional position via the same piecewise cumulative-distance mapping used for chainage; only the viewport SCROLLS toward keeping the marker in the top third, via a persistent rAF loop applying an ease-out-expo catch-up (next = cur + (target-cur) * (1 - 2^(-10*dt))), which lets scattered real GPS updates read as continuous glide. The follow loop yields the instant it detects a scrollTop delta it did not itself write (user scrolled by hand) for 2.5s, and again while any row button holds focus — it never hard-locks the viewport against the user's own scroll or keyboard review. Passed maneuvers (index <= the current leg) render in --ns-muted; the upcoming maneuver is --foreground and carries aria-current=\"step\". A11Y: the directions are a real <ol> of <li>, each wrapping one <button> whose accessible name is the composed sentence '{instruction}, {road}, in {distance}, step {i} of {n}' — screen-reader users get the same information sighted users get from the proportional gaps, since the ribbon (ticks/connectors/break glyphs/turn glyphs) and the compass rose are all aria-hidden and purely presentational. Roving tabindex: exactly one button carries tabIndex 0 at a time (the focused/current row), ArrowUp/ArrowDown/Home/End move focus row-to-row and scrollIntoView (block: 'nearest') to match, so Tab always reaches something and keyboard review never fights the mouse. A single polite aria-live region announces '{instruction}, in {distance remaining}, step {i} of {n}' only when the current leg actually changes, never per animation frame. REDUCED MOTION: the scroll-follow loop snaps instead of easing, the compass rotation transition is removed (instant snap), and the demo freezes positionM at one representative frame instead of animating — the layout, chainage, break glyphs and turn angles are all still fully legible statically. Zero dependencies, DOM + inline SVG only, no canvas."
  },
  "type": "registry:ui"
}