{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "candlestick-chart",
  "title": "Candlestick Chart",
  "description": "A responsive candlestick chart with drag-to-pan, zoom and an OHLC tooltip.",
  "dependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/clover-ui/candlestick-chart.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst VIEWBOX_W = 640;\nconst VIEWBOX_H = 220;\nconst PAD_X = 0;\nconst PAD_Y_TOP = 24;\nconst PAD_Y_BOTTOM = 12;\nconst LINE_WIDTH = 2;\nconst TRANSITION = \"160ms cubic-bezier(0.22, 1, 0.36, 1)\";\nconst MIN_VISIBLE = 8;\n\nconst TOOLTIP_SHADOW = [\n  \"0 0 0 1px rgba(0, 0, 0, 0.04)\",\n  \"0 1px 2px rgba(0, 0, 0, 0.04)\",\n  \"0 4px 16px -4px rgba(0, 0, 0, 0.12)\",\n  \"0 12px 32px -8px rgba(0, 0, 0, 0.08)\",\n].join(\", \");\nconst DOT_SHADOW = \"0 0 0 2px var(--background), 0 0 8px 2px rgba(0, 0, 0, 0.12)\";\n\nexport interface Candle {\n  time: string;\n  open: number;\n  high: number;\n  low: number;\n  close: number;\n}\n\nexport interface CandlestickChartProps {\n  data: Candle[];\n  name?: string;\n  upColor?: string;\n  downColor?: string;\n  width?: number;\n  formatValue?: (v: number) => React.ReactNode;\n  defaultIndex?: number;\n  defaultVisibleCount?: number;\n  showXAxis?: boolean;\n  animated?: boolean;\n  tickCount?: number;\n  bodyWidthRatio?: number;\n  maxBodyWidth?: number;\n  zoomable?: boolean;\n  className?: string;\n}\n\nconst REDUCED_MOTION = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribeReducedMotion(onChange: () => void) {\n  const query = window.matchMedia(REDUCED_MOTION);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n}\n\nfunction CandlestickChart({\n  data,\n  name,\n  upColor = \"#22c55e\",\n  downColor = \"#ef4444\",\n  width = 640,\n  formatValue = (v) => v.toLocaleString(),\n  defaultIndex,\n  defaultVisibleCount,\n  showXAxis = true,\n  animated = true,\n  tickCount = 6,\n  bodyWidthRatio = 0.6,\n  maxBodyWidth = 14,\n  zoomable = true,\n  className,\n}: CandlestickChartProps) {\n  const reduceMotion = React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(REDUCED_MOTION).matches,\n    () => false,\n  );\n  const transition = animated && !reduceMotion ? TRANSITION : \"0ms\";\n  const root = React.useRef<HTMLDivElement>(null);\n\n  const initialVisible = Math.min(defaultVisibleCount ?? Math.min(100, data.length), data.length);\n\n  const [rangeStart, setRangeStart] = React.useState(Math.max(0, data.length - initialVisible));\n\n  const [visibleCount, setVisibleCount] = React.useState(initialVisible);\n  const [containerWidth, setContainerWidth] = React.useState(width);\n\n  const [activeLocalIndex, setActiveLocalIndex] = React.useState(\n    defaultIndex !== undefined\n      ? Math.max(0, Math.min(defaultIndex, initialVisible - 1))\n      : initialVisible - 1,\n  );\n  const [seenLength, setSeenLength] = React.useState(data.length);\n  if (seenLength !== data.length) {\n    setSeenLength(data.length);\n    if (visibleCount === 0 && initialVisible > 0) {\n      setVisibleCount(initialVisible);\n      setRangeStart(Math.max(0, data.length - initialVisible));\n      setActiveLocalIndex(\n        defaultIndex === undefined\n          ? initialVisible - 1\n          : Math.max(0, Math.min(defaultIndex, initialVisible - 1)),\n      );\n    } else {\n      setRangeStart((start) => Math.max(0, Math.min(start, data.length - visibleCount)));\n    }\n  }\n\n  const [isDragging, setIsDragging] = React.useState(false);\n\n  const dragState = React.useRef<{\n    startX: number;\n    startRange: number;\n  } | null>(null);\n\n  const rafRef = React.useRef<number | null>(null);\n  const dragRafRef = React.useRef<number | null>(null);\n\n  React.useEffect(() => {\n    return () => {\n      if (rafRef.current !== null) {\n        cancelAnimationFrame(rafRef.current);\n      }\n\n      if (dragRafRef.current !== null) {\n        cancelAnimationFrame(dragRafRef.current);\n      }\n    };\n  }, []);\n\n  const clampRange = React.useCallback(\n    (start: number, count: number) => {\n      const c = Math.max(\n        Math.min(MIN_VISIBLE, data.length),\n        Math.min(Math.round(count), data.length),\n      );\n\n      const s = Math.max(0, Math.min(start, data.length - c));\n\n      return { start: s, count: c };\n    },\n    [data.length],\n  );\n\n  const visibleData = React.useMemo(\n    () => data.slice(rangeStart, rangeStart + visibleCount),\n    [data, rangeStart, visibleCount],\n  );\n  const hasVisibleData = visibleData.length > 0;\n\n  React.useEffect(() => {\n    if (!root.current) return;\n\n    const ro = new ResizeObserver((entries) => {\n      const w = entries[0]?.contentRect.width;\n      if (w) setContainerWidth(w);\n    });\n\n    ro.observe(root.current);\n\n    return () => ro.disconnect();\n  }, [hasVisibleData]);\n\n  const { candles, colWidth } = React.useMemo(() => {\n    const n = visibleData.length;\n\n    if (n === 0) {\n      return { candles: [], colWidth: 0 };\n    }\n\n    const allLows = visibleData.map((d) => d.low);\n    const allHighs = visibleData.map((d) => d.high);\n\n    const minV = Math.min(...allLows);\n    const maxV = Math.max(...allHighs);\n    const range = maxV - minV || 1;\n\n    const innerW = VIEWBOX_W - 2 * PAD_X;\n    const innerH = VIEWBOX_H - PAD_Y_TOP - PAD_Y_BOTTOM;\n    const col = innerW / n;\n\n    const toY = (v: number) => PAD_Y_TOP + (1 - (v - minV) / range) * innerH;\n\n    const mapped = visibleData.map((d, i) => {\n      const cx = PAD_X + (i + 0.5) * col;\n      const isUp = d.close >= d.open;\n      const yOpen = toY(d.open);\n      const yClose = toY(d.close);\n\n      return {\n        ...d,\n        index: i,\n        cx,\n        isUp,\n        yHigh: toY(d.high),\n        yLow: toY(d.low),\n        yClose,\n        bodyTop: Math.min(yOpen, yClose),\n        bodyHeight: Math.max(1, Math.abs(yClose - yOpen)),\n      };\n    });\n\n    return { candles: mapped, colWidth: col };\n  }, [visibleData]);\n\n  const active = candles[Math.max(0, Math.min(activeLocalIndex, candles.length - 1))] ?? candles[0];\n\n  const activeXPct = active ? active.cx / VIEWBOX_W : 0;\n  const activeYPct = active ? active.yClose / VIEWBOX_H : 0;\n\n  const handleHover = React.useCallback(\n    (clientX: number) => {\n      if (!root.current || candles.length === 0 || colWidth === 0) return;\n\n      if (rafRef.current !== null) {\n        cancelAnimationFrame(rafRef.current);\n      }\n\n      rafRef.current = requestAnimationFrame(() => {\n        if (!root.current) return;\n\n        const rect = root.current.getBoundingClientRect();\n        const relPx = (clientX - rect.left) / rect.width;\n        const relSvgX = relPx * VIEWBOX_W;\n\n        const idx = Math.floor((relSvgX - PAD_X) / colWidth);\n\n        setActiveLocalIndex(Math.max(0, Math.min(candles.length - 1, idx)));\n      });\n    },\n    [candles.length, colWidth],\n  );\n\n  function onPointerDown(e: React.PointerEvent<HTMLDivElement>) {\n    if (!zoomable || e.button !== 0) return;\n\n    dragState.current = {\n      startX: e.clientX,\n      startRange: rangeStart,\n    };\n\n    setIsDragging(true);\n    e.currentTarget.setPointerCapture(e.pointerId);\n  }\n\n  function onPointerMove(e: React.PointerEvent<HTMLDivElement>) {\n    if (dragState.current && root.current && colWidth > 0) {\n      const dxPx = e.clientX - dragState.current.startX;\n      const dxSvg = (dxPx / root.current.getBoundingClientRect().width) * VIEWBOX_W;\n      const dCandles = -(dxSvg / colWidth);\n\n      const { start } = clampRange(dragState.current.startRange + dCandles, visibleCount);\n\n      if (dragRafRef.current !== null) {\n        cancelAnimationFrame(dragRafRef.current);\n      }\n\n      dragRafRef.current = requestAnimationFrame(() => {\n        setRangeStart(Math.round(start));\n      });\n\n      return;\n    }\n\n    handleHover(e.clientX);\n  }\n\n  function onPointerUp(e: React.PointerEvent<HTMLDivElement>) {\n    if (dragState.current) {\n      dragState.current = null;\n      setIsDragging(false);\n\n      if (e.currentTarget.hasPointerCapture(e.pointerId)) {\n        e.currentTarget.releasePointerCapture(e.pointerId);\n      }\n    }\n  }\n\n  function onWheel(e: React.WheelEvent<HTMLDivElement>) {\n    if (!zoomable || !root.current) return;\n\n    e.preventDefault();\n\n    const rect = root.current.getBoundingClientRect();\n    const relPx = (e.clientX - rect.left) / rect.width;\n\n    const zoomFactor = e.deltaY > 0 ? 1.12 : 1 / 1.12;\n    const newCount = visibleCount * zoomFactor;\n\n    const cursorIndexInWindow = relPx * visibleCount;\n    const cursorAbsIndex = rangeStart + cursorIndexInWindow;\n\n    const { start, count } = clampRange(cursorAbsIndex - relPx * newCount, newCount);\n\n    setVisibleCount(count);\n    setRangeStart(start);\n  }\n\n  const axisVisible = showXAxis && candles.length > 0;\n\n  const tickIndices = React.useMemo(() => {\n    if (!axisVisible) return [];\n\n    const n = candles.length;\n    const maxFit = Math.max(2, Math.floor(containerWidth / 88));\n    const count = Math.min(tickCount, maxFit, n);\n\n    if (count <= 1) return [0];\n\n    return Array.from({ length: count }, (_, i) => Math.round((i * (n - 1)) / (count - 1)));\n  }, [axisVisible, candles.length, tickCount, containerWidth]);\n\n  if (candles.length === 0 || !active) return null;\n\n  const bw = Math.min(colWidth * bodyWidthRatio, maxBodyWidth);\n  const activeColor = active.isUp ? upColor : downColor;\n\n  return (\n    <div\n      style={\n        {\n          maxWidth: width,\n          \"--chart-up\": upColor,\n          \"--chart-down\": downColor,\n        } as React.CSSProperties\n      }\n      className={cn(\n        \"w-full select-none\",\n        \"[--spell-line:#c7c7c7]\",\n        \"dark:[--spell-line:#4f4f4f]\",\n        className,\n      )}\n    >\n      <div\n        ref={root}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerUp}\n        onPointerCancel={onPointerUp}\n        onPointerLeave={onPointerUp}\n        onWheel={onWheel}\n        className={cn(\n          \"relative aspect-[640/220] w-full touch-none\",\n          zoomable && (isDragging ? \"cursor-grabbing\" : \"cursor-grab\"),\n        )}\n      >\n        <svg\n          width=\"100%\"\n          height=\"100%\"\n          viewBox={`0 0 ${VIEWBOX_W} ${VIEWBOX_H}`}\n          fill=\"none\"\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={{\n            overflow: \"visible\",\n            shapeRendering: \"geometricPrecision\",\n          }}\n        >\n          {candles.map((c) => {\n            const color = c.isUp ? upColor : downColor;\n\n            return (\n              <g key={rangeStart + c.index}>\n                <line\n                  x1={c.cx}\n                  x2={c.cx}\n                  y1={c.yHigh}\n                  y2={c.yLow}\n                  stroke={color}\n                  strokeWidth={1.4}\n                  strokeLinecap=\"round\"\n                />\n\n                <rect\n                  x={c.cx - bw / 2}\n                  y={c.bodyTop}\n                  width={bw}\n                  height={c.bodyHeight}\n                  rx={Math.min(1.5, bw / 4)}\n                  fill={color}\n                />\n              </g>\n            );\n          })}\n        </svg>\n\n        {!isDragging &&\n          (() => {\n            const cursorPx = activeXPct * containerWidth;\n            const containerHeight = (containerWidth * VIEWBOX_H) / VIEWBOX_W;\n            const cursorYpx = activeYPct * containerHeight;\n            const tooltipOnLeft = activeXPct > 0.5;\n\n            return (\n              <>\n                <div\n                  className=\"pointer-events-none absolute left-0 rounded-full bg-[var(--spell-line)]\"\n                  style={{\n                    width: LINE_WIDTH,\n                    top: `${(PAD_Y_TOP / VIEWBOX_H) * 100}%`,\n                    height: `${((VIEWBOX_H - PAD_Y_TOP - PAD_Y_BOTTOM / 2) / VIEWBOX_H) * 100}%`,\n                    transform: `translate3d(${cursorPx - LINE_WIDTH / 2}px, 0, 0)`,\n                    transition: `transform ${transition}`,\n                    willChange: \"transform\",\n                  }}\n                />\n\n                <div\n                  className=\"pointer-events-none absolute top-0 left-0 z-10 size-3 rounded-full\"\n                  style={{\n                    background: activeColor,\n                    transform: `translate3d(${cursorPx - 6}px, ${cursorYpx - 6}px, 0)`,\n                    boxShadow: DOT_SHADOW,\n                    transition: `transform ${transition}`,\n                    willChange: \"transform\",\n                  }}\n                />\n\n                <div\n                  className=\"bg-background pointer-events-none absolute top-0 left-0 z-20 grid min-w-36 items-start gap-1 rounded-lg px-2.5 py-1.5 text-xs\"\n                  style={{\n                    transform: tooltipOnLeft\n                      ? `translate3d(calc(${cursorPx}px - 100% - 12px), calc(${cursorYpx}px - 50%), 0)`\n                      : `translate3d(${cursorPx + 12}px, calc(${cursorYpx}px - 50%), 0)`,\n                    transition: `transform ${transition}`,\n                    willChange: \"transform\",\n                    boxShadow: TOOLTIP_SHADOW,\n                  }}\n                >\n                  <div className=\"text-foreground font-medium whitespace-nowrap\">{active.time}</div>\n\n                  <div className=\"flex items-center gap-2\">\n                    <div\n                      className=\"h-2.5 w-2.5 shrink-0 rounded-[2px]\"\n                      style={{ background: activeColor }}\n                    />\n\n                    {name && (\n                      <span className=\"text-muted-foreground whitespace-nowrap\">{name}</span>\n                    )}\n                  </div>\n\n                  <div className=\"text-foreground grid grid-cols-2 gap-x-3 gap-y-0.5 font-mono tabular-nums\">\n                    <span>O {formatValue(active.open)}</span>\n                    <span>H {formatValue(active.high)}</span>\n                    <span>L {formatValue(active.low)}</span>\n                    <span>C {formatValue(active.close)}</span>\n                  </div>\n                </div>\n              </>\n            );\n          })()}\n      </div>\n\n      {axisVisible && (\n        <div className=\"relative mt-2 h-5\">\n          {tickIndices.map((i, tick) => {\n            const c = candles[i];\n            const xPct = (c.cx / VIEWBOX_W) * 100;\n            const align = tick === 0 ? \"0%\" : tick === tickIndices.length - 1 ? \"-100%\" : \"-50%\";\n\n            return (\n              <div\n                key={`${rangeStart}-${visibleCount}-${i}`}\n                className=\"text-muted-foreground absolute top-0 text-[11px] leading-none whitespace-nowrap tabular-nums antialiased\"\n                style={{\n                  left: `${xPct}%`,\n                  transform: `translateX(${align})`,\n                  transition: `left ${transition}, opacity ${transition}`,\n                  willChange: \"left\",\n                  fontVariantNumeric: \"tabular-nums\",\n                  WebkitFontSmoothing: \"antialiased\",\n                  MozOsxFontSmoothing: \"grayscale\",\n                }}\n              >\n                {c.time}\n              </div>\n            );\n          })}\n        </div>\n      )}\n    </div>\n  );\n}\n\nexport { CandlestickChart };\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component"
}