Bklit UI
componentchart-contextsource: ok · refreshed
Chart Context
Shared context and hooks for Bklit chart components
npx shadcn add "https://ui.bklit.com/r/chart-context.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
Registry dependencies
@bklit/utils@bklit/chart-utils
Files12 · source cached 10/8/2026, 8:49:33 AM
src/charts/chart-context.tsx
"use client";
import type { scaleBand, scaleLinear, scaleTime } from "@visx/scale";
type ScaleLinear<Output, _Input = number> = ReturnType<
typeof scaleLinear<Output>
>;
type ScaleTime<Output, _Input = Date | number> = ReturnType<
typeof scaleTime<Output>
>;
type ScaleBand<Domain extends { toString(): string }> = ReturnType<
typeof scaleBand<Domain>
>;
import type { Transition } from "motion/react";
import {
createContext,
type Dispatch,
type ReactNode,
type RefObject,
type SetStateAction,
useContext,
useMemo,
} from "react";
import type { ChartPhase, ChartStatus } from "./chart-phase";
import type { ReferenceAreaConfig } from "./reference-area-config";
import type { ChartSelection } from "./use-chart-interaction";
import { DEFAULT_Y_AXIS_ID } from "./y-axis-scales";
import type { YDomain } from "./y-domain-utils";
// CSS variable references for theming
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)",
brushBorder: "var(--chart-brush-border)",
tooltipBackground: "var(--chart-tooltip-background)",
};
/** Default scatter series colors from the chart palette (`--chart-1` … `--chart-5`). */
export const defaultScatterColors = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
] as const;
export interface Margin {
top: number;
right: number;
bottom: number;
left: number;
}
export interface TooltipData {
/** The data point being hovered */
point: Record<string, unknown>;
/** Index in the data array */
index: number;
/** X position in pixels (relative to chart area) */
x: number;
/** Y positions for each line, keyed by dataKey */
yPositions: Record<string, number>;
/** X positions for each series (for grouped bars), keyed by dataKey */
xPositions?: Record<string, number>;
}
export interface LineConfig {
dataKey: string;
stroke: string;
strokeWidth: number;
/** Scale group id (Recharts `yAxisId`). Default: `"left"`. */
yAxisId?: string | number;
}
/**
* Hover/selection state — every field here changes on mouse movement.
* Lives in its own context so cold consumers (Grid, YAxis, PatternArea, …)
* can subscribe to the stable slice and skip re-rendering on every hover.
*/
export interface ChartHoverContextValue {
// Tooltip state
tooltipData: TooltipData | null;
setTooltipData: Dispatch<SetStateAction<TooltipData | null>>;
// Selection state (optional - only present when useChartInteraction is used)
/** Current drag/pinch selection range */
selection?: ChartSelection | null;
/** Clear the current selection */
clearSelection?: () => void;
// Bar chart hover (optional - only present in BarChart)
/** Index of currently hovered bar */
hoveredBarIndex?: number | null;
/** Setter for hovered bar index */
setHoveredBarIndex?: (index: number | null) => void;
// Candlestick hover (optional - only present in CandlestickChart)
/** Index of currently hovered candle */
hoveredCandleIndex?: number | null;
/** Setter for hovered candle index */
setHoveredCandleIndex?: (index: number | null) => void;
}
export interface ChartContextValue extends ChartHoverContextValue {
// Data
data: Record<string, unknown>[];
/** Decimated subset for SVG path rendering; equals `data` when no decimation is needed. */
renderData: Record<string, unknown>[];
// Scales
xScale: ScaleTime<number, number>;
/** Primary (left) y-scale — alias for `yScales[DEFAULT_Y_AXIS_ID]`. */
yScale: ScaleLinear<number, number>;
/** Per-axis y-scales keyed by `yAxisId`. */
yScales: Record<string, ScaleLinear<number, number>>;
// Dimensions
width: number;
height: number;
innerWidth: number;
innerHeight: number;
margin: Margin;
// Column width for spacing calculations
columnWidth: number;
// Container ref for portals
containerRef: RefObject<HTMLDivElement | null>;
// Line configurations (extracted from children)
lines: LineConfig[];
/** {@link ReferenceArea} bands — drives y-axis label colors in range. */
referenceAreas: ReferenceAreaConfig[];
// Loading / lifecycle (LineChart status transitions)
chartPhase: ChartPhase;
chartStatus: ChartStatus;
/** Centered label while `chartPhase` shows loading chrome. */
loadingLabel?: string;
/** Y-domain tween duration when transitioning loading ↔ ready (ms). */
yDomainTweenDuration: number;
/** Nice’d y-domains per axis from skeleton data (placeholder). */
yDomainSkeletonByAxis: Record<string, YDomain>;
/** Nice’d y-domains per axis from the current target data. */
yDomainTargetByAxis: Record<string, YDomain>;
// Animation state
isLoaded: boolean;
animationDuration: number;
/** CSS easing for clip-reveal / line draw (cartesian charts). */
animationEasing?: string;
/** Motion enter transition (spring or tween) — drives clip reveal when spring. */
enterTransition?: Transition;
/** Increments when enter animation should replay. */
revealEpoch?: number;
/** Fired when a one-shot loading pulse (exit / enter) completes. */
notifyLoadingPulseComplete?: () => void;
// X accessor - how to get the x value from data points
xAccessor: (d: Record<string, unknown>) => Date;
// Pre-computed date labels for ticker animation
dateLabels: string[];
/** Active brush zoom range — when set, axis ticks align to visible data rows. */
xDomain?: [Date, Date];
/** Full dataset length when brush zoom is enabled (for zoom vs full-range detection). */
xDomainSlotCount?: number;
// Bar chart specific (optional - only present in BarChart)
/** Band scale for categorical x-axis (bar charts) */
barScale?: ScaleBand<string>;
/** Width of each bar band */
bandWidth?: number;
/** X accessor for bar charts (returns string instead of Date) */
barXAccessor?: (d: Record<string, unknown>) => string;
/** Bar chart orientation */
orientation?: "vertical" | "horizontal";
/** Whether bars are stacked */
stacked?: boolean;
/** Stack offsets: Map of data index -> Map of dataKey -> cumulative offset */
stackOffsets?: Map<number, Map<string, number>>;
/** Squares variant — snap tooltip to top square and size ring dots. */
squareSnap?: { squareGap: number; groupGap?: number; fit?: boolean };
// ComposedChart + SeriesBar (optional)
/** `SeriesBar` dataKeys in tree order, for grouped columns at each x */
composedBarDataKeys?: string[];
/** Target bar width in px (Recharts `barSize` style). */
composedBarSize?: number;
/** Max bar width in px (Recharts `maxBarSize`). */
composedMaxBarSize?: number;
/** Gap between grouped `SeriesBar` columns in px. */
composedBarGap?: number;
/** When true, `SeriesBar` segments stack in child order at each x. */
composedStacked?: boolean;
/** Per-row cumulative offsets for stacked `SeriesBar` (data index → dataKey → offset). */
composedStackOffsets?: Map<number, Map<string, number>>;
/** Vertical gap in px between stacked `SeriesBar` segments. Default: 0 */
composedStackGap?: number;
}
/**
* Stable slice of the chart context — everything that doesn't change on hover
* (data, scales, dimensions, animation state, layout config). Consumers that
* subscribe via `useChartStable()` skip re-renders on every mouse move.
*/
export type ChartStableContextValue = Omit<
ChartContextValue,
keyof ChartHoverContextValue
>;
const ChartStableContext = createContext<ChartStableContextValue | null>(null);
const ChartHoverContext = createContext<ChartHoverContextValue | null>(null);
/**
* Splits the merged `value` into a stable slice and a volatile hover slice,
* publishing each to its own context. Each slice is memoized on its own
* field identities, so changing `tooltipData` does not bust the stable
* slice — consumers of `useChartStable()` skip re-renders on hover.
*/
export function ChartProvider({
children,
value,
}: {
children: ReactNode;
value: ChartContextValue;
}) {
const stable = useMemo<ChartStableContextValue>(
() => ({
data: value.data,
renderData: value.renderData,
xScale: value.xScale,
yScale: value.yScale,
yScales: value.yScales,
width: value.width,
height: value.height,
innerWidth: value.innerWidth,
innerHeight: value.innerHeight,
margin: value.margin,
columnWidth: value.columnWidth,
containerRef: value.containerRef,
lines: value.lines,
referenceAreas: value.referenceAreas,
chartPhase: value.chartPhase,
chartStatus: value.chartStatus,
loadingLabel: value.loadingLabel,
yDomainTweenDuration: value.yDomainTweenDuration,
yDomainSkeletonByAxis: value.yDomainSkeletonByAxis,
yDomainTargetByAxis: value.yDomainTargetByAxis,
isLoaded: value.isLoaded,
animationDuration: value.animationDuration,
animationEasing: value.animationEasing,
enterTransition: value.enterTransition,
revealEpoch: value.revealEpoch,
notifyLoadingPulseComplete: value.notifyLoadingPulseComplete,
xAccessor: value.xAccessor,
dateLabels: value.dateLabels,
xDomain: value.xDomain,
xDomainSlotCount: value.xDomainSlotCount,
barScale: value.barScale,
bandWidth: value.bandWidth,
barXAccessor: value.barXAccessor,
orientation: value.orientation,
stacked: value.stacked,
stackOffsets: value.stackOffsets,
composedBarDataKeys: value.composedBarDataKeys,
composedBarSize: value.composedBarSize,
composedMaxBarSize: value.composedMaxBarSize,
composedBarGap: value.composedBarGap,
composedStacked: value.composedStacked,
composedStackOffsets: value.composedStackOffsets,
composedStackGap: value.composedStackGap,
}),
[
value.data,
value.renderData,
value.xScale,
value.yScale,
value.yScales,
value.width,
value.height,
value.innerWidth,
value.innerHeight,
value.margin,
value.columnWidth,
value.containerRef,
value.lines,
value.referenceAreas,
value.chartPhase,
value.chartStatus,
value.loadingLabel,
value.yDomainTweenDuration,
value.yDomainSkeletonByAxis,
value.yDomainTargetByAxis,
value.isLoaded,
value.animationDuration,
value.animationEasing,
value.enterTransition,
value.revealEpoch,
value.notifyLoadingPulseComplete,
value.xAccessor,
value.dateLabels,
value.xDomain,
value.xDomainSlotCount,
value.barScale,
value.bandWidth,
value.barXAccessor,
value.orientation,
value.stacked,
value.stackOffsets,
value.composedBarDataKeys,
value.composedBarSize,
value.composedMaxBarSize,
value.composedBarGap,
value.composedStacked,
value.composedStackOffsets,
value.composedStackGap,
]
);
const hover = useMemo<ChartHoverContextValue>(
() => ({
tooltipData: value.tooltipData,
setTooltipData: value.setTooltipData,
selection: value.selection,
clearSelection: value.clearSelection,
hoveredBarIndex: value.hoveredBarIndex,
setHoveredBarIndex: value.setHoveredBarIndex,
hoveredCandleIndex: value.hoveredCandleIndex,
setHoveredCandleIndex: value.setHoveredCandleIndex,
}),
[
value.tooltipData,
value.setTooltipData,
value.selection,
value.clearSelection,
value.hoveredBarIndex,
value.setHoveredBarIndex,
value.hoveredCandleIndex,
value.setHoveredCandleIndex,
]
);
return (
<ChartStableContext.Provider value={stable}>
<ChartHoverContext.Provider value={hover}>
{children}
</ChartHoverContext.Provider>
</ChartStableContext.Provider>
);
}
/**
* Stable slice — data, scales, dimensions, animation state, layout config.
* Subscribers skip re-renders on hover (the hover slice lives in a separate
* context). Prefer this in cold consumers like axes, grid, pattern fills.
*/
export function useChartStable(): ChartStableContextValue {
const context = useContext(ChartStableContext);
if (!context) {
throw new Error(
"useChartStable must be used within a ChartProvider. " +
"Make sure your component is wrapped in <LineChart>, <AreaChart>, <BarChart>, or <ComposedChart>."
);
}
return context;
}
/** Y-scale for a series axis (`yAxisId` on Line / Area / YAxis). */
export function useYScale(
yAxisId?: string | number
): ScaleLinear<number, number> {
const { yScales, yScale } = useChartStable();
const id =
yAxisId == null || yAxisId === "" ? DEFAULT_Y_AXIS_ID : String(yAxisId);
return yScales[id] ?? yScale;
}
/**
* Hover slice — tooltipData, selection, hovered bar / candle indices.
* Subscribers re-render on every mouse move. Use only when the component
* actually reads hover state.
*/
export function useChartHover(): ChartHoverContextValue {
const context = useContext(ChartHoverContext);
if (!context) {
throw new Error(
"useChartHover must be used within a ChartProvider. " +
"Make sure your component is wrapped in <LineChart>, <AreaChart>, <BarChart>, or <ComposedChart>."
);
}
return context;
}
/**
* Merged stable + hover context. Convenient for components that need both,
* but re-renders on every hover (because hover changes). Prefer
* `useChartStable()` or `useChartHover()` for hot consumers that only need
* one slice.
*/
export function useChart(): ChartContextValue {
const stable = useChartStable();
const hover = useChartHover();
// Identity changes on every hover (hover is the volatile slice) — that's
// fine for consumers using this merged hook; they explicitly opted in to
// re-rendering on hover.
return { ...stable, ...hover };
}
export default ChartStableContext;
src/charts/reference-area-config.ts
import {
Children,
isValidElement,
type ReactElement,
type ReactNode,
} from "react";
import { normalizeYAxisId } from "./y-axis-scales";
export interface ReferenceAreaConfig {
yAxisId: string;
y1?: number;
y2?: number;
axisLabelColor?: string;
}
interface ReferenceAreaConfigProps {
yAxisId?: string | number;
y1?: number;
y2?: number;
axisLabelColor?: string;
}
function getChildComponentName(child: ReactElement) {
const childType = child.type as { displayName?: string; name?: string };
return typeof child.type === "function"
? childType.displayName || childType.name || ""
: "";
}
function isReferenceAreaElement(child: ReactElement): boolean {
return getChildComponentName(child) === "ReferenceArea";
}
/** Collect {@link ReferenceArea} props from chart children for axis label styling. */
export function extractReferenceAreaConfigs(
children: ReactNode
): ReferenceAreaConfig[] {
const configs: ReferenceAreaConfig[] = [];
const visit = (node: ReactNode) => {
Children.forEach(node, (child) => {
if (!isValidElement(child)) {
return;
}
if (isReferenceAreaElement(child)) {
const props = child.props as ReferenceAreaConfigProps | undefined;
if (props) {
configs.push({
yAxisId: normalizeYAxisId(props.yAxisId),
y1: props.y1,
y2: props.y2,
axisLabelColor: props.axisLabelColor,
});
}
return;
}
const childProps = child.props as { children?: ReactNode } | undefined;
if (childProps?.children) {
visit(childProps.children);
}
});
};
visit(children);
return configs;
}
src/charts/use-chart-interaction.ts
"use client";
import { localPoint } from "@visx/event";
import type { scaleLinear, scaleTime } from "@visx/scale";
import { useCallback, useEffect, useRef, useState } from "react";
import type { LineConfig, Margin, TooltipData } from "./chart-context";
import { useScheduledTooltip } from "./use-scheduled-tooltip";
import { normalizeYAxisId } from "./y-axis-scales";
type ScaleTime = ReturnType<typeof scaleTime<number>>;
type ScaleLinear = ReturnType<typeof scaleLinear<number>>;
export interface ChartSelection {
startX: number;
endX: number;
startIndex: number;
endIndex: number;
active: boolean;
}
interface UseChartInteractionParams {
xScale: ScaleTime;
yScale: ScaleLinear;
yScales: Record<string, 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: React.Dispatch<React.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;
}
export function useChartInteraction({
xScale,
yScale,
yScales,
data,
lines,
margin,
xAccessor,
bisectDate,
canInteract,
}: UseChartInteractionParams): ChartInteractionResult {
const [selection, setSelection] = useState<ChartSelection | null>(null);
const {
tooltipData,
setTooltipData,
scheduleTooltip,
clearTooltip,
resetTooltipDedupe,
} = useScheduledTooltip<TooltipData>();
const isDraggingRef = useRef(false);
const dragStartXRef = useRef<number>(0);
const lastHoveredXRef = useRef<number | null>(null);
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") {
const axisScale = yScales[normalizeYAxisId(line.yAxisId)] ?? yScale;
yPositions[line.dataKey] = axisScale(value) ?? 0;
}
}
return {
point: d,
index: finalIndex,
x: xScale(xAccessor(d)) ?? 0,
yPositions,
};
},
[xScale, yScale, yScales, 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;
}
lastHoveredXRef.current = chartX;
const tooltip = resolveTooltipFromX(chartX);
if (tooltip) {
scheduleTooltip(tooltip);
}
},
[getChartX, resolveTooltipFromX, resolveIndexFromX, scheduleTooltip]
);
const handleMouseLeave = useCallback(() => {
lastHoveredXRef.current = null;
clearTooltip();
if (isDraggingRef.current) {
isDraggingRef.current = false;
}
setSelection(null);
}, [clearTooltip]);
const handleMouseDown = useCallback(
(event: React.MouseEvent<SVGGElement>) => {
const chartX = getChartX(event);
if (chartX === null) {
return;
}
isDraggingRef.current = true;
dragStartXRef.current = chartX;
clearTooltip();
setSelection(null);
},
[getChartX, clearTooltip]
);
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;
}
lastHoveredXRef.current = chartX;
const tooltip = resolveTooltipFromX(chartX);
if (tooltip) {
scheduleTooltip(tooltip);
}
} else if (event.touches.length === 2) {
event.preventDefault();
resetTooltipDedupe();
clearTooltip();
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,
scheduleTooltip,
resetTooltipDedupe,
clearTooltip,
]
);
const handleTouchMove = useCallback(
(event: React.TouchEvent<SVGGElement>) => {
if (event.touches.length === 1) {
event.preventDefault();
const chartX = getChartX(event, 0);
if (chartX === null) {
return;
}
lastHoveredXRef.current = chartX;
const tooltip = resolveTooltipFromX(chartX);
if (tooltip) {
scheduleTooltip(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, scheduleTooltip]
);
const handleTouchEnd = useCallback(() => {
clearTooltip();
setSelection(null);
}, [clearTooltip]);
const clearSelection = useCallback(() => {
setSelection(null);
}, []);
// Re-anchor tooltip/crosshair when x-scale or visible data changes (e.g. brush zoom commit).
useEffect(() => {
if (!canInteract || lastHoveredXRef.current === null) {
return;
}
const tooltip = resolveTooltipFromX(lastHoveredXRef.current);
if (tooltip) {
scheduleTooltip(tooltip, `${tooltip.index}:${Math.round(tooltip.x)}`);
return;
}
clearTooltip();
}, [canInteract, clearTooltip, resolveTooltipFromX, scheduleTooltip]);
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,
};
}
src/charts/y-axis-scales.ts
import { scaleLinear } from "@visx/scale";
import type { LineConfig } from "./chart-context";
/** Default axis id when `yAxisId` is omitted (Recharts-style `0` / primary left axis). */
export const DEFAULT_Y_AXIS_ID = "left";
export type YAxisOrientation = "left" | "right";
export function normalizeYAxisId(id?: string | number): string {
if (id == null || id === "") {
return DEFAULT_Y_AXIS_ID;
}
return String(id);
}
export function groupLinesByYAxisId(
lines: LineConfig[]
): Map<string, LineConfig[]> {
const groups = new Map<string, LineConfig[]>();
for (const line of lines) {
const axisId = normalizeYAxisId(line.yAxisId);
const bucket = groups.get(axisId) ?? [];
bucket.push(line);
groups.set(axisId, bucket);
}
return groups;
}
type YScale = ReturnType<typeof scaleLinear<number>>;
export function getPrimaryYScale(
yScales: Record<string, YScale>,
fallback: YScale
): YScale {
const primary = yScales[DEFAULT_Y_AXIS_ID];
if (primary) {
return primary;
}
const first = Object.values(yScales)[0];
return first ?? fallback;
}
export function buildYScalesForLines({
lines,
innerHeight,
resolveDomain,
}: {
lines: LineConfig[];
/** Passed by callers; domain is resolved via `resolveDomain`. */
data?: Record<string, unknown>[];
innerHeight: number;
resolveDomain: (dataKeys: string[]) => [number, number];
}): Record<string, YScale> {
const groups = groupLinesByYAxisId(lines);
const scales: Record<string, YScale> = {};
for (const [axisId, axisLines] of groups) {
const dataKeys = axisLines.map((line) => line.dataKey);
const domain = resolveDomain(dataKeys);
scales[axisId] = scaleLinear({
range: [innerHeight, 0],
domain,
nice: true,
});
}
if (!scales[DEFAULT_Y_AXIS_ID]) {
scales[DEFAULT_Y_AXIS_ID] = scaleLinear({
range: [innerHeight, 0],
domain: [0, 100],
nice: true,
});
}
return scales;
}
/** Build y-scales from pre-computed (already nice'd) domain endpoints. */
export function buildYScalesFromDomains({
lines,
innerHeight,
domainsByAxis,
}: {
lines: LineConfig[];
innerHeight: number;
domainsByAxis: Record<string, [number, number]>;
}): Record<string, YScale> {
const groups = groupLinesByYAxisId(lines);
const scales: Record<string, YScale> = {};
for (const [axisId] of groups) {
const domain =
domainsByAxis[axisId] ??
domainsByAxis[DEFAULT_Y_AXIS_ID] ??
([0, 100] as [number, number]);
scales[axisId] = scaleLinear({
range: [innerHeight, 0],
domain,
});
}
if (!scales[DEFAULT_Y_AXIS_ID]) {
scales[DEFAULT_Y_AXIS_ID] = scaleLinear({
range: [innerHeight, 0],
domain: domainsByAxis[DEFAULT_Y_AXIS_ID] ?? [0, 100],
});
}
return scales;
}
/** Single-axis charts (bar, scatter, candlestick, live line). */
export function wrapSingleYScale(yScale: YScale): Record<string, YScale> {
return { [DEFAULT_Y_AXIS_ID]: yScale };
}
src/charts/y-axis-ticks.ts
/** Default hint passed to `scale.ticks()` (d3 — approximate tick count). */
export const Y_AXIS_DEFAULT_TICK_COUNT = 5;
/** Minimum valid `numTicks` for `scale.ticks()` — values ≤ 0 yield no ticks. */
export const Y_AXIS_MIN_TICK_COUNT = 1;
/**
* Upper bound for the tick count hint. D3 may return more "nice" ticks above ~10;
* keeping the hint in a modest range avoids overcrowded axes.
*/
export const Y_AXIS_MAX_TICK_COUNT = 10;
/** Clamps a user `numTicks` value to a valid d3 tick-count hint. */
export function resolveYAxisTickCount(numTicks?: number): number {
if (numTicks == null || !Number.isFinite(numTicks)) {
return Y_AXIS_DEFAULT_TICK_COUNT;
}
const rounded = Math.round(numTicks);
if (rounded < Y_AXIS_MIN_TICK_COUNT) {
return Y_AXIS_MIN_TICK_COUNT;
}
if (rounded > Y_AXIS_MAX_TICK_COUNT) {
return Y_AXIS_MAX_TICK_COUNT;
}
return rounded;
}
src/charts/chart-phase.ts
/** Consumer-facing fetch / display status on time-series charts. */
export type ChartStatus = "loading" | "ready";
/** Loading animation style: the default traveling pulse, or a diagonal
* shimmer that sweeps across the skeleton. */
export type LoadingStyle = "pulse" | "sweep";
/**
* Internal visual lifecycle phase. Forward and reverse transitions add
* intermediate phases in later stack branches.
*/
export type ChartPhase =
| "loading"
| "exiting"
| "gridTweenReady"
| "revealing"
| "ready"
| "exitingReady"
| "gridTweenLoading"
| "revealingLoading";
export const DEFAULT_CHART_STATUS: ChartStatus = "ready";
/** Default Y-domain tween when transitioning loading ↔ ready (ms). */
export const DEFAULT_Y_DOMAIN_TWEEN_MS = 500;
/** Relative domain delta below which Y tween may be skipped (see plan). */
export const Y_DOMAIN_TWEEN_SKIP_THRESHOLD = 0.02;
/** Resting phase for a given status before transition orchestration runs. */
export function resolveRestingChartPhase(status: ChartStatus): ChartPhase {
return status === "loading" ? "loading" : "ready";
}
export function isChartInteractionPhase(phase: ChartPhase): boolean {
return phase === "ready";
}
export const DEFAULT_CHART_LIFECYCLE = {
chartPhase: "ready",
chartStatus: "ready",
loadingLabel: undefined,
yDomainTweenDuration: DEFAULT_Y_DOMAIN_TWEEN_MS,
yDomainSkeletonByAxis: { left: [0, 100] as [number, number] },
yDomainTargetByAxis: { left: [0, 100] as [number, number] },
} as const satisfies {
chartPhase: ChartPhase;
chartStatus: ChartStatus;
loadingLabel: undefined;
yDomainTweenDuration: number;
yDomainSkeletonByAxis: Record<string, [number, number]>;
yDomainTargetByAxis: Record<string, [number, number]>;
};
src/charts/y-domain-utils.ts
import { scaleLinear } from "@visx/scale";
import type { LineConfig } from "./chart-context";
import { type ChartPhase, Y_DOMAIN_TWEEN_SKIP_THRESHOLD } from "./chart-phase";
import { groupLinesByYAxisId, normalizeYAxisId } from "./y-axis-scales";
export type YDomain = [number, number];
/** Apply visx `nice()` to raw domain endpoints for stable grid ticks. */
export function niceYDomain(domain: YDomain): YDomain {
const scale = scaleLinear({ domain, range: [0, 1], nice: true });
const niceDomain = scale.domain();
return [niceDomain[0] ?? domain[0], niceDomain[1] ?? domain[1]];
}
/**
* Skip Y tween when both endpoints move less than the threshold relative to span.
* When in doubt callers should tween — beauty wins over micro-optimization.
*/
export function shouldTweenYDomain(from: YDomain, to: YDomain): boolean {
const span = Math.max(
Math.abs(to[1] - to[0]),
Math.abs(from[1] - from[0]),
1
);
const deltaMin = Math.abs(to[0] - from[0]) / span;
const deltaMax = Math.abs(to[1] - from[1]) / span;
return (
deltaMin >= Y_DOMAIN_TWEEN_SKIP_THRESHOLD ||
deltaMax >= Y_DOMAIN_TWEEN_SKIP_THRESHOLD
);
}
/** Phases where the chart shows loading chrome (shimmer, pulse, label). */
export function isLoadingChromePhase(phase: ChartPhase): boolean {
return phase === "loading" || phase === "revealingLoading";
}
/** Phases where grid lines use loading stroke styling (muted / dashed chrome). */
export function isLoadingGridChromePhase(phase: ChartPhase): boolean {
return (
phase === "loading" || phase === "exiting" || phase === "gridTweenLoading"
);
}
/** Phases where Y-domain tween runs after the series has exited. */
export function isYDomainTweenPhase(phase: ChartPhase): boolean {
return phase === "gridTweenLoading" || phase === "gridTweenReady";
}
/** Phases where {@link ReferenceArea} bands are shown (fade in/out on transitions). */
export function isReferenceAreaVisiblePhase(phase: ChartPhase): boolean {
return (
phase === "ready" || phase === "revealing" || phase === "gridTweenReady"
);
}
export function resolveAnimatedYDestinationDomains(
chartPhase: ChartPhase,
skeletonByAxis: Record<string, YDomain>,
targetByAxis: Record<string, YDomain>
): Record<string, YDomain> {
switch (chartPhase) {
case "loading":
case "exiting":
case "gridTweenLoading":
return skeletonByAxis;
case "exitingReady":
case "gridTweenReady":
case "revealing":
case "ready":
return targetByAxis;
default:
return targetByAxis;
}
}
export function computeYDomainsByAxis({
lines,
resolveDomain,
}: {
lines: LineConfig[];
resolveDomain: (dataKeys: string[]) => YDomain;
}): Record<string, YDomain> {
const groups = groupLinesByYAxisId(lines);
const domains: Record<string, YDomain> = {};
for (const [axisId, axisLines] of groups) {
const dataKeys = axisLines.map((line) => line.dataKey);
domains[normalizeYAxisId(axisId)] = niceYDomain(resolveDomain(dataKeys));
}
if (!domains.left) {
domains.left = niceYDomain([0, 100]);
}
return domains;
}
/** Merge domain maps, normalizing axis ids to strings. */
export function mergeYDomainRecords(
...records: Record<string, YDomain>[]
): Record<string, YDomain> {
const merged: Record<string, YDomain> = {};
for (const record of records) {
for (const [axisId, domain] of Object.entries(record)) {
merged[normalizeYAxisId(axisId)] = domain;
}
}
return merged;
}
export function domainsEqual(
left: Record<string, YDomain>,
right: Record<string, YDomain>
): boolean {
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if (leftKeys.length !== rightKeys.length) {
return false;
}
for (const axisId of leftKeys) {
const from = left[axisId];
const to = right[axisId];
if (!(from && to) || from[0] !== to[0] || from[1] !== to[1]) {
return false;
}
}
return true;
}
src/charts/filter-data-by-x-domain.ts
export function filterDataByXDomain(
data: Record<string, unknown>[],
xDomain: [Date, Date],
xAccessor: (d: Record<string, unknown>) => Date
): Record<string, unknown>[] {
const start = xDomain[0].getTime();
const end = xDomain[1].getTime();
const minTime = Math.min(start, end);
const maxTime = Math.max(start, end);
return data.filter((d) => {
const time = xAccessor(d).getTime();
return time >= minTime && time <= maxTime;
});
}
export function resolveDataXExtent(
data: Record<string, unknown>[],
xAccessor: (d: Record<string, unknown>) => Date
): [Date, Date] | null {
if (data.length === 0) {
return null;
}
let minTime = Number.POSITIVE_INFINITY;
let maxTime = Number.NEGATIVE_INFINITY;
for (const point of data) {
const time = xAccessor(point).getTime();
if (time < minTime) {
minTime = time;
}
if (time > maxTime) {
maxTime = time;
}
}
if (minTime === Number.POSITIVE_INFINITY) {
return null;
}
return [new Date(minTime), new Date(maxTime)];
}
/** Brush track extent — optionally extends past the last data row (e.g. projections). */
export function resolveBrushTrackXExtent(
data: Record<string, unknown>[],
xAccessor: (d: Record<string, unknown>) => Date,
xExtentMax?: Date
): [Date, Date] | null {
const extent = resolveDataXExtent(data, xAccessor);
if (!extent) {
return null;
}
if (!xExtentMax || xExtentMax.getTime() <= extent[1].getTime()) {
return extent;
}
return [extent[0], xExtentMax];
}
src/charts/generate-chart-skeleton-data.ts
const DEFAULT_SKELETON_DATA_KEY = "value";
const DEFAULT_SKELETON_POINT_COUNT = 7;
export interface GenerateChartSkeletonDataOptions {
/** Key used for y values in each row. Default: `"value"`. */
dataKey?: string;
/** Number of points. Default: 7. */
pointCount?: number;
/** Start date for the x axis. Default: 2025-01-01. */
baseDate?: Date;
}
/** Placeholder series used while `status="loading"` and data is empty. */
export function generateChartSkeletonData(
options: GenerateChartSkeletonDataOptions = {}
): Record<string, unknown>[] {
const dataKey = options.dataKey ?? DEFAULT_SKELETON_DATA_KEY;
const pointCount = options.pointCount ?? DEFAULT_SKELETON_POINT_COUNT;
const baseDate = options.baseDate ?? new Date("2025-01-01");
return Array.from({ length: pointCount }, (_, index) => {
const date = new Date(baseDate);
date.setDate(baseDate.getDate() + index);
return {
date,
[dataKey]: Math.round(110 + Math.sin(index * 1.15) * 36 + index * 9),
};
});
}
/** Skeleton rows that mirror target dates/count with lower magnitudes for Y tween. */
export function generateChartSkeletonFromTarget(
targetData: Record<string, unknown>[],
dataKey: string
): Record<string, unknown>[] {
return targetData.map((row, index) => ({
...row,
[dataKey]: Math.round(95 + Math.sin(index * 1.05) * 28 + index * 7),
}));
}
export { DEFAULT_SKELETON_DATA_KEY, DEFAULT_SKELETON_POINT_COUNT };
src/charts/use-animated-y-domains.ts
"use client";
import { animate, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import type { ChartPhase } from "./chart-phase";
import { LINE_LOADING_PULSE_EASE } from "./line-loading-timing";
import {
domainsEqual,
isYDomainTweenPhase,
resolveAnimatedYDestinationDomains,
shouldTweenYDomain,
type YDomain,
} from "./y-domain-utils";
function lerpDomain(from: YDomain, to: YDomain, progress: number): YDomain {
return [
from[0] + (to[0] - from[0]) * progress,
from[1] + (to[1] - from[1]) * progress,
];
}
function snapDomains(
domains: Record<string, YDomain>,
setAnimatedByAxis: (domains: Record<string, YDomain>) => void,
animatedRef: { current: Record<string, YDomain> }
) {
if (domainsEqual(animatedRef.current, domains)) {
return;
}
setAnimatedByAxis(domains);
animatedRef.current = domains;
}
function tweenDomains({
destination,
durationMs,
enabled,
reducedMotion,
animatedRef,
setAnimatedByAxis,
onSettled,
}: {
destination: Record<string, YDomain>;
durationMs: number;
enabled: boolean;
reducedMotion: boolean | null;
animatedRef: { current: Record<string, YDomain> };
setAnimatedByAxis: (domains: Record<string, YDomain>) => void;
onSettled?: () => void;
}) {
if (domainsEqual(animatedRef.current, destination)) {
onSettled?.();
return;
}
if (!enabled || reducedMotion) {
snapDomains(destination, setAnimatedByAxis, animatedRef);
onSettled?.();
return;
}
const axisIds = Object.keys(destination);
const fromSnapshot = animatedRef.current;
let needsTween = false;
for (const axisId of axisIds) {
const from =
fromSnapshot[axisId] ?? destination[axisId] ?? ([0, 100] as YDomain);
const to = destination[axisId] ?? from;
if (shouldTweenYDomain(from, to)) {
needsTween = true;
break;
}
}
if (!needsTween) {
snapDomains(destination, setAnimatedByAxis, animatedRef);
onSettled?.();
return;
}
const fromByAxis: Record<string, YDomain> = {};
for (const axisId of axisIds) {
fromByAxis[axisId] = fromSnapshot[axisId] ??
destination[axisId] ?? [0, 100];
}
const control = animate(0, 1, {
duration: durationMs / 1000,
ease: [...LINE_LOADING_PULSE_EASE],
onUpdate: (progress) => {
const next: Record<string, YDomain> = {};
for (const axisId of axisIds) {
const from =
fromByAxis[axisId] ?? destination[axisId] ?? ([0, 100] as YDomain);
const to = destination[axisId] ?? from;
next[axisId] = shouldTweenYDomain(from, to)
? lerpDomain(from, to, progress)
: to;
}
animatedRef.current = next;
setAnimatedByAxis(next);
},
onComplete: () => {
snapDomains(destination, setAnimatedByAxis, animatedRef);
onSettled?.();
},
});
return control;
}
export interface UseAnimatedYDomainsOptions {
enabled: boolean;
durationMs: number;
chartPhase: ChartPhase;
skeletonByAxis: Record<string, YDomain>;
targetByAxis: Record<string, YDomain>;
onSettled?: () => void;
/** When true, tweens y-domains on target changes while the chart is in the ready phase (e.g. brush zoom). */
tweenOnTargetChange?: boolean;
}
export function useAnimatedYDomains({
enabled,
durationMs,
chartPhase,
skeletonByAxis,
targetByAxis,
onSettled,
tweenOnTargetChange = false,
}: UseAnimatedYDomainsOptions): Record<string, YDomain> {
const reducedMotion = useReducedMotion();
const destinationByAxis = resolveAnimatedYDestinationDomains(
chartPhase,
skeletonByAxis,
targetByAxis
);
const destinationRef = useRef(destinationByAxis);
destinationRef.current = destinationByAxis;
const skeletonRef = useRef(skeletonByAxis);
skeletonRef.current = skeletonByAxis;
const targetRef = useRef(targetByAxis);
targetRef.current = targetByAxis;
const [animatedByAxis, setAnimatedByAxis] = useState(destinationByAxis);
const animatedRef = useRef(animatedByAxis);
const prevPhaseRef = useRef(chartPhase);
const onSettledRef = useRef(onSettled);
onSettledRef.current = onSettled;
useEffect(() => {
animatedRef.current = animatedByAxis;
}, [animatedByAxis]);
useEffect(() => {
if (prevPhaseRef.current === chartPhase) {
return;
}
prevPhaseRef.current = chartPhase;
const settle = () => {
onSettledRef.current?.();
};
// Keep grid spacing frozen while the series exits the viewport.
if (chartPhase === "exiting") {
snapDomains(skeletonRef.current, setAnimatedByAxis, animatedRef);
return;
}
if (chartPhase === "exitingReady") {
snapDomains(targetRef.current, setAnimatedByAxis, animatedRef);
return;
}
if (chartPhase === "loading") {
snapDomains(skeletonRef.current, setAnimatedByAxis, animatedRef);
return;
}
if (chartPhase === "revealing" || chartPhase === "ready") {
snapDomains(targetRef.current, setAnimatedByAxis, animatedRef);
return;
}
if (!isYDomainTweenPhase(chartPhase)) {
return;
}
const control = tweenDomains({
destination: destinationRef.current,
durationMs,
enabled,
reducedMotion,
animatedRef,
setAnimatedByAxis,
onSettled: settle,
});
return () => control?.stop();
}, [chartPhase, durationMs, enabled, reducedMotion]);
const targetSignature = JSON.stringify(targetByAxis);
const prevTargetSignatureRef = useRef(targetSignature);
useEffect(() => {
const inLivePhase = chartPhase === "ready" || chartPhase === "revealing";
if (!inLivePhase) {
prevTargetSignatureRef.current = targetSignature;
return;
}
if (prevTargetSignatureRef.current === targetSignature) {
return;
}
prevTargetSignatureRef.current = targetSignature;
if (tweenOnTargetChange && chartPhase === "ready") {
const control = tweenDomains({
destination: targetRef.current,
durationMs,
enabled,
reducedMotion,
animatedRef,
setAnimatedByAxis,
onSettled: () => onSettledRef.current?.(),
});
return () => control?.stop();
}
snapDomains(targetRef.current, setAnimatedByAxis, animatedRef);
}, [
chartPhase,
durationMs,
enabled,
reducedMotion,
targetSignature,
tweenOnTargetChange,
]);
return animatedByAxis;
}
src/charts/use-chart-phase-orchestrator.ts
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
type ChartPhase,
type ChartStatus,
resolveRestingChartPhase,
} from "./chart-phase";
export interface UseChartPhaseOrchestratorOptions {
chartStatus: ChartStatus;
targetData: Record<string, unknown>[];
skeletonData: Record<string, unknown>[];
animationDuration: number;
yDomainTweenDuration: number;
/** Signature of motion URL state — replays clip reveal in Studio. */
revealSignature?: string;
/** Skip mount/signature enter reveal (static docs previews). */
skipEnterReveal?: boolean;
}
export function useChartPhaseOrchestrator({
chartStatus,
targetData,
skeletonData,
animationDuration,
yDomainTweenDuration,
revealSignature = "",
skipEnterReveal = false,
}: UseChartPhaseOrchestratorOptions) {
const [chartPhase, setChartPhase] = useState<ChartPhase>(() =>
resolveRestingChartPhase(chartStatus)
);
const [plotData, setPlotData] = useState<Record<string, unknown>[]>(() =>
chartStatus === "loading" ? skeletonData : targetData
);
const [revealEpoch, setRevealEpoch] = useState(0);
const [concealEpoch, setConcealEpoch] = useState(0);
const [isLoaded, setIsLoaded] = useState(() => chartStatus === "ready");
const prevStatusRef = useRef(chartStatus);
const phaseRef = useRef(chartPhase);
phaseRef.current = chartPhase;
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: status transition branches for animation durations
useEffect(() => {
const prevStatus = prevStatusRef.current;
if (prevStatus === chartStatus) {
return;
}
prevStatusRef.current = chartStatus;
if (chartStatus === "ready" && prevStatus === "loading") {
setIsLoaded(false);
if (animationDuration <= 0) {
if (yDomainTweenDuration <= 0) {
setPlotData(targetData);
setChartPhase("revealing");
} else {
setChartPhase("gridTweenReady");
}
} else {
setChartPhase("exiting");
}
return;
}
if (chartStatus === "loading" && prevStatus === "ready") {
setIsLoaded(false);
if (animationDuration <= 0) {
if (yDomainTweenDuration <= 0) {
setPlotData(skeletonData);
setChartPhase("loading");
} else {
setChartPhase("gridTweenLoading");
}
} else {
setConcealEpoch((epoch) => epoch + 1);
setChartPhase("exitingReady");
}
}
}, [
animationDuration,
chartStatus,
skeletonData,
targetData,
yDomainTweenDuration,
]);
// biome-ignore lint/correctness/useExhaustiveDependencies: revealSignature replays enter
useEffect(() => {
if (skipEnterReveal) {
return;
}
if (chartStatus !== "ready") {
return;
}
if (phaseRef.current !== "ready") {
return;
}
setChartPhase("revealing");
setIsLoaded(false);
}, [animationDuration, chartStatus, revealSignature, skipEnterReveal]);
useEffect(() => {
switch (chartPhase) {
case "loading":
if (chartStatus === "loading") {
setPlotData(skeletonData);
}
break;
case "exiting":
setPlotData(skeletonData);
break;
case "exitingReady":
case "gridTweenLoading":
case "gridTweenReady":
case "revealing":
case "ready":
setPlotData(targetData);
break;
default:
break;
}
}, [chartPhase, chartStatus, skeletonData, targetData]);
/** Loading pulse exit finished — tween grid to ready spacing next. */
const notifyLoadingPulseComplete = useCallback(() => {
if (phaseRef.current !== "exiting") {
return;
}
setChartPhase("gridTweenReady");
}, []);
/** Ready series conceal finished — tween grid to loading spacing next. */
const notifyRevealConcealComplete = useCallback(() => {
if (phaseRef.current !== "exitingReady") {
return;
}
setChartPhase("gridTweenLoading");
}, []);
/** Grid tween finished — enter the next resting phase. */
const notifyYDomainTweenComplete = useCallback(() => {
if (phaseRef.current === "gridTweenLoading") {
setChartPhase("loading");
return;
}
if (phaseRef.current === "gridTweenReady") {
setChartPhase("revealing");
}
}, []);
useEffect(() => {
if (chartPhase !== "revealing") {
return;
}
setRevealEpoch((epoch) => epoch + 1);
if (animationDuration <= 0) {
setChartPhase("ready");
setIsLoaded(true);
return;
}
const timer = window.setTimeout(() => {
setChartPhase("ready");
setIsLoaded(true);
}, animationDuration);
return () => window.clearTimeout(timer);
}, [animationDuration, chartPhase]);
return {
chartPhase,
plotData,
revealEpoch,
concealEpoch,
isLoaded,
notifyLoadingPulseComplete,
notifyRevealConcealComplete,
notifyYDomainTweenComplete,
};
}
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;