{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "live-line-chart",
  "type": "registry:component",
  "title": "Live Line Chart",
  "description": "Real-time streaming line chart with smooth scrolling, crosshair, and animated axes",
  "dependencies": [
    "@visx/curve@4.0.1-alpha.0",
    "@visx/scale@4.0.1-alpha.0",
    "@visx/shape@4.0.1-alpha.0",
    "@visx/responsive@4.0.1-alpha.0",
    "@visx/event@4.0.1-alpha.0",
    "d3-array",
    "motion"
  ],
  "registryDependencies": [
    "@bklit/chart-context",
    "@bklit/chart-tooltip",
    "@bklit/utils"
  ],
  "files": [
    {
      "path": "src/charts/live-line-chart.tsx",
      "content": "\"use client\";\n\nimport { localPoint } from \"@visx/event\";\nimport { ParentSize } from \"@visx/responsive\";\nimport { scaleLinear, scaleTime } from \"@visx/scale\";\nimport { bisector } from \"d3-array\";\nimport {\n  Children,\n  isValidElement,\n  memo,\n  type ReactElement,\n  type ReactNode,\n  startTransition,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  isClipExcludedComponent,\n  isUnderlayComponent,\n} from \"./chart-child-passthrough\";\nimport {\n  ChartProvider,\n  type LineConfig,\n  type Margin,\n  type TooltipData,\n} from \"./chart-context\";\nimport { hmsTimeFmt } from \"./chart-formatters\";\nimport { DEFAULT_CHART_LIFECYCLE } from \"./chart-phase\";\nimport type { LiveLineProps } from \"./live-line\";\nimport { extractReferenceAreaConfigs } from \"./reference-area-config\";\nimport { wrapSingleYScale } from \"./y-axis-scales\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface LiveLinePoint {\n  time: number;\n  value: number;\n}\n\nexport interface LiveLineChartProps {\n  /** Streaming data — array of { time: unixSeconds, value } */\n  data: LiveLinePoint[];\n  /** Latest value (smoothly interpolated to) */\n  value: number;\n  /** Key used for the value field in context data. Default: \"value\" */\n  dataKey?: string;\n  /** Visible time window in seconds. Default: 30 */\n  window?: number;\n  /** Number of X-axis ticks (used to compute leading offset). Default: 5 */\n  numXTicks?: number;\n  /** Leading offset in X-tick units (0 = now at right edge). Default: 0 */\n  nowOffsetUnits?: number;\n  /** Tight Y-axis. Default: false */\n  exaggerate?: boolean;\n  /** Interpolation speed (0–1). Default: 0.08 */\n  lerpSpeed?: number;\n  /** Chart margins */\n  margin?: Partial<Margin>;\n  /** Freeze chart scrolling. Default: false */\n  paused?: boolean;\n  /** Child components (LiveLine, Grid, ChartTooltip, LiveXAxis, LiveYAxis, etc.) */\n  children: ReactNode;\n  className?: string;\n  style?: React.CSSProperties;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nconst LERP_SPEED = 0.08;\nconst DEFAULT_MARGIN: Margin = { top: 24, right: 16, bottom: 32, left: 16 };\n/** React commit interval for the live animation loop (~30fps). */\nconst LIVE_FRAME_COMMIT_MS = 32;\n\ninterface AnimFrame {\n  now: number;\n  yMin: number;\n  yMax: number;\n  displayValue: number;\n}\n\nfunction computeTargetRange(\n  data: LiveLinePoint[],\n  value: number,\n  exaggerate: boolean\n) {\n  if (data.length === 0) {\n    return { yMin: 0, yMax: 100 };\n  }\n  let min = Number.POSITIVE_INFINITY;\n  let max = Number.NEGATIVE_INFINITY;\n  for (const d of data) {\n    if (d.value < min) {\n      min = d.value;\n    }\n    if (d.value > max) {\n      max = d.value;\n    }\n  }\n  if (value < min) {\n    min = value;\n  }\n  if (value > max) {\n    max = value;\n  }\n  const rawRange = max - min;\n  const paddingFactor = exaggerate ? 0.03 : 0.15;\n  const rangePad = rawRange * paddingFactor || (exaggerate ? 0.04 : 10);\n  return { yMin: min - rangePad, yMax: max + rangePad };\n}\n\nfunction nextAnimFrame(\n  prev: AnimFrame,\n  targetRange: { yMin: number; yMax: number },\n  targetValue: number,\n  speed: number,\n  isPaused: boolean\n): AnimFrame {\n  const nextNow = isPaused ? prev.now : Date.now();\n  const nextYMin =\n    targetRange.yMin < prev.yMin\n      ? targetRange.yMin\n      : prev.yMin + (targetRange.yMin - prev.yMin) * speed;\n  const nextYMax =\n    targetRange.yMax > prev.yMax\n      ? targetRange.yMax\n      : prev.yMax + (targetRange.yMax - prev.yMax) * speed;\n  const nextValue =\n    prev.displayValue + (targetValue - prev.displayValue) * speed;\n  return {\n    now: nextNow,\n    yMin: nextYMin,\n    yMax: nextYMax,\n    displayValue: nextValue,\n  };\n}\n\nfunction interpolateAtTime(\n  points: LiveLinePoint[],\n  timeSec: number\n): number | null {\n  if (points.length === 0) {\n    return null;\n  }\n  const firstPt = points[0] as LiveLinePoint;\n  const lastPt = points.at(-1) as LiveLinePoint;\n  if (timeSec <= firstPt.time) {\n    return firstPt.value;\n  }\n  if (timeSec >= lastPt.time) {\n    return lastPt.value;\n  }\n  let lo = 0;\n  let hi = points.length - 1;\n  while (hi - lo > 1) {\n    const mid = Math.floor((lo + hi) / 2);\n    const midPt = points[mid];\n    if (midPt && midPt.time <= timeSec) {\n      lo = mid;\n    } else {\n      hi = mid;\n    }\n  }\n  const p1 = points[lo];\n  if (!p1) {\n    return null;\n  }\n  const p2 = points[hi];\n  if (!p2) {\n    return null;\n  }\n  const dt = p2.time - p1.time;\n  if (dt === 0) {\n    return p1.value;\n  }\n  const t = (timeSec - p1.time) / dt;\n  return p1.value + (p2.value - p1.value) * t;\n}\n\nconst bisectTime = bisector<LiveLinePoint, number>((d) => d.time).left;\n\nfunction extractLiveLineConfigs(children: ReactNode): LineConfig[] {\n  const configs: LineConfig[] = [];\n  Children.forEach(children, (child) => {\n    if (!isValidElement(child)) {\n      return;\n    }\n    const childType = child.type as { displayName?: string; name?: string };\n    const name =\n      typeof child.type === \"function\"\n        ? childType.displayName || childType.name || \"\"\n        : \"\";\n    const props = child.props as LiveLineProps | undefined;\n    if (\n      (name === \"LiveLine\" || (props && \"dataKey\" in props)) &&\n      props?.dataKey\n    ) {\n      configs.push({\n        dataKey: props.dataKey,\n        stroke: props.stroke || \"var(--chart-line-primary)\",\n        strokeWidth: props.strokeWidth || 2,\n      });\n    }\n  });\n  return configs;\n}\n\n// ---------------------------------------------------------------------------\n// Inner chart\n// ---------------------------------------------------------------------------\n\nfunction liveTooltipKey(\n  tooltip: TooltipData | null,\n  dataKey: string\n): string | null {\n  if (!tooltip) {\n    return null;\n  }\n  return `${Math.round(tooltip.x)}:${Math.round(tooltip.yPositions[dataKey] ?? 0)}`;\n}\n\nfunction resolveLiveTooltip(\n  cursorX: number | null,\n  innerWidth: number,\n  innerHeight: number,\n  frame: AnimFrame,\n  leadingMs: number,\n  windowMs: number,\n  xTickUnitMs: number,\n  data: LiveLinePoint[],\n  dataKey: string\n): TooltipData | null {\n  if (cursorX === null || innerWidth <= 0 || innerHeight <= 0) {\n    return null;\n  }\n\n  const domainEndMs = frame.now + leadingMs;\n  const xScaleNext = scaleTime({\n    domain: [new Date(domainEndMs - windowMs), new Date(domainEndMs)],\n    range: [0, innerWidth],\n  });\n  const yScaleNext = scaleLinear({\n    domain: [frame.yMin, frame.yMax],\n    range: [innerHeight, 0],\n    nice: true,\n  });\n  const timeMs = xScaleNext.invert(cursorX).getTime();\n  const timeSec = timeMs / 1000;\n  const visible = data.filter((p) => p.time >= (domainEndMs - windowMs) / 1000);\n  visible.push({ time: frame.now / 1000, value: frame.displayValue });\n  visible.push({\n    time: (frame.now + xTickUnitMs) / 1000,\n    value: frame.displayValue,\n  });\n  const val = interpolateAtTime(visible, timeSec);\n  if (val === null) {\n    return null;\n  }\n\n  return {\n    point: { date: new Date(timeMs), [dataKey]: val },\n    index: 0,\n    x: cursorX,\n    yPositions: { [dataKey]: yScaleNext(val) ?? 0 },\n  };\n}\n\nfunction shouldCommitLiveUpdates(\n  now: number,\n  lastFrameCommit: number,\n  tooltipKey: string | null,\n  lastTooltipKey: string | null\n): { commitFrame: boolean; commitTooltip: boolean } {\n  const commitFrame = now - lastFrameCommit >= LIVE_FRAME_COMMIT_MS;\n  const commitTooltip = tooltipKey !== lastTooltipKey;\n  return { commitFrame, commitTooltip };\n}\n\ninterface InnerProps {\n  data: LiveLinePoint[];\n  value: number;\n  dataKey: string;\n  windowSecs: number;\n  numXTicks: number;\n  nowOffsetUnits: number;\n  exaggerate: boolean;\n  lerpSpeed: number;\n  margin: Margin;\n  paused: boolean;\n  width: number;\n  height: number;\n  containerRef: React.RefObject<HTMLDivElement | null>;\n  children: ReactNode;\n}\n\nfunction LiveLineChartInner(props: InnerProps) {\n  const { width, height, margin } = props;\n  const innerWidth = width - margin.left - margin.right;\n  const innerHeight = height - margin.top - margin.bottom;\n\n  if (innerWidth <= 0 || innerHeight <= 0) {\n    return null;\n  }\n\n  return <LiveLineChartCore {...props} />;\n}\n\nconst LiveLineChartCore = memo(function LiveLineChartCore({\n  data,\n  value,\n  dataKey,\n  windowSecs,\n  numXTicks,\n  nowOffsetUnits,\n  exaggerate,\n  lerpSpeed,\n  margin,\n  paused,\n  width,\n  height,\n  containerRef,\n  children,\n}: InnerProps) {\n  const windowMs = windowSecs * 1000;\n  const innerWidth = width - margin.left - margin.right;\n  const innerHeight = height - margin.top - margin.bottom;\n\n  // ---- Animation state ----\n  const animRef = useRef<AnimFrame>({\n    now: Date.now(),\n    yMin: 0,\n    yMax: 100,\n    displayValue: value,\n  });\n  const [frame, setFrame] = useState<AnimFrame>({\n    now: Date.now(),\n    yMin: 0,\n    yMax: 100,\n    displayValue: value,\n  });\n\n  const pausedRef = useRef(paused);\n  const dataRef = useRef(data);\n  const dataKeyRef = useRef(dataKey);\n  dataRef.current = data;\n  dataKeyRef.current = dataKey;\n\n  useEffect(() => {\n    pausedRef.current = paused;\n  }, [paused]);\n\n  const targetRange = useMemo(\n    () => computeTargetRange(data, value, exaggerate),\n    [data, value, exaggerate]\n  );\n\n  const lines = useMemo(() => extractLiveLineConfigs(children), [children]);\n\n  // Leading offset (used in rAF for tooltip)\n  const xTickUnitMs = windowMs / (numXTicks - 1);\n  const leadingMs = nowOffsetUnits * xTickUnitMs;\n\n  // ---- rAF loop: update frame and tooltip in one place to avoid effect→setState loops ----\n  const cursorXRef = useRef<number | null>(null);\n  const [tooltipData, setTooltipData] = useState<TooltipData | null>(null);\n  const lastFrameCommitRef = useRef(0);\n  const lastTooltipKeyRef = useRef<string | null>(null);\n\n  useEffect(() => {\n    let raf: number;\n    const tick = () => {\n      const next = nextAnimFrame(\n        animRef.current,\n        targetRange,\n        value,\n        lerpSpeed,\n        pausedRef.current\n      );\n      animRef.current = next;\n\n      const nextTooltip = resolveLiveTooltip(\n        cursorXRef.current,\n        innerWidth,\n        innerHeight,\n        next,\n        leadingMs,\n        windowMs,\n        xTickUnitMs,\n        dataRef.current,\n        dataKeyRef.current\n      );\n      const now = performance.now();\n      const tooltipKey = liveTooltipKey(nextTooltip, dataKeyRef.current);\n      const { commitFrame, commitTooltip } = shouldCommitLiveUpdates(\n        now,\n        lastFrameCommitRef.current,\n        tooltipKey,\n        lastTooltipKeyRef.current\n      );\n\n      if (!(commitFrame || commitTooltip)) {\n        raf = requestAnimationFrame(tick);\n        return;\n      }\n\n      if (commitFrame) {\n        lastFrameCommitRef.current = now;\n      }\n      if (commitTooltip) {\n        lastTooltipKeyRef.current = tooltipKey;\n      }\n\n      startTransition(() => {\n        if (commitFrame) {\n          setFrame(next);\n        }\n        if (commitTooltip) {\n          setTooltipData(nextTooltip);\n        }\n      });\n\n      raf = requestAnimationFrame(tick);\n    };\n    raf = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(raf);\n  }, [\n    targetRange,\n    value,\n    lerpSpeed,\n    leadingMs,\n    windowMs,\n    xTickUnitMs,\n    innerWidth,\n    innerHeight,\n  ]);\n\n  const domainEndMs = frame.now + leadingMs;\n\n  // ---- Scales ----\n  const xScale = useMemo(\n    () =>\n      scaleTime({\n        domain: [new Date(domainEndMs - windowMs), new Date(domainEndMs)],\n        range: [0, innerWidth],\n      }),\n    [domainEndMs, windowMs, innerWidth]\n  );\n\n  const yScale = useMemo(\n    () =>\n      scaleLinear({\n        domain: [frame.yMin, frame.yMax],\n        range: [innerHeight, 0],\n        nice: true,\n      }),\n    [frame.yMin, frame.yMax, innerHeight]\n  );\n\n  // ---- Build context-compatible data ----\n  // Convert LiveLinePoint[] to Record<string, unknown>[] with 2 virtual points:\n  // 1. At \"now\" — the live tip where the dot sits\n  // 2. At \"now + 1 unit\" — a queued point that the line fades into\n  const contextData = useMemo(() => {\n    const windowStart = domainEndMs - windowMs;\n    let startIdx = bisectTime(data, windowStart / 1000, 0);\n    if (startIdx > 0) {\n      startIdx--;\n    }\n    const sliced = data.slice(startIdx);\n    const records: Record<string, unknown>[] = sliced.map((p) => ({\n      date: new Date(p.time * 1000),\n      [dataKey]: p.value,\n    }));\n    // Virtual point 1: the \"now\" position (where the live dot sits)\n    records.push({\n      date: new Date(frame.now),\n      [dataKey]: frame.displayValue,\n    });\n    // Virtual point 2: queued ahead (the line extends and fades into this)\n    records.push({\n      date: new Date(frame.now + xTickUnitMs),\n      [dataKey]: frame.displayValue,\n    });\n    return records;\n  }, [\n    data,\n    frame.now,\n    frame.displayValue,\n    domainEndMs,\n    windowMs,\n    dataKey,\n    xTickUnitMs,\n  ]);\n\n  // ---- X accessor ----\n  const xAccessor = useCallback(\n    (d: Record<string, unknown>): Date =>\n      d.date instanceof Date ? d.date : new Date(d.date as number),\n    []\n  );\n\n  const handleMouseMove = useCallback(\n    (event: React.MouseEvent<SVGGElement>) => {\n      const coords = localPoint(event);\n      if (!coords) {\n        return;\n      }\n      const x = coords.x - margin.left;\n      cursorXRef.current = x >= 0 && x <= innerWidth ? x : null;\n    },\n    [margin.left, innerWidth]\n  );\n\n  const handleMouseLeave = useCallback(() => {\n    cursorXRef.current = null;\n    lastTooltipKeyRef.current = null;\n    setTooltipData(null);\n  }, []);\n\n  // Date labels (for ChartTooltip's DateTicker — not used in live but needed for context)\n  const dateLabels = useMemo(\n    () => contextData.map((d) => hmsTimeFmt.format(xAccessor(d))),\n    [contextData, xAccessor]\n  );\n\n  const columnWidth = useMemo(() => {\n    if (contextData.length < 2) {\n      return 0;\n    }\n    return innerWidth / (contextData.length - 1);\n  }, [innerWidth, contextData.length]);\n\n  const clipExcludedChildren: ReactElement[] = [];\n  const underlayChildren: ReactElement[] = [];\n  const seriesChildren: ReactElement[] = [];\n  Children.forEach(children, (child) => {\n    if (!isValidElement(child)) {\n      return;\n    }\n    if (isClipExcludedComponent(child)) {\n      clipExcludedChildren.push(child);\n    } else if (isUnderlayComponent(child)) {\n      underlayChildren.push(child);\n    } else {\n      seriesChildren.push(child);\n    }\n  });\n\n  const referenceAreas = useMemo(\n    () => extractReferenceAreaConfigs(children),\n    [children]\n  );\n\n  const contextValue = useMemo(\n    () => ({\n      ...DEFAULT_CHART_LIFECYCLE,\n      data: contextData,\n      renderData: contextData,\n      xScale,\n      yScale,\n      yScales: wrapSingleYScale(yScale),\n      width,\n      height,\n      innerWidth,\n      innerHeight,\n      margin,\n      columnWidth,\n      tooltipData,\n      setTooltipData,\n      containerRef,\n      lines,\n      referenceAreas,\n      isLoaded: true,\n      animationDuration: 0,\n      xAccessor,\n      dateLabels,\n    }),\n    [\n      contextData,\n      xScale,\n      yScale,\n      width,\n      height,\n      innerWidth,\n      innerHeight,\n      margin,\n      columnWidth,\n      tooltipData,\n      containerRef,\n      lines,\n      referenceAreas,\n      xAccessor,\n      dateLabels,\n    ]\n  );\n\n  return (\n    <ChartProvider value={contextValue}>\n      <svg\n        aria-hidden=\"true\"\n        className=\"overflow-visible\"\n        height={height}\n        width={width}\n      >\n        {/* biome-ignore lint/a11y/noStaticElementInteractions: SVG group for mouse tracking */}\n        <g\n          onMouseLeave={handleMouseLeave}\n          onMouseMove={handleMouseMove}\n          style={{ cursor: \"crosshair\" }}\n          transform={`translate(${margin.left},${margin.top})`}\n        >\n          <rect\n            fill=\"transparent\"\n            height={innerHeight}\n            width={innerWidth}\n            x={0}\n            y={0}\n          />\n          {clipExcludedChildren}\n          {underlayChildren}\n          {seriesChildren}\n        </g>\n      </svg>\n    </ChartProvider>\n  );\n});\n\n// ---------------------------------------------------------------------------\n// Public component\n// ---------------------------------------------------------------------------\n\nexport function LiveLineChart({\n  data,\n  value,\n  dataKey = \"value\",\n  window: windowSecs = 30,\n  numXTicks = 5,\n  nowOffsetUnits = 0,\n  exaggerate = false,\n  lerpSpeed = LERP_SPEED,\n  margin: marginProp,\n  paused = false,\n  children,\n  className,\n  style,\n}: LiveLineChartProps) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const margin = { ...DEFAULT_MARGIN, ...marginProp };\n\n  return (\n    <div\n      className={cn(\"relative w-full\", className)}\n      ref={containerRef}\n      style={{ height: 300, touchAction: \"none\", ...style }}\n    >\n      <ParentSize debounceTime={10}>\n        {({ width, height }) => (\n          <LiveLineChartInner\n            containerRef={containerRef}\n            data={data}\n            dataKey={dataKey}\n            exaggerate={exaggerate}\n            height={height}\n            lerpSpeed={lerpSpeed}\n            margin={margin}\n            nowOffsetUnits={nowOffsetUnits}\n            numXTicks={numXTicks}\n            paused={paused}\n            value={value}\n            width={width}\n            windowSecs={windowSecs}\n          >\n            {children}\n          </LiveLineChartInner>\n        )}\n      </ParentSize>\n    </div>\n  );\n}\n\nexport default LiveLineChart;\n",
      "type": "registry:component",
      "target": "components/charts/live-line-chart.tsx"
    },
    {
      "path": "src/charts/chart-child-passthrough.ts",
      "content": "import {\n  Children,\n  cloneElement,\n  Fragment,\n  isValidElement,\n  type ReactElement,\n  type ReactNode,\n} from \"react\";\n\n/** Marker on wrapper components whose single child should inherit clip classification. */\nexport const CHART_CLIP_PASSTHROUGH = \"__chartClipPassthrough\" as const;\n\nexport function isChartClipPassthrough(type: unknown): boolean {\n  return (\n    typeof type === \"function\" &&\n    (type as { [CHART_CLIP_PASSTHROUGH]?: boolean })[CHART_CLIP_PASSTHROUGH] ===\n      true\n  );\n}\n\n/** Unwrap visibility wrappers so `Grid` / axes stay outside the series clip. */\nexport function resolveChartChildElement(child: ReactElement): ReactElement {\n  if (isChartClipPassthrough(child.type)) {\n    const inner = (child.props as { children?: unknown }).children;\n    if (isValidElement(inner)) {\n      return resolveChartChildElement(inner);\n    }\n  }\n  return child;\n}\n\n/** Walk chart children, flattening React fragments (studio often groups layers in `<>...</>`). */\nexport function forEachChartChild(\n  children: ReactNode,\n  callback: (child: ReactElement, index: number) => void\n) {\n  let index = 0;\n  const visit = (nodes: ReactNode) => {\n    Children.forEach(nodes, (child) => {\n      if (!isValidElement(child)) {\n        return;\n      }\n      if (child.type === Fragment) {\n        visit((child.props as { children?: ReactNode }).children);\n        return;\n      }\n      callback(child, index);\n      index += 1;\n    });\n  };\n  visit(children);\n}\n\nconst CLIP_EXCLUDED_COMPONENT_NAMES = new Set([\n  \"Background\",\n  \"Grid\",\n  \"XAxis\",\n  \"YAxis\",\n  \"BarXAxis\",\n  \"BarYAxis\",\n  \"LiveXAxis\",\n  \"LiveYAxis\",\n]);\n\nconst UNDERLAY_COMPONENT_NAMES = new Set([\"ReferenceArea\", \"BarColumnTrack\"]);\n\n/** Markers render after the interaction overlay so they stay clickable. */\nexport function isPostOverlayComponent(child: ReactElement): boolean {\n  const childType = child.type as {\n    displayName?: string;\n    name?: string;\n    __isChartMarkers?: boolean;\n    __isPostOverlay?: boolean;\n  };\n\n  if (childType.__isChartMarkers || childType.__isPostOverlay) {\n    return true;\n  }\n\n  const componentName =\n    typeof child.type === \"function\"\n      ? childType.displayName || childType.name || \"\"\n      : \"\";\n\n  return (\n    componentName === \"ChartMarkers\" ||\n    componentName === \"MarkerGroup\" ||\n    componentName === \"ChartBrush\"\n  );\n}\n\n/** Renders above grid/axes but below series; excluded from grow-clip reveal. */\nexport function isUnderlayComponent(child: ReactElement): boolean {\n  const childType = child.type as { displayName?: string; name?: string };\n  const componentName =\n    typeof child.type === \"function\"\n      ? childType.displayName || childType.name || \"\"\n      : \"\";\n  return UNDERLAY_COMPONENT_NAMES.has(componentName);\n}\n\n/** Grid and axes stay visible during series clip reveal (e.g. loading → ready). */\nexport function isClipExcludedComponent(child: ReactElement): boolean {\n  const childType = child.type as { displayName?: string; name?: string };\n  const componentName =\n    typeof child.type === \"function\"\n      ? childType.displayName || childType.name || \"\"\n      : \"\";\n  return CLIP_EXCLUDED_COMPONENT_NAMES.has(componentName);\n}\n\n/** SVG layer lists from chart shells need stable keys when rendered as arrays. */\nexport function renderKeyedChartLayers(children: ReactElement[]) {\n  return children.map((child, index) =>\n    cloneElement(child, { key: child.key ?? `chart-layer-${index}` })\n  );\n}\n",
      "type": "registry:lib",
      "target": "components/charts/chart-child-passthrough.ts"
    },
    {
      "path": "src/charts/live-line.tsx",
      "content": "\"use client\";\n\nimport { curveMonotoneX } from \"@visx/curve\";\n\n// biome-ignore lint/suspicious/noExplicitAny: d3 curve factory type\ntype CurveFactory = any;\n\nimport { AreaClosed, LinePath } from \"@visx/shape\";\nimport { motion } from \"motion/react\";\nimport { useCallback, useId, useMemo } from \"react\";\nimport { chartCssVars, useChart } from \"./chart-context\";\n\nexport type Momentum = \"up\" | \"down\" | \"flat\";\n\nexport interface MomentumColors {\n  up: string;\n  down: string;\n  flat: string;\n}\n\nexport function detectMomentum(\n  data: Record<string, unknown>[],\n  dataKey: string,\n  lookback = 20\n): Momentum {\n  if (data.length < 5) {\n    return \"flat\";\n  }\n  const start = Math.max(0, data.length - lookback);\n  let min = Number.POSITIVE_INFINITY;\n  let max = Number.NEGATIVE_INFINITY;\n  for (let i = start; i < data.length; i++) {\n    const v = data[i]?.[dataKey];\n    if (typeof v === \"number\") {\n      if (v < min) {\n        min = v;\n      }\n      if (v > max) {\n        max = v;\n      }\n    }\n  }\n  const range = max - min;\n  if (range === 0) {\n    return \"flat\";\n  }\n  const tailStart = Math.max(start, data.length - 5);\n  const first = (data[tailStart]?.[dataKey] as number) ?? 0;\n  const last = (data.at(-1)?.[dataKey] as number) ?? 0;\n  const delta = last - first;\n  const threshold = range * 0.12;\n  if (delta > threshold) {\n    return \"up\";\n  }\n  if (delta < -threshold) {\n    return \"down\";\n  }\n  return \"flat\";\n}\n\nexport interface LiveLineProps {\n  /** Key in data to use for y values */\n  dataKey: string;\n  /** Stroke color. Default: var(--chart-line-primary) */\n  stroke?: string;\n  /** Stroke width. Default: 2 */\n  strokeWidth?: number;\n  /** Curve function. Default: curveMonotoneX */\n  curve?: CurveFactory;\n  /** Show gradient fill under the curve. Default: true */\n  fill?: boolean;\n  /** Show pulsing live dot at the right edge. Default: true */\n  pulse?: boolean;\n  /** Radius of the live dot. Default: 4 */\n  dotSize?: number;\n  /** Show value badge pill at the live tip. Default: true */\n  badge?: boolean;\n  /** Value label formatter for the badge */\n  formatValue?: (v: number) => string;\n  /**\n   * When set, the line/fill color changes based on momentum direction.\n   * Overrides `stroke` for the line and fill (dot always uses momentum colors).\n   */\n  momentumColors?: MomentumColors;\n}\n\nLiveLine.displayName = \"LiveLine\";\n\nexport function LiveLine({\n  dataKey,\n  stroke = chartCssVars.linePrimary,\n  strokeWidth = 2,\n  curve = curveMonotoneX,\n  fill = true,\n  pulse = true,\n  dotSize = 4,\n  badge = true,\n  formatValue = (v: number) => v.toFixed(2),\n  momentumColors,\n}: LiveLineProps) {\n  const {\n    data,\n    xScale,\n    yScale,\n    innerWidth,\n    innerHeight,\n    xAccessor,\n    lines,\n    tooltipData,\n  } = useChart();\n\n  const isScrubbing = tooltipData !== null;\n\n  const uid = useId();\n  const gradientId = `live-line-grad-${uid}`;\n  const areaGradientId = `live-area-grad-${uid}`;\n  const fadeId = `live-fade-${uid}`;\n  const fadeMaskId = `live-fade-mask-${uid}`;\n\n  const getX = useCallback(\n    (d: Record<string, unknown>) => xScale(xAccessor(d)) ?? 0,\n    [xScale, xAccessor]\n  );\n\n  const getY = useCallback(\n    (d: Record<string, unknown>) => {\n      const v = d[dataKey];\n      return typeof v === \"number\" ? (yScale(v) ?? 0) : 0;\n    },\n    [dataKey, yScale]\n  );\n\n  // The second-to-last point is the \"now\" position (live tip).\n  // The last point is the queued future point for the fade-out zone.\n  const nowPoint = data.length >= 2 ? data.at(-2) : data.at(-1);\n  const liveValue =\n    nowPoint && typeof nowPoint[dataKey] === \"number\"\n      ? (nowPoint[dataKey] as number)\n      : 0;\n\n  const liveDotX = nowPoint ? (xScale(xAccessor(nowPoint)) ?? 0) : innerWidth;\n  const liveDotY = yScale(liveValue) ?? 0;\n\n  const momentum = useMemo(\n    () => detectMomentum(data, dataKey),\n    [data, dataKey]\n  );\n\n  const defaultMomentumColors: MomentumColors = {\n    up: \"var(--chart-1)\",\n    down: \"var(--chart-5)\",\n    flat: stroke,\n  };\n  const dotMomentumColors = momentumColors ?? defaultMomentumColors;\n  const dotColor = dotMomentumColors[momentum];\n\n  // Find the line config for this dataKey to get the resolved stroke\n  const lineConfig = lines.find((l) => l.dataKey === dataKey);\n  const baseStroke = lineConfig?.stroke ?? stroke;\n  const resolvedStroke = momentumColors ? momentumColors[momentum] : baseStroke;\n\n  return (\n    <>\n      <defs>\n        <linearGradient id={gradientId} x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">\n          <stop offset=\"0%\" stopColor={resolvedStroke} stopOpacity={1} />\n          <stop offset=\"100%\" stopColor={resolvedStroke} stopOpacity={0.6} />\n        </linearGradient>\n        <linearGradient id={areaGradientId} x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">\n          <stop offset=\"0%\" stopColor={resolvedStroke} stopOpacity={0.1} />\n          <stop offset=\"100%\" stopColor={resolvedStroke} stopOpacity={0} />\n        </linearGradient>\n        <linearGradient id={fadeId} x1=\"0\" x2=\"1\" y1=\"0\" y2=\"0\">\n          <stop offset=\"0%\" stopColor=\"white\" stopOpacity={0} />\n          <stop offset=\"4%\" stopColor=\"white\" stopOpacity={1} />\n          {liveDotX < innerWidth - 1 ? (\n            <>\n              <stop\n                offset={`${(liveDotX / innerWidth) * 100}%`}\n                stopColor=\"white\"\n                stopOpacity={1}\n              />\n              <stop offset=\"100%\" stopColor=\"white\" stopOpacity={0} />\n            </>\n          ) : (\n            <stop offset=\"100%\" stopColor=\"white\" stopOpacity={1} />\n          )}\n        </linearGradient>\n        <mask id={fadeMaskId}>\n          <rect\n            fill={`url(#${fadeId})`}\n            height={innerHeight + 40}\n            width={innerWidth}\n            x={0}\n            y={-20}\n          />\n        </mask>\n      </defs>\n\n      {/* Area fill */}\n      {fill && data.length > 1 && (\n        <g mask={`url(#${fadeMaskId})`}>\n          <AreaClosed\n            curve={curve}\n            data={data}\n            fill={`url(#${areaGradientId})`}\n            strokeWidth={0}\n            x={getX}\n            y={getY}\n            yScale={yScale}\n          />\n        </g>\n      )}\n\n      {/* Line */}\n      {data.length > 1 && (\n        <g mask={`url(#${fadeMaskId})`}>\n          <LinePath\n            curve={curve}\n            data={data}\n            stroke={`url(#${gradientId})`}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            strokeWidth={strokeWidth}\n            x={getX}\n            y={getY}\n          />\n        </g>\n      )}\n\n      {/* Dashed horizontal line at current value */}\n      <line\n        opacity={0.25}\n        stroke={resolvedStroke}\n        strokeDasharray=\"4,4\"\n        strokeWidth={1}\n        x1={0}\n        x2={innerWidth}\n        y1={liveDotY}\n        y2={liveDotY}\n      />\n\n      {/* Live indicator (dot + badge) — dims when crosshair is active */}\n      <motion.g\n        animate={{ opacity: isScrubbing ? 0.25 : 1 }}\n        transition={{ duration: 0.3, ease: \"easeInOut\" }}\n      >\n        {/* Pulsing dot */}\n        <g>\n          {pulse && (\n            <circle\n              cx={liveDotX}\n              cy={liveDotY}\n              fill=\"none\"\n              opacity={0.4}\n              r={dotSize * 2}\n              stroke={dotColor}\n              strokeWidth={1.5}\n            >\n              <animate\n                attributeName=\"r\"\n                dur=\"1.5s\"\n                from={String(dotSize)}\n                repeatCount=\"indefinite\"\n                to={String(dotSize * 3.5)}\n              />\n              <animate\n                attributeName=\"opacity\"\n                dur=\"1.5s\"\n                from=\"0.5\"\n                repeatCount=\"indefinite\"\n                to=\"0\"\n              />\n            </circle>\n          )}\n          <circle\n            cx={liveDotX}\n            cy={liveDotY}\n            fill={dotColor}\n            opacity={0.1}\n            r={dotSize + 2}\n          />\n          <circle\n            cx={liveDotX}\n            cy={liveDotY}\n            fill={dotColor}\n            r={dotSize}\n            stroke={chartCssVars.background}\n            strokeWidth={2}\n          />\n        </g>\n\n        {/* Badge — use popover vars so text is never white-on-white */}\n        {badge && (\n          <g transform={`translate(${liveDotX + 12},${liveDotY})`}>\n            <rect\n              fill=\"var(--popover)\"\n              height={24}\n              opacity={0.95}\n              rx={6}\n              width={formatValue(liveValue).length * 7.5 + 16}\n              x={0}\n              y={-12}\n            />\n            <text\n              fill=\"var(--popover-foreground)\"\n              fontFamily=\"SF Mono, Menlo, Monaco, monospace\"\n              fontSize={11}\n              fontWeight={500}\n              x={8}\n              y={4}\n            >\n              {formatValue(liveValue)}\n            </text>\n          </g>\n        )}\n      </motion.g>\n    </>\n  );\n}\n\nexport default LiveLine;\n",
      "type": "registry:component",
      "target": "components/charts/live-line.tsx"
    },
    {
      "path": "src/charts/live-x-axis.tsx",
      "content": "\"use client\";\n\nimport { motion, useSpring } from \"motion/react\";\nimport { memo, useEffect, useMemo, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { useChart, useChartStable } from \"./chart-context\";\nimport { hmsTimeFmt } from \"./chart-formatters\";\n\nconst TICKER_HALF_WIDTH = 50;\nconst FADE_BUFFER = 20;\n\nconst crosshairSpringConfig = { stiffness: 300, damping: 30 };\n\nfunction labelFadeOpacity(\n  labelX: number,\n  crosshairX: number | null,\n  isHovering: boolean\n): number {\n  if (!isHovering || crosshairX === null) {\n    return 1;\n  }\n  const distance = Math.abs(labelX - crosshairX);\n  if (distance < TICKER_HALF_WIDTH) {\n    return 0;\n  }\n  if (distance < TICKER_HALF_WIDTH + FADE_BUFFER) {\n    return (distance - TICKER_HALF_WIDTH) / FADE_BUFFER;\n  }\n  return 1;\n}\n\nexport interface LiveXAxisProps {\n  /** Number of time labels. Default: 5 */\n  numTicks?: number;\n  /** Time formatter. Default: HH:MM:SS */\n  formatTime?: (t: number) => string;\n}\n\nconst defaultFormatTime = (t: number) => hmsTimeFmt.format(new Date(t));\n\nexport function LiveXAxis(props: LiveXAxisProps) {\n  const { containerRef } = useChartStable();\n  const [mounted, setMounted] = useState(false);\n\n  useEffect(() => {\n    setMounted(true);\n  }, []);\n\n  const container = containerRef.current;\n  if (!(mounted && container)) {\n    return null;\n  }\n\n  return <LiveXAxisInner {...props} container={container} />;\n}\n\nconst LiveXAxisInner = memo(function LiveXAxisInner({\n  numTicks = 5,\n  formatTime = defaultFormatTime,\n  container,\n}: LiveXAxisProps & { container: HTMLDivElement }) {\n  const { xScale, margin, tooltipData } = useChart();\n\n  const domain = xScale.domain();\n  const startMs = domain[0]?.getTime() ?? 0;\n  const endMs = domain[1]?.getTime() ?? 0;\n\n  const labels = useMemo(() => {\n    const step = (endMs - startMs) / (numTicks - 1);\n    return Array.from({ length: numTicks }, (_, i) => {\n      const t = startMs + i * step;\n      const x = (xScale(new Date(t)) ?? 0) + margin.left;\n      return { x, label: formatTime(t), stableKey: i };\n    });\n  }, [startMs, endMs, numTicks, xScale, margin.left, formatTime]);\n\n  const isHovering = tooltipData !== null;\n  const crosshairX = tooltipData ? tooltipData.x + margin.left : null;\n\n  // Time pill label\n  const pillLabel = useMemo(() => {\n    if (!tooltipData) {\n      return null;\n    }\n    const timeMs = xScale.invert(tooltipData.x).getTime();\n    return formatTime(timeMs);\n  }, [tooltipData, xScale, formatTime]);\n\n  // Spring-animated pill position — matches TooltipIndicator's spring config\n  // so the pill and crosshair line move in lockstep\n  const pillX = tooltipData ? tooltipData.x + margin.left : 0;\n  const animatedPillX = useSpring(pillX, crosshairSpringConfig);\n  const springRef = useRef(animatedPillX);\n  springRef.current = animatedPillX;\n\n  useEffect(() => {\n    springRef.current.set(pillX);\n  }, [pillX]);\n\n  return createPortal(\n    <div className=\"pointer-events-none absolute inset-0\">\n      {/* Time labels */}\n      {labels.map((l) => (\n        <div\n          className=\"absolute\"\n          key={l.stableKey}\n          style={{\n            left: l.x,\n            bottom: 12,\n            width: 0,\n            display: \"flex\",\n            justifyContent: \"center\",\n          }}\n        >\n          <motion.span\n            animate={{\n              opacity: labelFadeOpacity(l.x, crosshairX, isHovering),\n            }}\n            className=\"whitespace-nowrap text-chart-label text-xs\"\n            transition={{ duration: 0.15, ease: \"easeOut\" }}\n          >\n            {l.label}\n          </motion.span>\n        </div>\n      ))}\n\n      {/* Time pill at crosshair — spring-animated to match crosshair line */}\n      {isHovering && pillLabel && (\n        <motion.div\n          className=\"absolute z-50\"\n          style={{\n            left: animatedPillX,\n            x: \"-50%\",\n            bottom: 4,\n          }}\n        >\n          <div className=\"overflow-hidden rounded-full bg-zinc-900 px-4 py-1 text-white shadow-lg dark:bg-zinc-100 dark:text-zinc-900\">\n            <span className=\"whitespace-nowrap font-medium text-sm\">\n              {pillLabel}\n            </span>\n          </div>\n        </motion.div>\n      )}\n    </div>,\n    container\n  );\n});\n\nLiveXAxis.displayName = \"LiveXAxis\";\n\nexport default LiveXAxis;\n",
      "type": "registry:component",
      "target": "components/charts/live-x-axis.tsx"
    },
    {
      "path": "src/charts/live-y-axis.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { memo, useEffect, useMemo, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { useChartStable } from \"./chart-context\";\n\n// ---------------------------------------------------------------------------\n// Interval picker (inspired by liveline's pickInterval)\n// Finds a \"nice\" step size that keeps labels ~minGap pixels apart.\n// Uses hysteresis: keeps the previous interval if it still fits, preventing\n// jittery step changes when the range oscillates near a boundary.\n// ---------------------------------------------------------------------------\n\nfunction pickNiceInterval(\n  valRange: number,\n  chartHeight: number,\n  minGap: number,\n  prevInterval: number\n): number {\n  if (valRange <= 0 || chartHeight <= 0) {\n    return 1;\n  }\n  const pxPerUnit = chartHeight / valRange;\n\n  // Keep previous interval if it still produces reasonable spacing\n  if (prevInterval > 0) {\n    const px = prevInterval * pxPerUnit;\n    if (px >= minGap * 0.5 && px <= minGap * 3) {\n      return prevInterval;\n    }\n  }\n\n  // Try multiple divisor sequences to find the best nice step\n  const divisorSets = [\n    [2, 2.5, 2],\n    [2, 2, 2.5],\n    [2.5, 2, 2],\n  ];\n  let best = Number.POSITIVE_INFINITY;\n  for (const divs of divisorSets) {\n    let span = 10 ** Math.ceil(Math.log10(valRange));\n    let i = 0;\n    let d = divs[i % 3] ?? 2;\n    while ((span / d) * pxPerUnit >= minGap) {\n      span /= d;\n      i++;\n      d = divs[i % 3] ?? 2;\n    }\n    if (span < best) {\n      best = span;\n    }\n  }\n  return best === Number.POSITIVE_INFINITY ? valRange / 5 : best;\n}\n\n// ---------------------------------------------------------------------------\n// Edge fade: labels near the top/bottom of the chart area fade out\n// ---------------------------------------------------------------------------\n\nconst EDGE_FADE_PX = 28;\n\nfunction edgeOpacity(y: number, chartHeight: number): number {\n  const fromEdge = Math.min(y, chartHeight - y);\n  if (fromEdge >= EDGE_FADE_PX) {\n    return 1;\n  }\n  if (fromEdge <= 0) {\n    return 0;\n  }\n  return fromEdge / EDGE_FADE_PX;\n}\n\n// ---------------------------------------------------------------------------\n// Component\n// ---------------------------------------------------------------------------\n\nexport interface LiveYAxisProps {\n  /** Minimum pixel gap between labels. Default: 36 */\n  minGap?: number;\n  /** Position. Default: \"left\" */\n  position?: \"left\" | \"right\";\n  /** Value formatter */\n  formatValue?: (v: number) => string;\n  /** Allow decimal tick values. Default: true */\n  allowDecimals?: boolean;\n}\n\nconst tickSpring = { type: \"spring\" as const, stiffness: 180, damping: 24 };\n\nexport function LiveYAxis(props: LiveYAxisProps) {\n  const { containerRef } = useChartStable();\n  const [mounted, setMounted] = useState(false);\n\n  useEffect(() => {\n    setMounted(true);\n  }, []);\n\n  const container = containerRef.current;\n  if (!(mounted && container)) {\n    return null;\n  }\n\n  return <LiveYAxisInner {...props} container={container} />;\n}\n\nconst LiveYAxisInner = memo(function LiveYAxisInner({\n  minGap = 36,\n  position = \"left\",\n  formatValue = (v: number) => v.toFixed(2),\n  allowDecimals = true,\n  container,\n}: LiveYAxisProps & { container: HTMLDivElement }) {\n  const { yScale, margin, innerHeight } = useChartStable();\n  const intervalRef = useRef(0);\n\n  const domain = yScale.domain() as [number, number];\n  const minVal = domain[0];\n  const maxVal = domain[1];\n  const valRange = maxVal - minVal;\n\n  // Pick a nice interval with hysteresis\n  const interval = useMemo(() => {\n    const next = pickNiceInterval(\n      valRange,\n      innerHeight,\n      minGap,\n      intervalRef.current\n    );\n    intervalRef.current = next;\n    return next;\n  }, [valRange, innerHeight, minGap]);\n\n  // Stabilize the tick VALUE set: only recompute which ticks exist when the\n  // domain crosses an interval boundary. We quantize min/max to interval\n  // boundaries so the set doesn't change on every sub-pixel lerp frame.\n  const quantizedMin = interval > 0 ? Math.floor(minVal / interval) : 0;\n  const quantizedMax = interval > 0 ? Math.ceil(maxVal / interval) : 0;\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: quantized values are intentional coarse-grained deps for stability\n  const stableTickValues = useMemo(() => {\n    if (interval <= 0 || valRange <= 0) {\n      return [];\n    }\n    const expandedMin = minVal - interval * 0.5;\n    const expandedMax = maxVal + interval * 0.5;\n    const first = Math.ceil(expandedMin / interval) * interval;\n    const values: number[] = [];\n    for (let v = first; v <= expandedMax; v += interval) {\n      const rounded = Math.round(v * 1e10) / 1e10;\n      const isDecimal = !Number.isInteger(rounded);\n      if (isDecimal && !allowDecimals) {\n        continue;\n      }\n      values.push(rounded);\n    }\n    return values;\n  }, [\n    quantizedMin,\n    quantizedMax,\n    interval,\n    minVal,\n    maxVal,\n    valRange,\n    allowDecimals,\n  ]);\n\n  // Pixel positions update every frame for smooth movement\n  const tickData = useMemo(\n    () =>\n      stableTickValues\n        .map((value) => {\n          const y = yScale(value) ?? 0;\n          return {\n            value,\n            y,\n            label: formatValue(value),\n            key: value.toPrecision(10),\n            edgeAlpha: edgeOpacity(y, innerHeight),\n          };\n        })\n        .filter((t) => t.y >= -10 && t.y <= innerHeight + 10),\n    [stableTickValues, yScale, innerHeight, formatValue]\n  );\n\n  const isLeft = position === \"left\";\n\n  return createPortal(\n    <div className=\"pointer-events-none absolute inset-0\">\n      <div\n        className=\"absolute overflow-hidden\"\n        style={{\n          top: margin.top,\n          height: innerHeight,\n          ...(isLeft\n            ? { left: 0, width: margin.left }\n            : { right: 0, width: margin.right }),\n        }}\n      >\n        <AnimatePresence initial={false}>\n          {tickData.map((tick) => (\n            <motion.div\n              animate={{ opacity: tick.edgeAlpha, y: tick.y }}\n              className=\"absolute w-full\"\n              exit={{ opacity: 0 }}\n              initial={{ opacity: 0, y: tick.y }}\n              key={tick.key}\n              style={{\n                ...(isLeft\n                  ? { right: 0, paddingRight: 8, textAlign: \"right\" }\n                  : { left: 0, paddingLeft: 8, textAlign: \"left\" }),\n              }}\n              transition={tickSpring}\n            >\n              <span className=\"whitespace-nowrap font-mono text-chart-label text-xs\">\n                {tick.label}\n              </span>\n            </motion.div>\n          ))}\n        </AnimatePresence>\n      </div>\n    </div>,\n    container\n  );\n});\n\nLiveYAxis.displayName = \"LiveYAxis\";\n\nexport default LiveYAxis;\n",
      "type": "registry:component",
      "target": "components/charts/live-y-axis.tsx"
    }
  ]
}