"use client"; import { motion, useSpring } from "motion/react"; import { memo, useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { resolveTooltipBoxMotion, type SpringConfig, useChartConfig, } from "../chart-config-context"; import { chartCssVars, type LineConfig, useChart, useChartStable, } from "../chart-context"; import { weekdayDateFmt } from "../chart-formatters"; import type { IndicatorFadeEdges } from "../indicator-fade"; import { DateTicker } from "./date-ticker"; import { TooltipBox } from "./tooltip-box"; import { TooltipContent, type TooltipRow } from "./tooltip-content"; import { TooltipDot } from "./tooltip-dot"; import { TooltipIndicator } from "./tooltip-indicator"; export interface ChartTooltipProps { /** Whether to show the date pill at bottom. Default: true */ showDatePill?: boolean; /** Whether to show the vertical crosshair line. Default: true */ showCrosshair?: boolean; /** Whether to show dots on the lines. Default: true */ showDots?: boolean; /** * Color for the crosshair/indicator line. When a function, receives the hovered point * (e.g. for candlestick: match candle color from close vs open). Default: --chart-crosshair. */ indicatorColor?: string | ((point: Record) => string); /** Custom content renderer for the tooltip box */ content?: (props: { point: Record; index: number; }) => React.ReactNode; /** Custom row renderer - return array of TooltipRow */ rows?: (point: Record) => TooltipRow[]; /** * Override tooltip dot fill. When omitted and `rows` is set, dot colors match row colors. * When a function, receives the hovered point and line config. */ dotColor?: | string | ((point: Record, line: LineConfig) => string); /** Additional content to show below rows (e.g., markers) */ children?: React.ReactNode; /** Custom class name */ className?: string; /** Per-chart override for the crosshair / dot / date-pill spring. */ springConfig?: SpringConfig; /** * When `true`, the floating panel uses the crosshair spring and stays in sync. * Default `false` — panel follow uses `damping` (`20`). */ matchCrosshair?: boolean; /** * Spring damping for the floating tooltip panel when `matchCrosshair` is `false`. * `0` disables spring motion (instant). Default: `20`. */ damping?: number; /** SVG stroke dash pattern for the crosshair. Omit for solid. */ indicatorDasharray?: string; /** Vertical crosshair fade: `both`, `top`, `bottom`, or `none` (solid). Default: `both`. */ indicatorFadeEdges?: IndicatorFadeEdges; /** Crosshair fade zone size (% of height). Default: `10`. */ indicatorFadeLength?: number; /** Per-chart override for the floating-panel spring. */ boxSpringConfig?: SpringConfig; /** Inline styles for the tooltip panel (background, blur, etc.). */ panelStyle?: React.CSSProperties; } interface ChartTooltipInnerProps extends ChartTooltipProps { container: HTMLElement; } const ChartTooltipInner = memo(function ChartTooltipInner({ showDatePill = true, showCrosshair = true, showDots = true, indicatorColor: indicatorColorProp, content, rows: rowsRenderer, dotColor: dotColorProp, children, className = "", container, springConfig, matchCrosshair = false, damping, indicatorDasharray, indicatorFadeEdges, indicatorFadeLength, boxSpringConfig, panelStyle, }: ChartTooltipInnerProps) { const { tooltipData, width, height, innerHeight, margin, columnWidth, lines, xAccessor, dateLabels, containerRef, orientation, barXAccessor, } = useChart(); const { tooltipSpring } = useChartConfig(); const isHorizontal = orientation === "horizontal"; const discreteInteraction = dateLabels.length > 60; const boxMotion = useMemo(() => { if (boxSpringConfig) { return { animate: !discreteInteraction, springConfig: boxSpringConfig, }; } if (matchCrosshair) { return { animate: !discreteInteraction, springConfig: springConfig ?? tooltipSpring, }; } return resolveTooltipBoxMotion(damping); }, [ boxSpringConfig, damping, discreteInteraction, matchCrosshair, springConfig, tooltipSpring, ]); const visible = tooltipData !== null; const x = tooltipData?.x ?? 0; const xWithMargin = x + margin.left; // For horizontal charts, get the y position from the first line's yPosition (center of bar) const firstLineDataKey = lines[0]?.dataKey; const firstLineY = firstLineDataKey ? (tooltipData?.yPositions[firstLineDataKey] ?? 0) : 0; const yWithMargin = firstLineY + margin.top; const tooltipRows = useMemo(() => { if (!tooltipData) { return []; } if (rowsRenderer) { return rowsRenderer(tooltipData.point); } // Default: generate rows from registered lines return lines.map((line) => ({ color: line.stroke, label: line.dataKey, value: (tooltipData.point[line.dataKey] as number) ?? 0, })); }, [tooltipData, lines, rowsRenderer]); const resolveDotColor = useMemo(() => { return (line: LineConfig, index: number): string => { if (rowsRenderer && tooltipRows[index]?.color) { return tooltipRows[index].color; } if (dotColorProp != null) { if (typeof dotColorProp === "function" && tooltipData) { return dotColorProp(tooltipData.point, line); } if (typeof dotColorProp === "string") { return dotColorProp; } } return line.stroke; }; }, [dotColorProp, rowsRenderer, tooltipData, tooltipRows]); // Resolve indicator color (static or from hovered point) const indicatorColor = useMemo(() => { if (indicatorColorProp == null) { return chartCssVars.crosshair; } if (typeof indicatorColorProp === "function") { return tooltipData ? indicatorColorProp(tooltipData.point) : chartCssVars.crosshair; } return indicatorColorProp; }, [indicatorColorProp, tooltipData]); // Title from date or category const title = useMemo(() => { if (!tooltipData) { return undefined; } // For bar charts (horizontal or vertical), use the category name if (barXAccessor) { return barXAccessor(tooltipData.point); } // For line/area charts, use the date return weekdayDateFmt.format(xAccessor(tooltipData.point)); }, [tooltipData, barXAccessor, xAccessor]); const tooltipContent = ( <> {/* Crosshair indicator - rendered as SVG overlay */} {showCrosshair && ( )} {/* Dots on bars/lines - show for vertical charts only */} {showDots && visible && !isHorizontal && ( )} {/* Tooltip Box */} {content && tooltipData ? content({ point: tooltipData.point, index: tooltipData.index, }) : !content && ( {children} )} {/* Date/Category Ticker - only show for vertical charts */} ); return createPortal(tooltipContent, container); }); export function ChartTooltip(props: ChartTooltipProps) { const { containerRef } = useChartStable(); const [mounted, setMounted] = useState(false); // Only render portals on client side after mount useEffect(() => { setMounted(true); }, []); const container = containerRef.current; if (!(mounted && container)) { return null; } return ; } ChartTooltip.displayName = "ChartTooltip"; interface DatePillTrackerProps { enabled: boolean; visible: boolean; labels: string[]; currentIndex: number; xWithMargin: number; discreteInteraction: boolean; springConfig?: SpringConfig; } // Inner-only-on-visible so `useSpring` initializes at the real cursor x // instead of `margin.left` on first hover. function DatePillTracker(props: DatePillTrackerProps) { if (!(props.enabled && props.visible && props.labels.length > 0)) { return null; } return ; } function DatePillTrackerInner({ labels, currentIndex, xWithMargin, discreteInteraction, springConfig, visible, }: DatePillTrackerProps) { const { tooltipSpring } = useChartConfig(); const effectiveSpring = springConfig ?? tooltipSpring; const animatedX = useSpring(xWithMargin, effectiveSpring); if (!discreteInteraction) { animatedX.set(xWithMargin); } // biome-ignore lint/correctness/useExhaustiveDependencies: we need to jump the animatedX when the visible prop changes useEffect(() => { animatedX.set(xWithMargin); }, [animatedX, visible]); return ( ); } export default ChartTooltip;