Bklit UI
componentcandlestick-chartsource: ok · hit
Candlestick Chart
A composable OHLC candlestick chart with gradients, patterns, tooltips, and hover interactions
npx shadcn add "https://ui.bklit.com/r/candlestick-chart.json"free
Visual style
gradient
Interaction
tooltip
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
Registry dependencies
@bklit/chart-context@bklit/chart-animation@bklit/grid@bklit/x-axis@bklit/y-axis@bklit/chart-tooltip@bklit/utils
Files4 · source cached 10/9/2026, 6:47:00 PM
src/charts/candlestick-chart.tsx
"use client";
import { ParentSize } from "@visx/responsive";
import { scaleLinear, scaleTime } from "@visx/scale";
import { bisector } from "d3-array";
import type { Transition } from "motion/react";
import {
Children,
isValidElement,
memo,
type ReactElement,
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
import {
isClipExcludedComponent,
isPostOverlayComponent,
isUnderlayComponent,
} from "./chart-child-passthrough";
import { ChartProvider, type LineConfig, type Margin } from "./chart-context";
import { shortDateFmt } from "./chart-formatters";
import { DEFAULT_CHART_LIFECYCLE } from "./chart-phase";
import {
decimateOhlcData,
maxRenderPointsForWidth,
} from "./decimate-time-series";
import { extractReferenceAreaConfigs } from "./reference-area-config";
import { useChartInteraction } from "./use-chart-interaction";
import { wrapSingleYScale } from "./y-axis-scales";
export interface OHLCDataPoint {
date: Date;
open: number;
high: number;
low: number;
close: number;
}
export interface CandlestickChartProps {
/** OHLC data array */
data: OHLCDataPoint[];
/** Key in data for the x-axis (date). Default: "date" */
xDataKey?: string;
/** Chart margins */
margin?: Partial<Margin>;
/** Animation duration in milliseconds. Default: 1500 */
animationDuration?: number;
/** Motion enter transition (spring or cubic-bezier tween). */
enterTransition?: Transition;
/** Signature of motion URL state — triggers enter replay when it changes. */
revealSignature?: string;
/** Aspect ratio as "width / height". Default: "2 / 1" */
aspectRatio?: string;
/** Additional class name for the container */
className?: string;
/** Inline styles for the container (e.g. { height: 320 }) */
style?: React.CSSProperties;
/** Gap between candles as fraction of slot width (0–1). Default: 0.2. Ignored when candleWidth is set. */
candleGap?: number;
/** Fixed candle body width in pixels. If set, overrides candleGap. */
candleWidth?: number;
/** When set, xScale uses this domain instead of deriving from data. Use with brush so main chart and strip share the same scale. */
xDomain?: [Date, Date];
/** When xDomain is set, use this as the number of slots for scale padding (e.g. full data length). */
xDomainSlotCount?: number;
/** Child components (Candlestick, Grid, XAxis, YAxis, ChartTooltip, etc.) */
children: ReactNode;
}
const DEFAULT_MARGIN: Margin = { top: 40, right: 40, bottom: 40, left: 40 };
interface ChartInnerProps {
width: number;
height: number;
data: Record<string, unknown>[];
xDataKey: string;
margin: Margin;
animationDuration: number;
enterTransition?: Transition;
revealSignature?: string;
candleGap: number;
candleWidthProp?: number;
xDomain?: [Date, Date];
xDomainSlotCount?: number;
children: ReactNode;
containerRef: React.RefObject<HTMLDivElement | null>;
}
function ChartInner(props: ChartInnerProps) {
const { width, height } = props;
if (width < 10 || height < 10) {
return null;
}
return <ChartCore {...props} />;
}
const ChartCore = memo(function ChartCore({
width,
height,
data,
xDataKey,
margin,
animationDuration,
enterTransition,
revealSignature = "",
candleGap,
candleWidthProp,
xDomain,
xDomainSlotCount,
children,
containerRef,
}: ChartInnerProps) {
const [isLoaded, setIsLoaded] = useState(false);
const [revealEpoch, setRevealEpoch] = useState(0);
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
const xAccessor = useCallback(
(d: Record<string, unknown>): Date => {
const value = d[xDataKey];
return value instanceof Date ? value : new Date(value as string | number);
},
[xDataKey]
);
const bisectDate = useMemo(
() => bisector<Record<string, unknown>, Date>((d) => xAccessor(d)).left,
[xAccessor]
);
const slotCount =
xDomain && xDomainSlotCount != null ? xDomainSlotCount : data.length;
const slotWidth = innerWidth / Math.max(slotCount, 1);
const xScale = useMemo(() => {
const minTime = xDomain
? xDomain[0].getTime()
: Math.min(...data.map((d) => xAccessor(d).getTime()));
const maxTime = xDomain
? xDomain[1].getTime()
: Math.max(...data.map((d) => xAccessor(d).getTime()));
const padding = slotWidth / 2;
return scaleTime({
range: [padding, innerWidth - padding],
domain: [minTime, maxTime],
});
}, [innerWidth, data, xAccessor, slotWidth, xDomain]);
const yScale = useMemo(() => {
let minVal = Number.POSITIVE_INFINITY;
let maxVal = Number.NEGATIVE_INFINITY;
for (const d of data) {
const low = d.low as number | undefined;
const high = d.high as number | undefined;
if (typeof low === "number" && low < minVal) {
minVal = low;
}
if (typeof high === "number" && high > maxVal) {
maxVal = high;
}
}
if (minVal === Number.POSITIVE_INFINITY) {
minVal = 0;
}
if (maxVal === Number.NEGATIVE_INFINITY) {
maxVal = 100;
}
const padding = (maxVal - minVal) * 0.05 || 1;
return scaleLinear({
range: [innerHeight, 0],
domain: [minVal - padding, maxVal + padding],
nice: true,
});
}, [innerHeight, data]);
const columnWidth = slotWidth;
const bandWidth = candleWidthProp ?? slotWidth * (1 - candleGap);
const lines: LineConfig[] = useMemo(
() => [
{ dataKey: "close", stroke: "var(--chart-line-primary)", strokeWidth: 0 },
],
[]
);
const renderData = useMemo(
() => decimateOhlcData(data, maxRenderPointsForWidth(innerWidth)),
[data, innerWidth]
);
const dateLabels = useMemo(
() => data.map((d) => shortDateFmt.format(xAccessor(d))),
[data, xAccessor]
);
// biome-ignore lint/correctness/useExhaustiveDependencies: revealSignature
useEffect(() => {
setRevealEpoch((n) => n + 1);
setIsLoaded(false);
const timer = setTimeout(() => setIsLoaded(true), animationDuration);
return () => clearTimeout(timer);
}, [animationDuration, revealSignature]);
const {
tooltipData,
setTooltipData,
selection,
clearSelection,
interactionHandlers,
interactionStyle,
} = useChartInteraction({
xScale,
yScale,
yScales: wrapSingleYScale(yScale),
data,
lines,
margin,
xAccessor,
bisectDate,
canInteract: isLoaded,
});
const hoveredCandleIndex = tooltipData?.index ?? null;
const isDefsComponent = (child: ReactElement): boolean => {
const displayName =
(child.type as { displayName?: string })?.displayName ||
(child.type as { name?: string })?.name ||
"";
return (
displayName.includes("Gradient") ||
displayName.includes("Pattern") ||
displayName === "LinearGradient" ||
displayName === "RadialGradient" ||
displayName === "Lines" ||
displayName === "PatternLines"
);
};
const defsChildren: ReactElement[] = [];
const clipExcludedChildren: ReactElement[] = [];
const underlayChildren: ReactElement[] = [];
const preOverlayChildren: ReactElement[] = [];
const postOverlayChildren: ReactElement[] = [];
Children.forEach(children, (child) => {
if (!isValidElement(child)) {
return;
}
if (isDefsComponent(child)) {
defsChildren.push(child);
} else if (isPostOverlayComponent(child)) {
postOverlayChildren.push(child);
} else if (isClipExcludedComponent(child)) {
clipExcludedChildren.push(child);
} else if (isUnderlayComponent(child)) {
underlayChildren.push(child);
} else {
preOverlayChildren.push(child);
}
});
const referenceAreas = useMemo(
() => extractReferenceAreaConfigs(children),
[children]
);
const yScales = useMemo(() => wrapSingleYScale(yScale), [yScale]);
const contextValue = useMemo(
() => ({
...DEFAULT_CHART_LIFECYCLE,
data,
renderData,
xScale,
yScale,
yScales,
width,
height,
innerWidth,
innerHeight,
margin,
columnWidth,
tooltipData,
setTooltipData,
containerRef,
lines,
referenceAreas,
isLoaded,
animationDuration,
enterTransition,
revealEpoch,
xAccessor,
dateLabels,
selection: selection ?? null,
clearSelection,
bandWidth,
hoveredCandleIndex,
}),
[
data,
renderData,
xScale,
yScale,
yScales,
width,
height,
innerWidth,
innerHeight,
margin,
columnWidth,
tooltipData,
setTooltipData,
containerRef,
lines,
referenceAreas,
isLoaded,
animationDuration,
enterTransition,
revealEpoch,
xAccessor,
dateLabels,
selection,
clearSelection,
bandWidth,
hoveredCandleIndex,
]
);
return (
<ChartProvider value={contextValue}>
<svg aria-hidden="true" height={height} width={width}>
<defs>
{/* Default vertical gradients for positive/negative candles (emerald / red) */}
<linearGradient id="candlestick-positive" x1="0" x2="0" y1="1" y2="0">
<stop offset="0%" stopColor="var(--color-emerald-500)" />
<stop offset="100%" stopColor="var(--color-emerald-500)" />
</linearGradient>
<linearGradient id="candlestick-negative" x1="0" x2="0" y1="1" y2="0">
<stop offset="0%" stopColor="var(--color-red-500)" />
<stop offset="100%" stopColor="var(--color-red-500)" />
</linearGradient>
{defsChildren}
</defs>
<rect fill="transparent" height={height} width={width} x={0} y={0} />
<g
{...interactionHandlers}
style={interactionStyle}
transform={`translate(${margin.left},${margin.top})`}
>
<rect
fill="transparent"
height={innerHeight}
width={innerWidth}
x={0}
y={0}
/>
{clipExcludedChildren}
{underlayChildren}
{preOverlayChildren}
{postOverlayChildren}
</g>
</svg>
</ChartProvider>
);
});
export function CandlestickChart({
data,
xDataKey = "date",
margin: marginProp,
animationDuration = 1100,
enterTransition,
revealSignature,
aspectRatio = "2 / 1",
className = "",
style,
candleGap = 0.2,
candleWidth,
xDomain,
xDomainSlotCount,
children,
}: CandlestickChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const margin = { ...DEFAULT_MARGIN, ...marginProp };
const dataAsRecords = data as unknown as Record<string, unknown>[];
return (
<div
className={cn("relative w-full", className)}
ref={containerRef}
style={{ aspectRatio, touchAction: "none", ...style }}
>
<ParentSize debounceTime={10}>
{({ width, height }) => (
<ChartInner
animationDuration={animationDuration}
candleGap={candleGap}
candleWidthProp={candleWidth}
containerRef={containerRef}
data={dataAsRecords}
enterTransition={enterTransition}
height={height}
margin={margin}
revealSignature={revealSignature}
width={width}
xDataKey={xDataKey}
xDomain={xDomain}
xDomainSlotCount={xDomainSlotCount}
>
{children}
</ChartInner>
)}
</ParentSize>
</div>
);
}
CandlestickChart.displayName = "CandlestickChart";
export default CandlestickChart;
src/charts/chart-child-passthrough.ts
import {
Children,
cloneElement,
Fragment,
isValidElement,
type ReactElement,
type ReactNode,
} from "react";
/** Marker on wrapper components whose single child should inherit clip classification. */
export const CHART_CLIP_PASSTHROUGH = "__chartClipPassthrough" as const;
export function isChartClipPassthrough(type: unknown): boolean {
return (
typeof type === "function" &&
(type as { [CHART_CLIP_PASSTHROUGH]?: boolean })[CHART_CLIP_PASSTHROUGH] ===
true
);
}
/** Unwrap visibility wrappers so `Grid` / axes stay outside the series clip. */
export function resolveChartChildElement(child: ReactElement): ReactElement {
if (isChartClipPassthrough(child.type)) {
const inner = (child.props as { children?: unknown }).children;
if (isValidElement(inner)) {
return resolveChartChildElement(inner);
}
}
return child;
}
/** Walk chart children, flattening React fragments (studio often groups layers in `<>...</>`). */
export function forEachChartChild(
children: ReactNode,
callback: (child: ReactElement, index: number) => void
) {
let index = 0;
const visit = (nodes: ReactNode) => {
Children.forEach(nodes, (child) => {
if (!isValidElement(child)) {
return;
}
if (child.type === Fragment) {
visit((child.props as { children?: ReactNode }).children);
return;
}
callback(child, index);
index += 1;
});
};
visit(children);
}
const CLIP_EXCLUDED_COMPONENT_NAMES = new Set([
"Background",
"Grid",
"XAxis",
"YAxis",
"BarXAxis",
"BarYAxis",
"LiveXAxis",
"LiveYAxis",
]);
const UNDERLAY_COMPONENT_NAMES = new Set(["ReferenceArea", "BarColumnTrack"]);
/** Markers render after the interaction overlay so they stay clickable. */
export function isPostOverlayComponent(child: ReactElement): boolean {
const childType = child.type as {
displayName?: string;
name?: string;
__isChartMarkers?: boolean;
__isPostOverlay?: boolean;
};
if (childType.__isChartMarkers || childType.__isPostOverlay) {
return true;
}
const componentName =
typeof child.type === "function"
? childType.displayName || childType.name || ""
: "";
return (
componentName === "ChartMarkers" ||
componentName === "MarkerGroup" ||
componentName === "ChartBrush"
);
}
/** Renders above grid/axes but below series; excluded from grow-clip reveal. */
export function isUnderlayComponent(child: ReactElement): boolean {
const childType = child.type as { displayName?: string; name?: string };
const componentName =
typeof child.type === "function"
? childType.displayName || childType.name || ""
: "";
return UNDERLAY_COMPONENT_NAMES.has(componentName);
}
/** Grid and axes stay visible during series clip reveal (e.g. loading → ready). */
export function isClipExcludedComponent(child: ReactElement): boolean {
const childType = child.type as { displayName?: string; name?: string };
const componentName =
typeof child.type === "function"
? childType.displayName || childType.name || ""
: "";
return CLIP_EXCLUDED_COMPONENT_NAMES.has(componentName);
}
/** SVG layer lists from chart shells need stable keys when rendered as arrays. */
export function renderKeyedChartLayers(children: ReactElement[]) {
return children.map((child, index) =>
cloneElement(child, { key: child.key ?? `chart-layer-${index}` })
);
}
src/charts/chart-legend-hover.tsx
"use client";
import { createContext, type ReactNode, useContext, useMemo } from "react";
interface ChartLegendHoverContextValue {
hoveredIndex: number | null;
setHoveredIndex: (index: number | null) => void;
}
const ChartLegendHoverContext =
createContext<ChartLegendHoverContextValue | null>(null);
export function ChartLegendHoverProvider({
hoveredIndex,
onHoverChange,
children,
}: {
hoveredIndex: number | null;
onHoverChange: (index: number | null) => void;
children: ReactNode;
}) {
const value = useMemo(
() => ({ hoveredIndex, setHoveredIndex: onHoverChange }),
[hoveredIndex, onHoverChange]
);
return (
<ChartLegendHoverContext.Provider value={value}>
{children}
</ChartLegendHoverContext.Provider>
);
}
export function useChartLegendHover(): ChartLegendHoverContextValue {
const context = useContext(ChartLegendHoverContext);
return (
context ?? {
hoveredIndex: null,
setHoveredIndex: () => {
/* noop outside ChartLegendHoverProvider */
},
}
);
}
src/charts/candlestick.tsx
"use client";
import type { Transition } from "motion/react";
import { motion } from "motion/react";
import { memo, useMemo } from "react";
import { useChart } from "./chart-context";
import { useChartLegendHover } from "./chart-legend-hover";
import { transitionWithDelay } from "./motion-utils";
const DEFAULT_POSITIVE = "url(#candlestick-positive)";
const DEFAULT_NEGATIVE = "url(#candlestick-negative)";
const SOLID_POSITIVE = "var(--color-emerald-500)";
const SOLID_NEGATIVE = "var(--color-red-500)";
const WICK_WIDTH = 1.5;
export interface CandlestickProps {
/** Whether to animate the candlesticks. Default: true */
animate?: boolean;
/** Fill for positive (close >= open) candles. Color or url(#gradient). Default: emerald */
positiveFill?: string;
/** Fill for negative candles. Color or url(#gradient). Default: red */
negativeFill?: string;
/** Optional pattern URL for body only (e.g. url(#pattern)). When set, body is drawn solid first, then pattern overlaid and masked to the body rect. */
bodyPatternPositive?: string;
/** Optional pattern URL for negative candle body. */
bodyPatternNegative?: string;
/** Inner border width on the body (drawn inside so it does not expand the shape). Default: 0 (off). */
insideStrokeWidth?: number;
/** Opacity when another candle is hovered. Default: 0.3 */
fadedOpacity?: number;
/** Dim non-hovered candles on hover. Default: true */
showHoverFade?: boolean;
}
interface CandleGeometry {
time: number;
centerX: number;
bodyTop: number;
bodyHeight: number;
bodyLeft: number;
candleWidth: number;
wickTop: number;
wickHeight: number;
wickLeft: number;
bodySolidFill: string;
wickFill: string;
bodyPattern?: string;
insideStrokeWidth: number;
isPositive: boolean;
}
function getSolidColor(isPositive: boolean): string {
return isPositive ? SOLID_POSITIVE : SOLID_NEGATIVE;
}
function computeGeometries(
renderData: Record<string, unknown>[],
xScale: (value: Date) => number | undefined,
yScale: (value: number) => number | undefined,
xAccessor: (d: Record<string, unknown>) => Date,
candleWidth: number,
positiveFill: string,
negativeFill: string,
bodyPatternPositive: string | undefined,
bodyPatternNegative: string | undefined,
insideStrokeWidth: number
): CandleGeometry[] {
return renderData.map((d) => {
const date = xAccessor(d);
const open = d.open as number;
const high = d.high as number;
const low = d.low as number;
const close = d.close as number;
const centerX = xScale(date) ?? 0;
const yHigh = yScale(high) ?? 0;
const yLow = yScale(low) ?? 0;
const yOpen = yScale(open) ?? 0;
const yClose = yScale(close) ?? 0;
const bodyTop = Math.min(yOpen, yClose);
const bodyHeight = Math.abs(yClose - yOpen) || 1;
const bodyLeft = centerX - candleWidth / 2;
const wickTop = Math.min(yHigh, yLow);
const wickHeight = Math.abs(yLow - yHigh) || 1;
const isPositive = close >= open;
const fill = isPositive ? positiveFill : negativeFill;
const bodyPattern = isPositive ? bodyPatternPositive : bodyPatternNegative;
const hasPatternOverlay = Boolean(bodyPattern);
const bodySolidFill = hasPatternOverlay ? getSolidColor(isPositive) : fill;
return {
time: date.getTime(),
centerX,
bodyTop,
bodyHeight,
bodyLeft,
candleWidth,
wickTop,
wickHeight,
wickLeft: centerX - WICK_WIDTH / 2,
bodySolidFill,
wickFill: hasPatternOverlay ? bodySolidFill : fill,
bodyPattern: hasPatternOverlay ? bodyPattern : undefined,
insideStrokeWidth,
isPositive,
};
});
}
function geometryDimOpacity(
geometry: CandleGeometry,
fadedOpacity: number,
legendHoveredIndex: number | null,
hoveredTime: number | null
): number {
if (legendHoveredIndex !== null) {
const dimFromLegend =
(legendHoveredIndex === 0 && !geometry.isPositive) ||
(legendHoveredIndex === 1 && geometry.isPositive);
return dimFromLegend ? fadedOpacity : 1;
}
if (hoveredTime !== null && geometry.time !== hoveredTime) {
return fadedOpacity;
}
return 1;
}
const CandlestickBody = memo(function CandlestickBody({
geometry,
}: {
geometry: CandleGeometry;
}) {
const {
wickLeft,
wickTop,
wickHeight,
wickFill,
bodyLeft,
bodyTop,
bodyHeight,
candleWidth,
bodySolidFill,
bodyPattern,
insideStrokeWidth,
} = geometry;
return (
<>
<rect
fill={wickFill}
height={wickHeight}
width={WICK_WIDTH}
x={wickLeft}
y={wickTop}
/>
<rect
fill={bodySolidFill}
height={bodyHeight}
rx={1}
ry={1}
stroke={bodySolidFill}
strokeWidth={1}
width={candleWidth}
x={bodyLeft}
y={bodyTop}
/>
{bodyPattern ? (
<rect
fill={bodyPattern}
height={bodyHeight}
rx={1}
ry={1}
width={candleWidth}
x={bodyLeft}
y={bodyTop}
/>
) : null}
{insideStrokeWidth > 0 ? (
<rect
fill="none"
height={bodyHeight - insideStrokeWidth}
rx={1}
ry={1}
stroke={bodySolidFill}
strokeWidth={insideStrokeWidth}
width={candleWidth - insideStrokeWidth}
x={bodyLeft + insideStrokeWidth / 2}
y={bodyTop + insideStrokeWidth / 2}
/>
) : null}
</>
);
});
const CandlestickBodies = memo(function CandlestickBodies({
geometries,
fadedOpacity,
legendHoveredIndex,
hoveredTime,
}: {
geometries: CandleGeometry[];
fadedOpacity: number;
legendHoveredIndex: number | null;
hoveredTime: number | null;
}) {
return (
<>
{geometries.map((geometry) => (
<g
key={geometry.time}
opacity={geometryDimOpacity(
geometry,
fadedOpacity,
legendHoveredIndex,
hoveredTime
)}
style={{ transition: "opacity 0.15s ease-in-out" }}
>
<CandlestickBody geometry={geometry} />
</g>
))}
</>
);
});
interface AnimatedCandleProps {
geometry: CandleGeometry;
delay: number;
enterTransition: Transition;
revealEpoch: number;
}
function AnimatedCandle({
geometry,
delay,
enterTransition,
revealEpoch,
}: AnimatedCandleProps) {
const t = transitionWithDelay(enterTransition, delay);
const bodyOrigin = `${geometry.centerX}px ${geometry.bodyTop + geometry.bodyHeight / 2}px`;
const wickCenterY = geometry.wickTop + geometry.wickHeight / 2;
return (
<motion.g
animate={{ opacity: 1 }}
initial={{ opacity: 0 }}
key={`candle-enter-${geometry.time}-${revealEpoch}`}
style={{ transformOrigin: `${geometry.centerX}px ${wickCenterY}px` }}
transition={{ ...t, opacity: { duration: 0.15 } }}
>
<motion.rect
animate={{ scaleY: 1 }}
fill={geometry.wickFill}
height={geometry.wickHeight}
initial={{ scaleY: 0 }}
style={{ transformOrigin: `${geometry.centerX}px ${wickCenterY}px` }}
transition={t}
width={WICK_WIDTH}
x={geometry.wickLeft}
y={geometry.wickTop}
/>
<motion.rect
animate={{ scaleY: 1 }}
fill={geometry.bodySolidFill}
height={geometry.bodyHeight}
initial={{ scaleY: 0 }}
rx={1}
ry={1}
stroke={geometry.bodySolidFill}
strokeWidth={1}
style={{ transformOrigin: bodyOrigin }}
transition={t}
width={geometry.candleWidth}
x={geometry.bodyLeft}
y={geometry.bodyTop}
/>
{geometry.bodyPattern ? (
<motion.rect
animate={{ scaleY: 1 }}
fill={geometry.bodyPattern}
height={geometry.bodyHeight}
initial={{ scaleY: 0 }}
rx={1}
ry={1}
style={{ transformOrigin: bodyOrigin }}
transition={t}
width={geometry.candleWidth}
x={geometry.bodyLeft}
y={geometry.bodyTop}
/>
) : null}
</motion.g>
);
}
export function Candlestick({
animate = true,
positiveFill = DEFAULT_POSITIVE,
negativeFill = DEFAULT_NEGATIVE,
bodyPatternPositive,
bodyPatternNegative,
insideStrokeWidth = 0,
fadedOpacity = 0.3,
showHoverFade = true,
}: CandlestickProps) {
const {
data,
xScale,
yScale,
xAccessor,
animationDuration,
enterTransition,
revealEpoch = 0,
isLoaded,
bandWidth,
columnWidth,
hoveredCandleIndex,
} = useChart();
const { hoveredIndex: legendHoveredIndex } = useChartLegendHover();
const candleWidth = Math.min(bandWidth ?? columnWidth * 0.8, columnWidth);
const geometries = useMemo(
() =>
computeGeometries(
data,
xScale,
yScale,
xAccessor,
candleWidth,
positiveFill,
negativeFill,
bodyPatternPositive,
bodyPatternNegative,
insideStrokeWidth
),
[
data,
xScale,
yScale,
xAccessor,
candleWidth,
positiveFill,
negativeFill,
bodyPatternPositive,
bodyPatternNegative,
insideStrokeWidth,
]
);
const hoveredTime = useMemo(() => {
if (hoveredCandleIndex == null) {
return null;
}
const point = data[hoveredCandleIndex];
return point ? xAccessor(point).getTime() : null;
}, [hoveredCandleIndex, data, xAccessor]);
const highlightGeometry = useMemo(() => {
if (hoveredCandleIndex == null) {
return null;
}
const point = data[hoveredCandleIndex];
if (!point) {
return null;
}
return (
computeGeometries(
[point],
xScale,
yScale,
xAccessor,
candleWidth,
positiveFill,
negativeFill,
bodyPatternPositive,
bodyPatternNegative,
insideStrokeWidth
)[0] ?? null
);
}, [
hoveredCandleIndex,
data,
xScale,
yScale,
xAccessor,
candleWidth,
positiveFill,
negativeFill,
bodyPatternPositive,
bodyPatternNegative,
insideStrokeWidth,
]);
const defaultEnter: Transition = {
type: "spring",
duration: 0.8,
bounce: 0.15,
};
const enter = enterTransition ?? defaultEnter;
const staggerDelayMs =
data.length > 0 ? (animationDuration * 0.6) / data.length : 0;
if (animate && !isLoaded) {
return (
<g className="chart-candlesticks">
{geometries.map((geometry, index) => (
<AnimatedCandle
delay={(index * staggerDelayMs) / 1000}
enterTransition={enter}
geometry={geometry}
key={geometry.time}
revealEpoch={revealEpoch}
/>
))}
</g>
);
}
return (
<g className="chart-candlesticks">
<CandlestickBodies
fadedOpacity={fadedOpacity}
geometries={geometries}
hoveredTime={showHoverFade ? hoveredTime : null}
legendHoveredIndex={legendHoveredIndex}
/>
{highlightGeometry ? (
<g>
<CandlestickBody geometry={highlightGeometry} />
</g>
) : null}
</g>
);
}
Candlestick.displayName = "Candlestick";
export default Candlestick;