shadimage
Bklit UI
componentline-chartsource: ok · refreshed

Line Chart

A composable line chart with multiple series support

npx shadcn add "https://ui.bklit.com/r/line-chart.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationmotion

Registry dependencies

@bklit/chart-context@bklit/chart-animation@bklit/chart-series@bklit/grid@bklit/x-axis@bklit/chart-tooltip@bklit/shimmering-text@bklit/utils

Files16 · source cached 10/8/2026, 10:11:34 AM

src/charts/line-chart.tsx
"use client";

import type { Transition } from "motion/react";
import {
  Children,
  type CSSProperties,
  isValidElement,
  type ReactElement,
  type ReactNode,
  useCallback,
  useMemo,
  useRef,
  useState,
} from "react";
import { cn } from "@/lib/utils";
import type { LineConfig, Margin } from "./chart-context";
import { ChartLoadingLabel } from "./chart-loading-label";
import { ParentSize } from "./chart-parent-size";
import {
  type ChartPhase,
  type ChartStatus,
  DEFAULT_CHART_STATUS,
  DEFAULT_Y_DOMAIN_TWEEN_MS,
  resolveRestingChartPhase,
} from "./chart-phase";
import { Line, type LineProps } from "./line";
import { TimeSeriesChartInner } from "./time-series-chart-shell";

export interface LineChartProps {
  /** 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;
  enterTransition?: Transition;
  revealSignature?: string;
  /** Aspect ratio as "width / height". Default: "2 / 1". Omit to fill a sized parent. */
  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 (Line, Grid, ChartTooltip, etc.) */
  children: ReactNode;
}

const DEFAULT_MARGIN: Margin = { top: 40, right: 40, bottom: 40, left: 40 };

/** Series renderers that carry a dataKey but must not drive the shared y-domain. */
const LINE_DOMAIN_EXCLUDED_NAMES = new Set([
  "ProfitLossLine",
  "LineSeriesTerminalMarker",
  "Area",
  "SeriesBar",
  "Scatter",
  "Candlestick",
  "Bar",
  "PatternArea",
]);

function getChildComponentName(child: ReactElement) {
  const childType = child.type as { displayName?: string; name?: string };
  return typeof child.type === "function"
    ? childType.displayName || childType.name || ""
    : "";
}

function registersLineDomain(
  child: ReactElement,
  props: LineProps | undefined
) {
  if (!props?.dataKey) {
    return false;
  }

  const componentName = getChildComponentName(child);
  if (componentName === "Line" || child.type === Line) {
    return true;
  }
  if (LINE_DOMAIN_EXCLUDED_NAMES.has(componentName)) {
    return false;
  }
  // MDX / duplicate bundle instances may not share the same `Line` reference.
  return typeof props.dataKey === "string" && props.dataKey.length > 0;
}

function extractLineConfigs(children: ReactNode): LineConfig[] {
  const configs: LineConfig[] = [];

  const visit = (node: ReactNode) => {
    Children.forEach(node, (child) => {
      if (!isValidElement(child)) {
        return;
      }

      const props = child.props as LineProps | undefined;

      if (registersLineDomain(child, props) && props?.dataKey) {
        configs.push({
          dataKey: props.dataKey,
          stroke: props.stroke || "var(--chart-line-primary)",
          strokeWidth: props.strokeWidth || 2.5,
          yAxisId: props.yAxisId,
        });
        return;
      }

      const childProps = child.props as { children?: ReactNode } | undefined;
      if (childProps?.children) {
        visit(childProps.children);
      }
    });
  };

  visit(children);
  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(() => extractLineConfigs(children), [children]);

  return (
    <TimeSeriesChartInner
      animationDuration={animationDuration}
      animationEasing={animationEasing}
      chartStatus={chartStatus}
      clipPathId="chart-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 LineChart({
  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,
}: LineChartProps) {
  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 ? { aspectRatio } : undefined),
        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 { Line, type LineProps } from "./line";

export default LineChart;
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/projection-line.tsx
"use client";

import { curveLinear } from "@visx/curve";
import { LinePath } from "@visx/shape";
import { useCallback, useId, useMemo } from "react";
import { useChartStable, useYScale } from "./chart-context";
import {
  buildHorizontalTangentBezierPath,
  type ProjectionCurveKind,
  type ProjectionPoint,
} from "./projection-utils";

// biome-ignore lint/suspicious/noExplicitAny: d3 curve factory type
type CurveFactory = any;

export type ProjectionStrokeStyle = "solid" | "gradient";

export interface ProjectionLineProps {
  /** Projection path points — anchor (last data row) + horizon end. */
  data: ProjectionPoint[];
  /** Y-scale group id. Default: `"left"`. */
  yAxisId?: string | number;
  /** Solid stroke color. Default: var(--chart-3) */
  stroke?: string;
  /** Solid or path-aligned gradient stroke. Default: solid */
  strokeStyle?: ProjectionStrokeStyle;
  /** Gradient start color when `strokeStyle` is gradient. Default: `stroke` */
  gradientStart?: string;
  /** Gradient end color when `strokeStyle` is gradient. Default: var(--chart-5) */
  gradientEnd?: string;
  /** Stroke width. Default: 2 */
  strokeWidth?: number;
  /** Straight segment or horizontal-tangent S-curve. Default: linear */
  curveKind?: ProjectionCurveKind;
  /** Advanced curve override (used when `curveKind` is omitted). */
  curve?: CurveFactory;
  /** Dash pattern. Default: "6,4" */
  strokeDasharray?: string;
  /** Stroke opacity. Default: 1 */
  strokeOpacity?: number;
  /** Show horizon endpoint marker. Default: true */
  showEndMarker?: boolean;
  /** @deprecated Use `showEndMarker`. */
  showEndpoints?: boolean;
  /** Endpoint marker radius. Default: 5 */
  endpointRadius?: number;
  className?: string;
}

function resolveVisibleEndX(
  endX: number,
  innerWidth: number,
  endpointRadius: number,
  strokeWidth: number
): number {
  const edgePadding = endpointRadius + strokeWidth * 0.5 + 1;
  return Math.min(endX, Math.max(0, innerWidth - edgePadding));
}

function renderProjectionStroke({
  bezierPath,
  curve,
  curveKind,
  data,
  getX,
  getY,
  linearPath,
  strokeProps,
}: {
  bezierPath: string | null;
  curve: CurveFactory | undefined;
  curveKind: ProjectionCurveKind;
  data: ProjectionPoint[];
  getX: (point: ProjectionPoint) => number;
  getY: (point: ProjectionPoint) => number;
  linearPath: string | null;
  strokeProps: {
    stroke: string;
    strokeDasharray: string;
    strokeLinecap: "round";
    strokeOpacity: number;
    strokeWidth: number;
  };
}) {
  if (curveKind === "bezier" && bezierPath) {
    return <path d={bezierPath} fill="none" {...strokeProps} />;
  }
  if (curveKind === "linear" && linearPath) {
    return <path d={linearPath} fill="none" {...strokeProps} />;
  }
  return (
    <LinePath
      curve={curve ?? curveLinear}
      data={data}
      {...strokeProps}
      x={getX}
      y={getY}
    />
  );
}

export function ProjectionLine({
  data,
  yAxisId,
  stroke = "var(--chart-3)",
  strokeStyle = "solid",
  gradientStart,
  gradientEnd = "var(--chart-5)",
  strokeWidth = 2,
  curveKind = "linear",
  curve,
  strokeDasharray = "6,4",
  strokeOpacity = 1,
  showEndMarker,
  showEndpoints,
  endpointRadius = 5,
  className,
}: ProjectionLineProps) {
  const { xScale, chartPhase, innerWidth } = useChartStable();
  const yScale = useYScale(yAxisId);
  const gradientId = useId().replace(/:/g, "");
  const showMarker = showEndMarker ?? showEndpoints ?? true;
  const resolvedGradientStart = gradientStart ?? stroke;

  const getX = useCallback(
    (point: ProjectionPoint) => xScale(point.date) ?? 0,
    [xScale]
  );
  const getY = useCallback(
    (point: ProjectionPoint) => yScale(point.value) ?? 0,
    [yScale]
  );

  const startPoint = data[0];
  const endPoint = data.at(-1);

  const geometry = useMemo(() => {
    if (!(startPoint && endPoint)) {
      return null;
    }
    const startX = getX(startPoint);
    const startY = getY(startPoint);
    const endX = getX(endPoint);
    const endY = getY(endPoint);
    const visibleEndX = resolveVisibleEndX(
      endX,
      innerWidth,
      showMarker ? endpointRadius : 0,
      strokeWidth
    );
    return { startX, startY, visibleEndX, endY };
  }, [
    endPoint,
    endpointRadius,
    getX,
    getY,
    innerWidth,
    showMarker,
    startPoint,
    strokeWidth,
  ]);

  const bezierPath = useMemo(() => {
    if (curveKind !== "bezier" || !geometry) {
      return null;
    }
    return buildHorizontalTangentBezierPath(
      geometry.startX,
      geometry.startY,
      geometry.visibleEndX,
      geometry.endY
    );
  }, [curveKind, geometry]);

  const linearPath = useMemo(() => {
    if (curveKind !== "linear" || !geometry) {
      return null;
    }
    return `M ${geometry.startX},${geometry.startY} L ${geometry.visibleEndX},${geometry.endY}`;
  }, [curveKind, geometry]);

  const showStroke =
    chartPhase === "revealing" ||
    chartPhase === "ready" ||
    chartPhase === "exitingReady";

  if (data.length < 2 || !geometry) {
    return null;
  }

  const resolvedStroke =
    strokeStyle === "gradient" && geometry ? `url(#${gradientId})` : stroke;
  const strokeProps = {
    stroke: showStroke ? resolvedStroke : "transparent",
    strokeDasharray,
    strokeLinecap: "round" as const,
    strokeOpacity,
    strokeWidth,
  };

  return (
    <g className={className ?? "chart-projection-line"}>
      {strokeStyle === "gradient" && geometry ? (
        <defs>
          <linearGradient
            gradientUnits="userSpaceOnUse"
            id={gradientId}
            x1={geometry.startX}
            x2={geometry.visibleEndX}
            y1={geometry.startY}
            y2={geometry.endY}
          >
            <stop offset="0%" stopColor={resolvedGradientStart} />
            <stop offset="100%" stopColor={gradientEnd} />
          </linearGradient>
        </defs>
      ) : null}
      {renderProjectionStroke({
        bezierPath,
        curve,
        curveKind,
        data,
        getX,
        getY,
        linearPath,
        strokeProps,
      })}
    </g>
  );
}

ProjectionLine.displayName = "ProjectionLine";

export default ProjectionLine;
src/charts/projection-line-end-marker.tsx
"use client";

import { useCallback } from "react";
import { useChartStable, useYScale } from "./chart-context";
import type { ProjectionPoint } from "./projection-utils";

export interface ProjectionLineEndMarkerProps {
  data: ProjectionPoint[];
  yAxisId?: string | number;
  stroke?: string;
  strokeOpacity?: number;
  radius?: number;
}

/** Renders the projection horizon dot outside the series reveal clip. */
export function ProjectionLineEndMarker({
  data,
  yAxisId,
  stroke = "var(--chart-3)",
  strokeOpacity = 1,
  radius = 5,
}: ProjectionLineEndMarkerProps) {
  const { xScale, chartPhase, innerWidth } = useChartStable();
  const yScale = useYScale(yAxisId);

  const getX = useCallback(
    (point: ProjectionPoint) => xScale(point.date) ?? 0,
    [xScale]
  );
  const getY = useCallback(
    (point: ProjectionPoint) => yScale(point.value) ?? 0,
    [yScale]
  );

  const showStroke =
    chartPhase === "revealing" ||
    chartPhase === "ready" ||
    chartPhase === "exitingReady";

  if (!showStroke || data.length < 2) {
    return null;
  }

  const endPoint = data.at(-1);
  if (!endPoint) {
    return null;
  }
  const edgePadding = radius + 1;
  const endX = Math.min(getX(endPoint), Math.max(0, innerWidth - edgePadding));
  const endY = getY(endPoint);

  return (
    <circle
      cx={endX}
      cy={endY}
      fill={stroke}
      fillOpacity={strokeOpacity}
      r={radius * 0.85}
    />
  );
}

ProjectionLineEndMarker.displayName = "ProjectionLineEndMarker";

(
  ProjectionLineEndMarker as unknown as Record<string, boolean>
).__isPostOverlay = true;

export default ProjectionLineEndMarker;
src/charts/line-series-terminal-marker.tsx
"use client";

import { motion } from "motion/react";
import { useMemo } from "react";
import { useChartStable, useYScale } from "./chart-context";
import { StaticSeriesPointMarker } from "./series-point-marker";

export interface LineSeriesTerminalMarkerProps {
  dataKey: string;
  yAxisId?: string | number;
  fill?: string;
  stroke?: string;
  radius?: number;
  ringGap?: number;
  strokeWidth?: number;
}

function isTerminalMarkerPhaseVisible(phase: string): boolean {
  return phase === "ready" || phase === "exitingReady";
}

/** Hollow ring at the last data point — shared anchor for projection lines. */
export function LineSeriesTerminalMarker({
  dataKey,
  yAxisId,
  fill = "transparent",
  stroke = "var(--chart-1)",
  radius = 5,
  ringGap = 0,
  strokeWidth = 1.5,
}: LineSeriesTerminalMarkerProps) {
  const { data, xScale, xAccessor, chartPhase, revealEpoch, enterTransition } =
    useChartStable();
  const yScale = useYScale(yAxisId);

  const point = useMemo(() => {
    const lastRow = data.at(-1);
    if (!lastRow) {
      return null;
    }
    const value = lastRow[dataKey];
    if (typeof value !== "number") {
      return null;
    }
    return {
      cx: xScale(xAccessor(lastRow)) ?? 0,
      cy: yScale(value) ?? 0,
    };
  }, [data, dataKey, xAccessor, xScale, yScale]);

  const visible = isTerminalMarkerPhaseVisible(chartPhase);
  const fadeTransition =
    enterTransition && typeof enterTransition === "object"
      ? enterTransition
      : { duration: 0.28, ease: [0.22, 1, 0.36, 1] as const };

  if (!point) {
    return null;
  }

  return (
    <motion.g
      animate={{
        opacity: visible ? 1 : 0,
        scale: visible ? 1 : 0.55,
      }}
      initial={{ opacity: 0, scale: 0.55 }}
      key={revealEpoch ?? 0}
      style={{
        transformBox: "fill-box" as const,
        transformOrigin: `${point.cx}px ${point.cy}px`,
      }}
      transition={fadeTransition}
    >
      <StaticSeriesPointMarker
        cx={point.cx}
        cy={point.cy}
        fill={fill}
        radius={radius}
        ringGap={ringGap}
        stroke={stroke}
        strokeWidth={strokeWidth}
      />
    </motion.g>
  );
}

LineSeriesTerminalMarker.displayName = "LineSeriesTerminalMarker";

(
  LineSeriesTerminalMarker as unknown as Record<string, boolean>
).__isPostOverlay = true;

export default LineSeriesTerminalMarker;
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/line.tsx
"use client";

import { curveNatural } from "@visx/curve";
import { LinePath } from "@visx/shape";

// CurveFactory type - simplified version compatible with visx
// biome-ignore lint/suspicious/noExplicitAny: d3 curve factory type
type CurveFactory = any;

import {
  type RefObject,
  useCallback,
  useId,
  useMemo,
  useRef,
  useState,
} from "react";
import { chartCssVars, useChartStable, useYScale } from "./chart-context";
import type { LoadingStyle } from "./chart-phase";
import {
  type FadeEdges,
  fadeGradientStops,
  resolveFadeSides,
  viewportFadeGradientAttrs,
} 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";
import { useAnimatedSeriesPath } from "./use-animated-series-path";

export interface LineProps {
  /** Key in data to use for y values */
  dataKey: string;
  /** Y-scale group id (Recharts `yAxisId`). Default: `"left"`. */
  yAxisId?: string | number;
  /** Stroke color. Default: var(--chart-line-primary) */
  stroke?: string;
  /** Stroke width. Default: 2.5 */
  strokeWidth?: number;
  /** Curve function. Default: curveNatural */
  curve?: CurveFactory;
  /** Whether to animate the line. Default: true */
  animate?: boolean;
  /**
   * Fade the line stroke toward transparent at the chart edges.
   * - `true` fades both edges, `false` disables the fade entirely.
   * - `"left"` / `"right"` fades only that side.
   * Default: true
   */
  fadeEdges?: FadeEdges;
  /** Whether to show highlight segment on hover. Default: true */
  showHighlight?: boolean;
  /** 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;
  /**
   * Show the loading pulse overlay. Default: follows chart loading phase.
   * Set `false` to disable even during loading.
   */
  loading?: boolean;
  /** Stroke color for the loading pulse overlay. Default: var(--foreground) */
  loadingStroke?: string;
  /** Loading pulse stroke opacity. Default: 0.5 */
  loadingStrokeOpacity?: number;
  /** Override pulse animation mode (loop / exit / enter). */
  loadingPulseMode?: LineLoadingPulseMode;
  /** Called when a loop-mode pulse cycle completes. */
  onLoadingPulseCycleComplete?: () => void;
  /**
   * Loading animation while the chart is in loading status: the default
   * traveling `"pulse"`, or a diagonal `"sweep"` shimmer across the skeleton
   * line. Default: `"pulse"`.
   */
  loadingStyle?: LoadingStyle;
}

function LineSeriesStroke({
  animatedPathD,
  curve,
  getY,
  pathRef,
  renderData,
  strokeWidth,
  useDataTransitionPath,
  visibleStroke,
  xAccessor,
  xScale,
}: {
  animatedPathD: string;
  curve: CurveFactory;
  getY: (datum: Record<string, unknown>) => number;
  pathRef: RefObject<SVGPathElement | null>;
  renderData: Record<string, unknown>[];
  strokeWidth: number;
  useDataTransitionPath: boolean;
  visibleStroke: string;
  xAccessor: (datum: Record<string, unknown>) => Date;
  xScale: (value: Date) => number | undefined;
}) {
  if (useDataTransitionPath && animatedPathD) {
    return (
      <path
        d={animatedPathD}
        fill="none"
        ref={pathRef}
        stroke={visibleStroke}
        strokeLinecap="round"
        strokeWidth={strokeWidth}
      />
    );
  }

  return (
    <LinePath
      curve={curve}
      data={renderData}
      innerRef={pathRef}
      stroke={visibleStroke}
      strokeLinecap="round"
      strokeWidth={strokeWidth}
      x={(d) => xScale(xAccessor(d)) ?? 0}
      y={getY}
    />
  );
}

function LineLoadingOverlays({
  curve,
  handleLoadingPulseComplete,
  innerWidth,
  loadingStroke,
  loadingStrokeOpacity,
  loadingStyle,
  pathD,
  pulseEpoch,
  pulseMode,
  showLoadingPulse,
  strokeWidth,
}: {
  curve: CurveFactory;
  handleLoadingPulseComplete: () => void;
  innerWidth: number;
  loadingStroke: string;
  loadingStrokeOpacity: number;
  loadingStyle: LoadingStyle;
  pathD: string | null;
  pulseEpoch: number;
  pulseMode: LineLoadingPulseMode | null;
  showLoadingPulse: boolean;
  strokeWidth: number;
}) {
  const sweepLoading =
    showLoadingPulse && innerWidth > 0 && loadingStyle === "sweep";
  const pulseLoading = showLoadingPulse && innerWidth > 0 && !sweepLoading;

  return (
    <>
      {sweepLoading ? (
        <LineLoadingSweep
          curve={curve}
          key="loading-sweep"
          mode={pulseMode ?? "loop"}
          onTransitionComplete={handleLoadingPulseComplete}
          stroke={loadingStroke}
          strokeOpacity={loadingStrokeOpacity}
          strokeWidth={strokeWidth}
        />
      ) : null}
      {pulseLoading && pathD ? (
        <LineLoadingPulseStroke
          key="loading-pulse"
          loopEpoch={pulseEpoch}
          mode={pulseMode ?? undefined}
          onCycleComplete={handleLoadingPulseComplete}
          pathD={pathD}
          stroke={loadingStroke}
          strokeOpacity={loadingStrokeOpacity}
          strokeWidth={strokeWidth}
        />
      ) : null}
    </>
  );
}

export function Line({
  dataKey,
  yAxisId,
  stroke = chartCssVars.linePrimary,
  strokeWidth = 2.5,
  curve = curveNatural,
  animate = true,
  fadeEdges = true,
  showHighlight = true,
  showMarkers = false,
  markers,
  dashFromIndex,
  dashArray = "6,4",
  loading,
  loadingStroke = chartCssVars.foreground,
  loadingStrokeOpacity = 0.5,
  loadingPulseMode,
  onLoadingPulseCycleComplete,
  loadingStyle = "pulse",
}: LineProps) {
  // 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-line
  // `<ChartRevealClip>` or read `revealEpoch` here.
  const {
    data,
    renderData,
    xScale,
    innerHeight,
    innerWidth,
    xAccessor,
    lines,
    chartPhase,
    notifyLoadingPulseComplete,
    yDomainTweenDuration,
  } = useChartStable();
  const yScale = useYScale(yAxisId);
  const useDataTransitionPath = animate && chartPhase === "ready";
  const { pathD: animatedPathD } = useAnimatedSeriesPath({
    chartPhase,
    curve,
    dataKey,
    durationMs: yDomainTweenDuration,
    enabled: useDataTransitionPath,
    innerWidth,
    renderData,
    xAccessor,
    xScale,
    yScale,
  });

  const phasePulseMode = resolveLineLoadingPulseMode(chartPhase);
  const pulseMode =
    loading === false
      ? null
      : (loadingPulseMode ?? (loading === true ? "loop" : phasePulseMode));
  const showLoadingPulse = pulseMode != null;
  const [pulseEpoch, setPulseEpoch] = useState(0);
  const effectiveShowHighlight = showHighlight && !showLoadingPulse;

  const handleLoadingPulseComplete = useCallback(() => {
    onLoadingPulseCycleComplete?.();
    if (pulseMode === "loop") {
      window.setTimeout(() => {
        setPulseEpoch((epoch) => epoch + 1);
      }, LINE_LOADING_LOOP_PAUSE_MS);
      return;
    }
    notifyLoadingPulseComplete?.();
  }, [notifyLoadingPulseComplete, onLoadingPulseCycleComplete, pulseMode]);

  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,
    animate,
    useDataTransitionPath ? animatedPathD : null,
  ]);

  const reactId = useId();
  const gradientId = `line-gradient-${dataKey}-${reactId}`;

  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);
  const fadeSides = resolveFadeSides(fadeEdges);
  const lineStroke = fadeSides.any ? `url(#${gradientId})` : stroke;
  const fadeStops = fadeSides.any ? fadeGradientStops(fadeSides) : null;
  const showSeriesStroke =
    chartPhase === "revealing" ||
    chartPhase === "ready" ||
    chartPhase === "exitingReady";
  let visibleStroke = "transparent";
  if (showSeriesStroke && !hasDashTail) {
    visibleStroke = lineStroke;
  }

  return (
    <>
      {fadeStops ? (
        <defs>
          <linearGradient
            id={gradientId}
            {...viewportFadeGradientAttrs(innerWidth)}
          >
            {fadeStops.map((stop) => (
              <stop
                key={stop.offset}
                offset={stop.offset}
                style={{ stopColor: stroke, stopOpacity: stop.opacity }}
              />
            ))}
          </linearGradient>
        </defs>
      ) : null}

      <SeriesHoverDim
        dimOpacity={0.3}
        enabled={effectiveShowHighlight}
        seriesIndex={seriesIndex}
      >
        <LineSeriesStroke
          animatedPathD={animatedPathD}
          curve={curve}
          getY={getY}
          pathRef={pathRef}
          renderData={renderData}
          strokeWidth={strokeWidth}
          useDataTransitionPath={useDataTransitionPath}
          visibleStroke={visibleStroke}
          xAccessor={xAccessor}
          xScale={xScale}
        />

        <SeriesDashTailOverlay
          dashArray={dashArray}
          dashFromIndex={dashFromIndex}
          data={data}
          innerHeight={innerHeight}
          innerWidth={innerWidth}
          pathD={pathD}
          pathLength={pathLength}
          stroke={lineStroke}
          strokeWidth={strokeWidth}
          xAccessor={xAccessor}
          xScale={xScale}
        />
      </SeriesHoverDim>

      {showMarkers ? (
        <SeriesMarkers
          animate={animate}
          dataKey={dataKey}
          {...markers}
          fill={markers?.fill ?? stroke}
          stroke={markers?.stroke ?? markers?.fill ?? stroke}
        />
      ) : null}

      <SeriesHighlightLayer
        enabled={effectiveShowHighlight}
        height={innerHeight}
        pathRef={pathRef}
        stroke={stroke}
        strokeWidth={strokeWidth}
      />

      <LineLoadingOverlays
        curve={curve}
        handleLoadingPulseComplete={handleLoadingPulseComplete}
        innerWidth={innerWidth}
        loadingStroke={loadingStroke}
        loadingStrokeOpacity={loadingStrokeOpacity}
        loadingStyle={loadingStyle}
        pathD={pathD}
        pulseEpoch={pulseEpoch}
        pulseMode={pulseMode}
        showLoadingPulse={showLoadingPulse}
        strokeWidth={strokeWidth}
      />
    </>
  );
}

Line.displayName = "Line";

export default Line;
src/charts/line-chart-loading.tsx
"use client";

import { curveNatural } from "@visx/curve";
import { useMemo } from "react";
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";
import { Line } from "./line";
import { LineChart } from "./line-chart";

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 LineChartLoadingProps {
  /** 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 line). 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 LineChartLoading({
  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 = "",
}: LineChartLoadingProps) {
  const data = useMemo(
    () =>
      generateChartSkeletonData({
        dataKey: DEFAULT_SKELETON_DATA_KEY,
        pointCount: DEFAULT_SKELETON_POINT_COUNT,
      }),
    []
  );

  return (
    <LineChart
      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}
      />
      <Line
        curve={curveNatural}
        dataKey={LOADING_DATA_KEY}
        fadeEdges={false}
        loadingStroke={stroke}
        loadingStrokeOpacity={strokeOpacity}
        loadingStyle={loadingStyle}
        showHighlight={false}
        stroke="transparent"
        strokeWidth={2.5}
      />
    </LineChart>
  );
}

export default LineChartLoading;
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;

More from Bklit UI

Browse all components in this library →