Bklit UI
componentarea-chartsource: ok · hit
Area Chart
A composable area chart with gradient fills and animations
npx shadcn add "https://ui.bklit.com/r/area-chart.json"free
Visual style
gradient
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/chart-series@bklit/grid@bklit/x-axis@bklit/chart-tooltip@bklit/shimmering-text@bklit/utils
Files14 · source cached 10/8/2026, 5:58:17 PM
src/charts/area-chart.tsx
"use client";
import { ParentSize } from "@visx/responsive";
import type { Transition } from "motion/react";
import {
Children,
type CSSProperties,
isValidElement,
type ReactNode,
useCallback,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
import { Area, type AreaProps } from "./area";
import type { LineConfig, Margin } from "./chart-context";
import { ChartLoadingLabel } from "./chart-loading-label";
import {
type ChartPhase,
type ChartStatus,
DEFAULT_CHART_STATUS,
DEFAULT_Y_DOMAIN_TWEEN_MS,
resolveRestingChartPhase,
} from "./chart-phase";
import { PatternArea } from "./pattern-area";
import { TimeSeriesChartInner } from "./time-series-chart-shell";
export interface AreaChartProps {
/** Data array - each item should have a date field and numeric values */
data: Record<string, unknown>[];
/** Key in data for the x-axis (date). Default: "date" */
xDataKey?: string;
/** Chart margins */
margin?: Partial<Margin>;
/** Animation duration in milliseconds. Default: 1100 */
animationDuration?: number;
/** CSS easing for clip-reveal. Default: cubic-bezier(0.85, 0, 0.15, 1) */
animationEasing?: string;
/** Motion enter transition (spring or cubic-bezier tween). */
enterTransition?: Transition;
/** Signature of motion URL state — triggers reveal replay when it changes. */
revealSignature?: string;
/** Aspect ratio as "width / height". Default: "2 / 1" */
aspectRatio?: string;
/** Additional class name for the container */
className?: string;
/** Loading vs ready — drives chart phase and loading chrome. Default: `"ready"`. */
status?: ChartStatus;
/** Centered shimmer label while loading. */
loadingLabel?: string;
/** Animate y-domain over this duration (ms) on status transitions. Default: 500. */
yDomainTweenDuration?: number;
/** Animate y-domain when status or target domain changes. Default: true */
yDomainTween?: boolean;
/** Visible x-domain for brush zoom. */
xDomain?: [Date, Date];
/** Full dataset length for x-scale padding when `xDomain` is set. */
xDomainSlotCount?: number;
/** Tween y-domain when brush changes the visible x-range. Default: false */
tweenYDomainOnXDomainChange?: boolean;
/** Inline container styles (e.g. fixed height for brush strip). */
style?: CSSProperties;
/** Fires when the internal chart phase changes (e.g. OG capture readiness). */
onPhaseChange?: (phase: ChartPhase) => void;
/** Child components (Area, Grid, ChartTooltip, etc.) */
children: ReactNode;
}
const DEFAULT_MARGIN: Margin = { top: 40, right: 40, bottom: 40, left: 40 };
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 isPatternArea =
componentName === "PatternArea" || child.type === PatternArea;
const isAreaComponent =
componentName === "Area" ||
child.type === Area ||
(props &&
typeof props.dataKey === "string" &&
props.dataKey.length > 0 &&
!isPatternArea);
if (isAreaComponent && props?.dataKey) {
configs.push({
dataKey: props.dataKey,
stroke: props.stroke || props.fill || "var(--chart-line-primary)",
strokeWidth: props.strokeWidth || 2,
yAxisId: props.yAxisId,
});
}
});
return configs;
}
interface ChartInnerProps {
width: number;
height: number;
data: Record<string, unknown>[];
xDataKey: string;
margin: Margin;
animationDuration: number;
animationEasing?: string;
enterTransition?: Transition;
revealSignature?: string;
chartStatus: ChartStatus;
loadingLabel?: string;
yDomainTweenDuration: number;
yDomainTween: boolean;
xDomain?: [Date, Date];
xDomainSlotCount?: number;
tweenYDomainOnXDomainChange?: boolean;
children: ReactNode;
containerRef: React.RefObject<HTMLDivElement | null>;
onPhaseChange: (phase: ChartPhase) => void;
}
function ChartInner({
width,
height,
data,
xDataKey,
margin,
animationDuration,
animationEasing,
enterTransition,
revealSignature,
chartStatus,
loadingLabel,
yDomainTweenDuration,
yDomainTween,
xDomain,
xDomainSlotCount,
tweenYDomainOnXDomainChange,
children,
containerRef,
onPhaseChange,
}: ChartInnerProps) {
const lines = useMemo(() => extractAreaConfigs(children), [children]);
return (
<TimeSeriesChartInner
animationDuration={animationDuration}
animationEasing={animationEasing}
chartStatus={chartStatus}
clipPathId="chart-area-grow-clip"
containerRef={containerRef}
data={data}
enterTransition={enterTransition}
height={height}
lines={lines}
loadingLabel={loadingLabel}
margin={margin}
onPhaseChange={onPhaseChange}
revealSignature={revealSignature}
tweenYDomainOnXDomainChange={tweenYDomainOnXDomainChange}
width={width}
xDataKey={xDataKey}
xDomain={xDomain}
xDomainSlotCount={xDomainSlotCount}
yDomainTween={yDomainTween}
yDomainTweenDuration={yDomainTweenDuration}
>
{children}
</TimeSeriesChartInner>
);
}
export function AreaChart({
data,
xDataKey = "date",
margin: marginProp,
animationDuration = 1100,
animationEasing,
enterTransition,
revealSignature,
aspectRatio = "2 / 1",
className = "",
status = DEFAULT_CHART_STATUS,
loadingLabel,
yDomainTweenDuration = DEFAULT_Y_DOMAIN_TWEEN_MS,
yDomainTween = true,
xDomain,
xDomainSlotCount,
tweenYDomainOnXDomainChange = false,
style,
onPhaseChange,
children,
}: AreaChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const margin = { ...DEFAULT_MARGIN, ...marginProp };
const [chartPhase, setChartPhase] = useState<ChartPhase>(() =>
resolveRestingChartPhase(status)
);
const handlePhaseChange = useCallback(
(phase: ChartPhase) => {
setChartPhase(phase);
onPhaseChange?.(phase);
},
[onPhaseChange]
);
const showLoadingLabel = Boolean(
loadingLabel?.trim() &&
(chartPhase === "loading" ||
chartPhase === "exiting" ||
chartPhase === "gridTweenReady" ||
chartPhase === "revealingLoading")
);
return (
<div
className={cn("relative w-full", className)}
ref={containerRef}
style={{ aspectRatio, touchAction: "none", ...style }}
>
<ParentSize debounceTime={10}>
{({ width, height }) => (
<ChartInner
animationDuration={animationDuration}
animationEasing={animationEasing}
chartStatus={status}
containerRef={containerRef}
data={data}
enterTransition={enterTransition}
height={height}
loadingLabel={loadingLabel}
margin={margin}
onPhaseChange={handlePhaseChange}
revealSignature={revealSignature}
tweenYDomainOnXDomainChange={tweenYDomainOnXDomainChange}
width={width}
xDataKey={xDataKey}
xDomain={xDomain}
xDomainSlotCount={xDomainSlotCount}
yDomainTween={yDomainTween}
yDomainTweenDuration={yDomainTweenDuration}
>
{children}
</ChartInner>
)}
</ParentSize>
{showLoadingLabel ? (
<ChartLoadingLabel
exiting={chartPhase !== "loading"}
text={loadingLabel}
/>
) : null}
</div>
);
}
export { Area, type AreaProps } from "./area";
export default AreaChart;
src/charts/time-series-chart-shell.tsx
"use client";
import { scaleLinear, scaleTime } from "@visx/scale";
import { bisector, extent } from "d3-array";
import type { Transition } from "motion/react";
import {
Children,
cloneElement,
isValidElement,
memo,
type ReactElement,
type ReactNode,
useCallback,
useEffect,
useId,
useMemo,
useState,
} from "react";
import {
DEFAULT_ANIMATION_EASING,
DEFAULT_CHART_ENTER_TRANSITION,
} from "./animation";
import {
isClipExcludedComponent,
isPostOverlayComponent,
isUnderlayComponent,
resolveChartChildElement,
} from "./chart-child-passthrough";
import { ChartProvider, type LineConfig, type Margin } from "./chart-context";
import { isGradientDefComponent, isPatternDefComponent } from "./chart-defs";
import { shortDateFmt } from "./chart-formatters";
import {
type ChartPhase,
type ChartStatus,
DEFAULT_CHART_STATUS,
DEFAULT_Y_DOMAIN_TWEEN_MS,
isChartInteractionPhase,
} from "./chart-phase";
import { ChartRevealClip } from "./chart-reveal-clip";
import {
decimateTimeSeries,
maxRenderPointsForWidth,
} from "./decimate-time-series";
import { filterDataByXDomain } from "./filter-data-by-x-domain";
import {
generateChartSkeletonData,
generateChartSkeletonFromTarget,
} from "./generate-chart-skeleton-data";
import {
extractProjectionLineConfigs,
mergeProjectionXDomainMax,
mergeProjectionYDomain,
} from "./projection-config";
import {
extractReferenceAreaConfigs,
type ReferenceAreaConfig,
} from "./reference-area-config";
import { ReferenceAreaRegistrationContext } from "./reference-area-registration-context";
import {
computeSeriesBarRevealClipPadding,
computeSeriesBarWidth,
} from "./series-bar-layout";
import { useStaticChartPreview } from "./static-chart-preview-context";
import { useAnimatedYDomains } from "./use-animated-y-domains";
import { useChartInteraction } from "./use-chart-interaction";
import { useChartPhaseOrchestrator } from "./use-chart-phase-orchestrator";
import {
buildYScalesFromDomains,
DEFAULT_Y_AXIS_ID,
getPrimaryYScale,
groupLinesByYAxisId,
} from "./y-axis-scales";
import { computeYDomainsByAxis } from "./y-domain-utils";
function collectNumericExtents(
data: Record<string, unknown>[],
dataKeys: string[]
) {
let minValue = Number.POSITIVE_INFINITY;
let maxValue = Number.NEGATIVE_INFINITY;
for (const d of data) {
for (const key of dataKeys) {
const value = d[key];
if (typeof value === "number") {
if (value < minValue) {
minValue = value;
}
if (value > maxValue) {
maxValue = value;
}
}
}
}
if (minValue === Number.POSITIVE_INFINITY) {
return { minValue: 0, maxValue: 100 };
}
return { minValue, maxValue };
}
function resolveTimeSeriesYDomain(
data: Record<string, unknown>[],
dataKeys: string[],
yScaleDomainMax: number | undefined
): [number, number] {
if (yScaleDomainMax != null && yScaleDomainMax > 0) {
return [0, yScaleDomainMax * 1.1];
}
const { minValue, maxValue } = collectNumericExtents(data, dataKeys);
if (minValue >= 0) {
const top = maxValue <= 0 ? 100 : maxValue * 1.1;
return [0, top];
}
const padding = (maxValue - minValue) * 0.05 || 1;
return [minValue - padding, maxValue + padding];
}
function ensureChildKey(child: ReactElement, index: number): ReactElement {
if (child.key != null) {
return child;
}
return cloneElement(child, { key: `chart-child-${index}` });
}
export interface TimeSeriesChartInnerProps {
width: number;
height: number;
data: Record<string, unknown>[];
xDataKey: string;
margin: Margin;
animationDuration: number;
animationEasing?: string;
enterTransition?: Transition;
/** Signature of motion URL state — triggers reveal replay when it changes. */
revealSignature?: string;
children: ReactNode;
containerRef: React.RefObject<HTMLDivElement | null>;
/** Series keys driving y-domain and tooltip (Line / Area / SeriesBar configs). */
lines: LineConfig[];
/**
* Base SVG clipPath id for grow animation. Uniquified per chart instance so
* multiple charts on one page do not share a clipPath (#226).
*/
clipPathId: string;
/** Optional ComposedChart bar layout (forwarded into context). */
composedBarDataKeys?: string[];
composedBarSize?: number;
composedMaxBarSize?: number;
composedBarGap?: number;
composedStacked?: boolean;
composedStackOffsets?: Map<number, Map<string, number>>;
composedStackGap?: number;
/** When set, drives the y-axis max instead of scanning `lines` (e.g. stacked bar totals). */
yScaleDomainMax?: number;
/** Loading vs ready — drives chart phase until transition orchestration lands. */
chartStatus?: ChartStatus;
loadingLabel?: string;
/** Animate y-domain on status / data transitions. Default: true */
yDomainTween?: boolean;
yDomainTweenDuration?: number;
/** Visible x-domain for brush zoom. When set, y-domain and series use data in this range. */
xDomain?: [Date, Date];
/** Full dataset length for x-scale padding when `xDomain` is set. */
xDomainSlotCount?: number;
/** Tween y-domain when the visible x-range changes during the ready phase. */
tweenYDomainOnXDomainChange?: boolean;
onPhaseChange?: (phase: ChartPhase) => void;
}
export function TimeSeriesChartInner(props: TimeSeriesChartInnerProps) {
const { width, height } = props;
if (width < 10 || height < 10) {
return null;
}
return <TimeSeriesChartCore {...props} />;
}
const TimeSeriesChartCore = memo(function TimeSeriesChartCore({
width,
height,
data,
xDataKey,
margin,
animationDuration,
animationEasing = DEFAULT_ANIMATION_EASING,
enterTransition,
revealSignature = "",
children,
containerRef,
lines,
clipPathId,
composedBarDataKeys,
composedBarSize,
composedMaxBarSize,
composedBarGap,
composedStacked,
composedStackOffsets,
composedStackGap,
yScaleDomainMax,
chartStatus = DEFAULT_CHART_STATUS,
loadingLabel,
yDomainTween = true,
yDomainTweenDuration = DEFAULT_Y_DOMAIN_TWEEN_MS,
xDomain,
xDomainSlotCount,
tweenYDomainOnXDomainChange = false,
onPhaseChange,
}: TimeSeriesChartInnerProps) {
// Unique per instance — shared clipPath ids crop later charts (#226).
const uniqueClipPathId = `${clipPathId}-${useId().replace(/:/g, "")}`;
const staticPreview = useStaticChartPreview();
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
const resolveYDomain = useCallback(
(sourceData: Record<string, unknown>[], dataKeys: string[]) => {
const axisGroups = groupLinesByYAxisId(lines);
const usesDefaultOnly =
axisGroups.size === 1 && axisGroups.has(DEFAULT_Y_AXIS_ID);
const domainMax =
usesDefaultOnly && yScaleDomainMax != null
? yScaleDomainMax
: undefined;
return resolveTimeSeriesYDomain(sourceData, dataKeys, domainMax);
},
[lines, yScaleDomainMax]
);
const skeletonData = useMemo(() => {
const primaryKey = lines[0]?.dataKey ?? "value";
if (data.length === 0) {
return generateChartSkeletonData({ dataKey: primaryKey });
}
return generateChartSkeletonFromTarget(data, primaryKey);
}, [data, lines]);
const {
chartPhase,
plotData,
revealEpoch,
concealEpoch,
isLoaded,
notifyLoadingPulseComplete,
notifyRevealConcealComplete,
notifyYDomainTweenComplete,
} = useChartPhaseOrchestrator({
animationDuration,
chartStatus,
revealSignature,
skeletonData,
skipEnterReveal: staticPreview,
targetData: data,
yDomainTweenDuration,
});
useEffect(() => {
onPhaseChange?.(chartPhase);
}, [chartPhase, onPhaseChange]);
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 visiblePlotData = useMemo(() => {
if (!xDomain) {
return plotData;
}
return filterDataByXDomain(plotData, xDomain, xAccessor);
}, [plotData, xDomain, xAccessor]);
const projectionConfigs = useMemo(
() => extractProjectionLineConfigs(children),
[children]
);
const xScale = useMemo(() => {
const minTime = xDomain
? xDomain[0].getTime()
: (extent(plotData, (d) => xAccessor(d).getTime())[0] ?? 0);
let maxTime = xDomain
? xDomain[1].getTime()
: (extent(plotData, (d) => xAccessor(d).getTime())[1] ?? minTime);
// Brush defines the viewport — projection horizon is included via brush
// track extent, not by extending past the selection on the main chart.
if (!xDomain) {
maxTime = mergeProjectionXDomainMax(maxTime, projectionConfigs);
}
return scaleTime({
range: [0, innerWidth],
domain: [minTime, maxTime],
});
}, [innerWidth, plotData, projectionConfigs, xAccessor, xDomain]);
// When brushing, keep the full series for path rendering so edge fades stay
// anchored to the viewport while the line pans through them. Y-domain and
// interaction still use the filtered visible slice.
const seriesSourceData = xDomain ? plotData : visiblePlotData;
const renderData = useMemo(() => {
const valueKeys = lines.map((line) => line.dataKey);
return decimateTimeSeries(
seriesSourceData,
maxRenderPointsForWidth(innerWidth),
valueKeys
);
}, [seriesSourceData, innerWidth, lines]);
const columnWidth = useMemo(() => {
const slotCount =
xDomain && xDomainSlotCount != null
? xDomainSlotCount
: visiblePlotData.length;
if (slotCount < 2) {
return 0;
}
return innerWidth / (slotCount - 1);
}, [innerWidth, visiblePlotData.length, xDomain, xDomainSlotCount]);
const yDomainSkeletonByAxis = useMemo(
() =>
computeYDomainsByAxis({
lines,
resolveDomain: (dataKeys) => resolveYDomain(skeletonData, dataKeys),
}),
[lines, resolveYDomain, skeletonData]
);
const yDomainTargetByAxis = useMemo(() => {
const base = computeYDomainsByAxis({
lines,
resolveDomain: (dataKeys) =>
resolveYDomain(xDomain ? visiblePlotData : data, dataKeys),
});
if (projectionConfigs.length === 0) {
return base;
}
const merged: Record<string, [number, number]> = { ...base };
for (const axisId of Object.keys(base)) {
merged[axisId] = mergeProjectionYDomain(
base[axisId] ?? [0, 100],
projectionConfigs,
axisId
);
}
for (const config of projectionConfigs) {
if (!merged[config.yAxisId]) {
merged[config.yAxisId] = mergeProjectionYDomain(
[0, 100],
projectionConfigs,
config.yAxisId
);
}
}
return merged;
}, [
data,
lines,
projectionConfigs,
resolveYDomain,
visiblePlotData,
xDomain,
]);
const animatedYDomainsByAxis = useAnimatedYDomains({
chartPhase,
durationMs: yDomainTweenDuration,
enabled: yDomainTween,
onSettled: notifyYDomainTweenComplete,
skeletonByAxis: yDomainSkeletonByAxis,
targetByAxis: yDomainTargetByAxis,
tweenOnTargetChange:
yDomainTween || (tweenYDomainOnXDomainChange && xDomain != null),
});
const yDomainsForScales = animatedYDomainsByAxis;
const yScales = useMemo(
() =>
buildYScalesFromDomains({
domainsByAxis: yDomainsForScales,
innerHeight,
lines,
}),
[yDomainsForScales, innerHeight, lines]
);
const yScale = getPrimaryYScale(
yScales,
scaleLinear({ range: [innerHeight, 0], domain: [0, 100], nice: true })
);
const dateLabels = useMemo(
() => visiblePlotData.map((d) => shortDateFmt.format(xAccessor(d))),
[visiblePlotData, xAccessor]
);
const canInteract = isLoaded && isChartInteractionPhase(chartPhase);
const {
tooltipData,
setTooltipData,
selection,
clearSelection,
interactionHandlers,
interactionStyle,
} = useChartInteraction({
bisectDate,
canInteract,
data: visiblePlotData,
lines,
margin,
xAccessor,
xScale,
yScale,
yScales,
});
const defsChildren: ReactElement[] = [];
const clipExcludedChildren: ReactElement[] = [];
const underlayChildren: ReactElement[] = [];
const preOverlayChildren: ReactElement[] = [];
const postOverlayChildren: ReactElement[] = [];
Children.forEach(children, (child, index) => {
if (!isValidElement(child)) {
return;
}
const keyedChild = ensureChildKey(child, index);
const resolvedChild = resolveChartChildElement(keyedChild);
if (isGradientDefComponent(resolvedChild)) {
defsChildren.push(resolvedChild);
} else if (isPatternDefComponent(resolvedChild)) {
preOverlayChildren.push(resolvedChild);
} else if (isPostOverlayComponent(resolvedChild)) {
postOverlayChildren.push(resolvedChild);
} else if (isClipExcludedComponent(resolvedChild)) {
clipExcludedChildren.push(resolvedChild);
} else if (isUnderlayComponent(resolvedChild)) {
underlayChildren.push(resolvedChild);
} else {
preOverlayChildren.push(resolvedChild);
}
});
const [registeredReferenceAreas, setRegisteredReferenceAreas] = useState(
() => new Map<string, ReferenceAreaConfig>()
);
const registerReferenceArea = useCallback(
(id: string, config: ReferenceAreaConfig) => {
setRegisteredReferenceAreas((prev) => {
const existing = prev.get(id);
if (
existing &&
existing.yAxisId === config.yAxisId &&
existing.y1 === config.y1 &&
existing.y2 === config.y2 &&
existing.axisLabelColor === config.axisLabelColor
) {
return prev;
}
const next = new Map(prev);
next.set(id, config);
return next;
});
},
[]
);
const unregisterReferenceArea = useCallback((id: string) => {
setRegisteredReferenceAreas((prev) => {
if (!prev.has(id)) {
return prev;
}
const next = new Map(prev);
next.delete(id);
return next;
});
}, []);
const referenceAreaRegistration = useMemo(
() => ({ registerReferenceArea, unregisterReferenceArea }),
[registerReferenceArea, unregisterReferenceArea]
);
const referenceAreas = useMemo(() => {
const extracted = extractReferenceAreaConfigs(children);
const registered = [...registeredReferenceAreas.values()];
if (registered.length === 0) {
return extracted;
}
if (extracted.length === 0) {
return registered;
}
return [...extracted, ...registered];
}, [children, registeredReferenceAreas]);
const contextValue = useMemo(
() => ({
data: visiblePlotData,
renderData,
xScale,
yScale,
yScales,
width,
height,
innerWidth,
innerHeight,
margin,
columnWidth,
tooltipData,
setTooltipData,
containerRef,
lines,
referenceAreas,
chartPhase,
chartStatus,
loadingLabel,
yDomainTweenDuration,
yDomainSkeletonByAxis,
yDomainTargetByAxis,
isLoaded,
animationDuration,
animationEasing,
enterTransition,
revealEpoch,
notifyLoadingPulseComplete,
xAccessor,
dateLabels,
xDomain,
xDomainSlotCount,
selection,
clearSelection,
composedBarDataKeys,
composedBarSize,
composedMaxBarSize,
composedBarGap,
composedStacked,
composedStackOffsets,
composedStackGap,
}),
[
visiblePlotData,
renderData,
xScale,
yScale,
yScales,
width,
height,
innerWidth,
innerHeight,
margin,
columnWidth,
tooltipData,
setTooltipData,
containerRef,
lines,
referenceAreas,
chartPhase,
chartStatus,
loadingLabel,
yDomainTweenDuration,
yDomainSkeletonByAxis,
yDomainTargetByAxis,
isLoaded,
animationDuration,
animationEasing,
enterTransition,
revealEpoch,
notifyLoadingPulseComplete,
xAccessor,
dateLabels,
xDomain,
xDomainSlotCount,
selection,
clearSelection,
composedBarDataKeys,
composedBarSize,
composedMaxBarSize,
composedBarGap,
composedStacked,
composedStackOffsets,
composedStackGap,
]
);
const useClipReveal =
!staticPreview &&
renderData.length > 1 &&
innerWidth > 0 &&
animationDuration > 0;
const isRevealAnimating = chartPhase === "revealing";
const isRevealConcealing =
chartPhase === "exitingReady" && animationDuration > 0;
const effectiveEnterTransition: Transition =
enterTransition ??
({
...DEFAULT_CHART_ENTER_TRANSITION,
duration: animationDuration / 1000,
} satisfies Transition);
const revealClipPadding = useMemo(() => {
if (!composedBarDataKeys?.length) {
return 0;
}
const barWidth = computeSeriesBarWidth({
columnWidth,
composedBarGap,
composedBarSize,
composedMaxBarSize,
dataLength: plotData.length,
innerWidth,
seriesCount: composedBarDataKeys.length,
stacked: composedStacked,
});
return computeSeriesBarRevealClipPadding({
barWidth,
gap: composedBarGap,
seriesCount: composedBarDataKeys.length,
stacked: composedStacked,
});
}, [
columnWidth,
composedBarDataKeys,
composedBarGap,
composedBarSize,
composedMaxBarSize,
composedStacked,
innerWidth,
plotData.length,
]);
return (
<ReferenceAreaRegistrationContext.Provider
value={referenceAreaRegistration}
>
<ChartProvider value={contextValue}>
<svg aria-hidden="true" height={height} width={width}>
<defs>
{defsChildren}
{useClipReveal ? (
<ChartRevealClip
animating={isRevealAnimating || isRevealConcealing}
clipPathId={uniqueClipPathId}
enterTransition={effectiveEnterTransition}
height={innerHeight + 20}
mode={isRevealConcealing ? "conceal" : "reveal"}
onComplete={
isRevealConcealing ? notifyRevealConcealComplete : undefined
}
padding={revealClipPadding}
revealEpoch={isRevealConcealing ? concealEpoch : revealEpoch}
targetWidth={innerWidth}
/>
) : null}
</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}
{useClipReveal ? (
<g clipPath={`url(#${uniqueClipPathId})`}>{preOverlayChildren}</g>
) : (
preOverlayChildren
)}
{postOverlayChildren}
</g>
</svg>
</ChartProvider>
</ReferenceAreaRegistrationContext.Provider>
);
});
src/charts/reference-area-registration-context.tsx
"use client";
import { createContext, useContext } from "react";
import type { ReferenceAreaConfig } from "./reference-area-config";
export interface ReferenceAreaRegistrationContextValue {
registerReferenceArea: (id: string, config: ReferenceAreaConfig) => void;
unregisterReferenceArea: (id: string) => void;
}
export const ReferenceAreaRegistrationContext =
createContext<ReferenceAreaRegistrationContextValue | null>(null);
export function useReferenceAreaRegistration(): ReferenceAreaRegistrationContextValue | null {
return useContext(ReferenceAreaRegistrationContext);
}
src/charts/projection-config.ts
import {
Children,
Fragment,
isValidElement,
type ReactElement,
type ReactNode,
} from "react";
import { isChartClipPassthrough } from "./chart-child-passthrough";
import type { ProjectionPoint } from "./projection-utils";
import {
projectionDateExtents,
projectionValueExtents,
} from "./projection-utils";
import { normalizeYAxisId } from "./y-axis-scales";
export interface ProjectionLineConfig {
yAxisId: string;
data: ProjectionPoint[];
}
interface ProjectionLineConfigProps {
data?: ProjectionPoint[];
yAxisId?: string | number;
}
function getChildComponentName(child: ReactElement) {
const childType = child.type as { displayName?: string; name?: string };
return typeof child.type === "function"
? childType.displayName || childType.name || ""
: "";
}
function isProjectionLineElement(child: ReactElement): boolean {
return getChildComponentName(child) === "ProjectionLine";
}
function normalizeProjectionData(
data: ProjectionPoint[] | undefined
): ProjectionPoint[] {
if (!data?.length) {
return [];
}
return data.map((point) => ({
date: point.date instanceof Date ? point.date : new Date(point.date),
value: point.value,
}));
}
/** Collect {@link ProjectionLine} props from chart children for domain extension. */
export function extractProjectionLineConfigs(
children: ReactNode
): ProjectionLineConfig[] {
const configs: ProjectionLineConfig[] = [];
const visit = (node: ReactNode) => {
Children.forEach(node, (child) => {
if (!isValidElement(child)) {
return;
}
if (child.type === Fragment) {
visit((child.props as { children?: ReactNode }).children);
return;
}
if (isProjectionLineElement(child)) {
const props = child.props as ProjectionLineConfigProps | undefined;
const data = normalizeProjectionData(props?.data);
if (data.length >= 2) {
configs.push({
yAxisId: normalizeYAxisId(props?.yAxisId),
data,
});
}
return;
}
if (isChartClipPassthrough(child.type)) {
visit((child.props as { children?: ReactNode }).children);
return;
}
const childProps = child.props as { children?: ReactNode } | undefined;
if (childProps?.children) {
visit(childProps.children);
}
});
};
visit(children);
return configs;
}
export function mergeProjectionYDomain(
domain: [number, number],
configs: ProjectionLineConfig[],
yAxisId: string
): [number, number] {
const paths = configs
.filter((config) => config.yAxisId === yAxisId)
.map((config) => config.data);
const extents = projectionValueExtents(paths);
if (!extents) {
return domain;
}
const [min, max] = domain;
const nextMin = Math.min(min, extents.minValue);
const nextMax = Math.max(max, extents.maxValue);
if (nextMin >= 0 && min >= 0) {
return [0, nextMax <= 0 ? 100 : nextMax * 1.1];
}
const padding = (nextMax - nextMin) * 0.05 || 1;
return [nextMin - padding, nextMax + padding];
}
export function mergeProjectionXDomainMax(
maxTime: number,
configs: ProjectionLineConfig[]
): number {
const paths = configs.map((config) => config.data);
const extents = projectionDateExtents(paths);
if (!extents) {
return maxTime;
}
return Math.max(maxTime, extents.maxTime);
}
src/charts/projection-utils.ts
export type ProjectionMode = "auto" | "target" | "manual";
export type ProjectionAutoMethod = "linearRegression" | "lastSegment";
/** How the projection segment is drawn between anchor and horizon. */
export type ProjectionCurveKind = "linear" | "bezier";
/** @deprecated Stepped density removed — projections always anchor → horizon. */
export type ProjectionPathDensity = "stepped" | "endpoints";
export interface ProjectionPoint {
date: Date;
value: number;
}
export interface BuildProjectionPathOptions {
sourceData: Record<string, unknown>[];
seriesKey: string;
xDataKey?: string;
mode: ProjectionMode;
autoMethod?: ProjectionAutoMethod;
/** Auto mode: stepped points per interval, or anchor + end only. Default: stepped */
pathDensity?: ProjectionPathDensity;
/** Index in sourceData where projection anchors (default: last point). */
startIndex?: number;
/** How many future points to generate (matches source cadence). */
horizonPoints?: number;
/** Target Y at the final projected date (target mode). */
endValue?: number;
/** Full manual path — anchor + future points (manual mode). */
points?: ProjectionPoint[];
}
function readDate(row: Record<string, unknown>, xDataKey: string): Date | null {
const raw = row[xDataKey];
if (raw instanceof Date && !Number.isNaN(raw.getTime())) {
return raw;
}
if (typeof raw === "number" && Number.isFinite(raw)) {
const date = new Date(raw);
return Number.isNaN(date.getTime()) ? null : date;
}
if (typeof raw === "string") {
const date = new Date(raw);
return Number.isNaN(date.getTime()) ? null : date;
}
return null;
}
function readValue(
row: Record<string, unknown>,
seriesKey: string
): number | null {
const raw = row[seriesKey];
return typeof raw === "number" && Number.isFinite(raw) ? raw : null;
}
function resolveStartIndex(
sourceData: Record<string, unknown>[],
startIndex: number | undefined
): number {
if (startIndex == null || !Number.isFinite(startIndex)) {
return Math.max(0, sourceData.length - 1);
}
return Math.min(Math.max(0, Math.floor(startIndex)), sourceData.length - 1);
}
function intervalFromAdjacentRows(
sourceData: Record<string, unknown>[],
xDataKey: string,
startIndex: number
): number | null {
if (startIndex < 1) {
return null;
}
const prevRow = sourceData[startIndex - 1];
const currentRow = sourceData[startIndex];
const prev = prevRow ? readDate(prevRow, xDataKey) : null;
const current = currentRow ? readDate(currentRow, xDataKey) : null;
if (!(prev && current)) {
return null;
}
const delta = current.getTime() - prev.getTime();
return delta > 0 ? delta : null;
}
function intervalFromSeriesSpan(
sourceData: Record<string, unknown>[],
xDataKey: string
): number | null {
if (sourceData.length < 2) {
return null;
}
const firstRow = sourceData[0];
const lastRow = sourceData.at(-1);
const first = firstRow ? readDate(firstRow, xDataKey) : null;
const last = lastRow ? readDate(lastRow, xDataKey) : null;
if (!(first && last)) {
return null;
}
const span = last.getTime() - first.getTime();
return span > 0 ? span / (sourceData.length - 1) : null;
}
function resolveIntervalMs(
sourceData: Record<string, unknown>[],
xDataKey: string,
startIndex: number
): number {
return (
intervalFromAdjacentRows(sourceData, xDataKey, startIndex) ??
intervalFromSeriesSpan(sourceData, xDataKey) ??
86_400_000
);
}
function linearRegressionSlope(points: { t: number; y: number }[]): number {
if (points.length < 2) {
return 0;
}
const n = points.length;
let sumT = 0;
let sumY = 0;
let sumTY = 0;
let sumTT = 0;
for (const { t, y } of points) {
sumT += t;
sumY += y;
sumTY += t * y;
sumTT += t * t;
}
const denom = n * sumTT - sumT * sumT;
if (Math.abs(denom) < 1e-12) {
return 0;
}
return (n * sumTY - sumT * sumY) / denom;
}
function buildAutoFutureValues(options: {
anchorTime: number;
anchorValue: number;
autoMethod: ProjectionAutoMethod;
historyPoints: { t: number; y: number }[];
horizonPoints: number;
intervalMs: number;
pathDensity: ProjectionPathDensity;
}): ProjectionPoint[] {
const {
anchorTime,
anchorValue,
autoMethod,
historyPoints,
horizonPoints,
intervalMs,
pathDensity,
} = options;
const slope =
autoMethod === "lastSegment" && historyPoints.length >= 2
? (() => {
const prev = historyPoints.at(-2);
const last = historyPoints.at(-1);
if (!(prev && last)) {
return 0;
}
const dt = last.t - prev.t;
return dt === 0 ? 0 : (last.y - prev.y) / dt;
})()
: linearRegressionSlope(historyPoints);
if (pathDensity === "endpoints") {
const endTime = anchorTime + intervalMs * horizonPoints;
const endValue = anchorValue + slope * intervalMs * horizonPoints;
return [
{ date: new Date(anchorTime), value: anchorValue },
{ date: new Date(endTime), value: endValue },
];
}
const result: ProjectionPoint[] = [
{ date: new Date(anchorTime), value: anchorValue },
];
for (let i = 1; i <= horizonPoints; i++) {
const t = anchorTime + intervalMs * i;
const value = anchorValue + slope * intervalMs * i;
result.push({ date: new Date(t), value });
}
return result;
}
/** Slope (value change per ms) at the projection anchor from the last data segment. */
export function computeProjectionAnchorTangentSlope(
sourceData: Record<string, unknown>[],
seriesKey: string,
xDataKey = "date",
startIndexProp?: number
): number {
if (sourceData.length < 2) {
return 0;
}
const startIndex = resolveStartIndex(sourceData, startIndexProp);
const historyPoints: { t: number; y: number }[] = [];
for (let i = 0; i <= startIndex; i++) {
const row = sourceData[i];
if (!row) {
continue;
}
const date = readDate(row, xDataKey);
const value = readValue(row, seriesKey);
if (date && value != null) {
historyPoints.push({ t: date.getTime(), y: value });
}
}
if (historyPoints.length < 2) {
return 0;
}
const prev = historyPoints.at(-2);
const last = historyPoints.at(-1);
if (!(prev && last)) {
return 0;
}
const dt = last.t - prev.t;
return dt === 0 ? 0 : (last.y - prev.y) / dt;
}
/** Cubic bezier with horizontal tangents at start and end (price-target S-curve). */
export function buildHorizontalTangentBezierPath(
x0: number,
y0: number,
x1: number,
y1: number,
/** How far control points sit along the x span (0–0.5). Default: 0.45 */
tension = 0.45
): string {
const dx = x1 - x0;
if (Math.abs(dx) < 1e-6) {
return `M ${x0},${y0} L ${x1},${y1}`;
}
const t = Math.min(0.5, Math.max(0.05, tension));
const c1x = x0 + dx * t;
const c2x = x1 - dx * t;
return `M ${x0},${y0} C ${c1x},${y0} ${c2x},${y1} ${x1},${y1}`;
}
function buildTargetPath(options: {
anchorTime: number;
anchorValue: number;
endValue: number;
horizonPoints: number;
intervalMs: number;
}): ProjectionPoint[] {
const { anchorTime, anchorValue, endValue, horizonPoints, intervalMs } =
options;
const endTime = anchorTime + intervalMs * horizonPoints;
return [
{ date: new Date(anchorTime), value: anchorValue },
{ date: new Date(endTime), value: endValue },
];
}
/** Build a projection path from historical chart data or explicit points. */
export function buildProjectionPath(
options: BuildProjectionPathOptions
): ProjectionPoint[] {
const {
sourceData,
seriesKey,
xDataKey = "date",
mode,
autoMethod = "linearRegression",
pathDensity = "endpoints",
startIndex: startIndexProp,
horizonPoints = 6,
endValue,
points,
} = options;
if (mode === "manual" && points && points.length >= 2) {
return points.map((point) => ({
date: new Date(point.date),
value: point.value,
}));
}
if (sourceData.length === 0) {
return [];
}
const startIndex = resolveStartIndex(sourceData, startIndexProp);
const anchorRow = sourceData[startIndex];
if (!anchorRow) {
return [];
}
const anchorDate = readDate(anchorRow, xDataKey);
const anchorValue = readValue(anchorRow, seriesKey);
if (!anchorDate || anchorValue == null) {
return [];
}
const intervalMs = resolveIntervalMs(sourceData, xDataKey, startIndex);
const anchorTime = anchorDate.getTime();
const historyPoints: { t: number; y: number }[] = [];
for (let i = 0; i <= startIndex; i++) {
const row = sourceData[i];
if (!row) {
continue;
}
const date = readDate(row, xDataKey);
const value = readValue(row, seriesKey);
if (date && value != null) {
historyPoints.push({ t: date.getTime(), y: value });
}
}
if (mode === "target" && endValue != null && Number.isFinite(endValue)) {
return buildTargetPath({
anchorTime,
anchorValue,
endValue,
horizonPoints,
intervalMs,
});
}
return buildAutoFutureValues({
anchorTime,
anchorValue,
autoMethod,
historyPoints,
horizonPoints,
intervalMs,
pathDensity,
});
}
/** Collect numeric Y extents from projection point arrays. */
export function projectionValueExtents(
paths: ProjectionPoint[][]
): { minValue: number; maxValue: number } | null {
let minValue = Number.POSITIVE_INFINITY;
let maxValue = Number.NEGATIVE_INFINITY;
for (const path of paths) {
for (const point of path) {
if (point.value < minValue) {
minValue = point.value;
}
if (point.value > maxValue) {
maxValue = point.value;
}
}
}
if (minValue === Number.POSITIVE_INFINITY) {
return null;
}
return { minValue, maxValue };
}
/** Collect date extents from projection point arrays. */
export function projectionDateExtents(
paths: ProjectionPoint[][]
): { minTime: number; maxTime: number } | null {
let minTime = Number.POSITIVE_INFINITY;
let maxTime = Number.NEGATIVE_INFINITY;
for (const path of paths) {
for (const point of path) {
const time = point.date.getTime();
if (time < minTime) {
minTime = time;
}
if (time > maxTime) {
maxTime = time;
}
}
}
if (minTime === Number.POSITIVE_INFINITY) {
return null;
}
return { minTime, maxTime };
}
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/series-bar-layout.ts
export function computeSeriesBarWidth(input: {
innerWidth: number;
dataLength: number;
columnWidth: number;
seriesCount: number;
composedBarSize?: number;
composedMaxBarSize?: number;
composedBarGap?: number;
stacked?: boolean;
}): number {
const {
innerWidth,
dataLength,
columnWidth,
seriesCount,
composedBarSize,
composedMaxBarSize,
composedBarGap = 4,
stacked = false,
} = input;
const gap = composedBarGap;
const groupCount = stacked ? 1 : Math.max(1, seriesCount);
let slot = columnWidth;
if (slot <= 0) {
slot = dataLength < 2 ? innerWidth : innerWidth / (dataLength - 1);
}
let width =
composedBarSize ??
Math.min(slot * 0.88, composedMaxBarSize ?? Number.POSITIVE_INFINITY);
if (composedMaxBarSize != null) {
width = Math.min(width, composedMaxBarSize);
}
if (groupCount > 1) {
const maxGroup = slot * 0.92;
const needed = groupCount * width + (groupCount - 1) * gap;
if (needed > maxGroup && maxGroup > 0) {
width = Math.max(4, (maxGroup - (groupCount - 1) * gap) / groupCount);
}
}
return Math.max(2, width);
}
/** Half-width of the bar group at each x — used to pad reveal clips. */
export function computeSeriesBarRevealClipPadding(input: {
barWidth: number;
seriesCount: number;
gap?: number;
stacked?: boolean;
}): number {
const { barWidth, seriesCount, gap = 4, stacked = false } = input;
if (stacked || seriesCount <= 1) {
return Math.ceil(barWidth / 2);
}
const groupWidth = seriesCount * barWidth + (seriesCount - 1) * gap;
return Math.ceil(groupWidth / 2);
}
src/charts/area.tsx
"use client";
import { curveMonotoneX } from "@visx/curve";
import { AreaClosed, LinePath } from "@visx/shape";
// CurveFactory type - simplified version compatible with visx
// biome-ignore lint/suspicious/noExplicitAny: d3 curve factory type
type CurveFactory = any;
import { useCallback, useId, useMemo, useRef, useState } from "react";
import { AreaGradientDefs } from "./area-gradient-defs";
import { chartCssVars, useChartStable, useYScale } from "./chart-context";
import type { ChartPhase, LoadingStyle } from "./chart-phase";
import { type FadeEdges, resolveFadeSides } from "./fade-edges";
import {
type LineLoadingPulseMode,
LineLoadingPulseStroke,
resolveLineLoadingPulseMode,
} from "./line-loading-pulse";
import { LINE_LOADING_LOOP_PAUSE_MS } from "./line-loading-timing";
import { LineLoadingSweep } from "./loading-sweep";
import {
resolveDashTailBounds,
usePathStrokeMetrics,
} from "./path-stroke-utils";
import { SeriesDashTailOverlay } from "./series-dash-tail-overlay";
import { SeriesHighlightLayer } from "./series-highlight-layer";
import { SeriesHoverDim } from "./series-hover-dim";
import { SeriesMarkers } from "./series-markers";
import type { SeriesPointMarkerStyle } from "./series-point-marker";
export interface AreaProps {
/** Key in data to use for y values */
dataKey: string;
/** Y-scale group id (Recharts `yAxisId`). Default: `"left"`. */
yAxisId?: string | number;
/** Fill color for the area gradient start. Default: var(--chart-line-primary) */
fill?: string;
/** Fill opacity at the top of the area. Default: 0.4 */
fillOpacity?: number;
/** Stroke color for the line. Default: same as fill */
stroke?: string;
/** Stroke width. Default: 2 */
strokeWidth?: number;
/** Curve function. Default: curveMonotoneX */
curve?: CurveFactory;
/** Whether to animate the area. Default: true */
animate?: boolean;
/** Whether to show the stroke line. Default: true */
showLine?: boolean;
/** Whether to show highlight segment on hover. Default: true */
showHighlight?: boolean;
/** Gradient opacity at bottom (0 = fully transparent). Default: 0 */
gradientToOpacity?: number;
/**
* Vertical extent of the fill gradient (0–1). `1` fades across the full
* height; lower values compress the gradient toward the top.
*/
gradientSpan?: number;
/**
* Fade the area fill (and stroke) toward transparent at the chart edges.
* - `true` fades both edges, `false` disables the fade entirely.
* - `"left"` / `"right"` fades only that side — useful when the opposite
* edge butts up against another element you don't want to fade into.
* Default: false
*/
fadeEdges?: FadeEdges;
/** Render scatter-style circle markers at each data point. Default: false */
showMarkers?: boolean;
/** Marker styling (same options as Scatter). */
markers?: SeriesPointMarkerStyle;
/**
* Data index from which the line stroke becomes dashed (inclusive).
* Useful for projecting incomplete periods, e.g. dashed from yesterday through today.
*/
dashFromIndex?: number;
/** Dash pattern for the tail segment when `dashFromIndex` is set. Default: "6,4" */
dashArray?: string;
/** Pulse stroke color while chart is loading. Default: var(--foreground) */
loadingStroke?: string;
/** Pulse stroke opacity while chart is loading. Default: 0.5 */
loadingStrokeOpacity?: number;
/**
* Show the loading pulse overlay. Default: follows chart loading phase.
* Set `false` to disable even during loading.
*/
loading?: boolean;
/** Override pulse animation mode (loop / exit / enter). */
loadingPulseMode?: LineLoadingPulseMode;
/**
* Loading animation while the chart is in loading status: the default
* traveling `"pulse"`, or a diagonal `"sweep"` shimmer across the skeleton
* area. Default: `"pulse"`.
*/
loadingStyle?: LoadingStyle;
}
function useAreaLoadingPulseState(
chartPhase: ChartPhase,
loading: boolean | undefined,
loadingPulseMode: LineLoadingPulseMode | undefined,
notifyLoadingPulseComplete?: () => void
) {
const phasePulseMode = resolveLineLoadingPulseMode(chartPhase);
const pulseMode =
loading === false
? null
: (loadingPulseMode ?? (loading === true ? "loop" : phasePulseMode));
const showLoadingPulse = pulseMode != null;
const showSeriesContent =
chartPhase === "revealing" ||
chartPhase === "ready" ||
chartPhase === "exitingReady";
const [pulseEpoch, setPulseEpoch] = useState(0);
const handleLoadingPulseComplete = useCallback(() => {
if (pulseMode === "loop") {
window.setTimeout(() => {
setPulseEpoch((epoch) => epoch + 1);
}, LINE_LOADING_LOOP_PAUSE_MS);
return;
}
notifyLoadingPulseComplete?.();
}, [notifyLoadingPulseComplete, pulseMode]);
return {
handleLoadingPulseComplete,
pulseMode,
pulseEpoch,
showLoadingPulse,
showSeriesContent,
};
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: mirrors Line series layout (fill, stroke, dash, markers, pulse)
export function Area({
dataKey,
yAxisId,
fill = chartCssVars.linePrimary,
fillOpacity = 0.4,
stroke,
strokeWidth = 2,
curve = curveMonotoneX,
animate = true,
showLine = true,
showHighlight = true,
gradientToOpacity = 0,
gradientSpan = 1,
fadeEdges = false,
showMarkers = false,
markers,
dashFromIndex,
dashArray = "6,4",
loading,
loadingStroke = chartCssVars.foreground,
loadingStrokeOpacity = 0.5,
loadingPulseMode,
loadingStyle = "pulse",
}: AreaProps) {
// Stable slice only: hover state lives inside `<SeriesHoverDim>` and
// `<SeriesHighlightLayer>` so this component (and its expensive
// <SeriesDashTailOverlay> child) does not re-render on cursor motion.
// The reveal-clip is now a single shared clipPath at the chart-shell
// level (`time-series-chart-shell.tsx`); we no longer render a per-area
// `<ChartRevealClip>` or read `revealEpoch` here.
const {
data,
renderData,
xScale,
innerHeight,
innerWidth,
xAccessor,
lines,
chartPhase,
notifyLoadingPulseComplete,
} = useChartStable();
const yScale = useYScale(yAxisId);
const {
handleLoadingPulseComplete,
pulseMode,
pulseEpoch,
showLoadingPulse,
showSeriesContent,
} = useAreaLoadingPulseState(
chartPhase,
loading,
loadingPulseMode,
notifyLoadingPulseComplete
);
const seriesIndex = useMemo(() => {
const index = lines.findIndex((line) => line.dataKey === dataKey);
return index >= 0 ? index : 0;
}, [lines, dataKey]);
const pathRef = useRef<SVGPathElement>(null);
const { pathLength, pathD } = usePathStrokeMetrics(pathRef, [
renderData,
innerWidth,
dashFromIndex,
showLine,
showSeriesContent,
showLoadingPulse,
]);
// Unique IDs for this area
const uniqueId = useId();
const gradientId = `area-gradient-${dataKey}-${uniqueId}`;
const strokeGradientId = `area-stroke-gradient-${dataKey}-${uniqueId}`;
const edgeMaskId = `area-edge-mask-${dataKey}-${uniqueId}`;
const edgeGradientId = `${edgeMaskId}-gradient`;
const isPatternFill = fill.startsWith("url(");
const showAreaFill = isPatternFill || fillOpacity > 0;
const areaFill = isPatternFill ? fill : `url(#${gradientId})`;
// Resolved stroke color (defaults to fill; pattern URLs need a real color)
const resolvedStroke =
stroke || (isPatternFill ? chartCssVars.linePrimary : fill);
const getY = useCallback(
(d: Record<string, unknown>) => {
const value = d[dataKey];
return typeof value === "number" ? (yScale(value) ?? 0) : 0;
},
[dataKey, yScale]
);
const hasDashTail = resolveDashTailBounds(dashFromIndex, data.length);
// The stroke gradient is only emitted when at least one edge fades, so fall
// back to the resolved solid color otherwise — avoids an invalid url(#...).
const fadeSides = resolveFadeSides(fadeEdges);
const useViewportEdgeFade = fadeSides.any && !isPatternFill;
let strokePaint = resolvedStroke;
if (!useViewportEdgeFade && fadeSides.any) {
strokePaint = `url(#${strokeGradientId})`;
}
const highlightEnabled =
showHighlight && showLine && !showLoadingPulse && showSeriesContent;
const showSeriesStroke = showSeriesContent && showLine;
let visibleStroke = "transparent";
if (showSeriesStroke && !hasDashTail) {
visibleStroke = strokePaint;
}
const shouldMeasurePath = showLine && (showSeriesContent || showLoadingPulse);
const seriesLayers = (
<>
{showSeriesContent && showAreaFill ? (
<AreaClosed
curve={curve}
data={renderData}
fill={areaFill}
x={(d) => xScale(xAccessor(d)) ?? 0}
y={getY}
yScale={yScale}
/>
) : null}
{shouldMeasurePath ? (
<>
<LinePath
curve={curve}
data={renderData}
innerRef={pathRef}
stroke={visibleStroke}
strokeLinecap="round"
strokeWidth={strokeWidth}
x={(d) => xScale(xAccessor(d)) ?? 0}
y={getY}
/>
{showSeriesStroke ? (
<SeriesDashTailOverlay
dashArray={dashArray}
dashFromIndex={dashFromIndex}
data={data}
innerHeight={innerHeight}
innerWidth={innerWidth}
pathD={pathD}
pathLength={pathLength}
stroke={strokePaint}
strokeWidth={strokeWidth}
xAccessor={xAccessor}
xScale={xScale}
/>
) : null}
</>
) : null}
</>
);
// Sweep style owns all loading modes (loop + the exit/enter transitions),
// drawing its own silhouette; the pulse covers the default style.
const sweepLoading =
showLoadingPulse && innerWidth > 0 && loadingStyle === "sweep";
const pulseLoading = showLoadingPulse && innerWidth > 0 && !sweepLoading;
return (
<>
<AreaGradientDefs
edgeGradientId={edgeGradientId}
edgeMaskId={edgeMaskId}
fadeEdges={fadeEdges}
fill={fill}
fillOpacity={fillOpacity}
gradientId={gradientId}
gradientSpan={gradientSpan}
gradientToOpacity={gradientToOpacity}
innerHeight={innerHeight}
innerWidth={innerWidth}
isPatternFill={isPatternFill}
resolvedStroke={resolvedStroke}
strokeGradientId={strokeGradientId}
/>
<SeriesHoverDim
dimOpacity={0.6}
enabled={showHighlight}
seriesIndex={seriesIndex}
>
{useViewportEdgeFade ? (
<g mask={`url(#${edgeMaskId})`}>{seriesLayers}</g>
) : (
seriesLayers
)}
</SeriesHoverDim>
{/* Highlight segment on hover — isolated hover subscriber. */}
<SeriesHighlightLayer
enabled={highlightEnabled}
height={innerHeight}
pathRef={pathRef}
stroke={resolvedStroke}
strokeWidth={strokeWidth}
/>
{showMarkers && showSeriesContent ? (
<SeriesMarkers
animate={animate}
dataKey={dataKey}
{...markers}
fill={markers?.fill ?? resolvedStroke}
stroke={markers?.stroke ?? markers?.fill ?? resolvedStroke}
/>
) : null}
{sweepLoading ? (
<LineLoadingSweep
curve={curve}
key="loading-sweep"
mode={pulseMode ?? "loop"}
onTransitionComplete={handleLoadingPulseComplete}
stroke={loadingStroke}
strokeOpacity={loadingStrokeOpacity}
strokeWidth={strokeWidth}
withArea
/>
) : null}
{pulseLoading && pathD ? (
<LineLoadingPulseStroke
key="loading-pulse"
loopEpoch={pulseEpoch}
mode={pulseMode ?? undefined}
onCycleComplete={handleLoadingPulseComplete}
pathD={pathD}
stroke={loadingStroke}
strokeOpacity={loadingStrokeOpacity}
strokeWidth={strokeWidth}
/>
) : null}
</>
);
}
Area.displayName = "Area";
export default Area;
src/charts/area-chart-loading.tsx
"use client";
import { curveNatural } from "@visx/curve";
import { useMemo } from "react";
import { Area } from "./area";
import { AreaChart } from "./area-chart";
import type { Margin } from "./chart-context";
import type { LoadingStyle } from "./chart-phase";
import {
DEFAULT_SKELETON_DATA_KEY,
DEFAULT_SKELETON_POINT_COUNT,
generateChartSkeletonData,
} from "./generate-chart-skeleton-data";
import { Grid } from "./grid";
const LOADING_DATA_KEY = DEFAULT_SKELETON_DATA_KEY;
const DEFAULT_LOADING_STROKE = "var(--foreground)";
const DEFAULT_LOADING_GRID_STROKE =
"color-mix(in oklch, var(--chart-grid) 50%, transparent)";
const DEFAULT_LOADING_GRID_SHIMMER_STROKE =
"color-mix(in oklch, var(--foreground) 68%, transparent)";
const DEFAULT_LOADING_STROKE_OPACITY = 0.5;
export interface AreaChartLoadingProps {
/** Chart margins */
margin?: Partial<Margin>;
/** Stroke color for the animated loading segment. */
stroke?: string;
/** Stroke opacity for the animated loading segment. Default: 0.5 */
strokeOpacity?: number;
/** Grid line stroke (color and opacity via color-mix or oklch alpha). */
gridStroke?: string;
/** Shimmer band stroke (color and opacity via color-mix or oklch alpha). */
gridShimmerStroke?: string;
/** Animate a shimmer band across grid lines. Default: true */
gridShimmer?: boolean;
/** Shimmer band width in pixels. Default: 140 */
gridShimmerLength?: number;
/** Shimmer speed multiplier (higher = faster). Default: 1 */
gridShimmerSpeed?: number;
/** Match shimmer loop to the loading line pulse (cycle + inter-loop pause). */
gridShimmerSync?: boolean;
/** Loading animation: `"pulse"` (default traveling pulse) or `"sweep"` (a
* diagonal shimmer across the skeleton area). Default: `"pulse"`. */
loadingStyle?: LoadingStyle;
/** Centered shimmer label text. Default: "Loading" */
label?: string;
/** Aspect ratio as "width / height". Default: "2 / 1" */
aspectRatio?: string;
/** Additional class name for the container */
className?: string;
}
export function AreaChartLoading({
margin,
stroke = DEFAULT_LOADING_STROKE,
strokeOpacity = DEFAULT_LOADING_STROKE_OPACITY,
gridStroke = DEFAULT_LOADING_GRID_STROKE,
gridShimmerStroke = DEFAULT_LOADING_GRID_SHIMMER_STROKE,
gridShimmer = true,
gridShimmerLength,
gridShimmerSpeed,
gridShimmerSync = false,
loadingStyle = "pulse",
label = "Loading",
aspectRatio = "2 / 1",
className = "",
}: AreaChartLoadingProps) {
const data = useMemo(
() =>
generateChartSkeletonData({
dataKey: DEFAULT_SKELETON_DATA_KEY,
pointCount: DEFAULT_SKELETON_POINT_COUNT,
}),
[]
);
return (
<AreaChart
animationDuration={0}
aspectRatio={aspectRatio}
className={className}
data={data}
loadingLabel={label}
margin={margin}
status="loading"
>
<Grid
horizontal
shimmer={loadingStyle === "sweep" ? false : gridShimmer}
shimmerLength={gridShimmerLength}
shimmerSpeed={gridShimmerSpeed}
shimmerStroke={gridShimmerStroke}
shimmerSync={gridShimmerSync}
stroke={gridStroke}
/>
<Area
curve={curveNatural}
dataKey={LOADING_DATA_KEY}
fadeEdges={false}
fill="transparent"
fillOpacity={0}
loading
loadingStroke={stroke}
loadingStrokeOpacity={strokeOpacity}
loadingStyle={loadingStyle}
showHighlight={false}
showLine
stroke="transparent"
strokeWidth={2}
/>
</AreaChart>
);
}
export default AreaChartLoading;
src/charts/line-loading-pulse.tsx
"use client";
import { animate, motion, useMotionValue, useTransform } from "motion/react";
import { useEffect, useId } from "react";
import { chartCssVars, useChartStable } from "./chart-context";
import type { ChartPhase } from "./chart-phase";
import {
fadeGradientStops,
resolveFadeSides,
viewportFadeGradientAttrs,
} from "./fade-edges";
import {
LINE_LOADING_PULSE_CYCLE_S,
LINE_LOADING_PULSE_EASE,
} from "./line-loading-timing";
const CLIP_PADDING = 10;
export type LineLoadingPulseMode = "loop" | "exit" | "enter";
export function resolveLineLoadingPulseMode(
phase: ChartPhase
): LineLoadingPulseMode | null {
switch (phase) {
case "loading":
return "loop";
case "exiting":
return "exit";
case "revealingLoading":
return "enter";
default:
return null;
}
}
export interface LineLoadingPulseStrokeProps {
pathD: string;
mode?: LineLoadingPulseMode;
/** Bumps to restart loop cycles without remounting the stroke. */
loopEpoch?: number;
stroke?: string;
/** Stroke opacity for the animated segment. Default: 0.5 */
strokeOpacity?: number;
strokeWidth?: number;
onCycleComplete?: () => void;
}
function useGrowExitClip(
innerWidth: number,
mode: LineLoadingPulseMode,
loopEpoch: number,
onComplete?: () => void
) {
const progress = useMotionValue(0);
const paddedFullWidth = innerWidth + CLIP_PADDING * 2;
const rightEdge = innerWidth + CLIP_PADDING;
const clipWidth = useTransform(progress, (p) => {
if (p <= 0.5) {
return (p / 0.5) * paddedFullWidth;
}
const shrink = (p - 0.5) / 0.5;
return (1 - shrink) * paddedFullWidth;
});
const clipX = useTransform(progress, (p) => {
if (p <= 0.5) {
return -CLIP_PADDING;
}
const shrink = (p - 0.5) / 0.5;
return rightEdge - (1 - shrink) * paddedFullWidth;
});
// biome-ignore lint/correctness/useExhaustiveDependencies: loopEpoch restarts pulse when orchestrator advances
useEffect(() => {
if (innerWidth <= 0) {
return;
}
const halfCycleS = LINE_LOADING_PULSE_CYCLE_S / 2;
let cancelled = false;
let controls: ReturnType<typeof animate> | undefined;
const finish = () => {
if (!cancelled) {
onComplete?.();
}
};
const runShrink = (from: number) => {
const shrinkDuration = halfCycleS * ((1 - from) / 0.5);
controls = animate(progress, 1, {
duration: Math.max(shrinkDuration, 0.01),
ease: [...LINE_LOADING_PULSE_EASE],
onComplete: finish,
});
};
if (mode === "loop") {
progress.set(0);
controls = animate(progress, 1, {
duration: LINE_LOADING_PULSE_CYCLE_S,
ease: [...LINE_LOADING_PULSE_EASE],
onComplete: finish,
});
return () => {
cancelled = true;
controls?.stop();
};
}
if (mode === "exit") {
const current = progress.get();
if (current < 0.5) {
const growDuration = halfCycleS * ((0.5 - current) / 0.5);
controls = animate(progress, 0.5, {
duration: Math.max(growDuration, 0.01),
ease: [...LINE_LOADING_PULSE_EASE],
onComplete: () => {
if (!cancelled) {
runShrink(0.5);
}
},
});
} else {
runShrink(current);
}
return () => {
cancelled = true;
controls?.stop();
};
}
if (mode === "enter") {
progress.set(0);
controls = animate(progress, 0.5, {
duration: halfCycleS,
ease: [...LINE_LOADING_PULSE_EASE],
onComplete: finish,
});
return () => {
cancelled = true;
controls?.stop();
};
}
}, [innerWidth, loopEpoch, mode, onComplete, progress]);
return { clipX, clipWidth };
}
export function LineLoadingPulseStroke({
pathD,
mode = "loop",
loopEpoch = 0,
stroke = chartCssVars.foreground,
strokeOpacity = 0.5,
strokeWidth = 2.5,
onCycleComplete,
}: LineLoadingPulseStrokeProps) {
const { innerWidth, innerHeight } = useChartStable();
const reactId = useId();
const clipPathId = `line-loading-clip-${reactId}`;
const gradientId = `line-loading-gradient-${reactId}`;
const fadeStops = fadeGradientStops(resolveFadeSides(true));
const clipHeight = innerHeight + CLIP_PADDING * 2;
const { clipX, clipWidth } = useGrowExitClip(
innerWidth,
mode,
loopEpoch,
onCycleComplete
);
if (innerWidth <= 0) {
return null;
}
return (
<>
<defs>
<clipPath id={clipPathId}>
<motion.rect
height={clipHeight}
style={{ width: clipWidth, x: clipX }}
y={-CLIP_PADDING}
/>
</clipPath>
<linearGradient
id={gradientId}
{...viewportFadeGradientAttrs(innerWidth)}
>
{fadeStops.map((stop) => (
<stop
key={stop.offset}
offset={stop.offset}
stopColor={stroke}
stopOpacity={stop.opacity}
/>
))}
</linearGradient>
</defs>
<path
clipPath={`url(#${clipPathId})`}
d={pathD}
fill="none"
opacity={strokeOpacity}
stroke={`url(#${gradientId})`}
strokeLinecap="round"
strokeWidth={strokeWidth}
/>
</>
);
}
LineLoadingPulseStroke.displayName = "LineLoadingPulseStroke";
export default LineLoadingPulseStroke;
src/charts/loading-sweep.tsx
"use client";
import { scaleLinear } from "@visx/scale";
import { AreaClosed, LinePath } from "@visx/shape";
import { motion, useReducedMotion } from "motion/react";
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { chartCssVars, useChartStable } from "./chart-context";
import {
LINE_LOADING_PULSE_EASE,
LOADING_LABEL_EXIT_S,
} from "./line-loading-timing";
/**
* Shared "sweep" loading visuals. A soft diagonal shimmer band travels across a
* self-contained skeleton silhouette on a loop, painted via an SVG mask. The
* silhouette re-randomizes between passes (held steady during a pass, re-rolled
* once the band clears the right edge) so it reads as live and still loading,
* without warping mid-sweep. Used as the `loadingStyle="sweep"` alternative to
* the traveling pulse on `<Line>` and `<Area>`, and as the skeleton for
* `<BarChart status="loading">`.
*/
// CurveFactory type - simplified version compatible with visx
// biome-ignore lint/suspicious/noExplicitAny: d3 curve factory type
type CurveFactory = any;
/** One shimmer sweep, in seconds. */
const DEFAULT_SWEEP_DURATION_S = 2;
/** Sweep travel in objectBoundingBox space: off the left edge to off the right. */
const SWEEP_START_X = -1;
const SWEEP_END_X = 2;
/** Diagonal tilt of the shimmer band, in degrees. */
const SWEEP_ANGLE_DEG = 25;
const HEIGHT_MIN_PCT = 20;
const HEIGHT_MAX_PCT = 80;
const DEFAULT_POINT_COUNT = 14;
const BAR_CORNER_RADIUS = 2;
const DEFAULT_BAR_COUNT = 12;
const DEFAULT_FILL = "var(--foreground)";
const DEFAULT_BAR_FILL_OPACITY = 0.45;
const LINE_STROKE_OPACITY = 0.55;
const AREA_FILL_TOP_OPACITY = 0.18;
const AREA_FILL_BOTTOM_OPACITY = 0.02;
/** Bar width as a fraction of its band (the rest is the inter-bar gap). */
const DEFAULT_BAR_FRACTION = 0.7;
// ─── Pure, SSR-safe helpers ──────────────────────────────────────────────
// Heights come from a deterministic hash of (index, seed), never
// `Math.random()`, so the first server render and first client render agree
// (no Next.js hydration mismatch). Re-randomizing only bumps the numeric seed
// on the client, after a sweep completes.
/** Cheap deterministic hash to a fractional part in [0, 1). */
function hashFract(n: number): number {
const x = Math.sin(n) * 43_758.5453;
return x - Math.floor(x);
}
/** Deterministic heights (percentages of the available height) for a seed. */
export function getSkeletonHeights(
count: number,
seed = 0,
min = HEIGHT_MIN_PCT,
max = HEIGHT_MAX_PCT
): number[] {
const range = max - min;
return Array.from(
{ length: count },
(_, i) => min + Math.floor(hashFract((i + 1) * 12.9898 + seed) * range)
);
}
/** Deterministic up/down (±1) per bar for the "center" baseline. */
function getSkeletonSigns(count: number, seed = 0): number[] {
return Array.from({ length: count }, (_, i) =>
hashFract((i + 1) * 78.233 + seed) < 0.5 ? -1 : 1
);
}
/** Bell-curve opacity stops (sin squared) for the shimmer band's soft edges. */
function generateEasedGradientStops(
steps = 17,
minOpacity = 0.05,
maxOpacity = 0.9
) {
return Array.from({ length: steps }, (_, i) => {
const t = i / (steps - 1);
const eased = Math.sin(t * Math.PI) ** 2;
const opacity = minOpacity + eased * (maxOpacity - minOpacity);
return {
offset: `${(t * 100).toFixed(0)}%`,
opacity: Number(opacity.toFixed(3)),
};
});
}
// ─── Shared mask defs (`${chartId}-mask` is the mask id) ─────────────────────
function LoadingSweepMask({
chartId,
width,
height,
durationSeconds,
onSweepComplete,
}: {
chartId: string;
width: number;
height: number;
durationSeconds: number;
onSweepComplete: () => void;
}) {
const gradientStops = useMemo(() => generateEasedGradientStops(), []);
const lastXRef = useRef(SWEEP_START_X);
const handleUpdate = useCallback(
(latest: { x?: number }) => {
const xValue = typeof latest.x === "number" ? latest.x : SWEEP_START_X;
// Re-roll once the band has cleared the visible area (crossed past 1),
// so the silhouette never changes shape under the user's eye.
if (xValue >= 1 && lastXRef.current < 1) {
onSweepComplete();
}
lastXRef.current = xValue;
},
[onSweepComplete]
);
return (
<>
<linearGradient id={`${chartId}-grad`} x1="0" x2="1" y1="0" y2="0">
{gradientStops.map(({ offset, opacity }) => (
<stop
key={offset}
offset={offset}
stopColor="white"
stopOpacity={opacity}
/>
))}
</linearGradient>
<pattern
height="1"
id={`${chartId}-pattern`}
patternContentUnits="objectBoundingBox"
patternTransform={`rotate(${SWEEP_ANGLE_DEG})`}
patternUnits="objectBoundingBox"
width={3}
x="0"
y="0"
>
<motion.rect
animate={{ x: SWEEP_END_X }}
fill={`url(#${chartId}-grad)`}
height="1"
initial={{ x: SWEEP_START_X }}
onUpdate={handleUpdate}
transition={{
duration: durationSeconds,
ease: "linear",
repeat: Number.POSITIVE_INFINITY,
repeatType: "loop",
}}
width="1"
y="0"
/>
</pattern>
<mask id={`${chartId}-mask`} maskUnits="userSpaceOnUse">
<rect fill={`url(#${chartId}-pattern)`} height={height} width={width} />
</mask>
</>
);
}
// ─── Line / Area loading sweep (its own re-randomizing silhouette) ───────────
export interface LineLoadingSweepProps {
/** Curve factory from the host `<Line>` / `<Area>`, so the silhouette matches
* the chart's interpolation (step, smooth, linear, …). */
curve: CurveFactory;
/** Fill the silhouette as an area (for `<Area>`); otherwise stroke only. */
withArea?: boolean;
/** Loading phase: `"loop"` (steady), `"exit"` (loading → ready), or `"enter"`
* (ready → loading). Exit/enter fade the silhouette and then signal the chart
* to continue its reveal. Default: `"loop"`. */
mode?: "loop" | "exit" | "enter";
/** Fired when an exit/enter transition finishes, to advance the chart phase. */
onTransitionComplete?: () => void;
stroke?: string;
strokeOpacity?: number;
strokeWidth?: number;
pointCount?: number;
durationSeconds?: number;
}
/**
* Renders a placeholder line/area silhouette (its own, not the chart's skeleton)
* with the shimmer sweeping across it. The silhouette re-randomizes between
* passes. Reads inner dimensions from chart context.
*/
export function LineLoadingSweep({
curve,
withArea = false,
mode = "loop",
onTransitionComplete,
stroke = chartCssVars.foreground,
strokeOpacity = LINE_STROKE_OPACITY,
strokeWidth = 2,
pointCount = DEFAULT_POINT_COUNT,
durationSeconds = DEFAULT_SWEEP_DURATION_S,
}: LineLoadingSweepProps) {
const { innerWidth, innerHeight } = useChartStable();
const reduceMotion = useReducedMotion();
const reactId = useId();
const chartId = `line-sweep-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
const isLoop = mode === "loop";
const [tick, setTick] = useState(0);
// Re-randomize only while looping; hold the silhouette steady through a
// transition so it fades out (or in) as one piece.
const onSweepComplete = useCallback(() => {
if (isLoop) {
setTick((prev) => prev + 1);
}
}, [isLoop]);
const heights = useMemo(
() => getSkeletonHeights(pointCount, tick),
[pointCount, tick]
);
// With reduced motion there is no fade to await, so signal the handoff
// immediately or the phase machine would stall mid-transition.
useEffect(() => {
if (reduceMotion && !isLoop) {
onTransitionComplete?.();
}
}, [reduceMotion, isLoop, onTransitionComplete]);
if (innerWidth <= 0 || innerHeight <= 0 || heights.length < 2) {
return null;
}
const xScale = scaleLinear({
domain: [0, heights.length - 1],
range: [0, innerWidth],
});
const yScale = scaleLinear({ domain: [0, 100], range: [innerHeight, 0] });
const points = heights.map((value, index) => ({ index, value }));
const getX = (d: { index: number }) => xScale(d.index);
const getY = (d: { value: number }) => yScale(d.value);
const silhouette = (
<>
{withArea ? (
<AreaClosed
curve={curve}
data={points}
fill={`url(#${chartId}-area)`}
x={getX}
y={getY}
yScale={yScale}
/>
) : null}
<LinePath
curve={curve}
data={points}
fill="none"
stroke={stroke}
strokeLinecap="round"
strokeOpacity={strokeOpacity}
strokeWidth={strokeWidth}
x={getX}
y={getY}
/>
</>
);
const areaGradient = withArea ? (
<linearGradient id={`${chartId}-area`} x1="0" x2="0" y1="0" y2="1">
<stop
offset="0%"
stopColor={stroke}
stopOpacity={AREA_FILL_TOP_OPACITY}
/>
<stop
offset="100%"
stopColor={stroke}
stopOpacity={AREA_FILL_BOTTOM_OPACITY}
/>
</linearGradient>
) : null;
if (reduceMotion) {
return (
<>
{areaGradient ? <defs>{areaGradient}</defs> : null}
{silhouette}
</>
);
}
const maskUrl = `url(#${chartId}-mask)`;
const defs = (
<defs>
{areaGradient}
<LoadingSweepMask
chartId={chartId}
durationSeconds={durationSeconds}
height={innerHeight}
onSweepComplete={onSweepComplete}
width={innerWidth}
/>
</defs>
);
if (isLoop) {
return (
<>
{defs}
<g mask={maskUrl}>{silhouette}</g>
</>
);
}
// Transition: fade the swept silhouette out (exit) or in (enter), then hand
// off to the chart so it can reveal the real series.
return (
<>
{defs}
<motion.g
animate={{ opacity: mode === "exit" ? 0 : 1 }}
initial={{ opacity: mode === "exit" ? 1 : 0 }}
mask={maskUrl}
onAnimationComplete={onTransitionComplete}
transition={{
duration: LOADING_LABEL_EXIT_S,
ease: [...LINE_LOADING_PULSE_EASE],
}}
>
{silhouette}
</motion.g>
</>
);
}
LineLoadingSweep.displayName = "LineLoadingSweep";
// ─── Bar loading skeleton (seeded bars under the sweep, inner coords) ─────────
function SkeletonBars({
heights,
signs,
innerWidth,
innerHeight,
baseline,
barFraction,
fill,
fillOpacity,
}: {
heights: number[];
signs: number[];
innerWidth: number;
innerHeight: number;
baseline: "bottom" | "center";
barFraction: number;
fill: string;
fillOpacity: number;
}) {
const bandWidth = innerWidth / heights.length;
const barW = bandWidth * barFraction;
const xOffset = (bandWidth * (1 - barFraction)) / 2;
const isCenter = baseline === "center";
const baselineY = isCenter ? innerHeight / 2 : innerHeight;
const halfBarH = isCenter ? innerHeight / 2 : innerHeight;
return (
<>
{heights.map((value, i) => {
const sign = isCenter ? (signs[i] ?? 1) : 1;
const barH = Math.max(1, halfBarH * (value / 100));
const x = i * bandWidth + xOffset;
const y = sign === 1 ? baselineY - barH : baselineY;
return (
<rect
fill={fill}
fillOpacity={fillOpacity}
height={barH}
key={`${x.toFixed(2)}-${value}`}
rx={BAR_CORNER_RADIUS}
width={barW}
x={x}
y={y}
/>
);
})}
</>
);
}
export interface BarLoadingSkeletonProps {
innerWidth: number;
innerHeight: number;
/** Number of skeleton bars. Default: 12 */
barCount?: number;
/** Bar fill color. Default: `var(--foreground)` */
fill?: string;
/** Bar fill opacity. Default: 0.45 */
fillOpacity?: number;
/** Bars rise from the bottom or diverge from the vertical center. Default: `"bottom"` */
baseline?: "bottom" | "center";
/** Bar width as a fraction of its band (0–1). Default: 0.7 */
barFraction?: number;
/** One shimmer sweep, in seconds. Default: 2 */
durationSeconds?: number;
}
/**
* Skeleton bars masked by the shimmer sweep, re-randomizing between passes.
* Rendered in the chart's inner coordinate space (origin at the inner top-left),
* so a `BarChart` drops it inside its margin-translated group.
*/
export function BarLoadingSkeleton({
innerWidth,
innerHeight,
barCount = DEFAULT_BAR_COUNT,
fill = DEFAULT_FILL,
fillOpacity = DEFAULT_BAR_FILL_OPACITY,
baseline = "bottom",
barFraction = DEFAULT_BAR_FRACTION,
durationSeconds = DEFAULT_SWEEP_DURATION_S,
}: BarLoadingSkeletonProps) {
const reduceMotion = useReducedMotion();
const reactId = useId();
const chartId = `bar-sweep-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
const [tick, setTick] = useState(0);
const onSweepComplete = useCallback(() => setTick((prev) => prev + 1), []);
const heights = useMemo(
() => getSkeletonHeights(barCount, tick),
[barCount, tick]
);
const signs = useMemo(
() => getSkeletonSigns(barCount, tick),
[barCount, tick]
);
if (innerWidth <= 0 || innerHeight <= 0) {
return null;
}
const bars = (
<SkeletonBars
barFraction={barFraction}
baseline={baseline}
fill={fill}
fillOpacity={fillOpacity}
heights={heights}
innerHeight={innerHeight}
innerWidth={innerWidth}
signs={signs}
/>
);
if (reduceMotion) {
return bars;
}
return (
<>
<defs>
<LoadingSweepMask
chartId={chartId}
durationSeconds={durationSeconds}
height={innerHeight}
onSweepComplete={onSweepComplete}
width={innerWidth}
/>
</defs>
<g mask={`url(#${chartId}-mask)`}>{bars}</g>
</>
);
}
BarLoadingSkeleton.displayName = "BarLoadingSkeleton";
src/charts/line-loading-timing.ts
/** Grow + exit timeline for `LineLoadingPulse` (seconds). */
export const LINE_LOADING_PULSE_CYCLE_S = 2.2;
/** Idle gap before the loading line pulse restarts (milliseconds). */
export const LINE_LOADING_LOOP_PAUSE_MS = 280;
/** Loading label exit on loading → ready (seconds). */
export const LOADING_LABEL_EXIT_S = 0.45;
/** Loading label drops this many pixels while exiting. */
export const LOADING_LABEL_EXIT_Y_PX = 30;
export const LINE_LOADING_PULSE_EASE = [0.85, 0, 0.15, 1] as const;
src/charts/chart-loading-label.tsx
"use client";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
import { ShimmeringText } from "../components/shimmering-text";
import {
LINE_LOADING_PULSE_EASE,
LOADING_LABEL_EXIT_S,
LOADING_LABEL_EXIT_Y_PX,
} from "./line-loading-timing";
export interface ChartLoadingLabelProps {
/** Label shown centered over the chart. */
text?: string;
className?: string;
/** Animate down, fade, and blur during loading → ready handoff. */
exiting?: boolean;
}
export function ChartLoadingLabel({
text = "Loading",
className,
exiting = false,
}: ChartLoadingLabelProps) {
if (!text.trim()) {
return null;
}
return (
<motion.div
animate={{
y: exiting ? LOADING_LABEL_EXIT_Y_PX : 0,
opacity: exiting ? 0 : 1,
filter: exiting ? "blur(2px)" : "blur(0px)",
}}
aria-live="polite"
className={cn(
"pointer-events-none absolute inset-0 flex items-center justify-center",
className
)}
initial={false}
role="status"
transition={{
duration: LOADING_LABEL_EXIT_S,
ease: [...LINE_LOADING_PULSE_EASE],
}}
>
<ShimmeringText
className="font-medium text-sm tracking-wide [--color:var(--muted-foreground)] [--shimmering-color:var(--foreground)]"
text={text}
/>
</motion.div>
);
}
export default ChartLoadingLabel;
src/charts/pattern-area.tsx
"use client";
import { curveMonotoneX } from "@visx/curve";
import { AreaClosed } from "@visx/shape";
import { useChartStable } from "./chart-context";
// biome-ignore lint/suspicious/noExplicitAny: d3 curve factory type
type CurveFactory = any;
export interface PatternAreaProps {
/** Key in data to use for y values */
dataKey: string;
/** Fill color or pattern URL (e.g. `url(#pattern-id)`) */
fill: string;
/** Curve function. Default: curveMonotoneX */
curve?: CurveFactory;
/** @deprecated Pattern fill is not clip-revealed; only the stroke `Area` animates. */
animate?: boolean;
}
/**
* Filled area using an SVG pattern (`url(#id)`).
* Pair with `PatternLines` in `AreaChart` children and an `Area` with `fillOpacity={0}` for the stroke line.
*/
export function PatternArea({
dataKey,
fill,
curve = curveMonotoneX,
}: PatternAreaProps) {
const { renderData, xScale, yScale, xAccessor } = useChartStable();
return (
<AreaClosed
curve={curve}
data={renderData}
fill={fill}
x={(d) => xScale(xAccessor(d)) ?? 0}
y={(d) => {
const v = d[dataKey];
return typeof v === "number" ? (yScale(v) ?? 0) : 0;
}}
yScale={yScale}
/>
);
}
PatternArea.displayName = "PatternArea";
export default PatternArea;