Files
pulse/components/ui/area-chart.tsx
Usman Baig 6521b694f4 fix: replace motion/react imports with framer-motion + rounded content panel
- 4 files imported from 'motion/react' which was the removed 'motion' package.
  Replaced with 'framer-motion' (the package actually installed).
- Dashboard content area now has rounded corners, subtle border, and inset
  margin creating a "panel inside shell" visual separation from the sidebar.
2026-03-23 19:54:44 +01:00

2294 lines
59 KiB
TypeScript

"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 "framer-motion";
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<Output, _Input = number> = ReturnType<
typeof scaleLinear<Output>
>;
type ScaleTimeType<Output, _Input = Date | number> = ReturnType<
typeof scaleTime<Output>
>;
type ScaleBandType<Domain extends { toString(): string }> = ReturnType<
typeof scaleBand<Domain>
>;
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<string, unknown>;
index: number;
x: number;
yPositions: Record<string, number>;
xPositions?: Record<string, number>;
}
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<string, unknown>[];
xScale: ScaleTimeType<number, number>;
yScale: ScaleLinearType<number, number>;
width: number;
height: number;
innerWidth: number;
innerHeight: number;
margin: Margin;
columnWidth: number;
tooltipData: TooltipData | null;
setTooltipData: Dispatch<SetStateAction<TooltipData | null>>;
containerRef: RefObject<HTMLDivElement | null>;
lines: LineConfig[];
isLoaded: boolean;
animationDuration: number;
xAccessor: (d: Record<string, unknown>) => Date;
dateLabels: string[];
selection?: ChartSelection | null;
clearSelection?: () => void;
barScale?: ScaleBandType<string>;
bandWidth?: number;
hoveredBarIndex?: number | null;
setHoveredBarIndex?: (index: number | null) => void;
barXAccessor?: (d: Record<string, unknown>) => string;
orientation?: "vertical" | "horizontal";
stacked?: boolean;
stackOffsets?: Map<number, Map<string, number>>;
}
const ChartContext = createContext<ChartContextValue | null>(null);
function ChartProvider({
children,
value,
}: {
children: ReactNode;
value: ChartContextValue;
}) {
return (
<ChartContext.Provider value={value}>{children}</ChartContext.Provider>
);
}
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 <AreaChart>."
);
}
return context;
}
// ─── useChartInteraction ─────────────────────────────────────────────────────
type ScaleTime = ReturnType<typeof scaleTime<number>>;
type ScaleLinear = ReturnType<typeof scaleLinear<number>>;
interface UseChartInteractionParams {
xScale: ScaleTime;
yScale: ScaleLinear;
data: Record<string, unknown>[];
lines: LineConfig[];
margin: Margin;
xAccessor: (d: Record<string, unknown>) => Date;
bisectDate: (
data: Record<string, unknown>[],
date: Date,
lo: number
) => number;
canInteract: boolean;
}
interface ChartInteractionResult {
tooltipData: TooltipData | null;
setTooltipData: Dispatch<SetStateAction<TooltipData | null>>;
selection: ChartSelection | null;
clearSelection: () => void;
interactionHandlers: {
onMouseMove?: (event: React.MouseEvent<SVGGElement>) => void;
onMouseLeave?: () => void;
onMouseDown?: (event: React.MouseEvent<SVGGElement>) => void;
onMouseUp?: () => void;
onTouchStart?: (event: React.TouchEvent<SVGGElement>) => void;
onTouchMove?: (event: React.TouchEvent<SVGGElement>) => void;
onTouchEnd?: () => void;
};
interactionStyle: React.CSSProperties;
}
function useChartInteraction({
xScale,
yScale,
data,
lines,
margin,
xAccessor,
bisectDate,
canInteract,
}: UseChartInteractionParams): ChartInteractionResult {
const [tooltipData, setTooltipData] = useState<TooltipData | null>(null);
const [selection, setSelection] = useState<ChartSelection | null>(null);
const isDraggingRef = useRef(false);
const dragStartXRef = useRef<number>(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<string, number> = {};
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<SVGGElement> | React.TouchEvent<SVGGElement>,
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<SVGGElement>) => {
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<SVGGElement>) => {
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<SVGGElement>) => {
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<SVGGElement>) => {
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 (
<motion.div
className="overflow-hidden rounded-full bg-zinc-100 px-4 py-1 text-zinc-900"
layout
transition={{
layout: { type: "spring", stiffness: 400, damping: 35 },
}}
>
<div className="relative h-6 overflow-hidden">
<div className="flex items-center justify-center gap-1">
<div className="relative h-6 overflow-hidden">
<motion.div className="flex flex-col" style={{ y: monthY }}>
{monthIndices.uniqueMonths.map((month) => (
<div
className="flex h-6 shrink-0 items-center justify-center"
key={month}
>
<span className="whitespace-nowrap font-medium text-sm">
{month}
</span>
</div>
))}
</motion.div>
</div>
<div className="relative h-6 overflow-hidden">
<motion.div className="flex flex-col" style={{ y: dayY }}>
{parsedLabels.map((label, index) => (
<div
className="flex h-6 shrink-0 items-center justify-center"
key={`${label.day}-${index}`}
>
<span className="whitespace-nowrap font-medium text-sm">
{label.day}
</span>
</div>
))}
</motion.div>
</div>
</div>
</div>
</motion.div>
);
}
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 (
<motion.circle
cx={animatedX}
cy={animatedY}
fill={color}
r={size}
stroke={strokeColor}
strokeWidth={strokeWidth}
/>
);
}
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 (
<g>
<defs>
<linearGradient id={gradientId} x1="0%" x2="0%" y1="0%" y2="100%">
<stop
offset="0%"
style={{ stopColor: colorEdge, stopOpacity: edgeOpacity }}
/>
<stop offset="10%" style={{ stopColor: colorEdge, stopOpacity: 1 }} />
<stop offset="50%" style={{ stopColor: colorMid, stopOpacity: 1 }} />
<stop offset="90%" style={{ stopColor: colorEdge, stopOpacity: 1 }} />
<stop
offset="100%"
style={{ stopColor: colorEdge, stopOpacity: edgeOpacity }}
/>
</linearGradient>
</defs>
<motion.rect
fill={`url(#${gradientId})`}
height={height}
width={pixelWidth}
x={animatedX}
y={0}
/>
</g>
);
}
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<number | null>(null);
const committedChildrenStateRef = useRef<boolean | null>(null);
const frameRef = useRef<number | null>(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 (
<motion.div
animate={
committedHeight !== null ? { height: committedHeight } : undefined
}
className="overflow-hidden"
initial={false}
transition={
shouldAnimate
? {
type: "spring",
stiffness: 500,
damping: 35,
mass: 0.8,
}
: { duration: 0 }
}
>
<div className="px-3 py-2.5" ref={measureRef}>
{title && (
<div className="mb-2 font-medium text-neutral-400 text-xs">
{title}
</div>
)}
<div className="space-y-1.5">
{rows.map((row) => (
<div
className="flex items-center justify-between gap-4"
key={`${row.label}-${row.color}`}
>
<div className="flex items-center gap-2">
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: row.color }}
/>
<span className="text-neutral-400 text-sm">
{row.label}
</span>
</div>
<span className="font-medium text-white text-sm tabular-nums">
{typeof row.value === "number"
? row.value.toLocaleString()
: row.value}
</span>
</div>
))}
</div>
<AnimatePresence mode="wait">
{children && (
<motion.div
animate={{ opacity: 1, filter: "blur(0px)" }}
className="mt-2"
exit={{ opacity: 0, filter: "blur(4px)" }}
initial={{ opacity: 0, filter: "blur(4px)" }}
key={markerKey}
transition={{ duration: 0.2, ease: "easeOut" }}
>
{children}
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
);
}
TooltipContent.displayName = "TooltipContent";
// TooltipBox
interface TooltipBoxProps {
x: number;
y: number;
visible: boolean;
containerRef: RefObject<HTMLDivElement | null>;
containerWidth: number;
containerHeight: number;
offset?: number;
className?: string;
children: ReactNode;
left?: number | ReturnType<typeof useSpring>;
top?: number | ReturnType<typeof useSpring>;
flipped?: boolean;
}
function TooltipBox({
x,
y,
visible,
containerRef,
containerWidth,
containerHeight,
offset = 16,
className = "",
children,
left: leftOverride,
top: topOverride,
flipped: flippedOverride,
}: TooltipBoxProps) {
const tooltipRef = useRef<HTMLDivElement>(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(
<motion.div
animate={{ opacity: 1 }}
className={cn("pointer-events-none absolute z-50", className)}
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
ref={tooltipRef}
style={{ left: finalLeft, top: finalTop }}
transition={{ duration: 0.1 }}
>
<motion.div
animate={{ scale: 1, opacity: 1, x: 0 }}
className="min-w-[140px] overflow-hidden rounded-lg bg-neutral-800/90 text-white shadow-lg backdrop-blur-md"
initial={{ scale: 0.85, opacity: 0, x: isFlipped ? 20 : -20 }}
key={flipKey}
style={{ transformOrigin }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
>
{children}
</motion.div>
</motion.div>,
container
);
}
TooltipBox.displayName = "TooltipBox";
// ChartTooltip
export interface ChartTooltipProps {
showDatePill?: boolean;
showCrosshair?: boolean;
showDots?: boolean;
content?: (props: {
point: Record<string, unknown>;
index: number;
}) => ReactNode;
rows?: (point: Record<string, unknown>) => 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-GB", {
weekday: "short",
month: "short",
day: "numeric",
});
}, [tooltipData, barXAccessor, xAccessor]);
const container = containerRef.current;
if (!(mounted && container)) {
return null;
}
const tooltipContent = (
<>
{showCrosshair && (
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0"
height="100%"
width="100%"
>
<g transform={`translate(${margin.left},${margin.top})`}>
<TooltipIndicator
colorEdge={chartCssVars.crosshair}
colorMid={chartCssVars.crosshair}
columnWidth={columnWidth}
fadeEdges
height={innerHeight}
visible={visible}
width="line"
x={x}
/>
</g>
</svg>
)}
{showDots && visible && !isHorizontal && (
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0"
height="100%"
width="100%"
>
<g transform={`translate(${margin.left},${margin.top})`}>
{lines.map((line) => (
<TooltipDot
color={line.stroke}
key={line.dataKey}
strokeColor={chartCssVars.background}
visible={visible}
x={tooltipData?.xPositions?.[line.dataKey] ?? x}
y={tooltipData?.yPositions[line.dataKey] ?? 0}
/>
))}
</g>
</svg>
)}
<TooltipBox
className={className}
containerHeight={height}
containerRef={containerRef}
containerWidth={width}
top={isHorizontal ? undefined : margin.top}
visible={visible}
x={xWithMargin}
y={isHorizontal ? yWithMargin : margin.top}
>
{content ? (
content({
point: tooltipData?.point ?? {},
index: tooltipData?.index ?? 0,
})
) : (
<TooltipContent rows={tooltipRows} title={title}>
{children}
</TooltipContent>
)}
</TooltipBox>
{showDatePill && dateLabels.length > 0 && visible && !isHorizontal && (
<motion.div
className="pointer-events-none absolute z-50"
style={{
left: animatedX,
transform: "translateX(-50%)",
bottom: 4,
}}
>
<DateTicker
currentIndex={tooltipData?.index ?? 0}
labels={dateLabels}
visible={visible}
/>
</motion.div>
)}
</>
);
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 (
<g className="chart-grid">
{horizontal && fadeHorizontal && (
<defs>
<linearGradient id={hGradientId} x1="0%" x2="100%" y1="0%" y2="0%">
<stop offset="0%" style={{ stopColor: "white", stopOpacity: 0 }} />
<stop offset="10%" style={{ stopColor: "white", stopOpacity: 1 }} />
<stop offset="90%" style={{ stopColor: "white", stopOpacity: 1 }} />
<stop
offset="100%"
style={{ stopColor: "white", stopOpacity: 0 }}
/>
</linearGradient>
<mask id={hMaskId}>
<rect
fill={`url(#${hGradientId})`}
height={innerHeight}
width={innerWidth}
x="0"
y="0"
/>
</mask>
</defs>
)}
{vertical && fadeVertical && (
<defs>
<linearGradient id={vGradientId} x1="0%" x2="0%" y1="0%" y2="100%">
<stop offset="0%" style={{ stopColor: "white", stopOpacity: 0 }} />
<stop offset="10%" style={{ stopColor: "white", stopOpacity: 1 }} />
<stop offset="90%" style={{ stopColor: "white", stopOpacity: 1 }} />
<stop
offset="100%"
style={{ stopColor: "white", stopOpacity: 0 }}
/>
</linearGradient>
<mask id={vMaskId}>
<rect
fill={`url(#${vGradientId})`}
height={innerHeight}
width={innerWidth}
x="0"
y="0"
/>
</mask>
</defs>
)}
{horizontal && (
<g mask={fadeHorizontal ? `url(#${hMaskId})` : undefined}>
<GridRows
numTicks={rowTickValues ? undefined : numTicksRows}
scale={yScale}
stroke={stroke}
strokeDasharray={strokeDasharray}
strokeOpacity={strokeOpacity}
strokeWidth={strokeWidth}
tickValues={rowTickValues}
width={innerWidth}
/>
</g>
)}
{vertical && columnScale && typeof columnScale === "function" && (
<g mask={fadeVertical ? `url(#${vMaskId})` : undefined}>
<GridColumns
height={innerHeight}
numTicks={numTicksColumns}
scale={columnScale}
stroke={stroke}
strokeDasharray={strokeDasharray}
strokeOpacity={strokeOpacity}
strokeWidth={strokeWidth}
/>
</g>
)}
</g>
);
}
Grid.displayName = "Grid";
// ─── XAxis ───────────────────────────────────────────────────────────────────
export interface XAxisProps {
numTicks?: number;
tickerHalfWidth?: number;
formatLabel?: (date: Date) => string;
}
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 (
<div
className="absolute"
style={{
left: x,
bottom: 12,
width: 0,
display: "flex",
justifyContent: "center",
}}
>
<motion.span
animate={{ opacity }}
className="whitespace-nowrap text-neutral-500 text-xs"
initial={{ opacity: 1 }}
transition={{ duration: 0.4, ease: "easeInOut" }}
>
{label}
</motion.span>
</div>
);
}
export function XAxis({ numTicks = 5, tickerHalfWidth = 50, formatLabel }: 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));
}
const defaultFormat = (d: Date) => d.toLocaleDateString("en-GB", { month: "short", day: "numeric" });
const fmt = formatLabel ?? defaultFormat;
return dates.map((date) => ({
date,
x: (xScale(date) ?? 0) + margin.left,
label: fmt(date),
}));
}, [xScale, margin.left, numTicks, formatLabel]);
const isHovering = tooltipData !== null;
const crosshairX = tooltipData ? tooltipData.x + margin.left : null;
const container = containerRef.current;
if (!(mounted && container)) {
return null;
}
return createPortal(
<div className="pointer-events-none absolute inset-0">
{labelsToShow.map((item) => (
<XAxisLabel
crosshairX={crosshairX}
isHovering={isHovering}
key={`${item.label}-${item.x}`}
label={item.label}
tickerHalfWidth={tickerHalfWidth}
x={item.x}
/>
))}
</div>,
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<HTMLDivElement | null>(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(
<div className="pointer-events-none absolute inset-0">
{ticks.map((tick) => (
<div
key={tick.value}
className="absolute"
style={{
left: 0,
top: tick.y,
width: margin.left - 8,
display: "flex",
justifyContent: "flex-end",
transform: "translateY(-50%)",
}}
>
<span className="whitespace-nowrap text-neutral-500 text-xs tabular-nums">
{tick.label}
</span>
</div>
))}
</div>,
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<SVGPathElement>(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<string, unknown>) => {
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 (
<>
<defs>
<linearGradient id={gradientId} x1="0%" x2="0%" y1="0%" y2="100%">
<stop
offset="0%"
style={{ stopColor: fill, stopOpacity: fillOpacity }}
/>
<stop
offset="100%"
style={{ stopColor: fill, stopOpacity: gradientToOpacity }}
/>
</linearGradient>
<linearGradient id={strokeGradientId} x1="0%" x2="100%" y1="0%" y2="0%">
<stop
offset="0%"
style={{ stopColor: resolvedStroke, stopOpacity: 0 }}
/>
<stop
offset="15%"
style={{ stopColor: resolvedStroke, stopOpacity: 1 }}
/>
<stop
offset="85%"
style={{ stopColor: resolvedStroke, stopOpacity: 1 }}
/>
<stop
offset="100%"
style={{ stopColor: resolvedStroke, stopOpacity: 0 }}
/>
</linearGradient>
{fadeEdges && (
<>
<linearGradient
id={edgeGradientId}
x1="0%"
x2="100%"
y1="0%"
y2="0%"
>
<stop
offset="0%"
style={{ stopColor: "white", stopOpacity: 0 }}
/>
<stop
offset="20%"
style={{ stopColor: "white", stopOpacity: 1 }}
/>
<stop
offset="80%"
style={{ stopColor: "white", stopOpacity: 1 }}
/>
<stop
offset="100%"
style={{ stopColor: "white", stopOpacity: 0 }}
/>
</linearGradient>
<mask id={edgeMaskId}>
<rect
fill={`url(#${edgeGradientId})`}
height={innerHeight}
width={innerWidth}
x="0"
y="0"
/>
</mask>
</>
)}
</defs>
{animate && (
<defs>
<clipPath id={`grow-clip-area-${dataKey}`}>
<rect
height={innerHeight + 20}
style={{
transition:
!isLoaded && clipWidth > 0
? `width ${animationDuration}ms ${easing}`
: "none",
}}
width={isLoaded ? innerWidth : clipWidth}
x={0}
y={0}
/>
</clipPath>
</defs>
)}
<g clipPath={animate ? `url(#grow-clip-area-${dataKey})` : undefined}>
<motion.g
animate={{ opacity: isHovering && showHighlight ? 0.6 : 1 }}
initial={{ opacity: 1 }}
transition={{ duration: 0.4, ease: "easeInOut" }}
>
<g mask={fadeEdges ? `url(#${edgeMaskId})` : undefined}>
<AreaClosed
curve={curve}
data={data}
fill={`url(#${gradientId})`}
x={(d) => xScale(xAccessor(d)) ?? 0}
y={getY}
yScale={yScale}
/>
</g>
{showLine && (
<LinePath
curve={curve}
data={data}
innerRef={pathRef}
stroke={`url(#${strokeGradientId})`}
strokeLinecap="round"
strokeWidth={strokeWidth}
x={(d) => xScale(xAccessor(d)) ?? 0}
y={getY}
/>
)}
</motion.g>
</g>
{showHighlight &&
showLine &&
isHovering &&
isLoaded &&
pathRef.current && (
<motion.path
animate={{ opacity: 1 }}
d={pathRef.current.getAttribute("d") || ""}
exit={{ opacity: 0 }}
fill="none"
initial={{ opacity: 0 }}
stroke={resolvedStroke}
strokeLinecap="round"
strokeWidth={strokeWidth}
style={{
strokeDasharray: animatedDasharray,
strokeDashoffset: offsetSpring,
}}
transition={{ duration: 0.4, ease: "easeInOut" }}
/>
)}
</>
);
}
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 (
<motion.rect
animate={{ opacity: 0.15 }}
fill={chartCssVars.linePrimary}
height={innerHeight}
initial={{ opacity: 0 }}
rx={4}
transition={{ duration: 0.2 }}
width={width}
x={x}
y={0}
/>
);
}
SegmentBackground.displayName = "SegmentBackground";
export function SegmentLineFrom() {
const { selection, innerHeight } = useChart();
if (!selection?.active) {
return null;
}
const x = Math.min(selection.startX, selection.endX);
return (
<motion.line
animate={{ opacity: 1 }}
initial={{ opacity: 0 }}
stroke={chartCssVars.linePrimary}
strokeDasharray="4,3"
strokeWidth={1.5}
transition={{ duration: 0.2 }}
x1={x}
x2={x}
y1={0}
y2={innerHeight}
/>
);
}
SegmentLineFrom.displayName = "SegmentLineFrom";
export function SegmentLineTo() {
const { selection, innerHeight } = useChart();
if (!selection?.active) {
return null;
}
const x = Math.max(selection.startX, selection.endX);
return (
<motion.line
animate={{ opacity: 1 }}
initial={{ opacity: 0 }}
stroke={chartCssVars.linePrimary}
strokeDasharray="4,3"
strokeWidth={1.5}
transition={{ duration: 0.2 }}
x1={x}
x2={x}
y1={0}
y2={innerHeight}
/>
);
}
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 (
<defs>
<pattern
id={id}
width={width}
height={height}
patternUnits="userSpaceOnUse"
>
<path
d={paths.join(" ")}
fill="none"
stroke={stroke}
strokeWidth={strokeWidth}
strokeLinecap="square"
/>
</pattern>
</defs>
);
}
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<string, unknown>) => {
const value = d[dataKey];
return typeof value === "number" ? (yScale(value) ?? 0) : 0;
},
[dataKey, yScale]
);
return (
<AreaClosed
curve={curve}
data={data}
fill={fill}
x={(d) => 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<string, unknown>[];
xDataKey?: string;
margin?: Partial<Margin>;
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<string, unknown>[];
xDataKey: string;
margin: Margin;
animationDuration: number;
children: ReactNode;
containerRef: RefObject<HTMLDivElement | null>;
}
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<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 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-GB", {
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 (
<ChartProvider value={contextValue}>
<svg aria-hidden="true" height={height} width={width}>
<defs>
<clipPath id="chart-area-grow-clip">
<rect
height={innerHeight + 20}
style={{
transition: isLoaded
? "none"
: `width ${animationDuration}ms cubic-bezier(0.85, 0, 0.15, 1)`,
}}
width={isLoaded ? innerWidth : 0}
x={0}
y={0}
/>
</clipPath>
</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}
/>
{preOverlayChildren}
{postOverlayChildren}
</g>
</svg>
</ChartProvider>
);
}
export function AreaChart({
data,
xDataKey = "date",
margin: marginProp,
animationDuration = 1100,
aspectRatio = "2 / 1",
className = "",
children,
}: AreaChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const margin = { ...DEFAULT_MARGIN, ...marginProp };
return (
<div
className={cn("relative w-full", className)}
ref={containerRef}
style={{ aspectRatio, touchAction: "none" }}
>
<ParentSize debounceTime={10}>
{({ width, height }) => (
<ChartInner
animationDuration={animationDuration}
containerRef={containerRef}
data={data}
height={height}
margin={margin}
width={width}
xDataKey={xDataKey}
>
{children}
</ChartInner>
)}
</ParentSize>
</div>
);
}
export default AreaChart;