{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "timeline-map",
  "type": "registry:ui",
  "title": "Timeline Map",
  "description": "TimelineMap — dated entities as linked dots on a shared time axis, one labeled strip per category lane.",
  "author": "ofri-peretz <https://github.com/ofri-peretz>",
  "categories": [
    "data",
    "pattern"
  ],
  "dependencies": [],
  "registryDependencies": [
    "https://ds.interlace.tools/r/theme.json",
    "https://ds.interlace.tools/r/cn.json",
    "https://ds.interlace.tools/r/toggle.json"
  ],
  "files": [
    {
      "path": "registry/interlace-ui/patterns/timeline-map.tsx",
      "target": "components/ui/patterns/timeline-map.tsx",
      "type": "registry:ui",
      "content": "'use client';\n\nimport * as React from 'react';\n\n// @interlace/timeline-map v1.3.0 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/timeline-map\n// What changed since: https://ds.interlace.tools/c/timeline-map#history\n// Generated banner — keep it, the upgrade diff reads this version.\n\nimport { cn } from '@/lib/utils';\nimport { Toggle } from '@/components/ui/toggle';\n\n/**\n * TimelineMap — dated entities as linked dots on a shared time axis, one\n * labeled strip per category lane.\n *\n * ## RFC (R3)\n *\n * Generalized from the blog's corpus map (100/100 Lighthouse accessibility\n * + agentic on the live site), born complete with the two rungs the app\n * version lacked: roving-tabindex arrow traversal and a controlled\n * category filter. Travel signal (R2): HIGH — proven on the blog's article\n * corpus; the docs site can map releases/changelog; anything with dated,\n * linkable entities fits.\n *\n * ### Anatomy (R11 — compound, one memoized context R15)\n *\n *   <TimelineMap items={…} data-testid=\"…\">\n *     <TimelineMap.Filter />          — optional category chips (aria-pressed)\n *     <TimelineMap.Chart />           — axis header + lanes + dots\n *     <TimelineMap.Detail />          — fixed-height preview strip (CLS=0, R23)\n *   </TimelineMap>\n *\n * ### Keyboard (R26)\n *\n * | Key            | Action                                              |\n * | -------------- | --------------------------------------------------- |\n * | Tab            | One stop: the current dot (roving tabindex)         |\n * | → / ←          | Next / previous dot in chronological order          |\n * | Home / End     | First / last visible dot                            |\n * | Enter          | Activate the dot's link (native anchor)             |\n *\n * The app version made every dot a tab stop (89 stops on the blog corpus —\n * hostile). Roving tabindex per APG: one stop, arrows traverse.\n *\n * ### API parity (R17)\n *\n * - `onItemClick(item)` mirrors MUI DataGrid's `onRowClick`: the anchor is\n *   an internal part, so the DS surfaces its activation; navigation itself\n *   stays native.\n * - `filter` + `defaultFilter` + `onFilterChange` follow the MUI/shadcn\n *   controlled+uncontrolled convention (R14). URL-state wiring is the\n *   consumer's job — the DS never touches routers.\n * - `linkComponent` (R10 `xxxComponent`) injects a framework Link; default\n *   is a plain anchor.\n *\n * ### Encoding contract\n *\n * x = the shared continuous axis — dates by default, or any numeric\n * measure via `axis={{ kind: \"number\", format }}` (reading minutes,\n * bundle KB): a landscape of category × whatever quantity the corpus\n * is actually navigated by. Row = category, dot diameter = optional\n * `weight` (0..1 → 10–16px; the Detail strip must spell the value out —\n * size is never the only carrier). Marks are single-hue `strand-a` (R19\n * token): identity is carried spatially by labeled lanes, so no\n * multi-hue palette is needed and none is offered.\n *\n * Same-(lane, day) groups fan into a center-first 3-row beeswarm (a solo\n * dot sits centered; burst rows cap radius for lane-border clearance) —\n * without the fan, 43 of the blog's 89 dots were perfectly stacked.\n *\n * Static markup is SSR-honest: every item renders as a real anchor with\n * its accessible name; no floating tooltip (the Detail strip reserves its\n * height).\n *\n * ### Fit-all width\n *\n * The strip stretches to fill the container (ResizeObserver), so the\n * WHOLE territory — every item — is visible at once whenever space\n * allows. 560px is the floor: below it the strip keeps its size and\n * scrolls internally, resting at the recent end (state, not motion —\n * reduced-motion safe). SSR renders honestly at the floor.\n *\n * ### The link weave\n *\n * `item.links` declares the corpus's internal reference graph, drawn as\n * strand-b threads between dots — the map shows not just WHEN and WHERE\n * things were published but how they weave into each other (the division\n * has an agenda; the threads make it legible). Interaction grammar from\n * the engage network graph: at rest the web is faint; touching a dot\n * (hover or keyboard focus — both set `previewed`) lights ITS threads\n * and recedes everything unrelated. State changes are instant — the\n * motion vocabulary stays draw/decode. The overlay is aria-hidden; the\n * Detail strip speaks the same links (\"weaves into …\") for screen\n * readers and crawlers.\n */\n\nexport interface TimelineMapItem {\n  /** Stable identity — used for roving focus and React keys. */\n  id: string;\n  /** Destination when the dot is activated. */\n  href: string;\n  /** Accessible/visible name of the entity. */\n  label: string;\n  /**\n   * Lane the item belongs to. `null`/`undefined` items share the\n   * `uncategorizedLabel` lane.\n   */\n  category?: string | null;\n  /**\n   * ISO `yyyy-mm-dd` — drives x in the default date axis. Items without\n   * a parseable date are not rendered there. Ignored by a number axis.\n   */\n  date?: string;\n  /**\n   * Numeric position for `axis={{ kind: \"number\" }}` (reading minutes,\n   * bundle KB, …). Items without a finite value are not rendered there.\n   * Ignored by the date axis.\n   */\n  value?: number;\n  /**\n   * Optional normalized magnitude (0..1) encoded as dot diameter\n   * (10–16px). The Detail strip should restate it — size is never the\n   * only carrier.\n   * @default 0\n   */\n  weight?: number;\n  /**\n   * Ids of items this one references — the corpus's internal link graph,\n   * drawn as strand-b threads between dots (see \"The link weave\" above).\n   * Unknown and self ids are ignored.\n   */\n  links?: readonly string[];\n}\n\n/**\n * Which continuous quantity the shared axis encodes. The lanes × axis\n * geometry, the weave, and every interaction are identical either way —\n * only the x scale, the ticks, and how an item's position is spoken\n * (aria-label, Detail strip) change.\n */\nexport type TimelineMapAxis =\n  /** The default: `item.date` drives x. */\n  | { kind: 'date' }\n  | {\n      /** `item.value` drives x. */\n      kind: 'number';\n      /**\n       * Renders a value for ticks, aria names, and the Detail strip —\n       * e.g. `(v) => `${v} min``.\n       * @default String\n       */\n      format?: (value: number) => string;\n    };\n\nexport interface TimelineMapProps\n  extends Omit<React.ComponentPropsWithoutRef<'figure'>, 'onClick'> {\n  items: readonly TimelineMapItem[];\n  /** Stable selector for E2E tests; consumer provides — no default (R5). */\n  'data-testid': string;\n  /**\n   * Axis semantics — see TimelineMapAxis. Pass a stable object (module\n   * constant or memo): the layout recomputes when its identity changes.\n   * @default { kind: \"date\" }\n   */\n  axis?: TimelineMapAxis;\n  /**\n   * Lane label for items without a category.\n   * @default \"Other\"\n   */\n  uncategorizedLabel?: string;\n  /** Controlled set of visible categories (R14). Omit for uncontrolled. */\n  filter?: readonly string[];\n  /** Uncontrolled initial visible categories. @default all categories */\n  defaultFilter?: readonly string[];\n  /** Fires with the next visible-category set when a chip is toggled. */\n  onFilterChange?: (categories: string[]) => void;\n  /** Fires when a dot gains hover/focus (drives the Detail strip too). */\n  onItemPreview?: (item: TimelineMapItem) => void;\n  /** Fires on dot activation, alongside native navigation. */\n  onItemClick?: (item: TimelineMapItem) => void;\n  /** Framework link injected for dot anchors (R10). @default \"a\" */\n  linkComponent?: React.ElementType;\n  /**\n   * The reader's own path through the corpus — see TimelineMapTrace.\n   * Client-only data (a reading history) belongs here AFTER hydration:\n   * SSR without it renders no trace, which is the honest crawler view.\n   */\n  trace?: TimelineMapTrace;\n  children: React.ReactNode;\n}\n\nexport interface TimelineMapTrace {\n  /**\n   * Ordered item ids — the path, drawn dot-to-dot in this order with\n   * the strand-a draw verb. Unknown and filtered-out ids are skipped;\n   * consecutive repeats collapse. Fewer than two visible points draws\n   * nothing.\n   */\n  ids: readonly string[];\n  /**\n   * Spoken summary of the trace for screen readers (the drawn overlay\n   * itself is decorative) — e.g. \"Your thread: 7 of 82 articles read.\"\n   */\n  label: string;\n}\n\nconst STRIP_W = 560; // floor width, rendered 1:1 — viewBox units ARE pixels\nconst LABEL_W = 160; // the 10rem sticky label column (grid-cols below)\nconst LANE_H = 44;\nconst DOT_ROWS = [0, -13, 13] as const; // center-first beeswarm fan\n\ninterface Dot {\n  item: TimelineMapItem;\n  cx: number;\n  cy: number;\n  r: number;\n}\n\ninterface Edge {\n  from: string;\n  to: string;\n  x1: number;\n  y1: number;\n  x2: number;\n  y2: number;\n}\n\ninterface Layout {\n  lanes: { name: string; count: number; dots: Dot[] }[];\n  ticks: { x: number; label: string }[];\n  /** All visible dots in chronological order — the traversal order. */\n  order: TimelineMapItem[];\n  /** Internal link graph in overlay coordinates (y spans the lane stack). */\n  edges: Edge[];\n}\n\n/** Threads between dots, in lane-stack coordinates. Unknown/self targets drop. */\nfunction computeEdges(lanes: Layout['lanes']): Edge[] {\n  const pos = new Map<string, { x: number; y: number }>();\n  lanes.forEach((lane, li) => {\n    for (const d of lane.dots)\n      pos.set(d.item.id, { x: d.cx, y: li * LANE_H + d.cy });\n  });\n  const edges: Edge[] = [];\n  // Mutual citations (a→b AND b→a) must render as ONE thread — two paths\n  // on the same geometry double the visual weight (caught in review).\n  const seen = new Set<string>();\n  for (const lane of lanes) {\n    for (const d of lane.dots) {\n      for (const target of d.item.links ?? []) {\n        const from = pos.get(d.item.id);\n        const to = pos.get(target);\n        if (!from || !to || target === d.item.id) continue;\n        const pairKey = [d.item.id, target].sort().join('→');\n        if (seen.has(pairKey)) continue;\n        seen.add(pairKey);\n        edges.push({ from: d.item.id, to: target, x1: from.x, y1: from.y, x2: to.x, y2: to.y });\n      }\n    }\n  }\n  return edges;\n}\n\n/**\n * Pure layout: lanes (by descending count, uncategorized last), quarter\n * ticks with labeled-endpoint fallback, center-first beeswarm fan.\n * Exported for tests — geometry invariants are locked without a DOM.\n */\nexport function computeTimelineLayout(\n  items: readonly TimelineMapItem[],\n  uncategorizedLabel: string,\n  stripWidth: number = STRIP_W,\n  axis: TimelineMapAxis = { kind: 'date' },\n): Layout {\n  const dated = (\n    axis.kind === 'number'\n      ? items.map((i) => ({ item: i, t: i.value ?? NaN }))\n      : items\n          .filter((i) => /^\\d{4}-\\d{2}-\\d{2}/.test(i.date ?? ''))\n          .map((i) => ({\n            item: i,\n            t: Date.parse(`${i.date!.slice(0, 10)}T00:00:00Z`),\n          }))\n  )\n    .filter((e) => Number.isFinite(e.t))\n    .sort((a, b) => a.t - b.t || a.item.id.localeCompare(b.item.id));\n  if (dated.length === 0) return { lanes: [], ticks: [], order: [], edges: [] };\n\n  const min = dated[0].t;\n  // Not .at(-1): its `T | undefined` forces a non-null assertion even\n  // though the empty case returned above. Length-index keeps tsc honest.\n  const max = dated[dated.length - 1].t;\n  const span = Math.max(max - min, 1);\n  const x = (t: number): number => 14 + ((t - min) / span) * (stripWidth - 46);\n\n  const laneNames = new Map<string, number>();\n  for (const { item } of dated) {\n    const key = item.category ?? uncategorizedLabel;\n    laneNames.set(key, (laneNames.get(key) ?? 0) + 1);\n  }\n  const ordered = [...laneNames.entries()]\n    .sort((a, b) =>\n      a[0] === uncategorizedLabel\n        ? 1\n        : b[0] === uncategorizedLabel\n          ? -1\n          : b[1] - a[1],\n    )\n    .map(([name, count]) => ({ name, count, dots: [] as Dot[] }));\n  const byName = new Map(ordered.map((l) => [l.name, l]));\n\n  const bursts = new Map<string, number>();\n  for (const { item, t } of dated) {\n    const laneName = item.category ?? uncategorizedLabel;\n    // Same-(lane, position) collisions fan into the beeswarm. On the date\n    // axis position = day; on a number axis = the exact value (integer\n    // minutes/KB collide constantly — the fan is what keeps them legible).\n    const key = `${laneName}|${t}`;\n    const n = bursts.get(key) ?? 0;\n    bursts.set(key, n + 1);\n    const dy = DOT_ROWS[n % 3];\n    const rBase = 5 + Math.min(Math.max(item.weight ?? 0, 0), 1) * 3;\n    byName.get(laneName)?.dots.push({\n      item,\n      cx: x(t) + Math.floor(n / 3) * 10,\n      cy: LANE_H / 2 + dy,\n      r: dy === 0 ? rBase : Math.min(rBase, 6),\n    });\n  }\n\n  if (axis.kind === 'number') {\n    return {\n      lanes: ordered,\n      ticks: numberTicks(min, max, x, axis.format ?? String),\n      order: dated.map((d) => d.item),\n      edges: computeEdges(ordered),\n    };\n  }\n\n  // Quarter-start ticks; a span too narrow for one falls back to labeled\n  // endpoints so the axis is never empty.\n  const ticks: Layout['ticks'] = [];\n  const start = new Date(min);\n  let year = start.getUTCFullYear();\n  let quarter = (Math.ceil((start.getUTCMonth() + 1) / 3) * 3) % 12;\n  for (let guard = 0; guard < 40; guard++) {\n    if (quarter === 0) year += 1;\n    const t = Date.UTC(year, quarter, 1);\n    if (t > max) break;\n    if (t >= min) {\n      ticks.push({\n        x: x(t),\n        label:\n          quarter === 0\n            ? String(year)\n            : new Date(t).toLocaleDateString('en-US', {\n                month: 'short',\n                timeZone: 'UTC',\n              }),\n      });\n    }\n    quarter = (quarter + 3) % 12;\n  }\n  const fmtEndpoint = (t: number): string =>\n    new Date(t).toLocaleDateString('en-US', {\n      month: 'short',\n      year: 'numeric',\n      timeZone: 'UTC',\n    });\n  if (ticks.length === 0) {\n    ticks.push(\n      { x: x(min), label: fmtEndpoint(min) },\n      { x: x(max), label: fmtEndpoint(max) },\n    );\n  }\n\n  return {\n    lanes: ordered,\n    ticks,\n    order: dated.map((d) => d.item),\n    edges: computeEdges(ordered),\n  };\n}\n\n/**\n * Nice-step ticks for a number axis: a 1/2/5×10ⁿ step sized for ~5\n * ticks, snapped to multiples so labels read as round values. Degenerate\n * spans (all items share one value) fall back to that single labeled\n * point so the axis is never empty.\n */\nfunction numberTicks(\n  min: number,\n  max: number,\n  x: (v: number) => number,\n  format: (v: number) => string,\n): Layout['ticks'] {\n  if (max === min) return [{ x: x(min), label: format(min) }];\n  const raw = (max - min) / 5;\n  const pow = 10 ** Math.floor(Math.log10(raw));\n  const step = [1, 2, 5, 10].map((m) => m * pow).find((s) => s >= raw)!; // last = 10×pow > raw always\n  const ticks: Layout['ticks'] = [];\n  for (\n    let v = Math.ceil(min / step) * step;\n    v <= max;\n    v = Math.round((v + step) * 1e9) / 1e9 // float-drift guard\n  ) {\n    ticks.push({ x: x(v), label: format(v) });\n  }\n  return ticks.length > 0\n    ? ticks\n    : [\n        { x: x(min), label: format(min) },\n        { x: x(max), label: format(max) },\n      ];\n}\n\ninterface TimelineMapContextValue {\n  layout: Layout;\n  /** Current strip width in px — 560 floor, stretched to the container. */\n  stripWidth: number;\n  /** Chart reports its measured container width here (fit-all). */\n  setStripWidth: (w: number) => void;\n  visible: (item: TimelineMapItem) => boolean;\n  categories: { name: string; count: number }[];\n  activeFilter: readonly string[];\n  toggleCategory: (name: string) => void;\n  previewed: TimelineMapItem | null;\n  preview: (item: TimelineMapItem) => void;\n  /**\n   * How an item's axis position is spoken — the date, or the formatted\n   * value on a number axis. One voice for aria-labels and the Detail\n   * strip, so the two can never disagree.\n   */\n  meta: (item: TimelineMapItem) => string | undefined;\n  focusedId: string | null;\n  moveFocus: (from: string, delta: 'next' | 'prev' | 'first' | 'last') => void;\n  onItemClick?: (item: TimelineMapItem) => void;\n  LinkComponent: React.ElementType;\n  testId: string;\n  trace?: TimelineMapTrace;\n}\n\n// One context for the whole pattern (R15) — value memoized below.\nconst TimelineMapContext =\n  React.createContext<TimelineMapContextValue | null>(null);\n\nfunction useTimelineMap(part: string): TimelineMapContextValue {\n  const ctx = React.useContext(TimelineMapContext);\n  if (!ctx) {\n    throw new Error(`TimelineMap.${part} must be used inside <TimelineMap>`);\n  }\n  return ctx;\n}\n\nexport function TimelineMap({\n  items,\n  'data-testid': testId,\n  uncategorizedLabel = 'Other',\n  axis,\n  filter,\n  defaultFilter,\n  onFilterChange,\n  onItemPreview,\n  onItemClick,\n  linkComponent = 'a',\n  trace,\n  className,\n  children,\n  ...rest\n}: TimelineMapProps) {\n  // Fit-all: the Chart measures its container and widens the strip so the\n  // WHOLE territory is visible when space allows; 560 is the floor below\n  // which the strip scrolls instead (dots need room to stay legible).\n  const [stripWidth, setStripWidth] = React.useState(STRIP_W);\n  const layout = React.useMemo(\n    () => computeTimelineLayout(items, uncategorizedLabel, stripWidth, axis),\n    [items, uncategorizedLabel, stripWidth, axis],\n  );\n  const categories = React.useMemo(\n    () => layout.lanes.map(({ name, count }) => ({ name, count })),\n    [layout],\n  );\n\n  // Controlled + uncontrolled filter (R14).\n  const [internalFilter, setInternalFilter] = React.useState<\n    readonly string[] | null\n  >(defaultFilter ?? null);\n  const activeFilter =\n    filter ?? internalFilter ?? categories.map((c) => c.name);\n\n  const [previewed, setPreviewed] = React.useState<TimelineMapItem | null>(\n    null,\n  );\n  const [focusedId, setFocusedId] = React.useState<string | null>(null);\n\n  const value = React.useMemo<TimelineMapContextValue>(() => {\n    const visibleSet = new Set(activeFilter);\n    const visible = (item: TimelineMapItem): boolean =>\n      visibleSet.has(item.category ?? uncategorizedLabel);\n    const visibleOrder = layout.order.filter(visible);\n    return {\n      layout,\n      stripWidth,\n      setStripWidth,\n      visible,\n      categories,\n      activeFilter,\n      toggleCategory: (name) => {\n        const next = visibleSet.has(name)\n          ? activeFilter.filter((c) => c !== name)\n          : [...activeFilter, name];\n        if (filter === undefined) setInternalFilter(next);\n        onFilterChange?.(next);\n      },\n      previewed,\n      preview: (item) => {\n        setPreviewed(item);\n        onItemPreview?.(item);\n      },\n      // Every item reaching meta came out of the layout, which already\n      // filtered non-finite values (number) / unparseable dates (date) —\n      // the assertion documents that invariant instead of dead-branching.\n      meta: (item) =>\n        axis?.kind === 'number'\n          ? (axis.format ?? String)(item.value!)\n          : item.date,\n      // Not a bare ??: when the focused item's lane gets filtered OUT, a\n      // stale focusedId would leave every dot at tabIndex=-1 and the chart\n      // unreachable by keyboard (a focus trap, caught in blog review).\n      // Any focus id outside visibleOrder falls back to the recent end.\n      focusedId:\n        focusedId !== null && visibleOrder.some((i) => i.id === focusedId)\n          ? focusedId\n          : (visibleOrder.at(-1)?.id ?? null),\n      moveFocus: (from, delta) => {\n        if (visibleOrder.length === 0) return;\n        const at = visibleOrder.findIndex((i) => i.id === from);\n        const next =\n          delta === 'first'\n            ? 0\n            : delta === 'last'\n              ? visibleOrder.length - 1\n              : Math.min(\n                  Math.max(at + (delta === 'next' ? 1 : -1), 0),\n                  visibleOrder.length - 1,\n                );\n        setFocusedId(visibleOrder[next].id);\n      },\n      onItemClick,\n      LinkComponent: linkComponent,\n      testId,\n      trace,\n    };\n  }, [\n    layout,\n    stripWidth,\n    categories,\n    activeFilter,\n    uncategorizedLabel,\n    axis,\n    filter,\n    onFilterChange,\n    previewed,\n    onItemPreview,\n    focusedId,\n    onItemClick,\n    linkComponent,\n    testId,\n    trace,\n  ]);\n\n  return (\n    <TimelineMapContext.Provider value={value}>\n      <figure\n        data-slot=\"timeline-map\"\n        data-testid={testId}\n        className={cn(\n          'rounded-lg border border-border bg-card p-4',\n          className,\n        )}\n        {...rest}\n      >\n        {children}\n      </figure>\n    </TimelineMapContext.Provider>\n  );\n}\n\n/** Optional category chips — toggle buttons, `aria-pressed`, count badges. */\nexport interface TimelineMapFilterProps\n  extends React.ComponentPropsWithoutRef<'div'> {}\n\nfunction TimelineMapFilter({ className, ...rest }: TimelineMapFilterProps) {\n  const { categories, activeFilter, toggleCategory, testId } =\n    useTimelineMap('Filter');\n  const active = new Set(activeFilter);\n  return (\n    <div\n      data-slot=\"timeline-map-filter\"\n      data-testid={`${testId}-filter`}\n      role=\"group\"\n      aria-label=\"Filter by category\"\n      className={cn('mb-3 flex flex-wrap gap-1.5', className)}\n      {...rest}\n    >\n      {/* The pill styling lives on Toggle's `pill` variant, not here —\n          this Filter is where the look was born (min-h-6 = the 24px\n          SC 2.5.8 floor, caught by the blog's real-layout audit), and\n          extracting it to the primitive is what keeps every later\n          chip surface from forking the classes. Base UI owns\n          aria-pressed. */}\n      {categories.map(({ name, count }) => (\n        <Toggle\n          key={name}\n          variant=\"pill\"\n          size=\"xs\"\n          pressed={active.has(name)}\n          onPressedChange={() => toggleCategory(name)}\n        >\n          {name}\n          {/* The count INHERITS the pill's text colour: a hardcoded\n              muted-foreground measured 4.37:1 on the ACTIVE pill's\n              strand-a/10 tint — under the 4.5 AA floor. Inactive pills\n              are muted anyway, so nothing changes visually there.\n              No ml-1: the pill variant's gap-1 owns the 4px. */}\n          <span>{count}</span>\n        </Toggle>\n      ))}\n    </div>\n  );\n}\n\n/** The chart: sticky lane labels, shared time axis, dot strips. */\nexport interface TimelineMapChartProps\n  extends React.ComponentPropsWithoutRef<'div'> {}\n\nfunction TimelineMapChart({ className, ...rest }: TimelineMapChartProps) {\n  const {\n    layout,\n    stripWidth,\n    setStripWidth,\n    visible,\n    preview,\n    previewed,\n    focusedId,\n    moveFocus,\n    onItemClick,\n    LinkComponent,\n    testId,\n    meta,\n    trace,\n  } = useTimelineMap('Chart');\n  const scrollerRef = React.useRef<HTMLDivElement | null>(null);\n\n  // The reader's thread — recomputed with the layout so it survives\n  // filtering and fit-all width changes (hidden dots drop out of the\n  // path rather than pinning stale coordinates).\n  const tracePath = React.useMemo(() => {\n    if (!trace) return null;\n    // Filtered-out dots drop from the path (review — lane ROWS always\n    // render, so indices stay stable; only the dots hide). The label\n    // rides along so the render needs one narrowing, not two.\n    const visibleLanes = layout.lanes.map((l) => ({\n      ...l,\n      dots: l.dots.filter((d) => visible(d.item)),\n    }));\n    const p = computeTracePath(visibleLanes, trace.ids);\n    return p ? { ...p, label: trace.label } : null;\n  }, [layout, trace, visible]);\n\n  // The link weave (engage-grammar): the last-touched dot's threads stay\n  // lit; everything unrelated recedes. Selection = `previewed` (hover and\n  // keyboard focus both set it), and it only takes hold when that item\n  // actually participates in a visible thread — a hover over a threadless\n  // dot must not dim the map.\n  const byId = React.useMemo(\n    () => new Map(layout.order.map((i) => [i.id, i])),\n    [layout],\n  );\n  const shownEdges = layout.edges.filter((e) => {\n    const a = byId.get(e.from);\n    const b = byId.get(e.to);\n    return a !== undefined && b !== undefined && visible(a) && visible(b);\n  });\n  const selected =\n    previewed && shownEdges.some((e) => e.from === previewed.id || e.to === previewed.id)\n      ? previewed.id\n      : null;\n  const related = new Set<string>();\n  if (selected) {\n    for (const e of shownEdges) {\n      if (e.from === selected) related.add(e.to);\n      if (e.to === selected) related.add(e.from);\n    }\n  }\n\n  // Fit-all: stretch the strip to the container so the ENTIRE territory\n  // is visible when space allows. Below the 560px floor the strip keeps\n  // its size and scrolls instead. (Guarded: jsdom has no ResizeObserver,\n  // and static SSR markup renders honestly at the floor.)\n  React.useEffect(() => {\n    const el = scrollerRef.current;\n    if (!el || typeof ResizeObserver === 'undefined') return;\n    const measure = () =>\n      setStripWidth(Math.max(STRIP_W, Math.floor(el.clientWidth) - LABEL_W));\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [setStripWidth]);\n\n  // Rest at the recent end when the strip does overflow: the left edge\n  // of a timeline is its sparsest region. Initial state, not motion —\n  // reduced-motion safe. (A fit-all strip has nothing to scroll.)\n  React.useEffect(() => {\n    const el = scrollerRef.current;\n    if (el) el.scrollLeft = el.scrollWidth;\n  }, []);\n\n  const onKeyDown = (item: TimelineMapItem) => (e: React.KeyboardEvent) => {\n    const delta =\n      e.key === 'ArrowRight'\n        ? 'next'\n        : e.key === 'ArrowLeft'\n          ? 'prev'\n          : e.key === 'Home'\n            ? 'first'\n            : e.key === 'End'\n              ? 'last'\n              : null;\n    if (!delta) return;\n    e.preventDefault();\n    moveFocus(item.id, delta);\n  };\n\n  // Move real focus when roving target changes via keyboard.\n  const focusRef = React.useRef<string | null>(null);\n  React.useEffect(() => {\n    if (focusedId && focusRef.current !== null && focusRef.current !== focusedId) {\n      // Dataset comparison, not selector interpolation: consumer-supplied\n      // ids need no escaping this way, and it drops the dependency on the\n      // CSS.escape global (absent in jsdom — surfaced by the mounted\n      // keyboard test).\n      const el = [\n        ...(scrollerRef.current?.querySelectorAll<HTMLElement>('[data-item-id]') ?? []),\n      ].find((e) => e.dataset.itemId === focusedId);\n      el?.focus();\n    }\n    focusRef.current = focusedId;\n  }, [focusedId]);\n\n  return (\n    <div\n      ref={scrollerRef}\n      data-slot=\"timeline-map-chart\"\n      data-testid={`${testId}-chart`}\n      className={cn('overflow-x-auto', className)}\n      {...rest}\n    >\n      <div className=\"relative grid w-max grid-cols-[10rem_max-content]\">\n        <div className=\"sticky left-0 z-10 border-b border-border bg-card\" />\n        <svg\n          data-slot=\"timeline-map-axis\"\n          viewBox={`0 0 ${stripWidth} 20`}\n          width={stripWidth}\n          height={20}\n          aria-hidden\n          className=\"h-5 border-b border-border\"\n        >\n          <g className=\"text-muted-foreground\">\n            {layout.ticks.map((t) => (\n              <g key={t.x}>\n                <line\n                  x1={t.x}\n                  x2={t.x}\n                  y1={14}\n                  y2={20}\n                  className=\"stroke-border\"\n                />\n                <text\n                  x={t.x}\n                  y={11}\n                  textAnchor=\"middle\"\n                  className=\"fill-current text-[10px]\"\n                >\n                  {t.label}\n                </text>\n              </g>\n            ))}\n          </g>\n        </svg>\n        {layout.lanes.map((lane, laneIdx) => (\n          <React.Fragment key={lane.name}>\n            <div\n              className={cn(\n                'sticky left-0 z-10 flex h-11 items-center justify-between gap-2 border-b border-border/60 bg-card pr-3 text-[11px] font-medium leading-tight text-foreground',\n                laneIdx % 2 === 1 && 'bg-muted/40',\n              )}\n            >\n              <span className=\"truncate\">{lane.name}</span>\n              <span className=\"text-muted-foreground\">{lane.count}</span>\n            </div>\n            <svg\n              viewBox={`0 0 ${stripWidth} ${LANE_H}`}\n              width={stripWidth}\n              height={LANE_H}\n              role=\"group\"\n              aria-label={`${lane.name} items`}\n              className={cn(\n                // overflow-visible: the hit circles on top/bottom-row\n                // dots extend 3px past the lane box, and the UA's\n                // svg overflow:hidden shaved them to ~21px (review).\n                // Nothing PAINTS outside — dots + strokes stay in\n                // bounds — so only pointer geometry escapes.\n                'h-11 overflow-visible border-b border-border/60',\n                laneIdx % 2 === 1 && 'bg-muted/40',\n              )}\n            >\n              <g className=\"text-muted-foreground/60\">\n                {layout.ticks.map((t) => (\n                  <line\n                    key={t.x}\n                    x1={t.x}\n                    x2={t.x}\n                    y1={0}\n                    y2={LANE_H}\n                    className=\"stroke-border\"\n                    strokeDasharray=\"2 4\"\n                  />\n                ))}\n              </g>\n              <g className=\"text-strand-a\">\n                {lane.dots.filter((d) => visible(d.item)).map((d) => (\n                  <LinkComponent\n                    key={d.item.id}\n                    href={d.item.href}\n                    data-item-id={d.item.id}\n                    aria-label={`${d.item.label} — ${[d.item.category, meta(d.item)].filter(Boolean).join(' · ')}`}\n                    tabIndex={focusedId === d.item.id ? 0 : -1}\n                    onMouseEnter={() => preview(d.item)}\n                    onFocus={() => preview(d.item)}\n                    onKeyDown={onKeyDown(d.item)}\n                    onClick={() => onItemClick?.(d.item)}\n                    className=\"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current\"\n                  >\n                    {/* SVG hit-slop: the visible dot can be as small as\n                        10px, under the 24px target floor of WCAG 2.2\n                        SC 2.5.8 (caught by the blog's layout audit at\n                        every viewport) — and a 10px dot is genuinely\n                        hard to tap. The transparent circle carries the\n                        pointer geometry; the painted one stays the\n                        map's visual scale. pointer-events on the link\n                        make both circles hit-testable. r=13, not 12:\n                        a nominal 24px union measured 23px in the\n                        gate's real-browser audit (sub-pixel rounding),\n                        so the radius carries a 2px margin. */}\n                    <circle\n                      cx={d.cx}\n                      cy={d.cy}\n                      r={13}\n                      fill=\"transparent\"\n                      stroke=\"none\"\n                    />\n                    <circle\n                      cx={d.cx}\n                      cy={d.cy}\n                      r={d.r}\n                      className={cn(\n                        'fill-current stroke-card stroke-2 transition-opacity',\n                        selected !== null &&\n                          d.item.id !== selected &&\n                          !related.has(d.item.id)\n                          ? 'opacity-30'\n                          : 'opacity-80 hover:opacity-100',\n                      )}\n                    />\n                  </LinkComponent>\n                ))}\n              </g>\n            </svg>\n          </React.Fragment>\n        ))}\n        {shownEdges.length > 0 && (\n          <svg\n            data-slot=\"timeline-map-links\"\n            aria-hidden\n            width={stripWidth}\n            height={layout.lanes.length * LANE_H}\n            className=\"pointer-events-none absolute left-40 top-5 text-strand-b\"\n          >\n            {shownEdges.map((e) => {\n              const lit =\n                selected !== null && (e.from === selected || e.to === selected);\n              return (\n                <path\n                  key={`${e.from}→${e.to}`}\n                  d={edgePath(e)}\n                  className={cn(\n                    'fill-none stroke-current',\n                    // Engage grammar: rest = faint web; a selection lights\n                    // its own threads and collapses the rest. Instant state\n                    // changes — the doctrine's motion verbs stay two.\n                    // Rest ink is BUDGETED: a heavily cross-cited corpus\n                    // (the blog rendered 735 threads) at 0.25 each is a\n                    // hairball that buries the dots. Opacity steps down\n                    // with density so the web reads as texture, never as\n                    // noise; the lit thread keeps full strength always.\n                    selected === null\n                      ? cn('stroke-1', restInk(shownEdges.length))\n                      : lit\n                        ? 'stroke-[1.5] opacity-90'\n                        : cn('stroke-1', dimInk(shownEdges.length)),\n                  )}\n                />\n              );\n            })}\n          </svg>\n        )}\n        {tracePath && (\n          <svg\n            data-slot=\"timeline-map-trace\"\n            aria-hidden\n            width={stripWidth}\n            height={layout.lanes.length * LANE_H}\n            className=\"pointer-events-none absolute left-40 top-5 text-strand-a\"\n          >\n            {/* The reader's own thread: warm strand-a over the cool\n                corpus web, drawn — never faded — with the draw verb\n                (pathLength normalizes any geometry onto the shared\n                strand-draw keyframe; instantly complete under reduce).\n                The visual is decorative; `trace.label` below speaks it. */}\n            <path\n              d={tracePath.d}\n              pathLength={100}\n              className=\"animate-strand-draw fill-none stroke-current stroke-[1.5] opacity-80 [stroke-dasharray:100] [stroke-dashoffset:100]\"\n            />\n          </svg>\n        )}\n        {tracePath && <span className=\"sr-only\">{tracePath.label}</span>}\n      </div>\n    </div>\n  );\n}\n\n/**\n * Rest-state ink budget for the link weave. Total rest ink ≈ edges ×\n * opacity; holding that roughly constant keeps a sparse corpus legible\n * (each thread readable) and a dense one calm (the web as texture).\n * Discrete steps because Tailwind classes are discrete.\n */\nfunction restInk(edgeCount: number): string {\n  if (edgeCount > 160) return 'opacity-[0.04]';\n  if (edgeCount > 48) return 'opacity-10';\n  return 'opacity-25';\n}\n\n/** Receded threads behind an illuminated selection — near-silent when dense. */\nfunction dimInk(edgeCount: number): string {\n  return edgeCount > 160 ? 'opacity-[0.02]' : 'opacity-[0.06]';\n}\n\n/**\n * Thread geometry: cross-lane links take a smooth S (horizontal-tangent\n * cubic); same-lane links bow upward so they don't hide inside the lane.\n */\nfunction curveTo(e: Pick<Edge, 'x1' | 'y1' | 'x2' | 'y2'>): string {\n  if (e.y1 === e.y2) {\n    const bow = Math.min(18, Math.abs(e.x2 - e.x1) / 8 + 8);\n    return `Q ${(e.x1 + e.x2) / 2} ${e.y1 - bow} ${e.x2} ${e.y2}`;\n  }\n  const mx = (e.x1 + e.x2) / 2;\n  return `C ${mx} ${e.y1} ${mx} ${e.y2} ${e.x2} ${e.y2}`;\n}\n\nfunction edgePath(e: Edge): string {\n  return `M ${e.x1} ${e.y1} ${curveTo(e)}`;\n}\n\n/**\n * The reader's thread: one continuous path through the given dots in\n * order, speaking the same curve grammar as the corpus threads. Pure\n * over the laid-out lanes so it is testable without a DOM; exported for\n * the locks. Returns null below two visible points — a single visited\n * dot is a beginning, not yet a thread.\n */\nexport function computeTracePath(\n  lanes: Layout['lanes'],\n  ids: readonly string[],\n): { d: string; points: number } | null {\n  const pos = new Map<string, { x: number; y: number }>();\n  lanes.forEach((lane, li) => {\n    for (const d of lane.dots)\n      pos.set(d.item.id, { x: d.cx, y: li * LANE_H + d.cy });\n  });\n  const path: { x: number; y: number }[] = [];\n  for (const id of ids) {\n    const p = pos.get(id);\n    if (!p) continue; // unknown or filtered out\n    const last = path[path.length - 1];\n    if (last && last.x === p.x && last.y === p.y) continue; // consecutive repeat\n    path.push(p);\n  }\n  if (path.length < 2) return null;\n  let d = `M ${path[0].x} ${path[0].y}`;\n  for (let i = 1; i < path.length; i++) {\n    const a = path[i - 1];\n    const b = path[i];\n    d += ` ${curveTo({ x1: a.x, y1: a.y, x2: b.x, y2: b.y })}`;\n  }\n  return { d, points: path.length };\n}\n\n/**\n * Fixed-height preview strip — the hover layer without a floating tooltip\n * (no positioning, no clipping, CLS=0). Children-as-function overrides the\n * default rendering.\n */\nexport interface TimelineMapDetailProps\n  extends Omit<React.ComponentPropsWithoutRef<'div'>, 'children'> {\n  /** Shown before any dot has been previewed. */\n  idle?: React.ReactNode;\n  children?: (item: TimelineMapItem) => React.ReactNode;\n}\n\nfunction TimelineMapDetail({\n  idle,\n  children,\n  className,\n  ...rest\n}: TimelineMapDetailProps) {\n  const { previewed, layout, testId, meta } = useTimelineMap('Detail');\n  // The visual threads are aria-hidden decoration; THIS line is where the\n  // link graph reaches screen readers and crawlers.\n  const linkedLabels = (previewed?.links ?? [])\n    .map((id) => layout.order.find((i) => i.id === id)?.label)\n    .filter((l): l is string => l !== undefined);\n  return (\n    <div\n      data-slot=\"timeline-map-detail\"\n      data-testid={`${testId}-detail`}\n      aria-live=\"polite\"\n      className={cn(\n        'mt-3 flex min-h-12 items-center rounded-md bg-muted/40 px-4 text-sm',\n        className,\n      )}\n      {...rest}\n    >\n      {previewed ? (\n        children ? (\n          children(previewed)\n        ) : (\n          <span className=\"truncate\">\n            <span className=\"font-medium text-foreground\">\n              {previewed.label}\n            </span>{' '}\n            <span className=\"text-muted-foreground\">\n              {previewed.category ? `· ${previewed.category} ` : ''}·{' '}\n              {meta(previewed)}\n              {linkedLabels.length > 0 && (\n                <> · weaves into {linkedLabels.join(', ')}</>\n              )}\n            </span>\n          </span>\n        )\n      ) : (\n        <span className=\"text-muted-foreground\">{idle}</span>\n      )}\n    </div>\n  );\n}\n\nTimelineMap.Filter = TimelineMapFilter;\nTimelineMap.Chart = TimelineMapChart;\nTimelineMap.Detail = TimelineMapDetail;\n"
    }
  ],
  "meta": {
    "tier": "pattern",
    "client": true,
    "minViewport": null,
    "loading": false,
    "version": "1.3.0",
    "since": null
  },
  "docs": "## @interlace/timeline-map\n\nInstalled to `components/ui/patterns/timeline-map.tsx`.\n\n```tsx\nimport { /* … */ } from '@/components/ui/patterns/timeline-map';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/timeline-map\n\nRequires the `@interlace/theme` CSS baseline (installed automatically as a registry dependency)."
}
