"use client"; import { localPoint } from "@visx/event"; import { curveMonotoneX } from "@visx/curve"; import { GridColumns, GridRows } from "@visx/grid"; import { ParentSize } from "@visx/responsive"; import { scaleLinear, scaleTime, type scaleBand } from "@visx/scale"; import { AreaClosed, LinePath } from "@visx/shape"; import { bisector } from "d3-array"; import { AnimatePresence, motion, useMotionTemplate, useSpring, } from "motion/react"; import { Children, createContext, isValidElement, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type Dispatch, type ReactElement, type ReactNode, type RefObject, type SetStateAction, } from "react"; import useMeasure from "react-use-measure"; import { createPortal } from "react-dom"; import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; // ─── Utils ─────────────────────────────────────────────────────────────────── function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } // ─── Chart Context ─────────────────────────────────────────────────────────── // biome-ignore lint/suspicious/noExplicitAny: d3 curve factory type type CurveFactory = any; type ScaleLinearType = ReturnType< typeof scaleLinear >; type ScaleTimeType = ReturnType< typeof scaleTime >; type ScaleBandType = ReturnType< typeof scaleBand >; export const chartCssVars = { background: "var(--chart-background)", foreground: "var(--chart-foreground)", foregroundMuted: "var(--chart-foreground-muted)", label: "var(--chart-label)", linePrimary: "var(--chart-line-primary)", lineSecondary: "var(--chart-line-secondary)", crosshair: "var(--chart-crosshair)", grid: "var(--chart-grid)", indicatorColor: "var(--chart-indicator-color)", indicatorSecondaryColor: "var(--chart-indicator-secondary-color)", markerBackground: "var(--chart-marker-background)", markerBorder: "var(--chart-marker-border)", markerForeground: "var(--chart-marker-foreground)", badgeBackground: "var(--chart-marker-badge-background)", badgeForeground: "var(--chart-marker-badge-foreground)", segmentBackground: "var(--chart-segment-background)", segmentLine: "var(--chart-segment-line)", }; export interface Margin { top: number; right: number; bottom: number; left: number; } export interface TooltipData { point: Record; index: number; x: number; yPositions: Record; xPositions?: Record; } export interface LineConfig { dataKey: string; stroke: string; strokeWidth: number; } export interface ChartSelection { startX: number; endX: number; startIndex: number; endIndex: number; active: boolean; } export interface ChartContextValue { data: Record[]; xScale: ScaleTimeType; yScale: ScaleLinearType; width: number; height: number; innerWidth: number; innerHeight: number; margin: Margin; columnWidth: number; tooltipData: TooltipData | null; setTooltipData: Dispatch>; containerRef: RefObject; lines: LineConfig[]; isLoaded: boolean; animationDuration: number; xAccessor: (d: Record) => Date; dateLabels: string[]; selection?: ChartSelection | null; clearSelection?: () => void; barScale?: ScaleBandType; bandWidth?: number; hoveredBarIndex?: number | null; setHoveredBarIndex?: (index: number | null) => void; barXAccessor?: (d: Record) => string; orientation?: "vertical" | "horizontal"; stacked?: boolean; stackOffsets?: Map>; } const ChartContext = createContext(null); function ChartProvider({ children, value, }: { children: ReactNode; value: ChartContextValue; }) { return ( {children} ); } function useChart(): ChartContextValue { const context = useContext(ChartContext); if (!context) { throw new Error( "useChart must be used within a ChartProvider. " + "Make sure your component is wrapped in ." ); } return context; } // ─── useChartInteraction ───────────────────────────────────────────────────── type ScaleTime = ReturnType>; type ScaleLinear = ReturnType>; interface UseChartInteractionParams { xScale: ScaleTime; yScale: ScaleLinear; data: Record[]; lines: LineConfig[]; margin: Margin; xAccessor: (d: Record) => Date; bisectDate: ( data: Record[], date: Date, lo: number ) => number; canInteract: boolean; } interface ChartInteractionResult { tooltipData: TooltipData | null; setTooltipData: Dispatch>; selection: ChartSelection | null; clearSelection: () => void; interactionHandlers: { onMouseMove?: (event: React.MouseEvent) => void; onMouseLeave?: () => void; onMouseDown?: (event: React.MouseEvent) => void; onMouseUp?: () => void; onTouchStart?: (event: React.TouchEvent) => void; onTouchMove?: (event: React.TouchEvent) => void; onTouchEnd?: () => void; }; interactionStyle: React.CSSProperties; } function useChartInteraction({ xScale, yScale, data, lines, margin, xAccessor, bisectDate, canInteract, }: UseChartInteractionParams): ChartInteractionResult { const [tooltipData, setTooltipData] = useState(null); const [selection, setSelection] = useState(null); const isDraggingRef = useRef(false); const dragStartXRef = useRef(0); const resolveTooltipFromX = useCallback( (pixelX: number): TooltipData | null => { const x0 = xScale.invert(pixelX); const index = bisectDate(data, x0, 1); const d0 = data[index - 1]; const d1 = data[index]; if (!d0) { return null; } let d = d0; let finalIndex = index - 1; if (d1) { const d0Time = xAccessor(d0).getTime(); const d1Time = xAccessor(d1).getTime(); if (x0.getTime() - d0Time > d1Time - x0.getTime()) { d = d1; finalIndex = index; } } const yPositions: Record = {}; for (const line of lines) { const value = d[line.dataKey]; if (typeof value === "number") { yPositions[line.dataKey] = yScale(value) ?? 0; } } return { point: d, index: finalIndex, x: xScale(xAccessor(d)) ?? 0, yPositions, }; }, [xScale, yScale, data, lines, xAccessor, bisectDate] ); const resolveIndexFromX = useCallback( (pixelX: number): number => { const x0 = xScale.invert(pixelX); const index = bisectDate(data, x0, 1); const d0 = data[index - 1]; const d1 = data[index]; if (!d0) { return 0; } if (d1) { const d0Time = xAccessor(d0).getTime(); const d1Time = xAccessor(d1).getTime(); if (x0.getTime() - d0Time > d1Time - x0.getTime()) { return index; } } return index - 1; }, [xScale, data, xAccessor, bisectDate] ); const getChartX = useCallback( ( event: React.MouseEvent | React.TouchEvent, touchIndex = 0 ): number | null => { let point: { x: number; y: number } | null = null; if ("touches" in event) { const touch = event.touches[touchIndex]; if (!touch) { return null; } const svg = event.currentTarget.ownerSVGElement; if (!svg) { return null; } point = localPoint(svg, touch as unknown as MouseEvent); } else { point = localPoint(event); } if (!point) { return null; } return point.x - margin.left; }, [margin.left] ); const handleMouseMove = useCallback( (event: React.MouseEvent) => { const chartX = getChartX(event); if (chartX === null) { return; } if (isDraggingRef.current) { const startX = Math.min(dragStartXRef.current, chartX); const endX = Math.max(dragStartXRef.current, chartX); setSelection({ startX, endX, startIndex: resolveIndexFromX(startX), endIndex: resolveIndexFromX(endX), active: true, }); return; } const tooltip = resolveTooltipFromX(chartX); if (tooltip) { setTooltipData(tooltip); } }, [getChartX, resolveTooltipFromX, resolveIndexFromX] ); const handleMouseLeave = useCallback(() => { setTooltipData(null); if (isDraggingRef.current) { isDraggingRef.current = false; } setSelection(null); }, []); const handleMouseDown = useCallback( (event: React.MouseEvent) => { const chartX = getChartX(event); if (chartX === null) { return; } isDraggingRef.current = true; dragStartXRef.current = chartX; setTooltipData(null); setSelection(null); }, [getChartX] ); const handleMouseUp = useCallback(() => { if (isDraggingRef.current) { isDraggingRef.current = false; } setSelection(null); }, []); const handleTouchStart = useCallback( (event: React.TouchEvent) => { if (event.touches.length === 1) { event.preventDefault(); const chartX = getChartX(event, 0); if (chartX === null) { return; } const tooltip = resolveTooltipFromX(chartX); if (tooltip) { setTooltipData(tooltip); } } else if (event.touches.length === 2) { event.preventDefault(); setTooltipData(null); const x0 = getChartX(event, 0); const x1 = getChartX(event, 1); if (x0 === null || x1 === null) { return; } const startX = Math.min(x0, x1); const endX = Math.max(x0, x1); setSelection({ startX, endX, startIndex: resolveIndexFromX(startX), endIndex: resolveIndexFromX(endX), active: true, }); } }, [getChartX, resolveTooltipFromX, resolveIndexFromX] ); const handleTouchMove = useCallback( (event: React.TouchEvent) => { if (event.touches.length === 1) { event.preventDefault(); const chartX = getChartX(event, 0); if (chartX === null) { return; } const tooltip = resolveTooltipFromX(chartX); if (tooltip) { setTooltipData(tooltip); } } else if (event.touches.length === 2) { event.preventDefault(); const x0 = getChartX(event, 0); const x1 = getChartX(event, 1); if (x0 === null || x1 === null) { return; } const startX = Math.min(x0, x1); const endX = Math.max(x0, x1); setSelection({ startX, endX, startIndex: resolveIndexFromX(startX), endIndex: resolveIndexFromX(endX), active: true, }); } }, [getChartX, resolveTooltipFromX, resolveIndexFromX] ); const handleTouchEnd = useCallback(() => { setTooltipData(null); setSelection(null); }, []); const clearSelection = useCallback(() => { setSelection(null); }, []); const interactionHandlers = canInteract ? { onMouseMove: handleMouseMove, onMouseLeave: handleMouseLeave, onMouseDown: handleMouseDown, onMouseUp: handleMouseUp, onTouchStart: handleTouchStart, onTouchMove: handleTouchMove, onTouchEnd: handleTouchEnd, } : {}; const interactionStyle: React.CSSProperties = { cursor: canInteract ? "crosshair" : "default", touchAction: "none", }; return { tooltipData, setTooltipData, selection, clearSelection, interactionHandlers, interactionStyle, }; } // ─── Tooltip Components ────────────────────────────────────────────────────── // DateTicker const TICKER_ITEM_HEIGHT = 24; interface DateTickerProps { currentIndex: number; labels: string[]; visible: boolean; } function DateTicker({ currentIndex, labels, visible }: DateTickerProps) { const parsedLabels = useMemo(() => { return labels.map((label) => { const parts = label.split(" "); const month = parts[0] || ""; const day = parts[1] || ""; return { month, day, full: label }; }); }, [labels]); const monthIndices = useMemo(() => { const uniqueMonths: string[] = []; const indices: number[] = []; parsedLabels.forEach((label, index) => { if (uniqueMonths.length === 0 || uniqueMonths.at(-1) !== label.month) { uniqueMonths.push(label.month); indices.push(index); } }); return { uniqueMonths, indices }; }, [parsedLabels]); const currentMonthIndex = useMemo(() => { if (currentIndex < 0 || currentIndex >= parsedLabels.length) { return 0; } const currentMonth = parsedLabels[currentIndex]?.month; return monthIndices.uniqueMonths.indexOf(currentMonth || ""); }, [currentIndex, parsedLabels, monthIndices]); const prevMonthIndexRef = useRef(-1); const dayY = useSpring(0, { stiffness: 400, damping: 35 }); const monthY = useSpring(0, { stiffness: 400, damping: 35 }); useEffect(() => { dayY.set(-currentIndex * TICKER_ITEM_HEIGHT); }, [currentIndex, dayY]); useEffect(() => { if (currentMonthIndex >= 0) { const isFirstRender = prevMonthIndexRef.current === -1; const monthChanged = prevMonthIndexRef.current !== currentMonthIndex; if (isFirstRender || monthChanged) { monthY.set(-currentMonthIndex * TICKER_ITEM_HEIGHT); prevMonthIndexRef.current = currentMonthIndex; } } }, [currentMonthIndex, monthY]); if (!visible || labels.length === 0) { return null; } return (
{monthIndices.uniqueMonths.map((month) => (
{month}
))}
{parsedLabels.map((label, index) => (
{label.day}
))}
); } DateTicker.displayName = "DateTicker"; // TooltipDot interface TooltipDotProps { x: number; y: number; visible: boolean; color: string; size?: number; strokeColor?: string; strokeWidth?: number; } function TooltipDot({ x, y, visible, color, size = 5, strokeColor = chartCssVars.background, strokeWidth = 2, }: TooltipDotProps) { const crosshairSpringConfig = { stiffness: 300, damping: 30 }; const animatedX = useSpring(x, crosshairSpringConfig); const animatedY = useSpring(y, crosshairSpringConfig); useEffect(() => { animatedX.set(x); animatedY.set(y); }, [x, y, animatedX, animatedY]); if (!visible) { return null; } return ( ); } TooltipDot.displayName = "TooltipDot"; // TooltipIndicator type IndicatorWidth = number | "line" | "thin" | "medium" | "thick"; interface TooltipIndicatorProps { x: number; height: number; visible: boolean; width?: IndicatorWidth; span?: number; columnWidth?: number; colorEdge?: string; colorMid?: string; fadeEdges?: boolean; gradientId?: string; } function resolveWidth(width: IndicatorWidth): number { if (typeof width === "number") { return width; } switch (width) { case "line": return 1; case "thin": return 2; case "medium": return 4; case "thick": return 8; default: return 1; } } function TooltipIndicator({ x, height, visible, width = "line", span, columnWidth, colorEdge = chartCssVars.crosshair, colorMid = chartCssVars.crosshair, fadeEdges = true, gradientId = "tooltip-indicator-gradient", }: TooltipIndicatorProps) { const pixelWidth = span !== undefined && columnWidth !== undefined ? span * columnWidth : resolveWidth(width); const crosshairSpringConfig = { stiffness: 300, damping: 30 }; const animatedX = useSpring(x - pixelWidth / 2, crosshairSpringConfig); useEffect(() => { animatedX.set(x - pixelWidth / 2); }, [x, animatedX, pixelWidth]); if (!visible) { return null; } const edgeOpacity = fadeEdges ? 0 : 1; return ( ); } TooltipIndicator.displayName = "TooltipIndicator"; // TooltipContent export interface TooltipRow { color: string; label: string; value: string | number; } interface TooltipContentProps { title?: string; rows: TooltipRow[]; children?: ReactNode; } function TooltipContent({ title, rows, children }: TooltipContentProps) { const [measureRef, bounds] = useMeasure({ debounce: 0, scroll: false }); const [committedHeight, setCommittedHeight] = useState(null); const committedChildrenStateRef = useRef(null); const frameRef = useRef(null); const hasChildren = !!children; const markerKey = hasChildren ? "has-marker" : "no-marker"; const isWaitingForSettlement = committedChildrenStateRef.current !== null && committedChildrenStateRef.current !== hasChildren; useEffect(() => { if (bounds.height <= 0) { return; } if (frameRef.current) { cancelAnimationFrame(frameRef.current); frameRef.current = null; } if (isWaitingForSettlement) { frameRef.current = requestAnimationFrame(() => { frameRef.current = requestAnimationFrame(() => { setCommittedHeight(bounds.height); committedChildrenStateRef.current = hasChildren; }); }); } else { setCommittedHeight(bounds.height); committedChildrenStateRef.current = hasChildren; } return () => { if (frameRef.current) { cancelAnimationFrame(frameRef.current); } }; }, [bounds.height, hasChildren, isWaitingForSettlement]); const shouldAnimate = committedHeight !== null; return (
{title && (
{title}
)}
{rows.map((row) => (
{row.label}
{typeof row.value === "number" ? row.value.toLocaleString() : row.value}
))}
{children && ( {children} )}
); } TooltipContent.displayName = "TooltipContent"; // TooltipBox interface TooltipBoxProps { x: number; y: number; visible: boolean; containerRef: RefObject; containerWidth: number; containerHeight: number; offset?: number; className?: string; children: ReactNode; left?: number | ReturnType; top?: number | ReturnType; flipped?: boolean; } function TooltipBox({ x, y, visible, containerRef, containerWidth, containerHeight, offset = 16, className = "", children, left: leftOverride, top: topOverride, flipped: flippedOverride, }: TooltipBoxProps) { const tooltipRef = useRef(null); const [tooltipWidth, setTooltipWidth] = useState(180); const [tooltipHeight, setTooltipHeight] = useState(80); const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); useLayoutEffect(() => { if (tooltipRef.current) { const w = tooltipRef.current.offsetWidth; const h = tooltipRef.current.offsetHeight; if (w > 0 && w !== tooltipWidth) { setTooltipWidth(w); } if (h > 0 && h !== tooltipHeight) { setTooltipHeight(h); } } }, [tooltipWidth, tooltipHeight]); const shouldFlipX = x + tooltipWidth + offset > containerWidth; const targetX = shouldFlipX ? x - offset - tooltipWidth : x + offset; const targetY = Math.max( offset, Math.min(y - tooltipHeight / 2, containerHeight - tooltipHeight - offset) ); const prevFlipRef = useRef(shouldFlipX); const [flipKey, setFlipKey] = useState(0); useEffect(() => { if (prevFlipRef.current !== shouldFlipX) { setFlipKey((k) => k + 1); prevFlipRef.current = shouldFlipX; } }, [shouldFlipX]); const springConfig = { stiffness: 100, damping: 20 }; const animatedLeft = useSpring(targetX, springConfig); const animatedTop = useSpring(targetY, springConfig); useEffect(() => { animatedLeft.set(targetX); }, [targetX, animatedLeft]); useEffect(() => { animatedTop.set(targetY); }, [targetY, animatedTop]); const finalLeft = leftOverride ?? animatedLeft; const finalTop = topOverride ?? animatedTop; const isFlipped = flippedOverride ?? shouldFlipX; const transformOrigin = isFlipped ? "right top" : "left top"; const container = containerRef.current; if (!(mounted && container)) { return null; } if (!visible) { return null; } return createPortal( {children} , container ); } TooltipBox.displayName = "TooltipBox"; // ChartTooltip export interface ChartTooltipProps { showDatePill?: boolean; showCrosshair?: boolean; showDots?: boolean; content?: (props: { point: Record; index: number; }) => ReactNode; rows?: (point: Record) => TooltipRow[]; children?: ReactNode; className?: string; } export function ChartTooltip({ showDatePill = true, showCrosshair = true, showDots = true, content, rows: rowsRenderer, children, className = "", }: ChartTooltipProps) { const { tooltipData, width, height, innerHeight, margin, columnWidth, lines, xAccessor, dateLabels, containerRef, orientation, barXAccessor, } = useChart(); const isHorizontal = orientation === "horizontal"; const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); const visible = tooltipData !== null; const x = tooltipData?.x ?? 0; const xWithMargin = x + margin.left; const firstLineDataKey = lines[0]?.dataKey; const firstLineY = firstLineDataKey ? (tooltipData?.yPositions[firstLineDataKey] ?? 0) : 0; const yWithMargin = firstLineY + margin.top; const crosshairSpringConfig = { stiffness: 300, damping: 30 }; const animatedX = useSpring(xWithMargin, crosshairSpringConfig); useEffect(() => { animatedX.set(xWithMargin); }, [xWithMargin, animatedX]); const tooltipRows = useMemo(() => { if (!tooltipData) { return []; } if (rowsRenderer) { return rowsRenderer(tooltipData.point); } return lines.map((line) => ({ color: line.stroke, label: line.dataKey, value: (tooltipData.point[line.dataKey] as number) ?? 0, })); }, [tooltipData, lines, rowsRenderer]); const title = useMemo(() => { if (!tooltipData) { return undefined; } if (barXAccessor) { return barXAccessor(tooltipData.point); } return xAccessor(tooltipData.point).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", }); }, [tooltipData, barXAccessor, xAccessor]); const container = containerRef.current; if (!(mounted && container)) { return null; } const tooltipContent = ( <> {showCrosshair && ( )} {showDots && visible && !isHorizontal && ( )} {content ? ( content({ point: tooltipData?.point ?? {}, index: tooltipData?.index ?? 0, }) ) : ( {children} )} {showDatePill && dateLabels.length > 0 && visible && !isHorizontal && ( )} ); return createPortal(tooltipContent, container); } ChartTooltip.displayName = "ChartTooltip"; // ─── Grid ──────────────────────────────────────────────────────────────────── export interface GridProps { horizontal?: boolean; vertical?: boolean; numTicksRows?: number; numTicksColumns?: number; rowTickValues?: number[]; stroke?: string; strokeOpacity?: number; strokeWidth?: number; strokeDasharray?: string; fadeHorizontal?: boolean; fadeVertical?: boolean; } export function Grid({ horizontal = true, vertical = false, numTicksRows = 5, numTicksColumns = 10, rowTickValues, stroke = chartCssVars.grid, strokeOpacity = 1, strokeWidth = 1, strokeDasharray = "4,4", fadeHorizontal = true, fadeVertical = false, }: GridProps) { const { xScale, yScale, innerWidth, innerHeight, orientation, barScale } = useChart(); const isHorizontalBarChart = orientation === "horizontal" && barScale; const columnScale = isHorizontalBarChart ? yScale : xScale; const uniqueId = useId(); const hMaskId = `grid-rows-fade-${uniqueId}`; const hGradientId = `${hMaskId}-gradient`; const vMaskId = `grid-cols-fade-${uniqueId}`; const vGradientId = `${vMaskId}-gradient`; return ( {horizontal && fadeHorizontal && ( )} {vertical && fadeVertical && ( )} {horizontal && ( )} {vertical && columnScale && typeof columnScale === "function" && ( )} ); } Grid.displayName = "Grid"; // ─── XAxis ─────────────────────────────────────────────────────────────────── export interface XAxisProps { numTicks?: number; tickerHalfWidth?: number; } interface XAxisLabelProps { label: string; x: number; crosshairX: number | null; isHovering: boolean; tickerHalfWidth: number; } function XAxisLabel({ label, x, crosshairX, isHovering, tickerHalfWidth, }: XAxisLabelProps) { const fadeBuffer = 20; const fadeRadius = tickerHalfWidth + fadeBuffer; let opacity = 1; if (isHovering && crosshairX !== null) { const distance = Math.abs(x - crosshairX); if (distance < tickerHalfWidth) { opacity = 0; } else if (distance < fadeRadius) { opacity = (distance - tickerHalfWidth) / fadeBuffer; } } return (
{label}
); } export function XAxis({ numTicks = 5, tickerHalfWidth = 50 }: XAxisProps) { const { xScale, margin, tooltipData, containerRef } = useChart(); const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); const labelsToShow = useMemo(() => { const domain = xScale.domain(); const startDate = domain[0]; const endDate = domain[1]; if (!(startDate && endDate)) { return []; } const startTime = startDate.getTime(); const endTime = endDate.getTime(); const timeRange = endTime - startTime; const tickCount = Math.max(2, numTicks); const dates: Date[] = []; for (let i = 0; i < tickCount; i++) { const t = i / (tickCount - 1); const time = startTime + t * timeRange; dates.push(new Date(time)); } return dates.map((date) => ({ date, x: (xScale(date) ?? 0) + margin.left, label: date.toLocaleDateString("en-US", { month: "short", day: "numeric", }), })); }, [xScale, margin.left, numTicks]); const isHovering = tooltipData !== null; const crosshairX = tooltipData ? tooltipData.x + margin.left : null; const container = containerRef.current; if (!(mounted && container)) { return null; } return createPortal(
{labelsToShow.map((item) => ( ))}
, container ); } XAxis.displayName = "XAxis"; // ─── YAxis ─────────────────────────────────────────────────────────────────── export interface YAxisProps { numTicks?: number; formatValue?: (value: number) => string; } export function YAxis({ numTicks = 5, formatValue, }: YAxisProps) { const { yScale, margin, containerRef } = useChart(); const [container, setContainer] = useState(null); useEffect(() => { setContainer(containerRef.current); }, [containerRef]); const ticks = useMemo(() => { const domain = yScale.domain() as [number, number]; const min = domain[0]; const max = domain[1]; const step = (max - min) / (numTicks - 1); return Array.from({ length: numTicks }, (_, i) => { const value = min + step * i; return { value, y: (yScale(value) ?? 0) + margin.top, label: formatValue ? formatValue(value) : value >= 1000 ? `${(value / 1000).toFixed(value % 1000 === 0 ? 0 : 1)}k` : value.toLocaleString(), }; }); }, [yScale, margin.top, numTicks, formatValue]); if (!container) { return null; } return createPortal(
{ticks.map((tick) => (
{tick.label}
))}
, container ); } YAxis.displayName = "YAxis"; // ─── Area ──────────────────────────────────────────────────────────────────── export interface AreaProps { dataKey: string; fill?: string; fillOpacity?: number; stroke?: string; strokeWidth?: number; curve?: CurveFactory; animate?: boolean; showLine?: boolean; showHighlight?: boolean; gradientToOpacity?: number; fadeEdges?: boolean; } export function Area({ dataKey, fill = chartCssVars.linePrimary, fillOpacity = 0.4, stroke, strokeWidth = 2, curve = curveMonotoneX, animate = true, showLine = true, showHighlight = true, gradientToOpacity = 0, fadeEdges = false, }: AreaProps) { const { data, xScale, yScale, innerHeight, innerWidth, tooltipData, selection, isLoaded, animationDuration, xAccessor, } = useChart(); const pathRef = useRef(null); const [pathLength, setPathLength] = useState(0); const [clipWidth, setClipWidth] = useState(0); const uniqueId = useId(); const gradientId = useMemo( () => `area-gradient-${dataKey}-${Math.random().toString(36).slice(2, 9)}`, [dataKey] ); const strokeGradientId = useMemo( () => `area-stroke-gradient-${dataKey}-${Math.random().toString(36).slice(2, 9)}`, [dataKey] ); const edgeMaskId = `area-edge-mask-${dataKey}-${uniqueId}`; const edgeGradientId = `${edgeMaskId}-gradient`; const resolvedStroke = stroke || fill; useEffect(() => { if (pathRef.current && animate) { const len = pathRef.current.getTotalLength(); if (len > 0) { setPathLength(len); if (!isLoaded) { requestAnimationFrame(() => { setClipWidth(innerWidth); }); } } } }, [animate, innerWidth, isLoaded]); const findLengthAtX = useCallback( (targetX: number): number => { const path = pathRef.current; if (!path || pathLength === 0) { return 0; } let low = 0; let high = pathLength; const tolerance = 0.5; while (high - low > tolerance) { const mid = (low + high) / 2; const point = path.getPointAtLength(mid); if (point.x < targetX) { low = mid; } else { high = mid; } } return (low + high) / 2; }, [pathLength] ); const segmentBounds = useMemo(() => { if (!pathRef.current || pathLength === 0) { return { startLength: 0, segmentLength: 0, isActive: false }; } if (selection?.active) { const startLength = findLengthAtX(selection.startX); const endLength = findLengthAtX(selection.endX); return { startLength, segmentLength: endLength - startLength, isActive: true, }; } if (!tooltipData) { return { startLength: 0, segmentLength: 0, isActive: false }; } const idx = tooltipData.index; const startIdx = Math.max(0, idx - 1); const endIdx = Math.min(data.length - 1, idx + 1); const startPoint = data[startIdx]; const endPoint = data[endIdx]; if (!(startPoint && endPoint)) { return { startLength: 0, segmentLength: 0, isActive: false }; } const startX = xScale(xAccessor(startPoint)) ?? 0; const endX = xScale(xAccessor(endPoint)) ?? 0; const startLength = findLengthAtX(startX); const endLength = findLengthAtX(endX); return { startLength, segmentLength: endLength - startLength, isActive: true, }; }, [ tooltipData, selection, data, xScale, pathLength, xAccessor, findLengthAtX, ]); const springConfig = { stiffness: 180, damping: 28 }; const offsetSpring = useSpring(0, springConfig); const segmentLengthSpring = useSpring(0, springConfig); const animatedDasharray = useMotionTemplate`${segmentLengthSpring} ${pathLength}`; useEffect(() => { offsetSpring.set(-segmentBounds.startLength); segmentLengthSpring.set(segmentBounds.segmentLength); }, [ segmentBounds.startLength, segmentBounds.segmentLength, offsetSpring, segmentLengthSpring, ]); const getY = useCallback( (d: Record) => { const value = d[dataKey]; return typeof value === "number" ? (yScale(value) ?? 0) : 0; }, [dataKey, yScale] ); const isHovering = tooltipData !== null || selection?.active === true; const easing = "cubic-bezier(0.85, 0, 0.15, 1)"; return ( <> {fadeEdges && ( <> )} {animate && ( 0 ? `width ${animationDuration}ms ${easing}` : "none", }} width={isLoaded ? innerWidth : clipWidth} x={0} y={0} /> )} xScale(xAccessor(d)) ?? 0} y={getY} yScale={yScale} /> {showLine && ( xScale(xAccessor(d)) ?? 0} y={getY} /> )} {showHighlight && showLine && isHovering && isLoaded && pathRef.current && ( )} ); } Area.displayName = "Area"; // ─── Segment Components ────────────────────────────────────────────────────── export function SegmentBackground() { const { selection, innerHeight } = useChart(); if (!selection?.active) { return null; } const x = Math.min(selection.startX, selection.endX); const width = Math.abs(selection.endX - selection.startX); return ( ); } SegmentBackground.displayName = "SegmentBackground"; export function SegmentLineFrom() { const { selection, innerHeight } = useChart(); if (!selection?.active) { return null; } const x = Math.min(selection.startX, selection.endX); return ( ); } SegmentLineFrom.displayName = "SegmentLineFrom"; export function SegmentLineTo() { const { selection, innerHeight } = useChart(); if (!selection?.active) { return null; } const x = Math.max(selection.startX, selection.endX); return ( ); } SegmentLineTo.displayName = "SegmentLineTo"; // ─── Pattern Components ────────────────────────────────────────────────────── export interface PatternLinesProps { id: string; width?: number; height?: number; stroke?: string; strokeWidth?: number; orientation?: ("diagonal" | "horizontal" | "vertical")[]; } export function PatternLines({ id, width = 6, height = 6, stroke = "var(--chart-line-primary)", strokeWidth = 1, orientation = ["diagonal"], }: PatternLinesProps) { const paths: string[] = []; for (const o of orientation) { if (o === "diagonal") { paths.push(`M0,${height}l${width},${-height}`); paths.push(`M${-width / 4},${height / 4}l${width / 2},${-height / 2}`); paths.push(`M${(3 * width) / 4},${height + height / 4}l${width / 2},${-height / 2}`); } else if (o === "horizontal") { paths.push(`M0,${height / 2}l${width},0`); } else if (o === "vertical") { paths.push(`M${width / 2},0l0,${height}`); } } return ( ); } PatternLines.displayName = "PatternLines"; export interface PatternAreaProps { dataKey: string; fill?: string; curve?: CurveFactory; } export function PatternArea({ dataKey, fill = "url(#area-pattern)", curve = curveMonotoneX, }: PatternAreaProps) { const { data, xScale, yScale, xAccessor } = useChart(); const getY = useCallback( (d: Record) => { const value = d[dataKey]; return typeof value === "number" ? (yScale(value) ?? 0) : 0; }, [dataKey, yScale] ); return ( xScale(xAccessor(d)) ?? 0} y={getY} yScale={yScale} /> ); } PatternArea.displayName = "PatternArea"; // ─── AreaChart ─────────────────────────────────────────────────────────────── function isPostOverlayComponent(child: ReactElement): boolean { const childType = child.type as { displayName?: string; name?: string; __isChartMarkers?: boolean; }; if (childType.__isChartMarkers) { return true; } const componentName = typeof child.type === "function" ? childType.displayName || childType.name || "" : ""; return componentName === "ChartMarkers" || componentName === "MarkerGroup"; } function extractAreaConfigs(children: ReactNode): LineConfig[] { const configs: LineConfig[] = []; Children.forEach(children, (child) => { if (!isValidElement(child)) { return; } const childType = child.type as { displayName?: string; name?: string; }; const componentName = typeof child.type === "function" ? childType.displayName || childType.name || "" : ""; const props = child.props as AreaProps | undefined; const isAreaComponent = componentName === "Area" || child.type === Area || (props && typeof props.dataKey === "string" && props.dataKey.length > 0); if (isAreaComponent && props?.dataKey) { configs.push({ dataKey: props.dataKey, stroke: props.stroke || props.fill || "var(--chart-line-primary)", strokeWidth: props.strokeWidth || 2, }); } }); return configs; } export interface AreaChartProps { data: Record[]; xDataKey?: string; margin?: Partial; animationDuration?: number; aspectRatio?: string; className?: string; children: ReactNode; } const DEFAULT_MARGIN: Margin = { top: 40, right: 40, bottom: 40, left: 40 }; interface ChartInnerProps { width: number; height: number; data: Record[]; xDataKey: string; margin: Margin; animationDuration: number; children: ReactNode; containerRef: RefObject; } function ChartInner({ width, height, data, xDataKey, margin, animationDuration, children, containerRef, }: ChartInnerProps) { const [isLoaded, setIsLoaded] = useState(false); const lines = useMemo(() => extractAreaConfigs(children), [children]); const innerWidth = width - margin.left - margin.right; const innerHeight = height - margin.top - margin.bottom; const xAccessor = useCallback( (d: Record): Date => { const value = d[xDataKey]; return value instanceof Date ? value : new Date(value as string | number); }, [xDataKey] ); const bisectDate = useMemo( () => bisector, Date>((d) => xAccessor(d)).left, [xAccessor] ); const xScale = useMemo(() => { const dates = data.map((d) => xAccessor(d)); const minTime = Math.min(...dates.map((d) => d.getTime())); const maxTime = Math.max(...dates.map((d) => d.getTime())); return scaleTime({ range: [0, innerWidth], domain: [minTime, maxTime], }); }, [innerWidth, data, xAccessor]); const columnWidth = useMemo(() => { if (data.length < 2) { return 0; } return innerWidth / (data.length - 1); }, [innerWidth, data.length]); const yScale = useMemo(() => { let maxValue = 0; for (const line of lines) { for (const d of data) { const value = d[line.dataKey]; if (typeof value === "number" && value > maxValue) { maxValue = value; } } } if (maxValue === 0) { maxValue = 100; } return scaleLinear({ range: [innerHeight, 0], domain: [0, maxValue * 1.1], nice: true, }); }, [innerHeight, data, lines]); const dateLabels = useMemo( () => data.map((d) => xAccessor(d).toLocaleDateString("en-US", { month: "short", day: "numeric", }) ), [data, xAccessor] ); useEffect(() => { const timer = setTimeout(() => { setIsLoaded(true); }, animationDuration); return () => clearTimeout(timer); }, [animationDuration]); const canInteract = isLoaded; const { tooltipData, setTooltipData, selection, clearSelection, interactionHandlers, interactionStyle, } = useChartInteraction({ xScale, yScale, data, lines, margin, xAccessor, bisectDate, canInteract, }); if (width < 10 || height < 10) { return null; } const preOverlayChildren: ReactElement[] = []; const postOverlayChildren: ReactElement[] = []; Children.forEach(children, (child) => { if (!isValidElement(child)) { return; } if (isPostOverlayComponent(child)) { postOverlayChildren.push(child); } else { preOverlayChildren.push(child); } }); const contextValue = { data, xScale, yScale, width, height, innerWidth, innerHeight, margin, columnWidth, tooltipData, setTooltipData, containerRef, lines, isLoaded, animationDuration, xAccessor, dateLabels, selection, clearSelection, }; return ( ); } export function AreaChart({ data, xDataKey = "date", margin: marginProp, animationDuration = 1100, aspectRatio = "2 / 1", className = "", children, }: AreaChartProps) { const containerRef = useRef(null); const margin = { ...DEFAULT_MARGIN, ...marginProp }; return (
{({ width, height }) => ( {children} )}
); } export default AreaChart;