{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"candlestick-chart","type":"registry:ui","title":"Candlestick Chart","description":"Prices over time as candles, like a trading chart: open, high, low and close per period, with volume. Plain SVG.","dependencies":[],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json"],"files":[{"path":"registry/dashral/candlestick-chart.tsx","type":"registry:file","target":"components/dashral/candlestick-chart.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\n\nimport { useLocale } from \"./lib/locale\"\nimport { cn } from \"./lib/utils\"\n\nexport type Candle = {\n  /** When the period starts: a timestamp in ms or an ISO date. */\n  time: number | string\n  open: number\n  high: number\n  low: number\n  close: number\n  /** Traded in the period; shown as bars under the candles when given. */\n  volume?: number\n}\n\ntype CandlestickChartProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** The periods, oldest first. */\n  data: Candle[]\n  /** The chart's height in pixels, axes included; the width follows its container. */\n  height?: number\n  /** Volume bars under the candles. On by default when the data has volume. */\n  showVolume?: boolean\n  /** A dashed line at the last close, with its price on the axis. */\n  showLastPrice?: boolean\n  /** BCP 47 locale for prices and dates. */\n  locale?: string\n  /** How prices are written, e.g. `{ style: \"currency\", currency: \"USD\" }`. Two decimals by default. */\n  formatOptions?: Intl.NumberFormatOptions\n  /** How the dates under the chart and in the header are written. `{ month: \"short\", day: \"numeric\" }` by default. */\n  timeFormat?: Intl.DateTimeFormatOptions\n  /** What the chart shows, e.g. \"BTC/USD, daily\". */\n  \"aria-label\"?: string\n  /** up and down are text colors for rising and falling candles (text-success and text-destructive by default). */\n  classNames?: { up?: string; down?: string; header?: string }\n}\n\nconst AXIS = 64\nconst TIME = 22\nconst TOP = 6\nconst GAP = 6\n\n/** A round step for about four price lines: 1, 2 or 5 times a power of ten. */\nfunction niceStep(span: number) {\n  const raw = span / 4\n  const magnitude = 10 ** Math.floor(Math.log10(raw))\n  const n = raw / magnitude\n  return (n < 1.5 ? 1 : n < 3 ? 2 : n < 7 ? 5 : 10) * magnitude\n}\n\n/**\n * Prices over time as candles, like a trading chart: each period's open, high, low and close, green when it closed\n * higher and red when lower, with volume underneath. Hover or use the arrow keys to read a period in the header and\n * see a crosshair; the last close is marked on the axis. Plain SVG, fills its container's width.\n */\nfunction CandlestickChart({\n  data,\n  height = 320,\n  showVolume,\n  showLastPrice = true,\n  locale: localeProp,\n  formatOptions,\n  timeFormat,\n  className,\n  classNames,\n  \"aria-label\": ariaLabel,\n  ...props\n}: CandlestickChartProps) {\n  const { t, locale: contextLocale } = useLocale()\n  const locale = localeProp ?? contextLocale\n  const box = React.useRef<HTMLDivElement>(null)\n  const [width, setWidth] = React.useState<number | null>(null)\n  const [drawn, setDrawn] = React.useState(false)\n  const [active, setActive] = React.useState<number | null>(null)\n  const [pointerY, setPointerY] = React.useState<number | null>(null)\n  const [announce, setAnnounce] = React.useState(\"\")\n\n  // The width is measured, so candles keep crisp, even widths however wide the chart is.\n  React.useLayoutEffect(() => {\n    const el = box.current\n    if (!el) return\n    const measure = () => setWidth(el.clientWidth)\n    measure()\n    const observer = new ResizeObserver(measure)\n    observer.observe(el)\n    return () => observer.disconnect()\n  }, [])\n  // The candles only render once the width is known; reveal them a frame later, so the hidden start is painted first.\n  const measured = width !== null\n  React.useEffect(() => {\n    if (!measured) return\n    const frame = requestAnimationFrame(() => setDrawn(true))\n    return () => cancelAnimationFrame(frame)\n  }, [measured])\n\n  const price = React.useMemo(\n    () => new Intl.NumberFormat(locale, formatOptions ?? { minimumFractionDigits: 2, maximumFractionDigits: 2 }),\n    [locale, formatOptions]\n  )\n  const compact = React.useMemo(() => new Intl.NumberFormat(locale, { notation: \"compact\", maximumFractionDigits: 1 }), [locale])\n  const date = React.useMemo(() => new Intl.DateTimeFormat(locale, timeFormat ?? { month: \"short\", day: \"numeric\" }), [locale, timeFormat])\n  const percent = React.useMemo(() => new Intl.NumberFormat(locale, { style: \"percent\", maximumFractionDigits: 2, signDisplay: \"exceptZero\" }), [locale])\n\n  const up = classNames?.up ?? \"text-success\"\n  const down = classNames?.down ?? \"text-destructive\"\n  const withVolume = (showVolume ?? data.some((d) => d.volume !== undefined)) && data.some((d) => (d.volume ?? 0) > 0)\n\n  const last = data.at(-1)\n  const shown = active !== null ? data[active] : last\n  const label = ariaLabel ?? (data.length ? t(\"trend\", { from: price.format(data[0]!.open), to: price.format(last!.close) }) : t(\"noData\"))\n\n  // Geometry, once the width is known.\n  const plotW = Math.max(0, (width ?? 0) - AXIS)\n  const plotH = height - TIME - TOP\n  const priceH = withVolume ? Math.round(plotH * 0.78) : plotH\n  const volTop = TOP + priceH + GAP\n  const volH = withVolume ? plotH - priceH - GAP : 0\n  const lows = data.map((d) => d.low)\n  const highs = data.map((d) => d.high)\n  const lo = data.length ? Math.min(...lows) : 0\n  const hi = data.length ? Math.max(...highs) : 1\n  const pad = (hi - lo) * 0.08 || Math.abs(hi) * 0.01 || 1\n  const min = lo - pad\n  const max = hi + pad\n  const y = (v: number) => TOP + ((max - v) / (max - min)) * priceH\n  const slot = data.length ? plotW / data.length : 0\n  const body = Math.max(1, Math.min(18, Math.floor(slot * 0.64)))\n  const cx = (i: number) => Math.round(slot * (i + 0.5)) + 0.5\n  const step = niceStep(max - min)\n  const ticks: number[] = []\n  for (let v = Math.ceil(min / step) * step; v <= max; v += step) ticks.push(Number(v.toFixed(10)))\n  const maxVolume = Math.max(1, ...data.map((d) => d.volume ?? 0))\n  const every = Math.max(1, Math.ceil(data.length / Math.max(2, Math.floor(plotW / 90))))\n  const timeTicks = data.map((_, i) => i).filter((i) => (data.length - 1 - i) % every === 0)\n  const toDate = (time: number | string) => new Date(typeof time === \"number\" ? time : Date.parse(time))\n  const rising = (d: Candle) => d.close >= d.open\n\n  const describe = (d: Candle) =>\n    t(\"candle\", { date: date.format(toDate(d.time)), open: price.format(d.open), high: price.format(d.high), low: price.format(d.low), close: price.format(d.close) })\n  const short = { open: t(\"candleOpen\"), high: t(\"candleHigh\"), low: t(\"candleLow\"), close: t(\"candleClose\") }\n\n  function pick(i: number | null, fromKeyboard = false) {\n    setActive(i)\n    if (fromKeyboard && i !== null) setAnnounce(describe(data[i]!))\n  }\n\n  return (\n    <div\n      data-slot=\"candlestick-chart\"\n      role=\"group\"\n      aria-roledescription={t(\"candlestickChart\")}\n      aria-label={label}\n      tabIndex={0}\n      className={cn(\"grid gap-2 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring/50\", className)}\n      onKeyDown={(event) => {\n        if (!data.length) return\n        const current = active ?? data.length - 1\n        const next =\n          event.key === \"ArrowLeft\" ? Math.max(0, current - 1)\n          : event.key === \"ArrowRight\" ? Math.min(data.length - 1, current + 1)\n          : event.key === \"Home\" ? 0\n          : event.key === \"End\" ? data.length - 1\n          : undefined\n        if (event.key === \"Escape\") return pick(null)\n        if (next === undefined) return\n        event.preventDefault()\n        setPointerY(null)\n        pick(next, true)\n      }}\n      onBlur={() => pick(null)}\n      {...props}\n    >\n      {/* The period under the pointer (or the latest one): what a trader reads first. */}\n      {shown && (\n        <div aria-hidden className={cn(\"flex flex-wrap items-baseline gap-x-3 gap-y-1 text-xs tabular-nums\", classNames?.header)}>\n          <span className=\"font-medium text-foreground\">{date.format(toDate(shown.time))}</span>\n          {([\"open\", \"high\", \"low\", \"close\"] as const).map((key) => (\n            <span key={key} className=\"text-muted-foreground\">\n              {short[key]} <span className={rising(shown) ? up : down}>{price.format(shown[key])}</span>\n            </span>\n          ))}\n          <span className={rising(shown) ? up : down}>{percent.format((shown.close - shown.open) / shown.open)}</span>\n          {withVolume && shown.volume !== undefined && <span className=\"text-muted-foreground\">\n              {t(\"volume\")} {compact.format(shown.volume)}\n            </span>}\n        </div>\n      )}\n\n      <div\n        ref={box}\n        // Time runs left to right in every language, as on any trading chart.\n        dir=\"ltr\"\n        className=\"relative w-full touch-none select-none\"\n        style={{ height }}\n        onPointerMove={(event) => {\n          if (!data.length || !slot) return\n          const rect = event.currentTarget.getBoundingClientRect()\n          const x = event.clientX - rect.left\n          setPointerY(event.clientY - rect.top)\n          pick(Math.min(data.length - 1, Math.max(0, Math.floor(x / slot))))\n        }}\n        onPointerLeave={() => {\n          setPointerY(null)\n          pick(null)\n        }}\n      >\n        {width !== null && data.length > 0 && (\n          <>\n            {/* The grid and the axes. */}\n            <svg aria-hidden width={width} height={height} className=\"absolute inset-0 overflow-visible text-muted-foreground\">\n              {ticks.map((v) => (\n                <g key={v}>\n                  <line x1={0} x2={plotW} y1={Math.round(y(v)) + 0.5} y2={Math.round(y(v)) + 0.5} stroke=\"var(--border)\" strokeDasharray=\"2 4\" />\n                  <text x={plotW + 8} y={y(v)} dy=\"0.32em\" fontSize={11} fill=\"currentColor\" className=\"tabular-nums\">\n                    {price.format(v)}\n                  </text>\n                </g>\n              ))}\n              <line x1={plotW + 0.5} x2={plotW + 0.5} y1={TOP} y2={height - TIME} stroke=\"var(--border)\" />\n              {timeTicks.map((i) => (\n                <text key={i} x={cx(i)} y={height - 6} fontSize={11} fill=\"currentColor\" textAnchor=\"middle\">\n                  {date.format(toDate(data[i]!.time))}\n                </text>\n              ))}\n            </svg>\n\n            {/* The candles and volume, revealed left to right when the chart first shows. */}\n            <svg\n              aria-hidden\n              width={width}\n              height={height}\n              className=\"absolute inset-0 transition-[clip-path] duration-(--duration-considered) ease-enter motion-reduce:transition-none\"\n              style={{ clipPath: drawn ? \"inset(0 0 0 0)\" : `inset(0 ${width}px 0 0)` }}\n            >\n              {data.map((d, i) => {\n                const x = cx(i)\n                const top = Math.round(y(Math.max(d.open, d.close)))\n                const bottom = Math.round(y(Math.min(d.open, d.close)))\n                return (\n                  <g key={i} className={rising(d) ? up : down} opacity={active === null || active === i ? 1 : 0.55}>\n                    <line x1={x} x2={x} y1={Math.round(y(d.high))} y2={Math.round(y(d.low))} stroke=\"currentColor\" />\n                    <rect x={Math.round(x - body / 2)} y={top} width={body} height={Math.max(1, bottom - top)} rx={body > 4 ? 1 : 0} fill=\"currentColor\" />\n                    {withVolume && d.volume !== undefined && (\n                      <rect\n                        x={Math.round(x - body / 2)}\n                        y={volTop + volH - Math.max(1, (d.volume / maxVolume) * volH)}\n                        width={body}\n                        height={Math.max(1, (d.volume / maxVolume) * volH)}\n                        fill=\"currentColor\"\n                        opacity={0.3}\n                      />\n                    )}\n                  </g>\n                )\n              })}\n            </svg>\n\n            {/* On top: the last price, and the crosshair while reading a period. */}\n            <svg aria-hidden width={width} height={height} className=\"pointer-events-none absolute inset-0 overflow-visible\">\n              {showLastPrice && last && (\n                <g className={rising(last) ? up : down}>\n                  <line x1={0} x2={plotW} y1={Math.round(y(last.close)) + 0.5} y2={Math.round(y(last.close)) + 0.5} stroke=\"currentColor\" strokeDasharray=\"3 3\" opacity={0.7} />\n                  <rect x={plotW + 2} y={y(last.close) - 9} width={AXIS - 4} height={18} rx={4} fill=\"currentColor\" />\n                  <text x={plotW + 8} y={y(last.close)} dy=\"0.32em\" fontSize={11} fontWeight={600} fill=\"var(--background)\" className=\"tabular-nums\">\n                    {price.format(last.close)}\n                  </text>\n                </g>\n              )}\n              {active !== null && (\n                <g className=\"text-muted-foreground\">\n                  <line x1={cx(active)} x2={cx(active)} y1={TOP} y2={height - TIME} stroke=\"currentColor\" strokeDasharray=\"3 3\" opacity={0.6} />\n                  {pointerY !== null && pointerY >= TOP && pointerY <= TOP + priceH && (\n                    <>\n                      <line x1={0} x2={plotW} y1={Math.round(pointerY) + 0.5} y2={Math.round(pointerY) + 0.5} stroke=\"currentColor\" strokeDasharray=\"3 3\" opacity={0.6} />\n                      <rect x={plotW + 2} y={pointerY - 9} width={AXIS - 4} height={18} rx={4} fill=\"var(--foreground)\" />\n                      <text x={plotW + 8} y={pointerY} dy=\"0.32em\" fontSize={11} fill=\"var(--background)\" className=\"tabular-nums\">\n                        {price.format(max - ((pointerY - TOP) / priceH) * (max - min))}\n                      </text>\n                    </>\n                  )}\n                </g>\n              )}\n            </svg>\n          </>\n        )}\n      </div>\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </p>\n    </div>\n  )\n}\n\nexport { CandlestickChart, type CandlestickChartProps }\n"}],"cssVars":{"theme":{"color-success":"var(--success)","color-success-foreground":"var(--success-foreground)","color-success-text":"var(--success-text)","ease-standard":"var(--ease-standard)","ease-enter":"var(--ease-enter)","ease-exit":"var(--ease-exit)","ease-spring":"var(--ease-spring)"},"light":{"success":"oklch(0.508 0.118 165.612)","success-foreground":"oklch(0.985 0 0)","success-text":"oklch(0.432 0.095 166.913)","duration-fast":"160ms","duration-standard":"240ms","duration-considered":"480ms","ease-standard":"cubic-bezier(0.2, 0, 0, 1)","ease-enter":"cubic-bezier(0, 0, 0, 1)","ease-exit":"cubic-bezier(0.3, 0, 1, 1)","ease-spring":"linear(0, 0.071 4%, 0.234 8%, 0.433 12%, 0.626 17%, 0.791 21%, 0.917 25%, 1.004 29%, 1.055 33%, 1.079 38%, 1.083 42%, 1.075 46%, 1.06 50%, 1.043 54%, 1.027 58%, 1.015 62%, 1.005 67%, 0.998 71%, 0.995 75%, 0.993 79%, 0.993 83%, 0.994 88%, 0.995 92%, 0.997 96%, 1)"},"dark":{"success":"oklch(0.696 0.17 162.48)","success-foreground":"oklch(0.2 0.04 160)","success-text":"oklch(0.765 0.177 163.223)"}}}