"use client"; import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import gsap from "gsap"; import { useGSAP } from "@gsap/react"; import { cn } from "@/lib/utils"; gsap.registerPlugin(useGSAP); export interface Candle { date: string; open: number; high: number; low: number; close: number; volume?: number; } export interface CandlestickRange { label: string; sessions?: number; } export interface CandlestickChartProps extends Omit< React.HTMLAttributes, "title" > { data?: Candle[]; title?: string; description?: string; valuePrefix?: string; showVolume?: boolean; ranges?: CandlestickRange[]; animated?: boolean; onCandleSelect?: (candle: Candle | null) => void; } interface Measurement { from: number; to: number; } const AXIS_WIDTH = 48; const AXIS_HEIGHT = 22; const PLOT_TOP = 8; const VOLUME_RATIO = 0.2; const VOLUME_GAP = 8; const PRICE_TICKS = 4; const MAX_DATE_TICKS = 5; const DATE_LABEL_SPACING = 64; const DEFAULT_RANGES: CandlestickRange[] = [ { label: "1W", sessions: 7 }, { label: "2W", sessions: 14 }, { label: "1M", sessions: 30 }, { label: "All" }, ]; const roundToCents = (value: number) => Math.round(value * 100) / 100; function getNiceStep(rawStep: number) { const magnitude = 10 ** Math.floor(Math.log10(rawStep)); const normalized = rawStep / magnitude; const factor = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 2.5 ? 2.5 : normalized <= 5 ? 5 : 10; return factor * magnitude; } function generateDemoCandles(count = 40): Candle[] { let seed = 7; const random = () => { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; }; const candles: Candle[] = []; let price = 182; for (let index = 0; index < count; index++) { const open = price; const close = Math.max( 1, open + (random() - 0.48) * 6 + Math.sin(index / 5) * 1.6, ); candles.push({ date: new Date(Date.UTC(2026, 7, 1 + index)).toISOString().slice(0, 10), open: roundToCents(open), high: roundToCents(Math.max(open, close) + random() * 2.6), low: roundToCents(Math.min(open, close) - random() * 2.6), close: roundToCents(close), volume: Math.round(1_200_000 + random() * 2_600_000), }); price = close; } return candles; } const DEMO_CANDLES = generateDemoCandles(); const priceFormatter = new Intl.NumberFormat("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2, }); const dateFormatter = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC", }); const formatDate = (date: string) => { const parsed = new Date(date); return Number.isNaN(parsed.getTime()) ? date : dateFormatter.format(parsed); }; const formatPercent = (value: number) => Number.isFinite(value) ? `${value >= 0 ? "+" : ""}${value.toFixed(2)}%` : "N/A"; const isValidCandle = (candle: Candle) => [candle.open, candle.high, candle.low, candle.close].every(Number.isFinite) && candle.low <= Math.min(candle.open, candle.close) && candle.high >= Math.max(candle.open, candle.close) && (candle.volume === undefined || (Number.isFinite(candle.volume) && candle.volume >= 0)); const rangeButtonClass = "relative z-10 w-9 rounded-md py-1 text-[11px] font-medium text-zinc-500 transition-colors duration-150 hover:text-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 aria-pressed:text-zinc-900 motion-reduce:transition-none dark:text-zinc-400 dark:hover:text-zinc-100 dark:aria-pressed:text-zinc-100"; export const CandlestickChart = forwardRef< HTMLDivElement, CandlestickChartProps >(function CandlestickChart( { data = DEMO_CANDLES, title = "Price action", description, valuePrefix = "$", showVolume = true, ranges = DEFAULT_RANGES, animated = true, onCandleSelect, className, ...props }, forwardedRef, ) { const containerRef = useRef(null); const plotRef = useRef(null); const dragRef = useRef<{ anchor: number; last: number } | null>(null); const [size, setSize] = useState({ width: 0, height: 0 }); const [rangeLabel, setRangeLabel] = useState(ranges.at(-1)?.label); const [storedHoverIndex, setHoverIndex] = useState(null); const [storedPinnedIndex, setPinnedIndex] = useState(null); const [storedMeasurement, setMeasurement] = useState(null); const [announcement, setAnnouncement] = useState(""); const setRefs = useCallback( (node: HTMLDivElement | null) => { containerRef.current = node; if (typeof forwardedRef === "function") forwardedRef(node); else if (forwardedRef) forwardedRef.current = node; }, [forwardedRef], ); const validCandles = useMemo(() => data.filter(isValidCandle), [data]); const rangeIndex = ranges.findIndex((range) => range.label === rangeLabel); const sessions = ranges[rangeIndex]?.sessions; const start = sessions !== undefined && Number.isFinite(sessions) ? Math.max(0, validCandles.length - Math.max(1, Math.floor(sessions))) : 0; const candles = useMemo( () => validCandles.slice(start), [validCandles, start], ); const candlesKey = useMemo(() => JSON.stringify(candles.map( ({ date, open, high, low, close, volume }) => [date, open, high, low, close, volume ?? null], )), [candles]); const [selectionKey, setSelectionKey] = useState(candlesKey); const hoverIndex = selectionKey === candlesKey ? storedHoverIndex : null; const pinnedIndex = selectionKey === candlesKey ? storedPinnedIndex : null; const measurement = selectionKey === candlesKey ? storedMeasurement : null; const previousSelection = useRef({ candlesKey, pinnedIndex }); if (selectionKey !== candlesKey) { setSelectionKey(candlesKey); setHoverIndex(null); setPinnedIndex(null); setMeasurement(null); setAnnouncement(""); } useEffect(() => { const previous = previousSelection.current; if (previous.candlesKey !== candlesKey) { dragRef.current = null; if (previous.pinnedIndex !== null) onCandleSelect?.(null); } previousSelection.current = { candlesKey, pinnedIndex }; }, [candlesKey, pinnedIndex, onCandleSelect]); const hasSize = size.width > AXIS_WIDTH && size.height > AXIS_HEIGHT + PLOT_TOP; const hasVolume = showVolume && candles.some((candle) => (candle.volume ?? 0) > 0); useEffect(() => { const node = plotRef.current; if (!node) return; const observer = new ResizeObserver(([entry]) => { const { width, height } = entry.contentRect; setSize({ width: Math.floor(width), height: Math.floor(height) }); }); observer.observe(node); return () => observer.disconnect(); }, []); useGSAP( () => { if (!animated || !hasSize || candles.length === 0) return; const mm = gsap.matchMedia(); mm.add("(prefers-reduced-motion: no-preference)", () => { gsap.from(".candle-grid", { opacity: 0, duration: 0.4 }); gsap.from(".candle-body", { scaleY: 0, transformOrigin: "50% 50%", duration: 0.55, ease: "power3.out", stagger: Math.min(0.018, 0.6 / candles.length), }); gsap.from(".candle-volume", { scaleY: 0, transformOrigin: "50% 100%", duration: 0.5, ease: "power2.out", stagger: Math.min(0.018, 0.6 / candles.length), delay: 0.1, }); }, containerRef); return () => mm.revert(); }, { scope: containerRef, dependencies: [candlesKey, animated, hasSize, hasVolume], revertOnUpdate: true, }, ); const layout = useMemo(() => { if (!hasSize || candles.length === 0) return null; const right = size.width - AXIS_WIDTH; const bottom = size.height - AXIS_HEIGHT; const volumeHeight = hasVolume ? (bottom - PLOT_TOP) * VOLUME_RATIO : 0; const priceBottom = hasVolume ? bottom - volumeHeight - VOLUME_GAP : bottom; const rawMin = Math.min(...candles.map((candle) => candle.low)); const rawMax = Math.max(...candles.map((candle) => candle.high)); const padding = (rawMax - rawMin) * 0.08 || 1; const min = rawMin - padding; const max = rawMax + padding; const step = right / candles.length; const tickStep = getNiceStep((max - min) / PRICE_TICKS); const dateTickCount = Math.max( 2, Math.min(MAX_DATE_TICKS, Math.floor(right / DATE_LABEL_SPACING)), ); return { right, bottom, priceBottom, volumeHeight, step, bodyWidth: Math.max(1, Math.min(14, step * 0.62)), maxVolume: Math.max(...candles.map((candle) => candle.volume ?? 0)), x: (index: number) => step * (index + 0.5), y: (value: number) => PLOT_TOP + ((max - value) / (max - min)) * (priceBottom - PLOT_TOP), ticks: Array.from( { length: Math.floor(max / tickStep) - Math.ceil(min / tickStep) + 1 }, (_, index) => (Math.ceil(min / tickStep) + index) * tickStep, ), tickFormatter: new Intl.NumberFormat("en-US", { minimumFractionDigits: tickStep < 1 ? 2 : 0, maximumFractionDigits: tickStep < 1 ? 2 : 0, }), dateTicks: [ ...new Set( Array.from({ length: dateTickCount }, (_, index) => Math.round((index * (candles.length - 1)) / (dateTickCount - 1)), ), ), ], }; }, [candles, hasSize, hasVolume, size.height, size.width]); const lastIndex = candles.length - 1; const cursorIndex = hoverIndex ?? pinnedIndex; const displayIndex = cursorIndex ?? lastIndex; const displayCandle = candles[displayIndex]; const previousClose = displayIndex > 0 ? candles[displayIndex - 1].close : displayCandle?.open; const change = displayCandle ? displayCandle.close - previousClose : 0; const changePercent = previousClose ? (change / previousClose) * 100 : NaN; const formatPrice = (value: number) => `${valuePrefix}${priceFormatter.format(value)}`; const clampIndex = (index: number) => Math.max(0, Math.min(lastIndex, index)); const describeCandle = (candle: Candle) => `${formatDate(candle.date)}: open ${formatPrice(candle.open)}, high ${formatPrice(candle.high)}, low ${formatPrice(candle.low)}, close ${formatPrice(candle.close)}`; const describeMeasurement = ({ from, to }: Measurement) => { const startCandle = candles[Math.min(from, to)]; const endCandle = candles[Math.max(from, to)]; const percent = startCandle.close ? ((endCandle.close - startCandle.close) / startCandle.close) * 100 : NaN; const span = Math.abs(to - from); return { percent, label: `${formatPercent(percent)} · ${span} ${span === 1 ? "bar" : "bars"}`, text: `${formatPercent(percent)} from ${formatDate(startCandle.date)} to ${formatDate(endCandle.date)}, ${span} ${span === 1 ? "session" : "sessions"}`, }; }; const togglePin = (index: number) => { const next = pinnedIndex === index ? null : index; setPinnedIndex(next); previousSelection.current.pinnedIndex = next; onCandleSelect?.(next === null ? null : candles[next]); setAnnouncement( next === null ? "Selection cleared" : `Pinned ${describeCandle(candles[next])}`, ); }; const clearSelection = () => { setHoverIndex(null); setMeasurement(null); if (pinnedIndex !== null) { previousSelection.current.pinnedIndex = null; onCandleSelect?.(null); } setPinnedIndex(null); }; const selectRange = (label: string) => { if (label === rangeLabel) return; clearSelection(); setRangeLabel(label); }; const indexAt = (event: React.PointerEvent) => { const bounds = event.currentTarget.getBoundingClientRect(); return clampIndex( Math.floor((event.clientX - bounds.left) / (layout?.step ?? 1)), ); }; const handlePointerDown = (event: React.PointerEvent) => { if (!layout || !event.isPrimary || event.button !== 0) return; const index = indexAt(event); event.currentTarget.focus({ preventScroll: true }); event.currentTarget.setPointerCapture(event.pointerId); dragRef.current = { anchor: index, last: index }; setHoverIndex(index); }; const handlePointerMove = (event: React.PointerEvent) => { if (!layout) return; const index = indexAt(event); setHoverIndex(index); const drag = dragRef.current; if (drag && index !== drag.last) { drag.last = index; setMeasurement({ from: drag.anchor, to: index }); } }; const handlePointerUp = () => { const drag = dragRef.current; dragRef.current = null; if (!drag) return; if (drag.last !== drag.anchor) { setAnnouncement( describeMeasurement({ from: drag.anchor, to: drag.last }).text, ); return; } setMeasurement(null); togglePin(drag.anchor); }; const handleKeyDown = (event: React.KeyboardEvent) => { if (candles.length === 0) return; const current = cursorIndex ?? lastIndex; if (event.key === "Escape") { clearSelection(); setAnnouncement("Selection cleared"); return; } if (event.key === "Enter" || event.key === " ") { event.preventDefault(); togglePin(current); return; } const targets: Record = { ArrowLeft: cursorIndex === null ? lastIndex : current - 1, ArrowRight: current + 1, Home: 0, End: lastIndex, }; if (!(event.key in targets)) return; event.preventDefault(); const target = clampIndex(targets[event.key]); setHoverIndex(target); if (event.shiftKey) { const next = { from: measurement?.from ?? current, to: target }; setMeasurement(next); setAnnouncement(describeMeasurement(next).text); return; } setMeasurement(null); setAnnouncement(describeCandle(candles[target])); }; const cursorCandle = cursorIndex === null ? null : candles[cursorIndex]; const lastCandle = candles[lastIndex]; const tagPrice = cursorCandle?.close ?? lastCandle?.close; const tagY = layout && tagPrice !== undefined ? layout.y(tagPrice) : null; const cursorX = layout && cursorIndex !== null ? layout.x(cursorIndex) : null; const lastIsUp = lastCandle ? lastCandle.close >= lastCandle.open : true; const measureLabelWidth = measurement ? describeMeasurement(measurement).label.length * 6.4 + 12 : 0; const measureView = measurement && layout ? { ...describeMeasurement(measurement), fromX: layout.x(measurement.from), toX: layout.x(measurement.to), fromY: layout.y(candles[measurement.from].close), toY: layout.y(candles[measurement.to].close), left: layout.x(Math.min(measurement.from, measurement.to)), right: layout.x(Math.max(measurement.from, measurement.to)), labelX: Math.max( measureLabelWidth / 2, Math.min( layout.right - measureLabelWidth / 2, (layout.x(measurement.from) + layout.x(measurement.to)) / 2, ), ), } : null; return (
{title} {[ description, displayCandle && formatDate(displayCandle.date), pinnedIndex !== null && hoverIndex === null && "Pinned", ] .filter(Boolean) .join(" · ")}
{displayCandle && (
{formatPrice(displayCandle.close)} = 0 ? "text-emerald-600 dark:text-emerald-400" : "text-rose-600 dark:text-rose-400", )} > {formatPercent(changePercent)}
)}
{ranges.length > 1 && (
{rangeIndex >= 0 && (
)}
0 ? 0 : -1} role="group" aria-roledescription="candlestick chart" aria-label={`${title}, ${candles.length} sessions. Arrow keys inspect candles, Enter pins one, Shift with arrow keys measures change, Escape clears.`} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onPointerCancel={() => (dragRef.current = null)} onPointerLeave={() => setHoverIndex(null)} onKeyDown={handleKeyDown} onBlur={() => setHoverIndex(null)} className="relative mt-3 h-56 sm:h-64 w-full cursor-crosshair touch-pan-y rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-zinc-500 dark:focus-visible:ring-offset-zinc-950" > {candles.length === 0 ? (
No price data
) : ( layout && ( ) )}

{announcement}

); }); CandlestickChart.displayName = "CandlestickChart";