{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "candlestick-chart",
  "type": "registry:component",
  "title": "Candlestick Chart",
  "description": "Open, high, low and close for a period, drawn as one mark.",
  "dependencies": [
    "@base-ui/react",
    "@tanstack/charts",
    "tailwind-variants"
  ],
  "registryDependencies": [],
  "cssVars": {
    "light": {
      "success": "oklch(0.696 0.17 162.48)",
      "warning": "oklch(0.769 0.188 70.08)",
      "info": "oklch(0.623 0.214 259.815)"
    },
    "dark": {
      "success": "oklch(0.696 0.17 162.48)",
      "warning": "oklch(0.769 0.188 70.08)",
      "info": "oklch(0.623 0.214 259.815)"
    }
  },
  "files": [
    {
      "path": "components/panelui/candlestick-chart/index.tsx",
      "type": "registry:component",
      "target": "@components/panelui/candlestick-chart/index.tsx",
      "content": "'use client';\n\n/**\n * CandlestickChart — four numbers a period, drawn as one mark.\n *\n * ```tsx\n * <CandlestickChart data={sessions} xDataKey=\"day\">\n *   <CandlestickChart.Grid />\n *   <CandlestickChart.Candles />\n *   <CandlestickChart.XAxis />\n *   <CandlestickChart.Tooltip />\n * </CandlestickChart>\n * ```\n *\n * ## The same parts as the app\n *\n * The parts and their props are the app library's CandlestickChart, so a chart\n * written for one reads the same in the other. What differs is what the web\n * changes: a colour is named by the theme's role, a period is read by hovering,\n * tapping or dragging, a click pins the reading, and the arrow keys walk the\n * periods.\n *\n * ## One mark, four numbers\n *\n * A row is `{ open, high, low, close }`. The body spans open to close and the\n * wick spans low to high behind it. Colour is the sign of the period and not a\n * series identity, so there is no series list: the two colours are two states of\n * the one thing plotted. Both are bars with explicit ends, so a body grows from\n * its open towards its close.\n *\n * ## The baseline is not zero\n *\n * A candle compares nothing to zero. What is read is the distance between four\n * numbers close together and far from the origin, so the axis runs from the\n * lowest low to the highest high, with a margin. See `candles.ts`.\n *\n * ## The parts describe, the root draws\n *\n * `Grid`, `Candles`, the axes and `Tooltip` draw nothing themselves. Each tells\n * the root what it wants, and the root builds one chart definition from them.\n * They are found by context, so a part can be wrapped in a component of your own.\n */\nimport {\n  barY,\n  defineChart,\n  type ChartInteractionController,\n  type ChartMark,\n  type ChartMotionContext,\n  type ChartPoint,\n  type SceneNode,\n} from '@tanstack/charts';\nimport { motion } from '@tanstack/charts/motion';\nimport { RendererChart } from '@tanstack/charts/react/tooltip';\nimport { scaleBand } from '@tanstack/charts/scales/band';\nimport { scaleLinear } from '@tanstack/charts/scales/linear';\nimport { tooltip as chartTooltip } from '@tanstack/charts/tooltip';\nimport {\n  createContext,\n  forwardRef,\n  useCallback,\n  useContext,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n  type ComponentProps,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n} from 'react';\nimport { tv } from 'tailwind-variants';\nimport { mergeProps } from '@base-ui/react/merge-props';\nimport { useRender } from '@base-ui/react/use-render';\nimport type { ElementType as RenderElementType } from 'react';\nimport { candleExtent, candlesOf, changeOf, labelledPeriods, ohlcOf, priceTicks, type Candle, type CandleKeys, type Ohlc } from './candles';\n\n/** Draws a part as another element, or composes it with a component: `render={<a href=\"/\" />}`. */\ntype RenderProp<Tag extends RenderElementType> = { render?: useRender.ComponentProps<Tag>['render'] };\n\nexport type CandlestickChartStatus = 'loading' | 'ready';\nexport type CandlestickChartDatum = Record<string, string | number | null | undefined>;\n\n/** Which of the theme's roles a direction is drawn in. */\nexport type CandlestickChartColor =\n  | 'primary'\n  | 'info'\n  | 'success'\n  | 'warning'\n  | 'destructive'\n  | 'chart-1'\n  | 'chart-2'\n  | 'chart-3'\n  | 'chart-4'\n  | 'chart-5';\n\nconst COLOR_VAR: Record<CandlestickChartColor, string> = {\n  primary: 'var(--primary)',\n  info: 'var(--info)',\n  success: 'var(--success)',\n  warning: 'var(--warning)',\n  destructive: 'var(--destructive)',\n  'chart-1': 'var(--chart-1)',\n  'chart-2': 'var(--chart-2)',\n  'chart-3': 'var(--chart-3)',\n  'chart-4': 'var(--chart-4)',\n  'chart-5': 'var(--chart-5)',\n};\n\nconst THEME = {\n  foreground: 'var(--foreground)',\n  muted: 'var(--muted-foreground)',\n  grid: 'var(--border)',\n  background: 'transparent',\n  palette: ['var(--success)', 'var(--destructive)'],\n};\n\nconst candlestickChartVariants = tv({\n  slots: {\n    root: 'relative grid w-full grid-cols-1 text-foreground',\n    plot: 'relative col-start-1 row-start-2 w-full',\n    // `pan-y` hands a vertical drag to the page and a sideways one to the chart.\n    chart:\n      'absolute! inset-0 touch-pan-y rounded-lg text-xs [&_svg]:rounded-lg [&_svg]:outline-none [&_svg:focus-visible]:ring-2 [&_svg:focus-visible]:ring-ring',\n    legend:\n      'pointer-events-none absolute right-2 top-1 col-start-1 row-start-2 flex gap-3 text-xs text-muted-foreground',\n    legendItem: 'flex items-center gap-1.5',\n    legendSwatch: 'size-2 shrink-0 rounded-full',\n    header: 'col-start-1 row-start-1 flex items-start justify-between gap-3 pb-3',\n    headerText: 'flex min-w-0 flex-1 flex-col gap-0.5',\n    headerMuted: 'text-xs text-muted-foreground',\n    headerValue: 'text-xl font-bold tabular-nums tracking-tight',\n    headerTrailing: 'flex shrink flex-wrap items-center justify-end gap-x-3 gap-y-1 pt-1 text-xs text-muted-foreground',\n    // A custom body gets no surface from the chart, so the readout draws its own.\n    readout:\n      'flex w-max flex-col gap-0.5 rounded-xl border border-border bg-popover px-2.5 py-1.5 text-xs tabular-nums text-popover-foreground shadow-lg',\n    readoutTitle: 'text-muted-foreground',\n    readoutPrices: 'grid grid-cols-2 gap-x-3 gap-y-0.5',\n    readoutPrice: 'flex items-baseline gap-1',\n    readoutField: 'text-muted-foreground',\n    readoutValue: 'font-medium',\n    readoutChange: 'font-medium',\n  },\n});\n\n/** The value-axis gutter held while loading, when there are no prices to size it from. */\nconst LOADING_GUTTER = 44;\n\n/** Thinnest a wick is drawn, in pixels. */\nconst WICK_WIDTH = 1.5;\n\n/**\n * Shortest a body is drawn, in pixels. A period that closed where it opened has\n * no height of its own, and that is not the same event as no period at all.\n */\nconst MIN_BODY_HEIGHT = 1.5;\n\n/** How much of the entrance goes to staggering the candles. */\nconst STAGGER_SHARE = 0.45;\n\nconst DEFAULT_RISING: CandlestickChartColor = 'success';\nconst DEFAULT_FALLING: CandlestickChartColor = 'destructive';\n\nconst COMPACT = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 });\nconst COMPACT_SMALL = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 2 });\n\n/** Below one, a single decimal would round most values down to nothing. */\nconst compactNumber = (value: number) => (Math.abs(value) < 1 ? COMPACT_SMALL : COMPACT).format(value);\n\n/** A period's change with its sign, so direction is not told by colour alone. */\nfunction changeText(ohlc: Pick<Ohlc, 'open' | 'close'>) {\n  const { change, percent } = changeOf(ohlc);\n  const sign = (value: number) => (value >= 0 ? '+' : '');\n  return `${sign(change)}${compactNumber(change)} (${sign(percent)}${percent.toFixed(2)}%)`;\n}\n\ntype TooltipField = 'open' | 'high' | 'low' | 'close';\n\n/**\n * What the parts have asked for. The readout's formatters are read through `latest`\n * when it opens, and the axes' are compared by what they write.\n */\ninterface Slots {\n  grid?: { rows: number; dashArray: string; opacity: number };\n  candles?: { rising: CandlestickChartColor; falling: CandlestickChartColor; cornerRadius: number };\n  xAxis?: Pick<CandlestickChartXAxisProps, 'ticks' | 'format'>;\n  yAxis?: { ticks: number; format: CandlestickChartYAxisProps['format'] };\n  tooltip?: {\n    className: string | undefined;\n    latest: { current: Pick<CandlestickChartTooltipProps, 'formatValue' | 'formatX' | 'showChange'> };\n  };\n}\n\ninterface CandlestickChartContextValue {\n  data: readonly CandlestickChartDatum[];\n  xDataKey: string;\n  setSlot: <K extends keyof Slots>(slot: K, value: Slots[K]) => void;\n  activeIndex: number;\n  rising: CandlestickChartColor;\n  falling: CandlestickChartColor;\n}\n\nconst CandlestickChartContext = createContext<CandlestickChartContextValue | null>(null);\n\nfunction useChart(component: string): CandlestickChartContextValue {\n  const context = useContext(CandlestickChartContext);\n  if (!context) {\n    throw new Error(`${component} must be used within a <CandlestickChart>`);\n  }\n  return context;\n}\n\n/**\n * The period being read, for something rendered inside the chart. A readout\n * outside it takes `onActiveIndexChange` instead.\n */\nexport function useCandlestickChart() {\n  const { data, activeIndex, xDataKey } = useChart('useCandlestickChart');\n  return {\n    activeIndex,\n    activePoint: activeIndex >= 0 ? (data[activeIndex] ?? null) : null,\n    xDataKey,\n  };\n}\n\n/** Holds a flat period's body at a visible height, about its own price, once the scales have placed it. */\nfunction withBodyFloor<TMark extends ChartMark<Candle, number, number, number, number>>(mark: TMark): TMark {\n  const adjust = (node: SceneNode): SceneNode => {\n    if (node.kind === 'group') return { ...node, children: node.children.map(adjust) };\n    if (node.kind !== 'rect' || node.height >= MIN_BODY_HEIGHT) return node;\n    return { ...node, y: node.y - (MIN_BODY_HEIGHT - node.height) / 2, height: MIN_BODY_HEIGHT };\n  };\n\n  return {\n    ...mark,\n    initialize(context) {\n      const initialized = mark.initialize(context);\n      return {\n        ...initialized,\n        postDomain(scene) {\n          const base = initialized.postDomain ? initialized.postDomain(scene) : scene;\n          return { ...base, nodes: base.nodes.map(adjust) };\n        },\n      };\n    },\n  };\n}\n\nexport interface CandlestickChartProps extends Omit<ComponentProps<'div'>, 'ref'> {\n  /** The rows. Each one is a period along the x-axis. */\n  data: readonly CandlestickChartDatum[];\n  /** Key holding the period label. Used by the axis and the readout. */\n  xDataKey?: string;\n  /** Key holding the opening price. */\n  openDataKey?: string;\n  /** Key holding the period's high. */\n  highDataKey?: string;\n  /** Key holding the period's low. */\n  lowDataKey?: string;\n  /** Key holding the closing price. */\n  closeDataKey?: string;\n  /**\n   * `loading` leaves the plot empty: the grid and the header stay, and no candles\n   * are drawn. Turning `ready` grows them in from the left. Nothing stands in for\n   * the candles, because a placeholder candle would be four made-up prices.\n   */\n  status?: CandlestickChartStatus;\n  /** The plot's width ÷ height. `1.6` suits a chart this dense better than `2`. */\n  aspectRatio?: number;\n  /** Milliseconds for the candles to grow in. */\n  animationDuration?: number;\n  /** Milliseconds for the candles and the axis to settle after the data changes. */\n  domainDuration?: number;\n  /** Fix the price axis instead of deriving it from the lows and highs. */\n  yDomain?: [number, number];\n  /** Fraction of each period's slice left empty, `0` to `1`. */\n  candleGap?: number;\n  /** Fixed body width in pixels, instead of deriving it from `candleGap`. One pixel always stays between bodies. */\n  candleWidth?: number;\n  /** Opacity of the candles that are not being read. */\n  fadedOpacity?: number;\n  /**\n   * The period being read, and `-1`/`null` when there is none. Fires when the\n   * index changes, not per frame.\n   */\n  onActiveIndexChange?: (index: number, datum: CandlestickChartDatum | null) => void;\n  /** Drop the axis padding, for a dense strip with no axis or readout. */\n  compact?: boolean;\n  /** What a screen reader calls the chart. */\n  label?: string;\n  /** What a screen reader says for one row. Defaults to its keys and values. */\n  labelForDatum?: (datum: CandlestickChartDatum, index: number) => string;\n}\n\n/** Imperative handle: re-run the grow-in, for a \"replay\" control. */\nexport interface CandlestickChartHandle {\n  replay: () => void;\n}\n\nconst CandlestickChartRoot = forwardRef<CandlestickChartHandle, CandlestickChartProps & RenderProp<'div'>>(\n  function CandlestickChartRoot(\n    {\n      data,\n      xDataKey = 'date',\n      openDataKey = 'open',\n      highDataKey = 'high',\n      lowDataKey = 'low',\n      closeDataKey = 'close',\n      status = 'ready',\n      aspectRatio = 1.6,\n      animationDuration = 700,\n      domainDuration = 500,\n      yDomain,\n      candleGap = 0.3,\n      candleWidth,\n      fadedOpacity = 0.3,\n      onActiveIndexChange,\n      compact = false,\n      label,\n      labelForDatum,\n      className,\n      children,\n      render,\n      ...props\n    },\n    ref\n  ) {\n    const slotClasses = candlestickChartVariants();\n    const plotRef = useRef<HTMLDivElement>(null);\n    const [width, setWidth] = useState(0);\n    const [slots, setSlots] = useState<Slots>({});\n    const [activeIndex, setActiveIndex] = useState(-1);\n    const [revision, setRevision] = useState(0);\n    const interactionRef = useRef<ChartInteractionController<Candle, number, number> | null>(null);\n    const releaseDrag = useRef<(() => void) | null>(null);\n\n    /*\n     * The chart mounts once its width is known. Mounted at a guess, the first\n     * measurement is a resize, and a resize ends the entrance before it is seen.\n     */\n    useEffect(() => {\n      const node = plotRef.current;\n      if (!node) return;\n      const read = () => setWidth(node.clientWidth);\n      read();\n      const observer = new ResizeObserver(read);\n      observer.observe(node);\n      return () => observer.disconnect();\n    }, []);\n\n    const setSlot = useCallback<CandlestickChartContextValue['setSlot']>(\n      (slot, value) => setSlots((current) => ({ ...current, [slot]: value })),\n      []\n    );\n\n    useImperativeHandle(ref, () => ({ replay: () => setRevision((current) => current + 1) }), []);\n\n    const loading = status === 'loading';\n    const { grid, candles: candlesSlot, xAxis, yAxis, tooltip } = slots;\n    const rising = candlesSlot?.rising ?? DEFAULT_RISING;\n    const falling = candlesSlot?.falling ?? DEFAULT_FALLING;\n    const keys = useMemo<CandleKeys>(\n      () => ({ open: openDataKey, high: highDataKey, low: lowDataKey, close: closeDataKey }),\n      [openDataKey, highDataKey, lowDataKey, closeDataKey]\n    );\n\n    // Labels thin with the width, so it is bucketed: a pixel of resize should not redefine the chart.\n    const labelRoom = Math.floor(width / 34);\n\n    const candles = useMemo(() => candlesOf(data, keys), [data, keys]);\n    const total = data.length;\n    const extent = useMemo<[number, number]>(\n      () => (yDomain ? [yDomain[0], yDomain[1]] : loading ? [0, 1] : candleExtent(candles)),\n      [yDomain?.[0], yDomain?.[1], loading, candles]\n    );\n\n    /*\n     * The ticks are evenly spaced prices rather than round ones: the domain's ends\n     * are not round, and `Grid` and `YAxis` count their lines separately. With\n     * both, the axis decides, so every label has a line.\n     */\n    const tickCount = yAxis?.ticks ?? grid?.rows;\n    const priceValues = tickCount === undefined ? [] : priceTicks(extent, tickCount);\n    const periodValues = xAxis ? labelledPeriods(total, labelRoom * 34, xAxis.ticks) : [];\n    const priceLabel = (value: number) => {\n      if (loading || !yAxis) return '';\n      return yAxis.format ? yAxis.format(value) : compactNumber(value);\n    };\n    const periodLabel = (index: number) => {\n      const datum = data[index];\n      if (!datum) return '';\n      return xAxis?.format ? xAxis.format(datum, index) : String(datum[xDataKey] ?? '');\n    };\n    /*\n     * What the axes will say, as the definition's dependency in place of the\n     * formatters. An inline `format` is a new function on every render, and a new\n     * definition restarts the readout's motion and drops the fade under the pointer.\n     */\n    const priceLabels = priceValues.map(priceLabel).join('|');\n    const periodLabels = periodValues.map(periodLabel).join('|');\n\n    const definition = useMemo(() => {\n      const indexes = Array.from({ length: total }, (_unused, index) => index);\n\n      const fillOf = (candle: Candle) => COLOR_VAR[candle.rising ? rising : falling];\n      const shared = {\n        x: 'index' as const,\n        fill: fillOf,\n        states: [\n          {\n            when: { focus: 'unmatched' as const },\n            style: { fillOpacity: fadedOpacity },\n            transition: { type: 'tween' as const, duration: 120, easing: 'ease-out' as const },\n          },\n        ],\n      };\n\n      // The wicks go first so the bodies sit over them, which reads as one mark.\n      const wicks = barY(candles, {\n        ...shared,\n        id: 'candlestick-wicks',\n        key: (candle: Candle) => `wick:${candle.index}`,\n        y1: 'low',\n        y2: 'high',\n        maxThickness: WICK_WIDTH,\n      });\n      const bodies = withBodyFloor(\n        barY(candles, {\n          ...shared,\n          id: 'candlestick-bodies',\n          key: (candle: Candle) => `body:${candle.index}`,\n          y1: 'open',\n          y2: 'close',\n          radius: candlesSlot?.cornerRadius ?? 1.5,\n          // A fixed width takes the whole slice and is capped by the option, less one pixel between bodies.\n          ...(candleWidth !== undefined ? { maxThickness: candleWidth, inset: 0.5 } : {}),\n        })\n      );\n\n      const gap = candleWidth !== undefined ? 0 : candleGap;\n      const x = {\n        scale: scaleBand(indexes, [0, 1]).paddingInner(gap).paddingOuter(gap / 2),\n        axis: xAxis\n          ? {\n              line: false as const,\n              tickLabels: { opacity: 1 },\n              ticks: {\n                size: 0,\n                format: periodLabel,\n                values: periodValues,\n              },\n            }\n          : (false as const),\n      };\n\n      const y = {\n        scale: scaleLinear(extent, [0, 1]),\n        grid: grid ? { stroke: 'var(--border)', strokeOpacity: grid.opacity, strokeDasharray: grid.dashArray } : false,\n        axis:\n          tickCount !== undefined\n            ? {\n                line: false as const,\n                tickLabels: { opacity: yAxis ? 1 : 0 },\n                ticks: {\n                  size: 0,\n                  values: priceValues,\n                  format: priceLabel,\n                },\n              }\n            : (false as const),\n      };\n\n      const interactive = tooltip !== undefined && !loading && candles.length > 0;\n\n      return defineChart({\n        theme: THEME,\n        marks: loading || !candlesSlot ? [] : [wicks, bodies],\n        scales: { x, y },\n        ...(compact ? { guides: false as const, margin: 0 } : {}),\n        ...(loading && yAxis ? { margin: { left: LOADING_GUTTER } } : {}),\n        focusRing: false,\n        /*\n         * Candles grow in one after another and all finish inside the one\n         * duration: the window each gets is what is left after the last one's\n         * delay. Only the candles arrive, so a replay does not fade the axis in.\n         */\n        motion: (context: ChartMotionContext<Candle>) => {\n          if (context.phase !== 'enter') {\n            return { transition: { type: 'tween' as const, duration: domainDuration, easing: 'ease-out' as const } };\n          }\n          if (context.role !== 'bar') return false as const;\n          const period = context.datum?.index ?? 0;\n          return {\n            delay: total > 1 ? (period / total) * animationDuration * STAGGER_SHARE : 0,\n            transition: {\n              type: 'tween' as const,\n              duration: animationDuration * (1 - (total > 1 ? STAGGER_SHARE : 0)),\n              easing: 'ease-out' as const,\n            },\n          };\n        },\n        keyboard: interactive,\n        pointer: interactive,\n        ...(interactive\n          ? {\n              focus: 'group-x' as const,\n              maxFocusDistance: Number.POSITIVE_INFINITY,\n              tooltip: {\n                use: chartTooltip,\n                // Drawn by `renderReadout`. This is what the live region announces.\n                content(points: readonly ChartPoint<Candle, number, number>[]) {\n                  const candle = points[0]?.datum;\n                  const datum = candle ? data[candle.index] : undefined;\n                  if (!candle || !datum) return { rows: [] };\n                  const { formatValue, formatX, showChange = true } = tooltip.latest.current;\n                  const price = (field: TooltipField) =>\n                    formatValue ? formatValue(candle[field], field) : compactNumber(candle[field]);\n                  return {\n                    title: formatX ? formatX(datum) : String(datum[xDataKey] ?? ''),\n                    rows: [\n                      { label: 'Open', value: price('open') },\n                      { label: 'High', value: price('high') },\n                      { label: 'Low', value: price('low') },\n                      { label: 'Close', value: price('close') },\n                      ...(showChange ? [{ label: 'Change', value: changeText(candle) }] : []),\n                    ],\n                  };\n                },\n              },\n            }\n          : {}),\n      });\n    }, [\n      candles,\n      extent,\n      total,\n      data,\n      xDataKey,\n      loading,\n      candleGap,\n      candleWidth,\n      fadedOpacity,\n      compact,\n      animationDuration,\n      domainDuration,\n      rising,\n      falling,\n      grid,\n      candlesSlot,\n      xAxis !== undefined,\n      yAxis !== undefined,\n      priceLabels,\n      periodLabels,\n      tooltip,\n    ]);\n\n    // Marks take their timing from the definition. This one is what the readout\n    // moves with, and it should keep up with the pointer.\n    const renderer = useMemo(() => motion({ transition: { type: 'tween', duration: 160, easing: 'ease-out' } }), []);\n\n    const summary = label ?? `Candlestick chart, ${data.length} ${data.length === 1 ? 'period' : 'periods'}`;\n\n    const context = useMemo<CandlestickChartContextValue>(\n      () => ({ data, xDataKey, setSlot, activeIndex, rising, falling }),\n      [data, xDataKey, setSlot, activeIndex, rising, falling]\n    );\n\n    const spokenRow = (datum: CandlestickChartDatum, index: number) =>\n      labelForDatum?.(datum, index) ??\n      [xDataKey, openDataKey, highDataKey, lowDataKey, closeDataKey]\n        .filter((key) => datum[key] !== null && datum[key] !== undefined)\n        .map((key) => `${key}, ${datum[key]}`)\n        .join('. ');\n\n    // The chart also reports a period again when the data is rebuilt under it. Only\n    // a change of period is news, and repeating it can loop a caller that keeps the datum.\n    const reportedIndex = useRef(-1);\n    const reportActive = (index: number) => {\n      if (reportedIndex.current === index) return;\n      reportedIndex.current = index;\n      setActiveIndex(index);\n      onActiveIndexChange?.(index, index >= 0 ? (data[index] ?? null) : null);\n    };\n    const onFocusChange = (point: ChartPoint<Candle, number, number> | null) =>\n      reportActive(point ? point.datum.index : -1);\n\n    // A remounted chart says nothing about the focus it lost.\n    useEffect(() => reportActive(-1), [revision, status]);\n\n    // A held reading ends with the next press, or with the chart it was read from.\n    useEffect(() => () => releaseDrag.current?.(), [revision, status]);\n\n    const clearReading = () => {\n      releaseDrag.current?.();\n      interactionRef.current?.setControlledFocus(null);\n    };\n\n    /*\n     * The chart follows a mouse on its own but not a finger, which it treats as a\n     * tap. A sideways drag reads the periods it crosses, and the last one stays\n     * until the next press, the same as a tap's. A vertical drag never gets here:\n     * `pan-y` gives it to the page, and the browser cancels the pointer.\n     */\n    const readDrag = (event: ReactPointerEvent<HTMLDivElement>) => {\n      if (event.pointerType !== 'touch' || !tooltip || loading) return;\n      const interaction = interactionRef.current;\n      const target = interaction?.resolvePointer(event.clientX, event.clientY);\n      if (!interaction || !target) return;\n      interaction.setControlledFocus(target);\n      if (releaseDrag.current) return;\n      document.addEventListener('pointerdown', clearReading, { capture: true });\n      releaseDrag.current = () => {\n        document.removeEventListener('pointerdown', clearReading, { capture: true });\n        releaseDrag.current = null;\n      };\n    };\n\n    const renderReadout = (index: number) => {\n      const datum = data[index];\n      const ohlc = ohlcOf(datum, keys);\n      if (!datum || !ohlc) return null;\n      const { formatValue, formatX, showChange = true } = tooltip?.latest.current ?? {};\n      const price = (value: number, field: TooltipField) => (formatValue ? formatValue(value, field) : compactNumber(value));\n      const fields: [string, number, TooltipField][] = [\n        ['O', ohlc.open, 'open'],\n        ['H', ohlc.high, 'high'],\n        ['L', ohlc.low, 'low'],\n        ['C', ohlc.close, 'close'],\n      ];\n      return (\n        <div data-slot=\"candlestick-chart-readout\" className={slotClasses.readout({ className: tooltip?.className })}>\n          <div className={slotClasses.readoutTitle()}>{formatX ? formatX(datum) : String(datum[xDataKey] ?? '')}</div>\n          <div className={slotClasses.readoutPrices()}>\n            {fields.map(([name, value, field]) => (\n              <div key={field} className={slotClasses.readoutPrice()}>\n                <span className={slotClasses.readoutField()}>{name}</span>\n                <span className={slotClasses.readoutValue()}>{price(value, field)}</span>\n              </div>\n            ))}\n          </div>\n          {showChange ? (\n            <div\n              className={slotClasses.readoutChange()}\n              style={{ color: COLOR_VAR[ohlc.close >= ohlc.open ? rising : falling] }}\n            >\n              {changeText(ohlc)}\n            </div>\n          ) : null}\n        </div>\n      );\n    };\n\n    const element = useRender({\n      defaultTagName: 'div',\n      render,\n      props: mergeProps(\n        {\n          'data-slot': 'candlestick-chart',\n          className: slotClasses.root({ className }),\n          children: (\n            <>\n              {loading ? null : (\n                <ul aria-label={summary} className=\"sr-only\">\n                  {data.map((datum, index) => (\n                    <li key={index}>{spokenRow(datum, index)}</li>\n                  ))}\n                </ul>\n              )}\n              <div\n                ref={plotRef}\n                data-slot=\"candlestick-chart-plot\"\n                className={slotClasses.plot()}\n                style={{ aspectRatio }}\n                onKeyDown={(event) => {\n                  if (event.key === 'Escape') clearReading();\n                }}\n                onPointerMove={readDrag}\n                onPointerCancel={clearReading}\n              >\n                {width > 0 ? (\n                  <RendererChart\n                    // An entrance only grows from the baseline on a chart's first paint,\n                    // so the candles arriving after a load, or a replay, are a new chart.\n                    key={`${revision}:${status}`}\n                    definition={definition}\n                    renderer={renderer}\n                    ariaLabel={summary}\n                    aspectRatio={aspectRatio}\n                    initialWidth={width}\n                    className={slotClasses.chart()}\n                    onFocusChange={onFocusChange}\n                    onRender={({ interaction }) => {\n                      interactionRef.current = interaction;\n                    }}\n                    renderTooltipBody={({ points }) => {\n                      const point = points[0] as ChartPoint<Candle, number, number> | undefined;\n                      return point ? renderReadout(point.datum.index) : null;\n                    }}\n                  />\n                ) : null}\n              </div>\n              {children}\n            </>\n          ),\n        },\n        props\n      ),\n    });\n\n    return <CandlestickChartContext.Provider value={context}>{element}</CandlestickChartContext.Provider>;\n  }\n);\nCandlestickChartRoot.displayName = 'CandlestickChart';\n\n/** Hands a part's formatters to the root without a new function rebuilding the chart. */\nfunction useLatest<T>(value: T) {\n  const [latest] = useState(() => ({ current: value }));\n  useEffect(() => {\n    latest.current = value;\n  });\n  return latest;\n}\n\nexport interface CandlestickChartGridProps {\n  /** How many intervals to divide the price axis into. Yields `rows + 1` lines. A `YAxis` beside it sets the count instead. */\n  rows?: number;\n  /** The stroke's dash pattern. */\n  dashArray?: string;\n  /** Opacity of the lines. */\n  opacity?: number;\n}\n\n/** Lines across the price axis, so a candle can be read against a number. */\nfunction CandlestickChartGrid({ rows = 4, dashArray = '4 6', opacity = 1 }: CandlestickChartGridProps) {\n  const { setSlot } = useChart('CandlestickChart.Grid');\n  useEffect(() => {\n    setSlot('grid', { rows, dashArray, opacity });\n    return () => setSlot('grid', undefined);\n  }, [rows, dashArray, opacity, setSlot]);\n  return null;\n}\nCandlestickChartGrid.displayName = 'CandlestickChart.Grid';\n\nexport interface CandlestickChartCandlesProps {\n  /** The theme role of a period that closed at or above its open. */\n  risingColor?: CandlestickChartColor;\n  /** The theme role of a period that closed below its open. */\n  fallingColor?: CandlestickChartColor;\n  /** Corner radius on a body. */\n  cornerRadius?: number;\n}\n\n/** The candles: a wick from low to high, and a body from open to close over it. */\nfunction CandlestickChartCandles({\n  risingColor = DEFAULT_RISING,\n  fallingColor = DEFAULT_FALLING,\n  cornerRadius = 1.5,\n}: CandlestickChartCandlesProps) {\n  const { setSlot } = useChart('CandlestickChart.Candles');\n  useEffect(() => {\n    setSlot('candles', { rising: risingColor, falling: fallingColor, cornerRadius });\n    return () => setSlot('candles', undefined);\n  }, [risingColor, fallingColor, cornerRadius, setSlot]);\n  return null;\n}\nCandlestickChartCandles.displayName = 'CandlestickChart.Candles';\n\nexport interface CandlestickChartXAxisProps {\n  /** How many labels to show. Derived from the room available when omitted. */\n  ticks?: number;\n  /** Format a row's label. Defaults to the value at `xDataKey`. */\n  format?: (datum: CandlestickChartDatum, index: number) => string;\n}\n\n/** The period labels, one under each candle there is room to name. */\nfunction CandlestickChartXAxis({ ticks, format }: CandlestickChartXAxisProps) {\n  const { setSlot } = useChart('CandlestickChart.XAxis');\n  useEffect(() => {\n    setSlot('xAxis', { ticks, format });\n    return () => setSlot('xAxis', undefined);\n  }, [ticks, format, setSlot]);\n  return null;\n}\nCandlestickChartXAxis.displayName = 'CandlestickChart.XAxis';\n\nexport interface CandlestickChartYAxisProps {\n  /** How many intervals to divide the price axis into. Yields `ticks + 1` labels. */\n  ticks?: number;\n  /** Format a price for its label. Defaults to a compact number. */\n  format?: (value: number) => string;\n}\n\n/** Price labels down the side, on the grid lines, with room made for them. */\nfunction CandlestickChartYAxis({ ticks = 4, format }: CandlestickChartYAxisProps) {\n  const { setSlot } = useChart('CandlestickChart.YAxis');\n  useEffect(() => {\n    setSlot('yAxis', { ticks, format });\n    return () => setSlot('yAxis', undefined);\n  }, [ticks, format, setSlot]);\n  return null;\n}\nCandlestickChartYAxis.displayName = 'CandlestickChart.YAxis';\n\nexport interface CandlestickChartTooltipProps {\n  /** Format one of the four prices. Defaults to a compact number. */\n  formatValue?: (value: number, field: TooltipField) => string;\n  /** Format the readout's heading from the row. Defaults to the value at xDataKey. */\n  formatX?: (datum: CandlestickChartDatum) => string;\n  /** Show the period's change from open to close under the four prices. */\n  showChange?: boolean;\n  /** Added to the readout's surface. */\n  className?: string;\n}\n\n/**\n * The readout, and what drives it: hovering a period, pressing or dragging across\n * them, or the arrow keys. A click or a tap pins the reading until the next press\n * elsewhere or Escape. The whole plot is the hit area, because candles are thin.\n */\nfunction CandlestickChartTooltip({ formatValue, formatX, showChange = true, className }: CandlestickChartTooltipProps) {\n  const { setSlot } = useChart('CandlestickChart.Tooltip');\n  const latest = useLatest({ formatValue, formatX, showChange });\n  useEffect(() => {\n    setSlot('tooltip', { className, latest });\n    return () => setSlot('tooltip', undefined);\n  }, [className, latest, setSlot]);\n  return null;\n}\nCandlestickChartTooltip.displayName = 'CandlestickChart.Tooltip';\n\n/** A swatch and a name for each direction, in the colours the candles are drawn in. */\nfunction useDirectionKey(risingLabel: string, fallingLabel: string, classes: ReturnType<typeof candlestickChartVariants>) {\n  const { rising, falling } = useChart('CandlestickChart.Legend');\n  const entries: [string, CandlestickChartColor][] = [\n    [risingLabel, rising],\n    [fallingLabel, falling],\n  ];\n  return entries.map(([name, color]) => (\n    <span key={name} className={classes.legendItem()}>\n      <span className={classes.legendSwatch()} style={{ background: COLOR_VAR[color] }} />\n      {name}\n    </span>\n  ));\n}\n\nexport interface CandlestickChartLegendProps extends ComponentProps<'div'> {\n  /** What the rising colour is called. */\n  risingLabel?: string;\n  /** What the falling colour is called. */\n  fallingLabel?: string;\n}\n\n/** The two colours and what they mean, over the top corner of the plot. */\nfunction CandlestickChartLegend({\n  risingLabel = 'Up',\n  fallingLabel = 'Down',\n  className,\n  render,\n  ...props\n}: CandlestickChartLegendProps & RenderProp<'div'>) {\n  const slots = candlestickChartVariants();\n  const key = useDirectionKey(risingLabel, fallingLabel, slots);\n\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'candlestick-chart-legend',\n        'aria-hidden': true,\n        className: slots.legend({ className }),\n        children: key,\n      },\n      props\n    ),\n  });\n}\nCandlestickChartLegend.displayName = 'CandlestickChart.Legend';\n\nexport interface CandlestickChartHeaderProps extends Omit<ComponentProps<'div'>, 'title'> {\n  /** Small line above the value: what the chart is of. */\n  title?: ReactNode;\n  /** The readout. The largest thing on the card, and the first thing read. */\n  value?: ReactNode;\n  /** One muted line under the value: a period, a comparison, a change. */\n  caption?: ReactNode;\n  /** Name the two colours along the trailing edge. */\n  legend?: boolean;\n  /** What the rising colour is called. */\n  risingLabel?: string;\n  /** What the falling colour is called. */\n  fallingLabel?: string;\n  /** Trailing slot: a control, a badge, a range picker. Wins over `legend`. */\n  children?: ReactNode;\n}\n\n/**\n * The strip above the plot: what the chart is of, what it reads and what the two\n * colours mean. The value is not derived here. A readout that follows the pointer\n * takes it from `onActiveIndexChange` and passes the string down, so one header\n * can show the latest close at rest and a period's close while one is read.\n */\nfunction CandlestickChartHeader({\n  title,\n  value,\n  caption,\n  legend = false,\n  risingLabel = 'Up',\n  fallingLabel = 'Down',\n  className,\n  children,\n  render,\n  ...props\n}: CandlestickChartHeaderProps & RenderProp<'div'>) {\n  const slots = candlestickChartVariants();\n  const key = useDirectionKey(risingLabel, fallingLabel, slots);\n  const trailing = children ?? (legend ? key : null);\n\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'candlestick-chart-header',\n        className: slots.header({ className }),\n        children: (\n          <>\n            <div className={slots.headerText()}>\n              {title ? <div className={slots.headerMuted()}>{title}</div> : null}\n              {value ? <div className={slots.headerValue()}>{value}</div> : null}\n              {caption ? <div className={slots.headerMuted()}>{caption}</div> : null}\n            </div>\n            {trailing ? <div className={slots.headerTrailing()}>{trailing}</div> : null}\n          </>\n        ),\n      },\n      props\n    ),\n  });\n}\nCandlestickChartHeader.displayName = 'CandlestickChart.Header';\n\nexport const CandlestickChart = Object.assign(CandlestickChartRoot, {\n  Header: CandlestickChartHeader,\n  Grid: CandlestickChartGrid,\n  Candles: CandlestickChartCandles,\n  XAxis: CandlestickChartXAxis,\n  YAxis: CandlestickChartYAxis,\n  Tooltip: CandlestickChartTooltip,\n  Legend: CandlestickChartLegend,\n});\n\n/* By name as well, for a Server Component, which cannot dot into a client\n   module. */\nexport {\n  CandlestickChartHeader,\n  CandlestickChartGrid,\n  CandlestickChartCandles,\n  CandlestickChartXAxis,\n  CandlestickChartYAxis,\n  CandlestickChartTooltip,\n  CandlestickChartLegend,\n};\n"
    },
    {
      "path": "components/panelui/candlestick-chart/candles.ts",
      "type": "registry:component",
      "target": "@components/panelui/candlestick-chart/candles.ts",
      "content": "/**\n * The arithmetic behind a candlestick chart, apart from the drawing so it can be\n * tested on its own: reading rows into candles, the price axis and which periods\n * get a label.\n */\n\n/** Which columns of a row hold the four prices. */\nexport interface CandleKeys {\n  open: string;\n  high: string;\n  low: string;\n  close: string;\n}\n\n/** One period's four prices. */\nexport interface Ohlc {\n  open: number;\n  high: number;\n  low: number;\n  close: number;\n}\n\n/** One candle: a period that has all four prices, and where it sits along the axis. */\nexport interface Candle extends Ohlc {\n  index: number;\n  rising: boolean;\n}\n\n/** The closest two period labels may sit, in pixels, so they read as a row of dates. */\nconst MIN_LABEL_SPACING = 68;\n\n/**\n * A row's four prices, or `null` if any of them is missing. A candle cannot be\n * drawn from some of them, unlike a point on a line.\n */\nexport function ohlcOf(row: Record<string, unknown> | null | undefined, keys: CandleKeys): Ohlc | null {\n  if (!row) return null;\n  const { [keys.open]: open, [keys.high]: high, [keys.low]: low, [keys.close]: close } = row;\n  if (\n    typeof open !== 'number' ||\n    typeof high !== 'number' ||\n    typeof low !== 'number' ||\n    typeof close !== 'number' ||\n    ![open, high, low, close].every(Number.isFinite)\n  ) {\n    return null;\n  }\n  return { open, high, low, close };\n}\n\n/**\n * The periods that can be drawn. A row with a missing price is skipped but keeps\n * its place along the axis, because the period still happened.\n */\nexport function candlesOf(data: readonly Record<string, unknown>[], keys: CandleKeys): Candle[] {\n  return data.flatMap((row, index) => {\n    const ohlc = ohlcOf(row, keys);\n    return ohlc ? [{ ...ohlc, index, rising: ohlc.close >= ohlc.open }] : [];\n  });\n}\n\n/**\n * The price axis: the lowest low to the highest high, with a tenth of the span as\n * margin so the extremes are not drawn on the frame.\n *\n * It does not reach for zero. A candle's prices sit close together and usually\n * far from the origin, and including zero would flatten every candle into a dash.\n */\nexport function candleExtent(candles: readonly Candle[]): [number, number] {\n  if (candles.length === 0) return [0, 1];\n  let min = Infinity;\n  let max = -Infinity;\n  // A loop, because a long series spread into `Math.min` passes the engine's argument limit.\n  for (const { low, high } of candles) {\n    min = Math.min(min, low);\n    max = Math.max(max, high);\n  }\n  // A price that never moved still needs a span to divide by.\n  if (min === max) {\n    const nudge = Math.abs(min) * 0.05 || 1;\n    return [min - nudge, max + nudge];\n  }\n  const margin = (max - min) * 0.1;\n  return [min - margin, max + margin];\n}\n\n/**\n * Evenly spaced prices from the top of the axis to the bottom, `count` intervals\n * of them. Even rather than rounded, because the domain's ends are not round.\n */\nexport function priceTicks([min, max]: readonly [number, number], count: number): number[] {\n  const intervals = Math.max(1, Math.round(count));\n  return Array.from({ length: intervals + 1 }, (_unused, index) => max - ((max - min) * index) / intervals);\n}\n\n/**\n * Which periods get a label: every nth, starting at the first. `ticks` asks for a\n * count, and otherwise the width decides how many fit.\n */\nexport function labelledPeriods(total: number, width: number, ticks?: number): number[] {\n  if (total <= 0) return [];\n  const room = Math.max(1, Math.floor(width / MIN_LABEL_SPACING));\n  const count = Math.max(1, Math.min(ticks ?? room, total));\n  const stride = Math.ceil(total / count);\n  return Array.from({ length: Math.ceil(total / stride) }, (_unused, index) => index * stride);\n}\n\n/** What a period moved by, open to close, and that as a percentage of the open. */\nexport function changeOf({ open, close }: Pick<Ohlc, 'open' | 'close'>): { change: number; percent: number } {\n  const change = close - open;\n  return { change, percent: open === 0 ? 0 : (change / open) * 100 };\n}\n"
    }
  ]
}
