shadimage
Bklit UI
componentbar-chartsource: ok · refreshed

Bar Chart

A composable bar chart with horizontal/vertical orientations and animations

npx shadcn add "https://ui.bklit.com/r/bar-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/grid@bklit/chart-tooltip@bklit/utils

Files13 · source cached 10/8/2026, 5:34:42 AM

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

import { localPoint } from "@visx/event";
import { scaleBand, scaleLinear } from "@visx/scale";
import type { Transition } from "motion/react";
import {
  memo,
  type ReactElement,
  type ReactNode,
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import { cn } from "@/lib/utils";
import { DEFAULT_ANIMATION_EASING } from "./animation";
import type { BarProps } from "./bar";
import { topSquareCenterY } from "./bar-squares-layout";
import {
  forEachChartChild,
  isChartClipPassthrough,
  isClipExcludedComponent,
  isPostOverlayComponent,
  isUnderlayComponent,
  renderKeyedChartLayers,
  resolveChartChildElement,
} from "./chart-child-passthrough";
import {
  ChartProvider,
  type LineConfig,
  type Margin,
  type TooltipData,
} from "./chart-context";
import { isGradientDefComponent, isPatternDefComponent } from "./chart-defs";
import { shortDateFmt } from "./chart-formatters";
import { ParentSize } from "./chart-parent-size";
import {
  type ChartPhase,
  type ChartStatus,
  DEFAULT_CHART_LIFECYCLE,
  resolveRestingChartPhase,
} from "./chart-phase";
import { BarLoadingSkeleton } from "./loading-sweep";
import { extractReferenceAreaConfigs } from "./reference-area-config";
import { useScheduledTooltip } from "./use-scheduled-tooltip";
import {
  buildYScalesForLines,
  getPrimaryYScale,
  normalizeYAxisId,
  wrapSingleYScale,
} from "./y-axis-scales";

/** Skeleton bars to show when `status="loading"` and `data` is empty. */
const FALLBACK_LOADING_BARS = 12;

export type BarOrientation = "vertical" | "horizontal";

export interface BarChartProps {
  /** Data array - each item should have an x-axis key and numeric values */
  data: Record<string, unknown>[];
  /** Key in data for the categorical axis. Default: "name" */
  xDataKey?: string;
  /** Chart margins */
  margin?: Partial<Margin>;
  /** Animation duration in milliseconds. Default: 1100 */
  animationDuration?: number;
  /** CSS easing for bar grow transitions. */
  animationEasing?: string;
  /** Motion enter transition (spring or cubic-bezier tween). */
  enterTransition?: Transition;
  /** Signature of motion URL state — triggers enter replay when it changes. */
  revealSignature?: string;
  /** Aspect ratio as "width / height". Default: "2 / 1" */
  aspectRatio?: string;
  /** Additional class name for the container */
  className?: string;
  /** Gap between bar groups as a fraction of band width (0-1). Default: 0.2 */
  barGap?: number;
  /** Fixed bar width in pixels. If not set, bars auto-size to fill the band. */
  barWidth?: number;
  /** Bar chart orientation. Default: "vertical" */
  orientation?: BarOrientation;
  /** Whether to stack bars instead of grouping them. Default: false */
  stacked?: boolean;
  /** Gap between stacked bar segments in pixels. Default: 0 */
  stackGap?: number;
  /** When set, tooltip Y positions snap to the top square center (shape variant). */
  squareSnap?: { squareGap: number; groupGap?: number; fit?: boolean };
  /** Child components (Bar, Grid, ChartTooltip, etc.). Optional — omit for a
   * pure `status="loading"` skeleton. */
  children?: ReactNode;
  /** Reports reveal lifecycle for OG screenshots and loading orchestration. */
  onPhaseChange?: (phase: ChartPhase) => void;
  /** Fetch / display status. When `"loading"`, a shimmer skeleton replaces the
   * bars (no chart data required). Default: `"ready"`. */
  status?: ChartStatus;
}

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

// Extract bar configs from children synchronously
function extractBarConfigs(children: ReactNode): LineConfig[] {
  const configs: LineConfig[] = [];

  forEachChartChild(children, (child) => {
    const childType = child.type as {
      displayName?: string;
      name?: string;
      __isBarDepthLayer?: boolean;
    };
    // Bar-depth surface layers (BarDepthBack/Front, BarPulse) carry a
    // `dataKey` to pair with a Bar but are not series themselves — skip them
    // so they don't inflate the series count and shrink the real bars.
    if (childType.__isBarDepthLayer) {
      return;
    }
    const componentName =
      typeof child.type === "function"
        ? childType.displayName || childType.name || ""
        : "";

    const props = child.props as BarProps | undefined;
    const isBarComponent =
      componentName === "Bar" ||
      componentName === "BarSquares" ||
      (props && typeof props.dataKey === "string" && props.dataKey.length > 0);

    if (isBarComponent && props?.dataKey) {
      // Use stroke for tooltip dot color if provided, otherwise fall back to fill
      // This allows gradient/pattern fills to have a solid dot color
      const dotColor =
        props.stroke || props.fill || "var(--chart-line-primary)";
      configs.push({
        dataKey: props.dataKey,
        stroke: dotColor,
        strokeWidth: 0,
        yAxisId: props.yAxisId,
      });
    }
  });

  return configs;
}

interface ChartInnerProps {
  width: number;
  height: number;
  data: Record<string, unknown>[];
  xDataKey: string;
  margin: Margin;
  animationDuration: number;
  animationEasing: string;
  enterTransition?: Transition;
  revealSignature?: string;
  barGap: number;
  barWidthProp?: number;
  orientation: BarOrientation;
  stacked: boolean;
  stackGap: number;
  squareSnap?: { squareGap: number; groupGap?: number; fit?: boolean };
  children: ReactNode;
  containerRef: React.RefObject<HTMLDivElement | null>;
  onPhaseChange?: (phase: ChartPhase) => void;
  status: ChartStatus;
}

function ChartInner(props: ChartInnerProps) {
  const { width, height } = props;
  if (width < 10 || height < 10) {
    return null;
  }
  return <ChartCore {...props} />;
}

const ChartCore = memo(function ChartCore({
  width,
  height,
  data,
  xDataKey,
  margin,
  animationDuration,
  animationEasing,
  enterTransition,
  revealSignature = "",
  barGap,
  barWidthProp,
  orientation,
  stacked,
  stackGap,
  squareSnap,
  children,
  containerRef,
  onPhaseChange,
  status,
}: ChartInnerProps) {
  const { tooltipData, setTooltipData, scheduleTooltip, clearTooltip } =
    useScheduledTooltip<TooltipData>();
  const [isLoaded, setIsLoaded] = useState(false);
  const [revealEpoch, setRevealEpoch] = useState(0);
  const hoveredBarIndex = tooltipData?.index ?? null;

  const isHorizontal = orientation === "horizontal";

  // Extract bar configs synchronously from children
  const lines = useMemo(() => extractBarConfigs(children), [children]);

  const innerWidth = width - margin.left - margin.right;
  const innerHeight = height - margin.top - margin.bottom;

  // Category accessor function - returns string for categorical scale
  const categoryAccessor = useCallback(
    (d: Record<string, unknown>): string => {
      const value = d[xDataKey];
      if (value instanceof Date) {
        return shortDateFmt.format(value);
      }
      return String(value ?? "");
    },
    [xDataKey]
  );

  // For compatibility with ChartContext, provide a Date-based xAccessor
  const xAccessorDate = useCallback(
    (d: Record<string, unknown>): Date => {
      const value = d[xDataKey];
      if (value instanceof Date) {
        return value;
      }
      return new Date();
    },
    [xDataKey]
  );

  // Category scale (band) - for the categorical axis
  const categoryScale = useMemo(() => {
    const domain = data.map((d) => categoryAccessor(d));
    const range: [number, number] = isHorizontal
      ? [0, innerHeight]
      : [0, innerWidth];
    return scaleBand<string>({
      range,
      domain,
      padding: barGap,
    });
  }, [innerWidth, innerHeight, data, categoryAccessor, barGap, isHorizontal]);

  // Band width for bars - use prop if provided, otherwise use scale's bandwidth
  const bandWidth = barWidthProp ?? categoryScale.bandwidth();

  // Compute max value considering stacking
  const maxValue = useMemo(() => {
    if (stacked) {
      // For stacked bars, sum all values at each data point
      let max = 0;
      for (const d of data) {
        let sum = 0;
        for (const line of lines) {
          const value = d[line.dataKey];
          if (typeof value === "number") {
            sum += value;
          }
        }
        if (sum > max) {
          max = sum;
        }
      }
      return max || 100;
    }
    // For grouped bars, find max single value
    let max = 0;
    for (const line of lines) {
      for (const d of data) {
        const value = d[line.dataKey];
        if (typeof value === "number" && value > max) {
          max = value;
        }
      }
    }
    return max || 100;
  }, [data, lines, stacked]);

  // Value scale (linear) - for the value axis
  const valueScale = useMemo(() => {
    const range = isHorizontal ? [0, innerWidth] : [innerHeight, 0];
    return scaleLinear({
      range,
      domain: [0, maxValue * 1.1],
      nice: true,
    });
  }, [innerWidth, innerHeight, maxValue, isHorizontal]);

  const yScales = useMemo(() => {
    if (isHorizontal) {
      return wrapSingleYScale(valueScale);
    }
    return buildYScalesForLines({
      lines,
      data,
      innerHeight,
      resolveDomain: (dataKeys) => {
        let max = 0;
        for (const d of data) {
          for (const key of dataKeys) {
            const value = d[key];
            if (typeof value === "number" && value > max) {
              max = value;
            }
          }
        }
        return [0, (max || 100) * 1.1];
      },
    });
  }, [data, innerHeight, isHorizontal, lines, valueScale]);

  const primaryYScale = getPrimaryYScale(yScales, valueScale);

  // Compute stack offsets for stacked bars
  const stackOffsets = useMemo(() => {
    if (!stacked) {
      return undefined;
    }
    const offsets = new Map<number, Map<string, number>>();
    for (let i = 0; i < data.length; i++) {
      const d = data[i];
      if (!d) {
        continue;
      }
      const pointOffsets = new Map<string, number>();
      let cumulative = 0;
      for (const line of lines) {
        pointOffsets.set(line.dataKey, cumulative);
        const value = d[line.dataKey];
        if (typeof value === "number") {
          cumulative += value;
        }
      }
      offsets.set(i, pointOffsets);
    }
    return offsets;
  }, [data, lines, stacked]);

  // Column width for tooltip indicator
  const columnWidth = useMemo(() => {
    if (data.length < 1) {
      return 0;
    }
    return isHorizontal ? innerHeight / data.length : innerWidth / data.length;
  }, [innerWidth, innerHeight, data.length, isHorizontal]);

  // Pre-compute labels for ticker animation
  const dateLabels = useMemo(
    () => data.map((d) => categoryAccessor(d)),
    [data, categoryAccessor]
  );

  // Create a fake time scale for compatibility with ChartContext
  const fakeTimeScale = useMemo(() => {
    const now = Date.now();
    const start = now - data.length * 24 * 60 * 60 * 1000;
    const scale = {
      ...categoryScale,
      domain: () => [new Date(start), new Date(now)],
      range: () => [0, innerWidth] as [number, number],
      invert: (x: number) => new Date(start + (x / innerWidth) * (now - start)),
      copy: () => scale,
    };
    return scale;
  }, [categoryScale, innerWidth, data.length]);

  // Animation timing — replay when motion settings change
  // biome-ignore lint/correctness/useExhaustiveDependencies: revealSignature
  useEffect(() => {
    setRevealEpoch((n) => n + 1);
    setIsLoaded(false);
    // While loading, hold the skeleton (no reveal, no interaction). When
    // status flips to "ready" this effect re-runs and plays the grow reveal.
    if (status === "loading") {
      return;
    }
    const staggerMs = data.length > 1 ? animationDuration * 0.4 : 0;
    const timer = setTimeout(() => {
      setIsLoaded(true);
    }, animationDuration + staggerMs);
    return () => clearTimeout(timer);
  }, [animationDuration, revealSignature, status]);

  useEffect(() => {
    onPhaseChange?.(isLoaded ? "ready" : "revealing");
  }, [isLoaded, onPhaseChange]);

  // Mouse move handler
  const handleMouseMove = useCallback(
    (event: React.MouseEvent<SVGGElement>) => {
      const point = localPoint(event);
      if (!point) {
        return;
      }

      const pos = isHorizontal ? point.y - margin.top : point.x - margin.left;

      // Find which band the mouse is over
      const bandIndex = Math.floor(pos / columnWidth);
      const clampedIndex = Math.max(0, Math.min(data.length - 1, bandIndex));
      const d = data[clampedIndex];

      if (!d) {
        return;
      }

      // Calculate positions for each bar
      const yPositions: Record<string, number> = {};
      const xPositions: Record<string, number> = {};
      const barPos = categoryScale(categoryAccessor(d)) ?? 0;

      if (isHorizontal) {
        // Horizontal bars: dots at end of bar (x = value), centered vertically in band
        const seriesCount = lines.length;
        const groupGap = seriesCount > 1 ? 4 : 0;
        const individualBarHeight =
          seriesCount > 0
            ? (bandWidth - groupGap * (seriesCount - 1)) / seriesCount
            : bandWidth;

        if (stacked) {
          // Stacked horizontal: all bars same y, x at cumulative end
          let cumulative = 0;
          for (const line of lines) {
            const value = d[line.dataKey];
            if (typeof value === "number") {
              cumulative += value;
              const axisScale =
                yScales[normalizeYAxisId(line.yAxisId)] ?? valueScale;
              xPositions[line.dataKey] = axisScale(cumulative) ?? 0;
              yPositions[line.dataKey] = barPos + bandWidth / 2;
            }
          }
        } else {
          // Grouped horizontal: each bar at its own y position
          lines.forEach((line, idx) => {
            const value = d[line.dataKey];
            if (typeof value === "number") {
              const axisScale =
                yScales[normalizeYAxisId(line.yAxisId)] ?? valueScale;
              xPositions[line.dataKey] = axisScale(value) ?? 0;
              yPositions[line.dataKey] =
                barPos +
                idx * (individualBarHeight + groupGap) +
                individualBarHeight / 2;
            }
          });
        }
      } else if (stacked) {
        // Vertical stacked bars
        let cumulative = 0;
        let seriesIdx = 0;
        for (const line of lines) {
          const value = d[line.dataKey];
          if (typeof value === "number") {
            cumulative += value;
            const axisScale =
              yScales[normalizeYAxisId(line.yAxisId)] ?? primaryYScale;
            const gapOffset = seriesIdx * stackGap;
            yPositions[line.dataKey] = (axisScale(cumulative) ?? 0) - gapOffset;
            seriesIdx++;
          }
        }
      } else {
        // Vertical grouped bars
        const seriesCount = lines.length;
        const groupGap = seriesCount > 1 ? 4 : 0;
        const individualBarWidth =
          seriesCount > 0
            ? (bandWidth - groupGap * (seriesCount - 1)) / seriesCount
            : bandWidth;

        lines.forEach((line, idx) => {
          const value = d[line.dataKey];
          if (typeof value === "number") {
            const axisScale =
              yScales[normalizeYAxisId(line.yAxisId)] ?? primaryYScale;
            const baselineY = axisScale(0) ?? innerHeight;
            const valueY = axisScale(value) ?? 0;
            const barLengthPx = baselineY - valueY;

            if (squareSnap && !isHorizontal && value > 0) {
              yPositions[line.dataKey] = topSquareCenterY({
                baselineY,
                barLengthPx,
                squareSize: individualBarWidth,
                gap: squareSnap.squareGap,
                fit: squareSnap.fit,
              });
            } else {
              yPositions[line.dataKey] = valueY;
            }

            xPositions[line.dataKey] =
              barPos +
              idx * (individualBarWidth + groupGap) +
              individualBarWidth / 2;
          }
        });
      }

      // Tooltip position: for horizontal, position at max bar end; for vertical, center of band
      let tooltipX: number;
      if (isHorizontal) {
        // Position tooltip at the end of the longest bar
        const maxX = Math.max(...Object.values(xPositions), 0);
        tooltipX = maxX;
      } else {
        tooltipX = barPos + bandWidth / 2;
      }

      scheduleTooltip({
        point: d,
        index: clampedIndex,
        x: tooltipX,
        yPositions,
        xPositions: Object.keys(xPositions).length > 0 ? xPositions : undefined,
      });
    },
    [
      categoryScale,
      valueScale,
      data,
      lines,
      margin.left,
      margin.top,
      categoryAccessor,
      columnWidth,
      bandWidth,
      isHorizontal,
      stacked,
      stackGap,
      scheduleTooltip,
      yScales,
      primaryYScale,
      squareSnap,
      innerHeight,
    ]
  );

  const handleMouseLeave = useCallback(() => {
    clearTooltip();
  }, [clearTooltip]);

  const canInteract = isLoaded;

  // Separate children into defs, pre-overlay, and post-overlay
  const defsChildren: ReactElement[] = [];
  const clipExcludedChildren: ReactElement[] = [];
  const underlayChildren: ReactElement[] = [];
  const preOverlayChildren: ReactElement[] = [];
  const postOverlayChildren: ReactElement[] = [];

  forEachChartChild(children, (child) => {
    const resolvedChild = resolveChartChildElement(child);

    if (isGradientDefComponent(child)) {
      defsChildren.push(child);
    } else if (isPatternDefComponent(child)) {
      preOverlayChildren.push(child);
    } else if (isPostOverlayComponent(resolvedChild)) {
      postOverlayChildren.push(resolvedChild);
    } else if (isClipExcludedComponent(resolvedChild)) {
      clipExcludedChildren.push(
        isChartClipPassthrough(child.type) ? resolvedChild : child
      );
    } else if (isUnderlayComponent(resolvedChild)) {
      underlayChildren.push(resolvedChild);
    } else {
      preOverlayChildren.push(child);
    }
  });

  const referenceAreas = useMemo(
    () => extractReferenceAreaConfigs(children),
    [children]
  );

  const contextValue = {
    ...DEFAULT_CHART_LIFECYCLE,
    chartPhase: resolveRestingChartPhase(status),
    chartStatus: status,
    data,
    renderData: data,
    xScale: fakeTimeScale as unknown as ReturnType<
      typeof import("@visx/scale").scaleTime<number>
    >,
    yScale: isHorizontal ? valueScale : primaryYScale,
    yScales,
    width,
    height,
    innerWidth,
    innerHeight,
    margin,
    columnWidth,
    tooltipData,
    setTooltipData,
    containerRef,
    lines,
    referenceAreas,
    isLoaded,
    animationDuration,
    animationEasing,
    enterTransition,
    revealEpoch,
    xAccessor: xAccessorDate,
    dateLabels,
    // Bar-specific properties
    barScale: categoryScale,
    bandWidth,
    hoveredBarIndex,
    barXAccessor: categoryAccessor,
    orientation,
    stacked,
    stackOffsets,
    squareSnap,
  };

  return (
    <ChartProvider value={contextValue}>
      <svg
        aria-hidden="true"
        className="overflow-visible"
        height={height}
        width={width}
      >
        {/* Gradient and pattern definitions */}
        {defsChildren.length > 0 && <defs>{defsChildren}</defs>}

        <rect fill="transparent" height={height} width={width} x={0} y={0} />

        {/* biome-ignore lint/a11y/noStaticElementInteractions: Chart interaction area */}
        <g
          onMouseLeave={canInteract ? handleMouseLeave : undefined}
          onMouseMove={canInteract ? handleMouseMove : undefined}
          style={{ cursor: canInteract ? "crosshair" : "default" }}
          transform={`translate(${margin.left},${margin.top})`}
        >
          {/* Background rect for mouse event detection */}
          <rect
            fill="transparent"
            height={innerHeight}
            width={innerWidth}
            x={0}
            y={0}
          />

          {renderKeyedChartLayers(clipExcludedChildren)}
          {renderKeyedChartLayers(underlayChildren)}
          {status === "loading" ? (
            <BarLoadingSkeleton
              barCount={data.length || FALLBACK_LOADING_BARS}
              innerHeight={innerHeight}
              innerWidth={innerWidth}
            />
          ) : (
            renderKeyedChartLayers(preOverlayChildren)
          )}

          {/* Markers rendered last so they're on top for interaction */}
          {renderKeyedChartLayers(postOverlayChildren)}
        </g>
      </svg>
    </ChartProvider>
  );
});

export function BarChart({
  data,
  xDataKey = "name",
  margin: marginProp,
  animationDuration = 1100,
  animationEasing = DEFAULT_ANIMATION_EASING,
  enterTransition,
  revealSignature,
  aspectRatio = "2 / 1",
  className = "",
  barGap = 0.2,
  barWidth,
  orientation = "vertical",
  stacked = false,
  stackGap = 0,
  squareSnap,
  children,
  onPhaseChange,
  status = "ready",
}: BarChartProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const margin = { ...DEFAULT_MARGIN, ...marginProp };

  return (
    <div
      className={cn("relative w-full overflow-visible", className)}
      ref={containerRef}
      style={{ aspectRatio }}
    >
      <ParentSize debounceTime={10}>
        {({ width, height }) => (
          <ChartInner
            animationDuration={animationDuration}
            animationEasing={animationEasing}
            barGap={barGap}
            barWidthProp={barWidth}
            containerRef={containerRef}
            data={data}
            enterTransition={enterTransition}
            height={height}
            margin={margin}
            onPhaseChange={onPhaseChange}
            orientation={orientation}
            revealSignature={revealSignature}
            squareSnap={squareSnap}
            stacked={stacked}
            stackGap={stackGap}
            status={status}
            width={width}
            xDataKey={xDataKey}
          >
            {children}
          </ChartInner>
        )}
      </ParentSize>
    </div>
  );
}

BarChart.displayName = "BarChart";

export default BarChart;
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/chart-legend-hover.tsx
"use client";

import { createContext, type ReactNode, useContext, useMemo } from "react";

interface ChartLegendHoverContextValue {
  hoveredIndex: number | null;
  setHoveredIndex: (index: number | null) => void;
}

const ChartLegendHoverContext =
  createContext<ChartLegendHoverContextValue | null>(null);

export function ChartLegendHoverProvider({
  hoveredIndex,
  onHoverChange,
  children,
}: {
  hoveredIndex: number | null;
  onHoverChange: (index: number | null) => void;
  children: ReactNode;
}) {
  const value = useMemo(
    () => ({ hoveredIndex, setHoveredIndex: onHoverChange }),
    [hoveredIndex, onHoverChange]
  );

  return (
    <ChartLegendHoverContext.Provider value={value}>
      {children}
    </ChartLegendHoverContext.Provider>
  );
}

export function useChartLegendHover(): ChartLegendHoverContextValue {
  const context = useContext(ChartLegendHoverContext);
  return (
    context ?? {
      hoveredIndex: null,
      setHoveredIndex: () => {
        /* noop outside ChartLegendHoverProvider */
      },
    }
  );
}
src/charts/bar.tsx
"use client";

import type { scaleBand } from "@visx/scale";
import type { Transition } from "motion/react";
import { motion } from "motion/react";
import { memo, useId, useMemo } from "react";
import { barDepthAndRise, barDepthMaxDepth } from "./bar-depth-geometry";
import {
  chartCssVars,
  useChart,
  useChartStable,
  useYScale,
} from "./chart-context";
import { useChartLegendHover } from "./chart-legend-hover";
import { transitionWithDelay } from "./motion-utils";

type ScaleBand<Domain extends { toString(): string }> = ReturnType<
  typeof scaleBand<Domain>
>;

export type BarLineCap = "round" | "butt" | number;
export type BarAnimationType = "grow" | "fade";

// ── Bar-depth perspective trim ───────────────────────────────────────────
// Uses the SHARED geometry (`bar-depth-geometry.ts`) so a
// `<Bar perspective>` front face lines up exactly with
// `<BarDepthBack>`'s lid — the formula lives in one place for both.

/** perspectiveRise for a positive bar whose visual top sits at `topY`.
 * Returns 0 for a dead-center bar or a dense chart (degenerate depth). */
function barDepthPerspectiveRise(
  barScale: ScaleBand<string>,
  bandWidth: number,
  barXAccessor: (d: Record<string, unknown>) => string,
  innerWidth: number,
  datum: Record<string, unknown>,
  topY: number,
  baselineY: number
): number {
  const centerX = innerWidth / 2;
  if (centerX <= 0) {
    return 0;
  }
  const step =
    (barScale as unknown as { step?: () => number }).step?.() ?? bandWidth;
  const maxDepth = barDepthMaxDepth(step, bandWidth);
  const bandX = barScale(barXAccessor(datum)) ?? 0;
  const cx = bandX + bandWidth / 2;
  const absOffset = Math.min(1, Math.abs((cx - centerX) / centerX));
  const naturalHeight = Math.abs(baselineY - topY);
  return barDepthAndRise(absOffset, naturalHeight, maxDepth).perspectiveRise;
}

export interface BarProps {
  /** Key in data to use for y values */
  dataKey: string;
  /** Y-scale group id for vertical bars (Recharts `yAxisId`). Default: `"left"`. */
  yAxisId?: string | number;
  /** Fill color for the bar. Can be a color, gradient url, or pattern url. Default: var(--chart-line-primary) */
  fill?: string;
  /** Color for tooltip dot. Use when fill is a gradient/pattern. Default: uses fill value */
  stroke?: string;
  /** Line cap style for bar ends: "round", "butt", or a number for custom radius. Default: "round" */
  lineCap?: BarLineCap;
  /** Whether to animate the bars. Default: true */
  animate?: boolean;
  /** Animation type: "grow" (height) or "fade" (opacity + blur). Default: "grow" */
  animationType?: BarAnimationType;
  /** Opacity when not hovered (when another bar is hovered). Default: 0.3 */
  fadedOpacity?: number;
  /** Stagger delay between bars in seconds. Auto-calculated if not provided. */
  staggerDelay?: number;
  /** Gap between stacked bars in pixels. Default: 0 */
  stackGap?: number;
  /** Gap between grouped bars in pixels. Default: 4 */
  groupGap?: number;
  /** Shrink each positive bar's top by its perspective rise so the front face
   * lines up with `<BarDepthBack>`'s lid (instead of the lid sitting above the
   * front face). Pass `true` whenever the chart also renders the bar-depth 3D
   * surfaces. Default: false */
  perspective?: boolean;
  /** Minimum rendered bar height in px (non-stacked, vertical). Floors short or
   * zero-value bars so they stay visible. Pair with the same value on
   * `<BarDepthProvider minBarHeight>` when using the 3D surfaces. Default: 0 */
  minBarHeight?: number;
}

interface BarInnerProps extends BarProps {
  barScale: ScaleBand<string>;
  bandWidth: number;
  barXAccessor: (d: Record<string, unknown>) => string;
}

interface AnimatedBarProps {
  x: number;
  y: number;
  width: number;
  height: number;
  fill: string;
  rx: number;
  ry: number;
  index: number;
  isFaded: boolean;
  animationType: BarAnimationType;
  innerHeight: number;
  fadedOpacity: number;
  staggerDelay: number;
  enterTransition?: Transition;
  revealEpoch: number;
  isHorizontal: boolean;
}

function AnimatedBar({
  x,
  y,
  width,
  height,
  fill,
  rx,
  ry,
  index,
  isFaded,
  animationType,
  innerHeight,
  fadedOpacity,
  staggerDelay,
  enterTransition,
  revealEpoch,
  isHorizontal,
}: AnimatedBarProps) {
  const enterAnim = transitionWithDelay(enterTransition, index * staggerDelay);

  if (animationType === "fade") {
    return (
      <motion.rect
        animate={{
          opacity: isFaded ? fadedOpacity : 1,
          filter: "blur(0px)",
        }}
        fill={fill}
        height={height}
        initial={{ opacity: 0, filter: "blur(2px)" }}
        key={`fade-${index}-${revealEpoch}`}
        rx={rx}
        ry={ry}
        transition={enterAnim}
        width={width}
        x={x}
        y={y}
      />
    );
  }

  const initial = isHorizontal
    ? { width: 0, height, x: 0, y }
    : { width, height: 0, x, y: innerHeight };
  const target = isHorizontal
    ? { width, height, x: 0, y }
    : { width, height, x, y };

  return (
    <g
      opacity={isFaded ? fadedOpacity : 1}
      style={{ transition: "opacity 0.15s ease-in-out" }}
    >
      <motion.rect
        animate={target}
        fill={fill}
        initial={initial}
        key={`grow-${index}-${revealEpoch}`}
        rx={rx}
        ry={ry}
        transition={enterAnim}
      />
    </g>
  );
}

const BarInner = memo(function BarInner({
  dataKey,
  yAxisId,
  fill = chartCssVars.linePrimary,
  lineCap = "round",
  animate = true,
  animationType = "grow",
  fadedOpacity = 0.3,
  staggerDelay,
  stackGap = 0,
  groupGap = 4,
  perspective = false,
  minBarHeight = 0,
  barScale,
  bandWidth,
  barXAccessor,
}: BarInnerProps) {
  const {
    data,
    yScale: chartYScale,
    innerHeight,
    innerWidth,
    isLoaded,
    hoveredBarIndex,
    lines,
    orientation,
    stacked,
    stackOffsets,
    animationDuration,
    enterTransition,
    revealEpoch = 0,
  } = useChart();

  // Calculate stagger delay automatically if not provided
  // Total animation duration is ~1200ms, with 40% for stagger spread and 60% for bar animation
  const totalAnimDuration = animationDuration || 1100;
  const staggerSpread = totalAnimDuration * 0.4; // 40% of time for stagger spread
  const calculatedStaggerDelay =
    staggerDelay ?? (data.length > 1 ? staggerSpread / 1000 / data.length : 0);
  const uniqueId = useId();

  const isHorizontal = orientation === "horizontal";

  // Find the index of this bar series among all bar series
  const { hoveredIndex: legendHoveredIndex } = useChartLegendHover();

  const seriesIndex = useMemo(() => {
    const idx = lines.findIndex((l) => l.dataKey === dataKey);
    return idx >= 0 ? idx : 0;
  }, [lines, dataKey]);

  const seriesConfig = lines[seriesIndex];
  const valueScale = useYScale(yAxisId ?? seriesConfig?.yAxisId);

  const isLegendDimmed =
    legendHoveredIndex !== null && legendHoveredIndex !== seriesIndex;

  const seriesCount = lines.length;
  const isLastSeries = seriesIndex === seriesCount - 1;

  // Calculate the width for each bar within a group (for non-stacked)
  const barWidth = useMemo(() => {
    if (!bandWidth || seriesCount === 0) {
      return 0;
    }
    if (stacked) {
      // Stacked bars use full band width
      return bandWidth;
    }
    // Leave a gap between grouped bars (controlled by groupGap prop)
    const effectiveGroupGap = seriesCount > 1 ? groupGap : 0;
    return (bandWidth - effectiveGroupGap * (seriesCount - 1)) / seriesCount;
  }, [bandWidth, seriesCount, stacked, groupGap]);

  // Calculate corner radius based on lineCap. Perspective bars force a flat
  // top (radius 0) so the 3D lid from `<BarDepthBack>` meets the bar with no
  // gap — rounded corners would leave a wedge, so `perspective` overrides it.
  const cornerRadius = useMemo(() => {
    if (perspective) {
      return 0;
    }
    if (typeof lineCap === "number") {
      return lineCap;
    }
    if (lineCap === "round" && barWidth) {
      return Math.min(barWidth / 2, 8);
    }
    return 0;
  }, [lineCap, barWidth, perspective]);

  return (
    <g className={`bar-series-${uniqueId}`}>
      {data.map((d, i) => {
        const value = d[dataKey];
        if (typeof value !== "number") {
          return null;
        }

        const categoryValue = barXAccessor(d);
        const bandPos = barScale(categoryValue) ?? 0;

        let x: number;
        let y: number;
        let barHeight: number;
        let barW: number;

        const scale = isHorizontal ? chartYScale : valueScale;

        if (isHorizontal) {
          // Horizontal bars: category on y-axis, value on x-axis
          const valuePos = scale(value) ?? 0;
          barW = valuePos; // Width is the value position (grows from left)
          barHeight = barWidth;

          if (stacked && stackOffsets) {
            const offset = stackOffsets.get(i)?.get(dataKey) ?? 0;
            x = scale(offset) ?? 0;
            barW = valuePos - x;
            // Apply stack gap for horizontal: shift right and reduce width
            const gapOffset = seriesIndex * stackGap;
            x += gapOffset;
            if (!isLastSeries && stackGap > 0) {
              barW = Math.max(0, barW - stackGap);
            }
          } else {
            x = 0;
            // For grouped bars, offset y position
            const effectiveGroupGap = seriesCount > 1 ? groupGap : 0;
            y = bandPos + seriesIndex * (barWidth + effectiveGroupGap);
          }
          y = stacked
            ? bandPos
            : bandPos +
              seriesIndex * (barWidth + (seriesCount > 1 ? groupGap : 0));
        } else {
          // Vertical bars: category on x-axis, value on y-axis
          const valuePos = scale(value) ?? 0;
          barHeight = innerHeight - valuePos;
          barW = barWidth;

          if (stacked && stackOffsets) {
            const offset = stackOffsets.get(i)?.get(dataKey) ?? 0;
            const offsetY = scale(offset) ?? innerHeight;
            // Apply stack gap: shift up and reduce height
            const gapOffset = seriesIndex * stackGap;
            y = offsetY - barHeight - gapOffset;
            // Reduce height slightly for non-last bars to create visual gap
            if (!isLastSeries && stackGap > 0) {
              barHeight = Math.max(0, barHeight - stackGap);
            }
          } else {
            y = valuePos;
            // For grouped bars, offset x position
            const effectiveGroupGap = seriesCount > 1 ? groupGap : 0;
            x = bandPos + seriesIndex * (barWidth + effectiveGroupGap);
          }
          x = stacked
            ? bandPos
            : bandPos +
              seriesIndex * (barWidth + (seriesCount > 1 ? groupGap : 0));

          // Minimum visible height — floor short/zero non-stacked bars so a
          // zero-value data point still reads as a tiny bar instead of
          // vanishing. Grows up from the baseline. Floored bars skip the
          // perspective trim (sub-pixel on a 3px bar; keeps the front aligned
          // with bar-depth, which also skips trim for floored bars).
          let isFloored = false;
          if (
            !stacked &&
            minBarHeight > 0 &&
            value >= 0 &&
            barHeight < minBarHeight
          ) {
            const baselineY = scale(0) ?? innerHeight;
            barHeight = minBarHeight;
            y = baselineY - minBarHeight;
            isFloored = true;
          }

          // Perspective trim — shrink the topmost positive bar's front-face
          // top down by its perspective rise so it meets `<BarDepthBack>`'s
          // lid back edge. Stacked: only the last (topmost) series; grouped or
          // single: every positive bar. Clamped to `barHeight - 1` so very
          // short bars keep a positive height (matches bar-depth's clamp).
          if (
            perspective &&
            value > 0 &&
            !isFloored &&
            (!stacked || isLastSeries)
          ) {
            const baselineY = scale(0) ?? innerHeight;
            const rise = barDepthPerspectiveRise(
              barScale,
              bandWidth,
              barXAccessor,
              innerWidth,
              d,
              y,
              baselineY
            );
            const trim = Math.min(rise, Math.max(0, barHeight - 1));
            y += trim;
            barHeight -= trim;
          }
        }

        const isFaded =
          (hoveredBarIndex !== null && hoveredBarIndex !== i) || isLegendDimmed;

        // Use categoryValue as key since it's the unique identifier from data
        const barKey = `bar-${dataKey}-${categoryValue}`;

        // Apply rounded corners:
        // - For non-stacked: always apply
        // - For stacked with gap: apply to all bars
        // - For stacked without gap: only apply to the last series
        const applyRounding = !stacked || stackGap > 0 || isLastSeries;
        const effectiveRx = applyRounding ? cornerRadius : 0;
        const effectiveRy = applyRounding ? cornerRadius : 0;

        if (animate && !isLoaded) {
          return (
            <AnimatedBar
              animationType={animationType}
              enterTransition={enterTransition}
              fadedOpacity={fadedOpacity}
              fill={fill}
              height={barHeight}
              index={i}
              innerHeight={innerHeight}
              isFaded={isFaded}
              isHorizontal={isHorizontal}
              key={barKey}
              revealEpoch={revealEpoch}
              rx={effectiveRx}
              ry={effectiveRy}
              staggerDelay={calculatedStaggerDelay}
              width={barW}
              x={x}
              y={y}
            />
          );
        }

        // Static bar after animation completes
        return (
          <rect
            fill={fill}
            height={barHeight}
            key={barKey}
            opacity={isFaded ? fadedOpacity : 1}
            rx={effectiveRx}
            ry={effectiveRy}
            style={{
              cursor: "default",
              transition: "opacity 0.15s ease-in-out",
            }}
            width={barW}
            x={x}
            y={y}
          />
        );
      })}
    </g>
  );
});

export function Bar(props: BarProps) {
  const { barScale, bandWidth, barXAccessor } = useChartStable();

  if (!(barScale && bandWidth && barXAccessor)) {
    console.warn("Bar component must be used within a BarChart");
    return null;
  }

  return (
    <BarInner
      {...props}
      bandWidth={bandWidth}
      barScale={barScale}
      barXAccessor={barXAccessor}
    />
  );
}

Bar.displayName = "Bar";

export default Bar;
src/charts/bar-squares.tsx
"use client";

import type { scaleBand } from "@visx/scale";
import type { Transition } from "motion/react";
import { motion } from "motion/react";
import { memo, useId, useMemo } from "react";
import { computeSquareColumn } from "./bar-squares-layout";
import {
  chartCssVars,
  useChart,
  useChartStable,
  useYScale,
} from "./chart-context";
import { useChartLegendHover } from "./chart-legend-hover";
import { transitionWithDelay } from "./motion-utils";
import { type PatternPresetId, renderPatternPreset } from "./pattern-preset";

type ScaleBand<Domain extends { toString(): string }> = ReturnType<
  typeof scaleBand<Domain>
>;

export interface GradientStop {
  offset: number;
  color: string;
}

export interface BarSquaresProps {
  dataKey: string;
  yAxisId?: string | number;
  /** Fill color, gradient url, or pattern url. Default: var(--chart-line-primary) */
  fill?: string;
  /** Tooltip dot / ring stroke color when fill is gradient/pattern */
  stroke?: string;
  /** Gap between stacked squares in pixels. Default: 3 */
  squareGap?: number;
  /** Corner radius as a fraction of square size (0 = flat, 0.5 = circle). Default: 0.25 */
  squareRadius?: number;
  /** Redistribute gap so columns fit bar height exactly */
  squareFit?: boolean;
  /** Apply bar-spanning gradient from gradientStops */
  useGradient?: boolean;
  gradientStops?: GradientStop[];
  /** Pattern preset when fill is a pattern (for gradient tinting) */
  patternPreset?: PatternPresetId;
  animate?: boolean;
  fadedOpacity?: number;
  staggerDelay?: number;
  groupGap?: number;
}

export interface BarColumnTrackProps {
  /** Fill color or pattern url. Default: var(--chart-grid) */
  fill?: string;
  opacity?: number;
  squareGap?: number;
  /** Corner radius fraction (matches squares). Default: 0.25 */
  squareRadius?: number;
  groupGap?: number;
  squareFit?: boolean;
  staggerDelay?: number;
}

interface BarSquaresInnerProps extends BarSquaresProps {
  barScale: ScaleBand<string>;
  bandWidth: number;
  barXAccessor: (d: Record<string, unknown>) => string;
}

interface SquareColumnProps {
  x: number;
  baselineY: number;
  barLengthPx: number;
  squareSize: number;
  squareGap: number;
  squareRadius: number;
  squareFit: boolean;
  fill: string;
  useGradient: boolean;
  gradientStops: GradientStop[];
  patternPreset?: PatternPresetId;
  index: number;
  isFaded: boolean;
  fadedOpacity: number;
  animate: boolean;
  staggerDelay: number;
  animationDuration: number;
  enterTransition?: Transition;
  revealEpoch: number;
}

function isPatternFill(fill: string): boolean {
  return fill.startsWith("url(");
}

/** Delay between stacked squares within one column (bottom → top). */
function squareCascadeStepSeconds(
  enterTransition: Transition | undefined,
  animationDurationMs: number,
  squareCount: number
): number {
  if (squareCount <= 1) {
    return 0;
  }
  const durationMs =
    enterTransition?.type === "tween" &&
    typeof enterTransition.duration === "number"
      ? enterTransition.duration * 1000
      : animationDurationMs;
  const cascadeSpreadMs = durationMs * 0.4;
  return cascadeSpreadMs / 1000 / (squareCount - 1);
}

function cascadeColumnTransition(
  enterTransition: Transition | undefined,
  animationDurationMs: number,
  columnIndex: number,
  columnStaggerDelay: number,
  squareCount: number
): Transition {
  const cascadeStep = squareCascadeStepSeconds(
    enterTransition,
    animationDurationMs,
    squareCount
  );
  const base = transitionWithDelay(
    enterTransition,
    columnIndex * columnStaggerDelay
  );
  if (squareCount <= 1 || base.type !== "tween") {
    return base;
  }
  const baseDuration =
    typeof base.duration === "number"
      ? base.duration
      : animationDurationMs / 1000;
  return {
    ...base,
    duration: baseDuration + cascadeStep * (squareCount - 1),
  };
}

function SquareColumn({
  x,
  baselineY,
  barLengthPx,
  squareSize,
  squareGap,
  squareRadius,
  squareFit,
  fill,
  useGradient,
  gradientStops,
  patternPreset,
  index,
  isFaded,
  fadedOpacity,
  animate,
  staggerDelay,
  animationDuration,
  enterTransition,
  revealEpoch,
}: SquareColumnProps) {
  const layout = useMemo(
    () =>
      computeSquareColumn({
        barLengthPx,
        squareSize,
        gap: squareGap,
        fit: squareFit,
      }),
    [barLengthPx, squareSize, squareGap, squareFit]
  );

  const rx = squareSize * squareRadius;
  const columnTop = baselineY - layout.columnHeight;
  const gradientId = `bar-squares-gradient-${index}-${revealEpoch}`;
  const patternFill = isPatternFill(fill);
  const patternId = `bar-squares-pattern-${index}-${revealEpoch}`;

  const effectiveFill = useMemo(() => {
    if (useGradient) {
      if (patternFill && patternPreset && patternPreset !== "none") {
        return `url(#${patternId})`;
      }
      return `url(#${gradientId})`;
    }
    return fill;
  }, [useGradient, patternFill, patternPreset, fill, gradientId, patternId]);

  const cascadeStep = squareCascadeStepSeconds(
    enterTransition,
    animationDuration,
    layout.count
  );
  const squareOpacity = isFaded ? fadedOpacity : 1;

  const gradientPatternNode =
    useGradient && patternFill && patternPreset && patternPreset !== "none"
      ? renderPatternPreset(patternPreset, patternId, {
          color: `url(#${gradientId})`,
        })
      : null;

  const gradientDefs = useGradient ? (
    <defs>
      <linearGradient
        gradientUnits="userSpaceOnUse"
        id={gradientId}
        x1={0}
        x2={0}
        y1={baselineY}
        y2={columnTop}
      >
        {gradientStops.map((stop) => (
          <stop
            key={`${stop.offset}-${stop.color}`}
            offset={`${stop.offset}%`}
            stopColor={stop.color}
          />
        ))}
      </linearGradient>
      {gradientPatternNode}
    </defs>
  ) : null;

  const squares = layout.positions.map((relY, squareIndex) => {
    const y = columnTop + relY;
    const bottomY = y + squareSize;
    const key = `sq-${index}-${squareIndex}-${revealEpoch}`;

    if (!animate) {
      return (
        <rect
          fill={effectiveFill}
          height={squareSize}
          key={key}
          opacity={squareOpacity}
          rx={rx}
          ry={rx}
          width={squareSize}
          x={x}
          y={y}
        />
      );
    }

    return (
      <motion.rect
        animate={{ attrY: y, height: squareSize, opacity: squareOpacity }}
        fill={effectiveFill}
        height={squareSize}
        initial={{ attrY: bottomY, height: 0, opacity: 1 }}
        key={key}
        rx={rx}
        ry={rx}
        transition={{
          ...transitionWithDelay(
            enterTransition,
            index * staggerDelay + squareIndex * cascadeStep
          ),
          opacity: { duration: 0.15 },
        }}
        width={squareSize}
        x={x}
      />
    );
  });

  return (
    <>
      {gradientDefs}
      {squares}
    </>
  );
}

const BarSquaresInner = memo(function BarSquaresInner({
  dataKey,
  yAxisId,
  fill = chartCssVars.linePrimary,
  squareGap = 3,
  squareRadius = 0.25,
  squareFit = false,
  useGradient = false,
  gradientStops = [],
  patternPreset,
  animate = true,
  fadedOpacity = 0.3,
  staggerDelay,
  groupGap = 4,
  barScale,
  bandWidth,
  barXAccessor,
}: BarSquaresInnerProps) {
  const {
    data,
    innerHeight,
    hoveredBarIndex,
    lines,
    orientation,
    stacked,
    animationDuration,
    enterTransition,
    revealEpoch = 0,
  } = useChart();

  const { hoveredIndex: legendHoveredIndex } = useChartLegendHover();
  const uniqueId = useId();

  const isHorizontal = orientation === "horizontal";
  const isUnsupported = isHorizontal || stacked;

  const seriesIndex = useMemo(() => {
    const idx = lines.findIndex((l) => l.dataKey === dataKey);
    return idx >= 0 ? idx : 0;
  }, [lines, dataKey]);

  const seriesConfig = lines[seriesIndex];
  const valueScale = useYScale(yAxisId ?? seriesConfig?.yAxisId);

  const isLegendDimmed =
    legendHoveredIndex !== null && legendHoveredIndex !== seriesIndex;

  const seriesCount = lines.length;
  const squareSize = useMemo(() => {
    if (!bandWidth || seriesCount === 0) {
      return 0;
    }
    const effectiveGroupGap = seriesCount > 1 ? groupGap : 0;
    return (bandWidth - effectiveGroupGap * (seriesCount - 1)) / seriesCount;
  }, [bandWidth, seriesCount, groupGap]);

  const totalAnimDuration = animationDuration || 1100;
  const staggerSpread = totalAnimDuration * 0.4;
  const calculatedStaggerDelay =
    staggerDelay ?? (data.length > 1 ? staggerSpread / 1000 / data.length : 0);

  const baselineY = valueScale(0) ?? innerHeight;
  const stops =
    gradientStops.length >= 2
      ? gradientStops
      : [
          { offset: 0, color: fill },
          { offset: 100, color: fill },
        ];

  if (isUnsupported) {
    return null;
  }

  return (
    <g className={`bar-squares-${uniqueId}`}>
      {data.map((d, i) => {
        const value = d[dataKey];
        if (typeof value !== "number" || value <= 0) {
          return null;
        }

        const categoryValue = barXAccessor(d);
        const bandPos = barScale(categoryValue) ?? 0;
        const effectiveGroupGap = seriesCount > 1 ? groupGap : 0;
        const x = bandPos + seriesIndex * (squareSize + effectiveGroupGap);

        const valuePos = valueScale(value) ?? 0;
        const barLengthPx = baselineY - valuePos;

        const isFaded =
          (hoveredBarIndex !== null && hoveredBarIndex !== i) || isLegendDimmed;

        return (
          <SquareColumn
            animate={animate}
            animationDuration={animationDuration || 1100}
            barLengthPx={barLengthPx}
            baselineY={baselineY}
            enterTransition={enterTransition}
            fadedOpacity={fadedOpacity}
            fill={fill}
            gradientStops={stops}
            index={i}
            isFaded={isFaded}
            key={`bar-squares-${dataKey}-${categoryValue}`}
            patternPreset={patternPreset}
            revealEpoch={revealEpoch}
            squareFit={squareFit}
            squareGap={squareGap}
            squareRadius={squareRadius}
            squareSize={squareSize}
            staggerDelay={calculatedStaggerDelay}
            useGradient={useGradient}
            x={x}
          />
        );
      })}
    </g>
  );
});

export function BarSquares(props: BarSquaresProps) {
  const { barScale, bandWidth, barXAccessor } = useChartStable();

  if (!(barScale && bandWidth && barXAccessor)) {
    console.warn("BarSquares must be used within a BarChart");
    return null;
  }

  return (
    <BarSquaresInner
      {...props}
      bandWidth={bandWidth}
      barScale={barScale}
      barXAccessor={barXAccessor}
    />
  );
}

BarSquares.displayName = "BarSquares";

const BarColumnTrackInner = memo(function BarColumnTrackInner({
  fill = chartCssVars.grid,
  opacity = 0.3,
  squareGap = 3,
  squareRadius = 0.25,
  squareFit = false,
  groupGap = 4,
  staggerDelay,
  barScale,
  bandWidth,
  barXAccessor,
}: BarColumnTrackProps & {
  barScale: ScaleBand<string>;
  bandWidth: number;
  barXAccessor: (d: Record<string, unknown>) => string;
}) {
  const {
    data,
    lines,
    orientation,
    stacked,
    hoveredBarIndex,
    animationDuration,
    enterTransition,
    revealEpoch = 0,
  } = useChart();
  const uniqueId = useId();

  const isHorizontal = orientation === "horizontal";
  const isUnsupported = isHorizontal || stacked;
  const seriesCount = lines.length;

  const squareSize = useMemo(() => {
    if (!bandWidth || seriesCount === 0) {
      return 0;
    }
    const effectiveGroupGap = seriesCount > 1 ? groupGap : 0;
    return (bandWidth - effectiveGroupGap * (seriesCount - 1)) / seriesCount;
  }, [bandWidth, seriesCount, groupGap]);

  const totalAnimDuration = animationDuration || 1100;
  const staggerSpread = totalAnimDuration * 0.4;
  const calculatedStaggerDelay =
    staggerDelay ?? (data.length > 1 ? staggerSpread / 1000 / data.length : 0);

  if (isUnsupported) {
    return null;
  }

  const rx = squareSize * squareRadius;
  const effectiveOpacity = hoveredBarIndex === null ? opacity : 0;

  return (
    <g
      className={`bar-column-track-${uniqueId}`}
      style={{ transition: "opacity 0.15s ease-in-out" }}
    >
      {data.map((d, i) => {
        const categoryValue = barXAccessor(d);
        const bandPos = barScale(categoryValue) ?? 0;
        const effectiveGroupGap = seriesCount > 1 ? groupGap : 0;

        return lines.map((line, seriesIndex) => (
          <TrackColumn
            animate
            bandPos={bandPos}
            d={d}
            dataKey={line.dataKey}
            effectiveGroupGap={effectiveGroupGap}
            effectiveOpacity={effectiveOpacity}
            enterTransition={enterTransition}
            fill={fill}
            index={i}
            key={`track-${i}-${line.dataKey}`}
            revealEpoch={revealEpoch}
            rx={rx}
            seriesIndex={seriesIndex}
            squareFit={squareFit}
            squareGap={squareGap}
            squareSize={squareSize}
            staggerDelay={calculatedStaggerDelay}
            yAxisId={line.yAxisId}
          />
        ));
      })}
    </g>
  );
});

function TrackColumn({
  d,
  dataKey,
  yAxisId,
  bandPos,
  seriesIndex,
  effectiveGroupGap,
  squareSize,
  squareGap,
  squareFit,
  fill,
  rx,
  effectiveOpacity,
  index,
  staggerDelay,
  animate,
  enterTransition,
  revealEpoch,
}: {
  d: Record<string, unknown>;
  dataKey: string;
  yAxisId?: string | number;
  bandPos: number;
  seriesIndex: number;
  effectiveGroupGap: number;
  squareSize: number;
  squareGap: number;
  squareFit: boolean;
  fill: string;
  rx: number;
  effectiveOpacity: number;
  index: number;
  staggerDelay: number;
  animate: boolean;
  enterTransition?: Transition;
  revealEpoch: number;
}) {
  const { innerHeight, animationDuration: chartAnimationDuration } = useChart();
  const valueScale = useYScale(yAxisId);
  const value = d[dataKey];

  if (typeof value !== "number" || value <= 0) {
    return null;
  }

  const baselineY = valueScale(0) ?? innerHeight;
  const valuePos = valueScale(value) ?? 0;
  const barLengthPx = baselineY - valuePos;
  const layout = computeSquareColumn({
    barLengthPx,
    squareSize,
    gap: squareGap,
    fit: squareFit,
  });
  const columnTop = baselineY - layout.columnHeight;
  const trackHeight = Math.max(0, columnTop);

  if (trackHeight <= 0 && !animate) {
    return null;
  }

  const x = bandPos + seriesIndex * (squareSize + effectiveGroupGap);
  const enterAnim = cascadeColumnTransition(
    enterTransition,
    chartAnimationDuration || 1100,
    index,
    staggerDelay,
    layout.count
  );
  const animatedHeight = trackHeight > 0 ? trackHeight : 0;

  if (animate) {
    return (
      <motion.rect
        animate={{ height: animatedHeight, y: 0 }}
        fill={fill}
        height={animatedHeight}
        initial={{ height: baselineY, y: 0 }}
        key={`track-${index}-${seriesIndex}-${revealEpoch}`}
        opacity={effectiveOpacity}
        rx={rx}
        ry={rx}
        transition={enterAnim}
        width={squareSize}
        x={x}
      />
    );
  }

  if (trackHeight <= 0) {
    return null;
  }

  return (
    <rect
      fill={fill}
      height={trackHeight}
      opacity={effectiveOpacity}
      rx={rx}
      ry={rx}
      width={squareSize}
      x={x}
      y={0}
    />
  );
}

export function BarColumnTrack(props: BarColumnTrackProps) {
  const { barScale, bandWidth, barXAccessor } = useChartStable();

  if (!(barScale && bandWidth && barXAccessor)) {
    console.warn("BarColumnTrack must be used within a BarChart");
    return null;
  }

  return (
    <BarColumnTrackInner
      {...props}
      bandWidth={bandWidth}
      barScale={barScale}
      barXAccessor={barXAccessor}
    />
  );
}

BarColumnTrack.displayName = "BarColumnTrack";

export default BarSquares;
src/charts/bar-squares-layout.ts
export interface SquareColumnLayout {
  /** Number of squares in the column */
  count: number;
  /** Top-left Y of each square, bottom square first (relative to bar top at 0) */
  positions: number[];
  /** Quantized column height in pixels */
  columnHeight: number;
  squareSize: number;
  /** Effective gap between squares (may differ when fit mode redistributes) */
  gap: number;
}

export interface SquareColumnInput {
  /** Raw bar length in pixels (baseline − value) */
  barLengthPx: number;
  /** Square width/height — typically equals bar width */
  squareSize: number;
  /** Gap between stacked squares in pixels */
  gap: number;
  /** When true, redistribute gap so column height matches barLengthPx exactly */
  fit?: boolean;
}

/** Quantize bar length into a stack of square cells. */
export function computeSquareColumn({
  barLengthPx,
  squareSize,
  gap,
  fit = false,
}: SquareColumnInput): SquareColumnLayout {
  if (barLengthPx <= 0 || squareSize <= 0) {
    return { count: 0, positions: [], columnHeight: 0, squareSize, gap };
  }

  if (fit) {
    const count = Math.max(
      1,
      Math.floor((barLengthPx + gap) / (squareSize + gap))
    );
    const effectiveGap =
      count > 1
        ? Math.max(0, (barLengthPx - count * squareSize) / (count - 1))
        : 0;
    const step = squareSize + effectiveGap;
    const columnHeight = barLengthPx;
    const positions: number[] = [];

    for (let i = 0; i < count; i++) {
      positions.push(columnHeight - squareSize - i * step);
    }

    return {
      count,
      positions,
      columnHeight,
      squareSize,
      gap: effectiveGap,
    };
  }

  const step = squareSize + gap;
  const count = Math.max(1, Math.round(barLengthPx / step));
  const columnHeight = count * squareSize + Math.max(0, count - 1) * gap;

  const positions: number[] = [];
  for (let i = 0; i < count; i++) {
    const offsetFromBottom = i * step;
    positions.push(columnHeight - squareSize - offsetFromBottom);
  }

  return { count, positions, columnHeight, squareSize, gap };
}

/** Y center of the topmost square in a vertical column. */
export function topSquareCenterY({
  baselineY,
  barLengthPx,
  squareSize,
  gap,
  fit = false,
}: SquareColumnInput & { baselineY: number }): number {
  const {
    count,
    squareSize: size,
    columnHeight,
  } = computeSquareColumn({
    barLengthPx,
    squareSize,
    gap,
    fit,
  });

  if (count === 0) {
    return baselineY;
  }

  const topY = baselineY - columnHeight;
  return topY + size / 2;
}
src/charts/pattern-preset.tsx
"use client";

import type { ReactNode } from "react";
import { PatternCircles, PatternLines } from "./visx-pattern";

export const PATTERN_PRESET_IDS = [
  "none",
  "diagonal",
  "horizontal",
  "vertical",
  "cross",
  "dots",
  "circles",
  "accent",
] as const;

export type PatternPresetId = (typeof PATTERN_PRESET_IDS)[number];

export interface PatternPresetOptions {
  color?: string;
  scale?: number;
  strokeWidth?: number;
  radius?: number;
  complement?: boolean;
  fill?: string;
  /** Dot grid only — when false, render hollow dots (stroke only). Default: true */
  dotFill?: boolean;
  tileBackground?: string;
}

/** Presets rendered with @visx/pattern `PatternCircles`. */
export function isCirclePattern(preset: PatternPresetId): boolean {
  return preset === "circles" || preset === "dots";
}

/** @deprecated Use `isCirclePattern`. */
export function isCirclesPattern(preset: PatternPresetId): boolean {
  return isCirclePattern(preset);
}

export function patternPresetTileSize(
  preset: PatternPresetId,
  scale = 1
): { width: number; height: number; strokeWidth: number } {
  let base = { width: 6, height: 6, strokeWidth: 1 };
  if (preset === "dots") {
    base = { width: 10, height: 10, strokeWidth: 0 };
  } else if (preset === "cross") {
    base = { width: 8, height: 8, strokeWidth: 1 };
  } else if (preset === "circles") {
    base = { width: 6, height: 6, strokeWidth: 1 };
  }

  return {
    width: base.width * scale,
    height: base.height * scale,
    strokeWidth: base.strokeWidth * scale,
  };
}

function renderPatternCircles(
  preset: "dots" | "circles",
  _id: string,
  color: string,
  common: {
    id: string;
    height: number;
    width: number;
    strokeWidth: number;
    background?: string;
  },
  options: PatternPresetOptions,
  scale: number
) {
  const isDotGrid = preset === "dots";
  const radius =
    options.radius ?? (isDotGrid ? Math.max(0.5, 1.5 * scale) : 2 * scale);
  const dotFillEnabled = options.dotFill !== false;

  if (isDotGrid) {
    const dotFill = dotFillEnabled ? options.fill || color : undefined;
    return (
      <PatternCircles
        {...common}
        complement={options.complement}
        fill={dotFill}
        radius={radius}
        stroke={dotFillEnabled && options.fill ? undefined : color}
        strokeWidth={
          dotFillEnabled && !options.fill
            ? (options.strokeWidth ?? 0)
            : (options.strokeWidth ?? 1)
        }
      />
    );
  }

  return (
    <PatternCircles
      {...common}
      complement={options.complement}
      fill={options.fill || undefined}
      radius={radius}
      stroke={color}
      strokeWidth={options.strokeWidth ?? common.strokeWidth}
    />
  );
}

/** Renders a @visx/pattern definition node for the given preset. */
export function renderPatternPreset(
  preset: PatternPresetId,
  id: string,
  options: PatternPresetOptions = {}
): ReactNode {
  if (preset === "none") {
    return null;
  }

  const color = options.color ?? "var(--chart-1)";
  const scale = options.scale ?? 1;
  const tile = patternPresetTileSize(preset, scale);
  const common = {
    id,
    height: tile.height,
    width: tile.width,
    strokeWidth: tile.strokeWidth,
    ...(options.tileBackground ? { background: options.tileBackground } : {}),
  };

  if (preset === "dots" || preset === "circles") {
    return renderPatternCircles(preset, id, color, common, options, scale);
  }

  const strokeWidth = options.strokeWidth ?? tile.strokeWidth;

  switch (preset) {
    case "diagonal":
      return (
        <PatternLines
          {...common}
          orientation={["diagonal"]}
          stroke={color}
          strokeWidth={strokeWidth}
        />
      );
    case "horizontal":
      return (
        <PatternLines
          {...common}
          orientation={["horizontal"]}
          stroke={color}
          strokeWidth={strokeWidth}
        />
      );
    case "vertical":
      return (
        <PatternLines
          {...common}
          orientation={["vertical"]}
          stroke={color}
          strokeWidth={strokeWidth}
        />
      );
    case "cross":
      return (
        <PatternLines
          {...common}
          orientation={["diagonal", "diagonalRightToLeft"]}
          stroke={color}
          strokeWidth={strokeWidth}
        />
      );
    case "accent":
      return (
        <PatternLines
          {...common}
          orientation={["diagonal"]}
          stroke="#e879f9"
          strokeWidth={strokeWidth}
        />
      );
    default:
      return null;
  }
}
src/charts/visx-pattern.tsx
"use client";

import type { ComponentProps } from "react";
import {
  PatternCircles as VisxPatternCircles,
  PatternHexagons as VisxPatternHexagons,
  PatternLines as VisxPatternLines,
  PatternWaves as VisxPatternWaves,
} from "./patterns/index";

export function PatternLines(props: ComponentProps<typeof VisxPatternLines>) {
  return <VisxPatternLines {...props} />;
}
PatternLines.displayName = "PatternLines";

export function PatternCircles(
  props: ComponentProps<typeof VisxPatternCircles>
) {
  return <VisxPatternCircles {...props} />;
}
PatternCircles.displayName = "PatternCircles";

export function PatternWaves(props: ComponentProps<typeof VisxPatternWaves>) {
  return <VisxPatternWaves {...props} />;
}
PatternWaves.displayName = "PatternWaves";

export function PatternHexagons(
  props: ComponentProps<typeof VisxPatternHexagons>
) {
  return <VisxPatternHexagons {...props} />;
}
PatternHexagons.displayName = "PatternHexagons";
src/charts/bar-depth-geometry.ts
/**
 * Shared 3D bar-depth geometry — the single source of truth for the
 * perspective math used by BOTH `<Bar perspective>` (which shrinks a
 * bar's front-face top) AND the bar-depth surfaces (`<BarDepthBack>`'s side +
 * lid). Keeping the formula here means the front face and the 3D lid can never
 * drift out of alignment.
 */

/** Hard ceiling on side-face thickness in px. Capped further by the column gap
 * so depth never bleeds past the next bar's leading edge. */
export const BAR_DEPTH_MAX_PX = 7;
/** The side parallelogram's back edge lifts by `depth * this ratio`, giving a
 * subtle head-on perspective slope. */
export const BAR_DEPTH_PERSPECTIVE_RATIO = 0.45;

/**
 * Maximum side-face depth in px for a chart, clamped so depth never spills past
 * the gap between bars. `stepWidth` is d3-scaleBand's `step()` (bandwidth +
 * gap); `bandWidth` is a single bar's width. Returns 0 for gapless/dense charts.
 */
export function barDepthMaxDepth(stepWidth: number, bandWidth: number): number {
  const gap = Math.max(0, stepWidth - bandWidth);
  return Math.min(bandWidth * 0.22, Math.max(0, gap - 1), BAR_DEPTH_MAX_PX);
}

/**
 * Per-bar side-face depth + perspective rise.
 *
 * - `absOffset` ∈ [0, 1]: the bar's normalized distance from the chart's
 *   horizontal center. 0 = dead center (no depth); 1 = chart edge (full depth).
 * - `naturalHeight`: the bar's pixel height. Depth is capped by it so a short
 *   bar's side never reads wider than the bar is tall.
 * - `maxDepth`: from `barDepthMaxDepth`.
 */
export function barDepthAndRise(
  absOffset: number,
  naturalHeight: number,
  maxDepth: number
): { depth: number; perspectiveRise: number } {
  const offset = Math.min(1, Math.max(0, absOffset));
  const cappedMaxDepth = Math.min(maxDepth, Math.max(0, naturalHeight));
  const depth = offset * cappedMaxDepth;
  return { depth, perspectiveRise: depth * BAR_DEPTH_PERSPECTIVE_RATIO };
}
src/charts/bar-x-axis.tsx
"use client";

import { motion } from "motion/react";
import { memo, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { useChart, useChartStable } from "./chart-context";

export interface BarXAxisProps {
  /** Width of the date ticker box for fade calculation. Default: 50 */
  tickerHalfWidth?: number;
  /** Whether to show all labels or skip some for dense data. Default: false */
  showAllLabels?: boolean;
  /** Maximum number of labels to show. Default: 12 */
  maxLabels?: number;
}

interface BarXAxisLabelProps {
  label: string;
  x: number;
  crosshairX: number | null;
  isHovering: boolean;
  tickerHalfWidth: number;
}

function BarXAxisLabel({
  label,
  x,
  crosshairX,
  isHovering,
  tickerHalfWidth,
}: BarXAxisLabelProps) {
  const fadeBuffer = 20;
  const fadeRadius = tickerHalfWidth + fadeBuffer;

  let opacity = 1;
  if (isHovering && crosshairX !== null) {
    const distance = Math.abs(x - crosshairX);
    if (distance < tickerHalfWidth) {
      opacity = 0;
    } else if (distance < fadeRadius) {
      opacity = (distance - tickerHalfWidth) / fadeBuffer;
    }
  }

  // Zero-width container approach for perfect centering
  return (
    <div
      className="absolute"
      style={{
        left: x,
        bottom: 12,
        width: 0,
        display: "flex",
        justifyContent: "center",
      }}
    >
      <motion.span
        animate={{ opacity }}
        className={cn("whitespace-nowrap text-chart-label text-xs")}
        initial={{ opacity: 1 }}
        transition={{ duration: 0.4, ease: "easeInOut" }}
      >
        {label}
      </motion.span>
    </div>
  );
}

export function BarXAxis(props: BarXAxisProps) {
  const { containerRef, barScale } = useChartStable();
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  const container = containerRef.current;
  if (!(mounted && container)) {
    return null;
  }

  if (!barScale) {
    return null;
  }

  return <BarXAxisInner {...props} container={container} />;
}

const BarXAxisInner = memo(function BarXAxisInner({
  tickerHalfWidth = 50,
  showAllLabels = false,
  maxLabels = 12,
  container,
}: BarXAxisProps & { container: HTMLDivElement }) {
  const { margin, tooltipData, barScale, bandWidth, barXAccessor, data } =
    useChart();

  // Generate labels for each bar
  const labelsToShow = useMemo(() => {
    if (!(barScale && bandWidth && barXAccessor)) {
      return [];
    }

    const allLabels = data.map((d) => {
      const label = barXAccessor(d);
      const bandX = barScale(label) ?? 0;
      // Center the label under the bar group
      const x = bandX + bandWidth / 2 + margin.left;
      return { label, x };
    });

    // If showAllLabels is true or we have fewer than maxLabels, show all
    if (showAllLabels || allLabels.length <= maxLabels) {
      return allLabels;
    }

    // Otherwise, skip some labels to avoid crowding
    const step = Math.ceil(allLabels.length / maxLabels);
    return allLabels.filter((_, i) => i % step === 0);
  }, [
    barScale,
    bandWidth,
    barXAccessor,
    data,
    margin.left,
    showAllLabels,
    maxLabels,
  ]);

  const isHovering = tooltipData !== null;
  const crosshairX = tooltipData ? tooltipData.x + margin.left : null;

  return createPortal(
    <div className="pointer-events-none absolute inset-0">
      {labelsToShow.map((item) => (
        <BarXAxisLabel
          crosshairX={crosshairX}
          isHovering={isHovering}
          key={`${item.label}-${item.x}`}
          label={item.label}
          tickerHalfWidth={tickerHalfWidth}
          x={item.x}
        />
      ))}
    </div>,
    container
  );
});

BarXAxis.displayName = "BarXAxis";

export default BarXAxis;
src/charts/bar-y-axis.tsx
"use client";

import { motion } from "motion/react";
import { memo, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { useChart, useChartStable } from "./chart-context";

export interface BarYAxisProps {
  /** Whether to show all labels or skip some for dense data. Default: true */
  showAllLabels?: boolean;
  /** Maximum number of labels to show. Default: 20 */
  maxLabels?: number;
}

interface BarYAxisLabelProps {
  label: string;
  y: number;
  bandHeight: number;
  isHovered: boolean;
}

function BarYAxisLabel({
  label,
  y,
  bandHeight,
  isHovered,
}: BarYAxisLabelProps) {
  return (
    <div
      className="absolute right-0 flex items-center justify-end pr-2"
      style={{
        top: y,
        height: bandHeight,
      }}
    >
      <motion.span
        animate={{
          opacity: isHovered ? 1 : 0.7,
          color: isHovered
            ? "var(--foreground)"
            : "var(--chart-label, var(--color-zinc-500))",
        }}
        className={cn("truncate whitespace-nowrap text-right text-xs")}
        initial={{
          opacity: 0.7,
          color: "var(--chart-label, var(--color-zinc-500))",
        }}
        style={{ maxWidth: 70 }}
        transition={{ duration: 0.15 }}
      >
        {label}
      </motion.span>
    </div>
  );
}

export function BarYAxis(props: BarYAxisProps) {
  const { containerRef, barScale } = useChartStable();
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  const container = containerRef.current;
  if (!(mounted && container)) {
    return null;
  }

  if (!barScale) {
    return null;
  }

  return <BarYAxisInner {...props} container={container} />;
}

const BarYAxisInner = memo(function BarYAxisInner({
  showAllLabels = true,
  maxLabels = 20,
  container,
}: BarYAxisProps & { container: HTMLDivElement }) {
  const { margin, barScale, bandWidth, barXAccessor, data, hoveredBarIndex } =
    useChart();

  // Generate labels for each bar
  const labelsToShow = useMemo(() => {
    if (!(barScale && bandWidth && barXAccessor)) {
      return [];
    }

    const allLabels = data.map((d, i) => {
      const label = barXAccessor(d);
      const bandY = barScale(label) ?? 0;
      // Center the label vertically within the band
      const y = bandY + margin.top;
      return { label, y, bandHeight: bandWidth, index: i };
    });

    // If showAllLabels is true or we have fewer than maxLabels, show all
    if (showAllLabels || allLabels.length <= maxLabels) {
      return allLabels;
    }

    // Otherwise, skip some labels to avoid crowding
    const step = Math.ceil(allLabels.length / maxLabels);
    return allLabels.filter((_, i) => i % step === 0);
  }, [
    barScale,
    bandWidth,
    barXAccessor,
    data,
    margin.top,
    showAllLabels,
    maxLabels,
  ]);

  return createPortal(
    <div
      className="pointer-events-none absolute top-0 bottom-0"
      style={{
        left: 0,
        width: margin.left,
      }}
    >
      {labelsToShow.map((item) => (
        <BarYAxisLabel
          bandHeight={item.bandHeight}
          isHovered={hoveredBarIndex === item.index}
          key={`${item.label}-${item.y}`}
          label={item.label}
          y={item.y}
        />
      ))}
    </div>,
    container
  );
});

BarYAxis.displayName = "BarYAxis";

export default BarYAxis;
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/bar-chart-loading.tsx
"use client";

import { BarChart } from "./bar-chart";
import type { Margin } from "./chart-context";

const EMPTY_DATA: Record<string, unknown>[] = [];

export interface BarChartLoadingProps {
  /** Chart margins. */
  margin?: Partial<Margin>;
  /** Aspect ratio as "width / height". Default: "2 / 1" */
  aspectRatio?: string;
  /** Additional class name for the container. */
  className?: string;
}

/**
 * Turnkey loading skeleton for bar charts, a thin shortcut for
 * `<BarChart status="loading" />`. Renders shimmer-swept placeholder bars while
 * data is fetching; swap in a real `<BarChart>` once it resolves.
 */
export function BarChartLoading({
  margin,
  aspectRatio = "2 / 1",
  className = "",
}: BarChartLoadingProps) {
  return (
    <BarChart
      aspectRatio={aspectRatio}
      className={className}
      data={EMPTY_DATA}
      margin={margin}
      status="loading"
    />
  );
}

export default BarChartLoading;

More from Bklit UI

Browse all components in this library →