{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "interactive-timeline",
  "type": "registry:component",
  "title": "Interactive Timeline",
  "description": "Zoomable, pannable, multi-track timeline with category filter, today marker, and click-to-select events.",
  "dependencies": [
    "@vllnt/ui@^0.2.1"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/interactive-timeline/interactive-timeline.tsx",
      "content": "\"use client\";\n\nimport {\n  type ComponentPropsWithoutRef,\n  createContext,\n  forwardRef,\n  type MouseEvent as ReactMouseEvent,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\n\nimport { cn } from \"@vllnt/ui\";\n\nconst MIN_ZOOM = 1;\nconst MAX_ZOOM = 32;\nconst ZOOM_STEP = 2;\nconst MS_PER_DAY = 86_400_000;\nconst TICK_TARGET_PX = 120;\n\n/**\n * Color theme for tracks and event categories.\n *\n * @public\n */\nexport type InteractiveTimelineColor =\n  | \"amber\"\n  | \"blue\"\n  | \"emerald\"\n  | \"neutral\"\n  | \"purple\"\n  | \"red\"\n  | \"rose\";\n\nconst COLOR_PALETTE: Record<\n  InteractiveTimelineColor,\n  { bar: string; chip: string; chipActive: string; dot: string }\n> = {\n  amber: {\n    bar: \"bg-amber-500/80\",\n    chip: \"border-amber-400 text-amber-700 dark:text-amber-300\",\n    chipActive: \"bg-amber-500 text-white border-amber-500\",\n    dot: \"border-amber-500 bg-amber-500\",\n  },\n  blue: {\n    bar: \"bg-blue-500/80\",\n    chip: \"border-blue-400 text-blue-700 dark:text-blue-300\",\n    chipActive: \"bg-blue-500 text-white border-blue-500\",\n    dot: \"border-blue-500 bg-blue-500\",\n  },\n  emerald: {\n    bar: \"bg-emerald-500/80\",\n    chip: \"border-emerald-400 text-emerald-700 dark:text-emerald-300\",\n    chipActive: \"bg-emerald-500 text-white border-emerald-500\",\n    dot: \"border-emerald-500 bg-emerald-500\",\n  },\n  neutral: {\n    bar: \"bg-muted-foreground/70\",\n    chip: \"border-border text-muted-foreground\",\n    chipActive: \"bg-foreground text-background border-foreground\",\n    dot: \"border-muted-foreground bg-muted-foreground\",\n  },\n  purple: {\n    bar: \"bg-purple-500/80\",\n    chip: \"border-purple-400 text-purple-700 dark:text-purple-300\",\n    chipActive: \"bg-purple-500 text-white border-purple-500\",\n    dot: \"border-purple-500 bg-purple-500\",\n  },\n  red: {\n    bar: \"bg-red-500/80\",\n    chip: \"border-red-400 text-red-700 dark:text-red-300\",\n    chipActive: \"bg-red-500 text-white border-red-500\",\n    dot: \"border-red-500 bg-red-500\",\n  },\n  rose: {\n    bar: \"bg-rose-500/80\",\n    chip: \"border-rose-400 text-rose-700 dark:text-rose-300\",\n    chipActive: \"bg-rose-500 text-white border-rose-500\",\n    dot: \"border-rose-500 bg-rose-500\",\n  },\n};\n\n/**\n * Track / lane definition.\n *\n * @public\n */\nexport type InteractiveTimelineTrack = {\n  /** Color theme. Defaults to `\"neutral\"`. */\n  color?: InteractiveTimelineColor;\n  /** Stable id; matches {@link InteractiveTimelineEvent.track}. */\n  id: string;\n  /** Display label. */\n  label: ReactNode;\n};\n\n/**\n * Category definition (drives the legend filter).\n *\n * @public\n */\nexport type InteractiveTimelineCategory = {\n  /** Color theme override; falls back to the track color. */\n  color?: InteractiveTimelineColor;\n  /** Stable id; matches {@link InteractiveTimelineEvent.category}. */\n  id: string;\n  /** Display label. */\n  label: ReactNode;\n};\n\n/**\n * A point or duration event.\n *\n * @public\n */\nexport type InteractiveTimelineEvent = {\n  /** Optional category id used by the filter chips. */\n  category?: string;\n  /** Optional explicit color theme for this event. Overrides track + category. */\n  color?: InteractiveTimelineColor;\n  /** Optional description shown in the tooltip. */\n  description?: ReactNode;\n  /** End date for duration events. Omit for point events. */\n  endDate?: Date;\n  /** Stable id. */\n  id: string;\n  /** Start date. */\n  startDate: Date;\n  /** Display title. */\n  title: ReactNode;\n  /** Optional id of the track to render in. Defaults to the first track. */\n  track?: string;\n};\n\n/**\n * Localizable strings.\n *\n * @public\n */\nexport type InteractiveTimelineLabels = {\n  /** Aria-label for the timeline section. Defaults to `\"Interactive timeline\"`. */\n  region?: string;\n  /** Aria-label for the today button. Defaults to `\"Jump to today\"`. */\n  today?: string;\n  /** Aria-label for the zoom-in button. Defaults to `\"Zoom in\"`. */\n  zoomIn?: string;\n  /** Aria-label for the zoom-out button. Defaults to `\"Zoom out\"`. */\n  zoomOut?: string;\n};\n\nconst DEFAULT_LABELS = {\n  region: \"Interactive timeline\",\n  today: \"Jump to today\",\n  zoomIn: \"Zoom in\",\n  zoomOut: \"Zoom out\",\n} as const satisfies Required<InteractiveTimelineLabels>;\n\n/**\n * Props for {@link InteractiveTimeline}.\n *\n * @public\n */\nexport type InteractiveTimelineProps = {\n  /** Optional category filter list. Renders a chip row that toggles per category. */\n  categories?: InteractiveTimelineCategory[];\n  /** End of the visible window. */\n  endDate: Date;\n  /** Events to render. */\n  events?: InteractiveTimelineEvent[];\n  /** Localizable strings. */\n  labels?: InteractiveTimelineLabels;\n  /** Fires after a marker click. */\n  onEventClick?: (event: InteractiveTimelineEvent) => void;\n  /** Start of the visible window. */\n  startDate: Date;\n  /** Track / lane definitions. Falls back to a single anonymous lane. */\n  tracks?: InteractiveTimelineTrack[];\n} & ComponentPropsWithoutRef<\"section\">;\n\ntype TimelineCtx = {\n  centerToday: () => void;\n  labels: Required<InteractiveTimelineLabels>;\n  toggleCategory: (id: string) => void;\n  visibleCategories: ReadonlySet<string>;\n  zoom: number;\n  zoomIn: () => void;\n  zoomOut: () => void;\n};\n\nconst TimelineContext = createContext<null | TimelineCtx>(null);\n\nfunction useTimelineContext(): TimelineCtx {\n  const ctx = useContext(TimelineContext);\n  if (!ctx) {\n    throw new Error(\"InteractiveTimeline subcomponent used outside its root.\");\n  }\n  return ctx;\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n  return Math.min(Math.max(value, min), max);\n}\n\nfunction dateToOffset(date: Date, start: number, span: number): number {\n  if (span <= 0) return 0;\n  return (date.getTime() - start) / span;\n}\n\nfunction formatDate(date: Date): string {\n  return date.toLocaleDateString(undefined, {\n    day: \"2-digit\",\n    month: \"short\",\n    year: \"numeric\",\n  });\n}\n\nfunction dayCount(start: number, end: number): number {\n  return Math.max(1, Math.round((end - start) / MS_PER_DAY));\n}\n\nfunction buildTicks(\n  start: number,\n  end: number,\n  containerWidth: number,\n): { date: Date; offset: number }[] {\n  if (containerWidth <= 0) return [];\n  const targetCount = Math.max(2, Math.floor(containerWidth / TICK_TARGET_PX));\n  const span = end - start;\n  if (span <= 0) return [];\n  const totalDays = dayCount(start, end);\n  const stepDays = Math.max(1, Math.round(totalDays / (targetCount - 1)));\n  const stepMs = stepDays * MS_PER_DAY;\n  return Array.from({ length: targetCount }).map((_, index) => {\n    const time = start + stepMs * index;\n    const clamped = Math.min(time, end);\n    return {\n      date: new Date(clamped),\n      offset: (clamped - start) / span,\n    };\n  });\n}\n\nfunction resolveEventColor(\n  event: InteractiveTimelineEvent,\n  tracks: InteractiveTimelineTrack[],\n  categories: InteractiveTimelineCategory[],\n): InteractiveTimelineColor {\n  if (event.color) return event.color;\n  if (event.category) {\n    const cat = categories.find((c) => c.id === event.category);\n    if (cat?.color) return cat.color;\n  }\n  if (event.track) {\n    const track = tracks.find((t) => t.id === event.track);\n    if (track?.color) return track.color;\n  }\n  return \"neutral\";\n}\n\ntype AxisProps = {\n  endTime: number;\n  startTime: number;\n  ticks: { date: Date; offset: number }[];\n};\n\nfunction Axis({ endTime, startTime, ticks }: AxisProps): ReactNode {\n  return (\n    <div\n      aria-hidden=\"true\"\n      className=\"relative h-7 border-b border-border text-[10px] font-medium uppercase tracking-wide text-muted-foreground\"\n      data-end={endTime}\n      data-start={startTime}\n    >\n      {ticks.map((tick) => (\n        <span\n          className=\"absolute top-1 -translate-x-1/2 whitespace-nowrap\"\n          key={tick.date.getTime()}\n          style={{ left: `${(tick.offset * 100).toString()}%` }}\n        >\n          {formatDate(tick.date)}\n        </span>\n      ))}\n    </div>\n  );\n}\n\ntype TodayMarkerProps = {\n  endTime: number;\n  startTime: number;\n};\n\nfunction getNow(): number {\n  return Date.now();\n}\n\nfunction noopUnsubscribe(): void {\n  return;\n}\n\nfunction emptySubscribe(): () => void {\n  return noopUnsubscribe;\n}\n\nfunction useNow(): number {\n  return useSyncExternalStore(emptySubscribe, getNow, getNow);\n}\n\nfunction TodayMarker({ endTime, startTime }: TodayMarkerProps): ReactNode {\n  const now = useNow();\n  if (now < startTime || now > endTime) return null;\n  const offset = (now - startTime) / (endTime - startTime);\n  return (\n    <div\n      aria-hidden=\"true\"\n      className=\"pointer-events-none absolute inset-y-0 z-20 w-px bg-primary\"\n      data-testid=\"today-marker\"\n      style={{ left: `${(offset * 100).toString()}%` }}\n    >\n      <span className=\"absolute -top-2 -translate-x-1/2 rounded bg-primary px-1 text-[10px] font-medium text-primary-foreground\">\n        Today\n      </span>\n    </div>\n  );\n}\n\ntype EventNodeProps = {\n  active: boolean;\n  categories: InteractiveTimelineCategory[];\n  endTime: number;\n  event: InteractiveTimelineEvent;\n  onSelect: (event: InteractiveTimelineEvent) => void;\n  startTime: number;\n  tracks: InteractiveTimelineTrack[];\n};\n\ntype EventGeometry = {\n  isDuration: boolean;\n  left: number;\n  visible: boolean;\n  width: number;\n};\n\nfunction eventGeometry(\n  event: InteractiveTimelineEvent,\n  startTime: number,\n  endTime: number,\n): EventGeometry {\n  const span = endTime - startTime;\n  const startOffset = dateToOffset(event.startDate, startTime, span);\n  const endOffset = event.endDate\n    ? dateToOffset(event.endDate, startTime, span)\n    : startOffset;\n  const visible =\n    endOffset >= 0 && startOffset <= 1 && event.startDate.getTime() <= endTime;\n  const left = clamp(startOffset, 0, 1);\n  const width = Math.max(0, Math.min(1, endOffset) - left);\n  return {\n    isDuration: width > 0 && Boolean(event.endDate),\n    left,\n    visible,\n    width,\n  };\n}\n\ntype EventTooltipProps = {\n  event: InteractiveTimelineEvent;\n  tooltipId: string;\n};\n\nfunction EventTooltip({ event, tooltipId }: EventTooltipProps): ReactNode {\n  return (\n    <span\n      className=\"pointer-events-none absolute left-1/2 top-5 hidden -translate-x-1/2 whitespace-nowrap rounded border bg-popover px-2 py-1 text-[11px] font-medium text-popover-foreground shadow-md group-hover:block group-focus-visible:block\"\n      id={tooltipId}\n      role=\"tooltip\"\n    >\n      <span className=\"block\">{event.title}</span>\n      <span className=\"block text-[10px] text-muted-foreground\">\n        {formatDate(event.startDate)}\n        {event.endDate ? ` – ${formatDate(event.endDate)}` : null}\n      </span>\n      {event.description ? (\n        <span className=\"block text-[10px] text-muted-foreground\">\n          {event.description}\n        </span>\n      ) : null}\n    </span>\n  );\n}\n\nfunction EventNode({\n  active,\n  categories,\n  endTime,\n  event,\n  onSelect,\n  startTime,\n  tracks,\n}: EventNodeProps): ReactNode {\n  const color = resolveEventColor(event, tracks, categories);\n  const palette = COLOR_PALETTE[color];\n  const { isDuration, left, visible, width } = eventGeometry(\n    event,\n    startTime,\n    endTime,\n  );\n  if (!visible) return null;\n  const titleText = typeof event.title === \"string\" ? event.title : \"Event\";\n  const tooltipId = `${event.id}-tooltip`;\n\n  const handleClick = (\n    mouseEvent: ReactMouseEvent<HTMLButtonElement>,\n  ): void => {\n    mouseEvent.stopPropagation();\n    onSelect(event);\n  };\n\n  return (\n    <button\n      aria-describedby={event.description ? tooltipId : undefined}\n      aria-label={titleText}\n      aria-pressed={active}\n      className={cn(\n        \"group absolute top-1/2 z-10 -translate-y-1/2 cursor-pointer rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n        isDuration ? \"\" : \"-translate-x-1/2\",\n      )}\n      data-event-id={event.id}\n      data-event-track={event.track ?? \"\"}\n      data-selected={active ? \"true\" : undefined}\n      onClick={handleClick}\n      style={{\n        left: `${(left * 100).toString()}%`,\n        width: isDuration ? `${(width * 100).toString()}%` : undefined,\n      }}\n      type=\"button\"\n    >\n      {isDuration ? (\n        <span\n          className={cn(\n            \"block h-3 rounded-sm shadow-sm ring-2 ring-background\",\n            palette.bar,\n            active ? \"ring-primary\" : \"\",\n          )}\n        />\n      ) : (\n        <span\n          className={cn(\n            \"block size-3 rounded-full border-2 ring-2 ring-background\",\n            palette.dot,\n            active ? \"ring-primary\" : \"\",\n          )}\n        />\n      )}\n      <EventTooltip event={event} tooltipId={tooltipId} />\n    </button>\n  );\n}\n\ntype TrackRowProps = {\n  categories: InteractiveTimelineCategory[];\n  endTime: number;\n  events: InteractiveTimelineEvent[];\n  onSelect: (event: InteractiveTimelineEvent) => void;\n  selectedId?: string;\n  startTime: number;\n  track: InteractiveTimelineTrack;\n};\n\nfunction TrackRow({\n  categories,\n  endTime,\n  events,\n  onSelect,\n  selectedId,\n  startTime,\n  track,\n}: TrackRowProps): ReactNode {\n  const palette = COLOR_PALETTE[track.color ?? \"neutral\"];\n  return (\n    <div\n      className=\"relative flex h-12 items-center border-t border-border/60\"\n      data-track-id={track.id}\n    >\n      <div className=\"absolute left-0 z-30 flex h-full w-32 shrink-0 items-center gap-2 border-r border-border bg-background px-3 text-xs font-medium\">\n        <span\n          aria-hidden=\"true\"\n          className={cn(\"size-2 rounded-full\", palette.dot)}\n        />\n        <span className=\"truncate\">{track.label}</span>\n      </div>\n      <div className=\"relative ml-32 h-full flex-1\">\n        <div className=\"absolute inset-x-0 top-1/2 h-px -translate-y-1/2 bg-border\" />\n        {events.map((event) => (\n          <EventNode\n            active={selectedId === event.id}\n            categories={categories}\n            endTime={endTime}\n            event={event}\n            key={event.id}\n            onSelect={onSelect}\n            startTime={startTime}\n            tracks={[track]}\n          />\n        ))}\n      </div>\n    </div>\n  );\n}\n\ntype ScrollAreaProps = {\n  categories: InteractiveTimelineCategory[];\n  endTime: number;\n  events: InteractiveTimelineEvent[];\n  onSelect: (event: InteractiveTimelineEvent) => void;\n  selectedId?: string;\n  startTime: number;\n  tracks: InteractiveTimelineTrack[];\n  zoom: number;\n};\n\ntype ScrollDragHandlers = {\n  onPointerCancel: (event: ReactPointerEvent<HTMLDivElement>) => void;\n  onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;\n  onPointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;\n  onPointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;\n};\n\nfunction useScrollDrag(\n  ref: React.RefObject<HTMLDivElement | null>,\n): ScrollDragHandlers {\n  const dragRef = useRef<null | { originScroll: number; originX: number }>(\n    null,\n  );\n\n  const onPointerDown = useCallback(\n    (event: ReactPointerEvent<HTMLDivElement>): void => {\n      const node = ref.current;\n      if (!node) return;\n      dragRef.current = {\n        originScroll: node.scrollLeft,\n        originX: event.clientX,\n      };\n      node.setPointerCapture(event.pointerId);\n    },\n    [ref],\n  );\n\n  const onPointerMove = useCallback(\n    (event: ReactPointerEvent<HTMLDivElement>): void => {\n      const node = ref.current;\n      const drag = dragRef.current;\n      if (!node || !drag) return;\n      node.scrollLeft = drag.originScroll - (event.clientX - drag.originX);\n    },\n    [ref],\n  );\n\n  const onPointerEnd = useCallback(\n    (event: ReactPointerEvent<HTMLDivElement>): void => {\n      const node = ref.current;\n      if (!node) return;\n      if (node.hasPointerCapture(event.pointerId)) {\n        node.releasePointerCapture(event.pointerId);\n      }\n      dragRef.current = null;\n    },\n    [ref],\n  );\n\n  return {\n    onPointerCancel: onPointerEnd,\n    onPointerDown,\n    onPointerMove,\n    onPointerUp: onPointerEnd,\n  };\n}\n\nfunction ScrollArea({\n  categories,\n  endTime,\n  events,\n  onSelect,\n  selectedId,\n  startTime,\n  tracks,\n  zoom,\n}: ScrollAreaProps): ReactNode {\n  const ref = useRef<HTMLDivElement | null>(null);\n  const [containerWidth, setContainerWidth] = useState(800);\n  const dragHandlers = useScrollDrag(ref);\n\n  const handleRef = useCallback((node: HTMLDivElement | null) => {\n    ref.current = node;\n    if (node) setContainerWidth(node.clientWidth);\n  }, []);\n\n  const innerWidth = `${(zoom * 100).toString()}%`;\n  const ticks = useMemo(\n    () => buildTicks(startTime, endTime, containerWidth * zoom),\n    [containerWidth, endTime, startTime, zoom],\n  );\n\n  return (\n    <div\n      className=\"relative w-full cursor-grab overflow-x-auto active:cursor-grabbing\"\n      data-zoom={zoom}\n      ref={handleRef}\n      {...dragHandlers}\n    >\n      <div className=\"relative\" style={{ width: innerWidth }}>\n        <Axis endTime={endTime} startTime={startTime} ticks={ticks} />\n        <div className=\"relative\">\n          <TodayMarker endTime={endTime} startTime={startTime} />\n          {tracks.map((track) => {\n            const trackEvents = events.filter(\n              (event) => (event.track ?? tracks[0]?.id) === track.id,\n            );\n            return (\n              <TrackRow\n                categories={categories}\n                endTime={endTime}\n                events={trackEvents}\n                key={track.id}\n                onSelect={onSelect}\n                selectedId={selectedId}\n                startTime={startTime}\n                track={track}\n              />\n            );\n          })}\n        </div>\n      </div>\n    </div>\n  );\n}\n\n/**\n * Toolbar slot. Pass {@link InteractiveTimelineZoomIn},\n * {@link InteractiveTimelineZoomOut}, {@link InteractiveTimelineToday},\n * {@link InteractiveTimelineFilter} as children.\n *\n * @public\n */\nexport const InteractiveTimelineToolbar = forwardRef<\n  HTMLDivElement,\n  ComponentPropsWithoutRef<\"div\">\n>(({ children, className, ...rest }, ref) => (\n  <div\n    className={cn(\n      \"flex flex-wrap items-center gap-2 border-b border-border bg-muted/30 px-3 py-2\",\n      className,\n    )}\n    ref={ref}\n    role=\"toolbar\"\n    {...rest}\n  >\n    {children}\n  </div>\n));\nInteractiveTimelineToolbar.displayName = \"InteractiveTimelineToolbar\";\n\n/**\n * Zoom-in button. Doubles the zoom factor up to a max.\n *\n * @public\n */\nexport const InteractiveTimelineZoomIn = forwardRef<\n  HTMLButtonElement,\n  Omit<ComponentPropsWithoutRef<\"button\">, \"type\">\n>(({ className, ...rest }, ref) => {\n  const { labels, zoomIn } = useTimelineContext();\n  return (\n    <button\n      aria-label={labels.zoomIn}\n      className={cn(\n        \"inline-flex size-8 items-center justify-center rounded-md border border-border bg-background text-sm font-medium hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n        className,\n      )}\n      onClick={zoomIn}\n      ref={ref}\n      type=\"button\"\n      {...rest}\n    >\n      <span aria-hidden=\"true\">+</span>\n    </button>\n  );\n});\nInteractiveTimelineZoomIn.displayName = \"InteractiveTimelineZoomIn\";\n\n/**\n * Zoom-out button.\n *\n * @public\n */\nexport const InteractiveTimelineZoomOut = forwardRef<\n  HTMLButtonElement,\n  Omit<ComponentPropsWithoutRef<\"button\">, \"type\">\n>(({ className, ...rest }, ref) => {\n  const { labels, zoomOut } = useTimelineContext();\n  return (\n    <button\n      aria-label={labels.zoomOut}\n      className={cn(\n        \"inline-flex size-8 items-center justify-center rounded-md border border-border bg-background text-sm font-medium hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n        className,\n      )}\n      onClick={zoomOut}\n      ref={ref}\n      type=\"button\"\n      {...rest}\n    >\n      <span aria-hidden=\"true\">−</span>\n    </button>\n  );\n});\nInteractiveTimelineZoomOut.displayName = \"InteractiveTimelineZoomOut\";\n\n/**\n * Today button. Centers the view on `Date.now()` if it falls inside the\n * timeline window.\n *\n * @public\n */\nexport const InteractiveTimelineToday = forwardRef<\n  HTMLButtonElement,\n  Omit<ComponentPropsWithoutRef<\"button\">, \"type\">\n>(({ children, className, ...rest }, ref) => {\n  const { centerToday, labels } = useTimelineContext();\n  return (\n    <button\n      aria-label={labels.today}\n      className={cn(\n        \"inline-flex h-8 items-center rounded-md border border-border bg-background px-3 text-xs font-medium hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n        className,\n      )}\n      onClick={centerToday}\n      ref={ref}\n      type=\"button\"\n      {...rest}\n    >\n      {children ?? \"Today\"}\n    </button>\n  );\n});\nInteractiveTimelineToday.displayName = \"InteractiveTimelineToday\";\n\n/**\n * Category filter chips. Toggles visibility of events by category.\n *\n * @public\n */\nexport type InteractiveTimelineFilterProps = {\n  categories: InteractiveTimelineCategory[];\n} & Omit<ComponentPropsWithoutRef<\"div\">, \"children\">;\n\nexport const InteractiveTimelineFilter = forwardRef<\n  HTMLDivElement,\n  InteractiveTimelineFilterProps\n>(({ categories, className, ...rest }, ref) => {\n  const { toggleCategory, visibleCategories } = useTimelineContext();\n  return (\n    <div\n      className={cn(\"flex flex-wrap items-center gap-1.5\", className)}\n      ref={ref}\n      role=\"group\"\n      {...rest}\n    >\n      {categories.map((category) => {\n        const active = visibleCategories.has(category.id);\n        const palette = COLOR_PALETTE[category.color ?? \"neutral\"];\n        return (\n          <button\n            aria-pressed={active}\n            className={cn(\n              \"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium\",\n              active ? palette.chipActive : palette.chip,\n            )}\n            data-category-id={category.id}\n            key={category.id}\n            onClick={() => {\n              toggleCategory(category.id);\n            }}\n            type=\"button\"\n          >\n            {category.label}\n          </button>\n        );\n      })}\n    </div>\n  );\n});\nInteractiveTimelineFilter.displayName = \"InteractiveTimelineFilter\";\n\nconst FALLBACK_TRACK: InteractiveTimelineTrack = {\n  color: \"neutral\",\n  id: \"default\",\n  label: \"Events\",\n};\n\nfunction noop(): void {\n  return;\n}\n\nfunction useToolbarHandlers(arguments_: {\n  endTime: number;\n  scrollerId: string;\n  setZoom: (next: ((previous: number) => number) | number) => void;\n  startTime: number;\n  zoom: number;\n}): {\n  centerToday: () => void;\n  zoomIn: () => void;\n  zoomOut: () => void;\n} {\n  const { endTime, scrollerId, setZoom, startTime } = arguments_;\n  const zoomIn = useCallback(() => {\n    setZoom((current) => clamp(current * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM));\n  }, [setZoom]);\n  const zoomOut = useCallback(() => {\n    setZoom((current) => clamp(current / ZOOM_STEP, MIN_ZOOM, MAX_ZOOM));\n  }, [setZoom]);\n  const centerToday = useCallback(() => {\n    if (typeof document === \"undefined\") return;\n    const node = document.querySelector<HTMLElement>(\n      `[data-scroller-id=\"${scrollerId}\"]`,\n    );\n    if (!node) return;\n    const span = endTime - startTime;\n    if (span <= 0) return;\n    const now = Date.now();\n    const offset = clamp((now - startTime) / span, 0, 1);\n    const targetX = offset * node.scrollWidth - node.clientWidth / 2;\n    node.scrollLeft = clamp(targetX, 0, node.scrollWidth);\n  }, [endTime, scrollerId, startTime]);\n  return { centerToday, zoomIn, zoomOut };\n}\n\ntype FilterState = {\n  filteredEvents: InteractiveTimelineEvent[];\n  toggleCategory: (id: string) => void;\n  visibleCategories: ReadonlySet<string>;\n};\n\ntype SelectionState = {\n  handleSelect: (event: InteractiveTimelineEvent) => void;\n  selectedId?: string;\n};\n\ntype Frame = {\n  endTime: number;\n  resolvedLabels: Required<InteractiveTimelineLabels>;\n  startTime: number;\n  tracks: InteractiveTimelineTrack[];\n};\n\nfunction useTimelineFrame(arguments_: {\n  endDate: Date;\n  labels?: InteractiveTimelineLabels;\n  startDate: Date;\n  trackProperty?: InteractiveTimelineTrack[];\n}): Frame {\n  const { endDate, labels, startDate, trackProperty } = arguments_;\n  const tracks =\n    trackProperty && trackProperty.length > 0\n      ? trackProperty\n      : [FALLBACK_TRACK];\n  const resolvedLabels = useMemo(\n    () => ({ ...DEFAULT_LABELS, ...labels }),\n    [labels],\n  );\n  return {\n    endTime: endDate.getTime(),\n    resolvedLabels,\n    startTime: startDate.getTime(),\n    tracks,\n  };\n}\n\nfunction useEventSelection(\n  onEventClick: (event: InteractiveTimelineEvent) => void,\n): SelectionState {\n  const [selectedId, setSelectedId] = useState<string | undefined>();\n  const handleSelect = useCallback(\n    (event: InteractiveTimelineEvent) => {\n      setSelectedId(event.id);\n      onEventClick(event);\n    },\n    [onEventClick],\n  );\n  return { handleSelect, selectedId };\n}\n\nfunction useTimelineContextValue(arguments_: {\n  centerToday: () => void;\n  labels: Required<InteractiveTimelineLabels>;\n  toggleCategory: (id: string) => void;\n  visibleCategories: ReadonlySet<string>;\n  zoom: number;\n  zoomIn: () => void;\n  zoomOut: () => void;\n}): TimelineCtx {\n  const {\n    centerToday,\n    labels,\n    toggleCategory,\n    visibleCategories,\n    zoom,\n    zoomIn,\n    zoomOut,\n  } = arguments_;\n  return useMemo<TimelineCtx>(\n    () => ({\n      centerToday,\n      labels,\n      toggleCategory,\n      visibleCategories,\n      zoom,\n      zoomIn,\n      zoomOut,\n    }),\n    [\n      centerToday,\n      labels,\n      toggleCategory,\n      visibleCategories,\n      zoom,\n      zoomIn,\n      zoomOut,\n    ],\n  );\n}\n\nfunction useTimelineFilter(\n  categories: InteractiveTimelineCategory[],\n  events: InteractiveTimelineEvent[],\n): FilterState {\n  const [hidden, setHidden] = useState<ReadonlySet<string>>(() => new Set());\n\n  const visibleCategories = useMemo(\n    () =>\n      new Set(\n        categories\n          .filter((category) => !hidden.has(category.id))\n          .map((category) => category.id),\n      ),\n    [categories, hidden],\n  );\n\n  const toggleCategory = useCallback((id: string) => {\n    setHidden((current) => {\n      const next = new Set(current);\n      if (next.has(id)) next.delete(id);\n      else next.add(id);\n      return next;\n    });\n  }, []);\n\n  const filteredEvents = useMemo(() => {\n    if (categories.length === 0) return events;\n    return events.filter((event) => {\n      if (!event.category) return true;\n      return !hidden.has(event.category);\n    });\n  }, [categories.length, events, hidden]);\n\n  return { filteredEvents, toggleCategory, visibleCategories };\n}\n\n/**\n * Zoomable, pannable, multi-track timeline. Drag horizontally to pan;\n * use {@link InteractiveTimelineZoomIn} / {@link InteractiveTimelineZoomOut}\n * to change zoom; click events to select. Out of scope for the MVP:\n * minimap, virtual rendering, image export, pinch-to-zoom — pan + button\n * zoom cover the data-rich case.\n *\n * @example\n * ```tsx\n * <InteractiveTimeline\n *   startDate={new Date(\"2024-01-01\")}\n *   endDate={new Date(\"2026-12-31\")}\n *   tracks={[{ id: \"release\", label: \"Releases\", color: \"blue\" }]}\n *   events={[\n *     { id: \"v1\", title: \"v1.0\", startDate: new Date(\"2024-06-01\"), track: \"release\" },\n *   ]}\n *   onEventClick={(event) => console.info(event.id)}\n * >\n *   <InteractiveTimelineToolbar>\n *     <InteractiveTimelineZoomIn />\n *     <InteractiveTimelineZoomOut />\n *     <InteractiveTimelineToday />\n *   </InteractiveTimelineToolbar>\n * </InteractiveTimeline>\n * ```\n *\n * @public\n */\nexport const InteractiveTimeline = forwardRef<\n  HTMLElement,\n  InteractiveTimelineProps\n>((props, ref) => {\n  const {\n    categories = [],\n    children,\n    className,\n    endDate,\n    events = [],\n    labels,\n    onEventClick = noop,\n    startDate,\n    tracks: trackProperty,\n    ...rest\n  } = props;\n\n  const { endTime, resolvedLabels, startTime, tracks } = useTimelineFrame({\n    endDate,\n    labels,\n    startDate,\n    trackProperty,\n  });\n  const scrollerId = useId();\n\n  const [zoom, setZoom] = useState<number>(1);\n\n  const { filteredEvents, toggleCategory, visibleCategories } =\n    useTimelineFilter(categories, events);\n\n  const { centerToday, zoomIn, zoomOut } = useToolbarHandlers({\n    endTime,\n    scrollerId,\n    setZoom,\n    startTime,\n    zoom,\n  });\n\n  const { handleSelect, selectedId } = useEventSelection(onEventClick);\n\n  const ctx = useTimelineContextValue({\n    centerToday,\n    labels: resolvedLabels,\n    toggleCategory,\n    visibleCategories,\n    zoom,\n    zoomIn,\n    zoomOut,\n  });\n\n  return (\n    <TimelineContext.Provider value={ctx}>\n      <section\n        aria-label={resolvedLabels.region}\n        className={cn(\n          \"flex w-full flex-col overflow-hidden rounded-2xl border bg-background text-foreground\",\n          className,\n        )}\n        ref={ref}\n        {...rest}\n      >\n        {children}\n        <div data-scroller-id={scrollerId} id={scrollerId}>\n          <ScrollArea\n            categories={categories}\n            endTime={endTime}\n            events={filteredEvents}\n            onSelect={handleSelect}\n            selectedId={selectedId}\n            startTime={startTime}\n            tracks={tracks}\n            zoom={zoom}\n          />\n        </div>\n      </section>\n    </TimelineContext.Provider>\n  );\n});\nInteractiveTimeline.displayName = \"InteractiveTimeline\";\n",
      "type": "registry:component"
    }
  ],
  "version": "0.2.1",
  "stability": "stable"
}
