diff --git a/frontend/app/(app)/inventory/page.tsx b/frontend/app/(app)/inventory/page.tsx
new file mode 100644
index 0000000..1ee75fb
--- /dev/null
+++ b/frontend/app/(app)/inventory/page.tsx
@@ -0,0 +1,10 @@
+import { InventoryView } from "@/components/pharmacy/inventory-view";
+import { SidebarInset } from "@/components/ui/sidebar";
+
+export default function InventoryPage() {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index 82ec521..4cecfdd 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -64,6 +64,22 @@
--animate-toast-error-odd: toast-error-odd 0.28s cubic-bezier(0.5, 1, 0.89, 1);
--animate-toast-error-even: toast-error-even 0.28s cubic-bezier(0.5, 1, 0.89, 1)
;
+ --color-chart-label: var(--chart-label);
+ --color-chart-ring-background: var(--chart-ring-background);
+ --color-chart-marker-foreground: var(--chart-marker-foreground);
+ --color-chart-marker-border: var(--chart-marker-border);
+ --color-chart-marker-background: var(--chart-marker-background);
+ --color-chart-tooltip-muted: var(--chart-tooltip-muted);
+ --color-chart-tooltip-foreground: var(--chart-tooltip-foreground);
+ --color-chart-tooltip-background: var(--chart-tooltip-background);
+ --chart-brush-border: var(--chart-brush-border);
+ --color-chart-grid: var(--chart-grid);
+ --color-chart-crosshair: var(--chart-crosshair);
+ --chart-line-secondary: var(--chart-line-secondary);
+ --chart-line-primary: var(--chart-line-primary);
+ --color-chart-foreground-muted: var(--chart-foreground-muted);
+ --color-chart-foreground: var(--chart-foreground);
+ --color-chart-background: var(--chart-background);
@keyframes toast-success-odd {
0% {
scale: 1;}
@@ -145,6 +161,22 @@
--success-foreground: var(--color-emerald-700);
--warning: var(--color-amber-500);
--warning-foreground: var(--color-amber-700);
+ --chart-background: oklch(1 0 0);
+ --chart-foreground: oklch(0.145 0.004 285);
+ --chart-foreground-muted: oklch(0.55 0.014 260);
+ --chart-line-primary: var(--chart-1);
+ --chart-line-secondary: var(--chart-2);
+ --chart-crosshair: oklch(0.4 0.1828 274.34);
+ --chart-grid: oklch(0.9 0 0);
+ --chart-brush-border: var(--chart-grid);
+ --chart-tooltip-background: oklch(0.21 0.006 285 / 0.8);
+ --chart-tooltip-foreground: oklch(0.985 0 0);
+ --chart-tooltip-muted: oklch(0.65 0.01 260);
+ --chart-marker-background: oklch(0.97 0.005 260);
+ --chart-marker-border: oklch(0.85 0.01 260);
+ --chart-marker-foreground: oklch(0.3 0.01 260);
+ --chart-ring-background: oklch(0.9 0.005 260 / 0.25);
+ --chart-label: oklch(0.45 0.01 260);
}
/* COSS neutral dark palette: near-black canvas, neutral accent, hairline borders. */
@@ -187,6 +219,17 @@
--success-foreground: var(--color-emerald-400);
--warning: var(--color-amber-500);
--warning-foreground: var(--color-amber-400);
+ --chart-background: oklch(0.145 0 0);
+ --chart-foreground: oklch(0.45 0 0);
+ --chart-foreground-muted: oklch(0.65 0.01 260);
+ --chart-crosshair: oklch(0.45 0 0);
+ --chart-grid: oklch(0.25 0 0);
+ --chart-brush-border: var(--chart-grid);
+ --chart-marker-background: oklch(0.25 0.01 260);
+ --chart-marker-border: oklch(0.4 0.01 260);
+ --chart-marker-foreground: oklch(0.9 0 0);
+ --chart-ring-background: oklch(0.35 0.01 260 / 0.25);
+ --chart-label: oklch(0.75 0.01 260);
}
@layer base {
diff --git a/frontend/components.json b/frontend/components.json
index 9521df1..e73582c 100644
--- a/frontend/components.json
+++ b/frontend/components.json
@@ -23,6 +23,7 @@
},
"registries": {
"@blocks-so": "https://blocks.so/r/{name}.json",
- "@coss": "https://coss.com/ui/r/{name}.json"
+ "@coss": "https://coss.com/ui/r/{name}.json",
+ "@bklit": "https://ui.bklit.com/r/{name}.json"
}
}
diff --git a/frontend/components/analysis/analysis-view.tsx b/frontend/components/analysis/analysis-view.tsx
index 8f7499e..a502141 100644
--- a/frontend/components/analysis/analysis-view.tsx
+++ b/frontend/components/analysis/analysis-view.tsx
@@ -1,9 +1,17 @@
"use client";
-import { type ReactNode, useEffect, useState } from "react";
+import { type ReactNode, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
-import { TrendCard } from "@/components/analysis/trend-card";
+import { LiveHospitalChart } from "@/components/analysis/live-hospital-chart";
+import { Area, AreaChart } from "@/components/charts/area-chart";
+import { Bar } from "@/components/charts/bar";
+import { BarChart } from "@/components/charts/bar-chart";
+import { BarXAxis } from "@/components/charts/bar-x-axis";
+import { BarYAxis } from "@/components/charts/bar-y-axis";
+import { Grid } from "@/components/charts/grid";
+import { ChartTooltip } from "@/components/charts/tooltip";
+import { XAxis } from "@/components/charts/x-axis";
import { Card } from "@/components/ui/card";
import { type Analytics, getAnalytics } from "@/lib/analytics";
@@ -55,6 +63,28 @@ function Section({
);
}
+// A titled card that frames a chart, used for the trend visualisations.
+function ChartCard({
+ title,
+ total,
+ children,
+}: {
+ title: string;
+ total: number;
+ children: ReactNode;
+}) {
+ return (
+
+
+ {title}
+
+ {total}
+
+
+ {children}
+
+ );
+}
export function AnalysisView() {
const { t } = useTranslation();
@@ -76,6 +106,34 @@ export function AnalysisView() {
const n = (v: number | undefined) => String(v ?? 0);
+ // The area chart needs real Date x-values: synthesise one month per point,
+ // ending with the current month.
+ const monthData = useMemo(() => {
+ const points = data?.trends.patientsByMonth ?? [];
+ const now = new Date();
+ return points.map((p, i) => ({
+ date: new Date(
+ now.getFullYear(),
+ now.getMonth() - (points.length - 1 - i),
+ 1,
+ ),
+ patients: p.count,
+ }));
+ }, [data]);
+
+ // The bar chart is categorical (one bar per weekday).
+ const weekdayData = useMemo(
+ () =>
+ (data?.trends.appointmentsByWeekday ?? []).map((p) => ({
+ name: p.label,
+ appointments: p.count,
+ })),
+ [data],
+ );
+
+ const monthTotal = monthData.reduce((sum, p) => sum + p.patients, 0);
+ const weekdayTotal = weekdayData.reduce((sum, p) => sum + p.appointments, 0);
+
return (
@@ -85,6 +143,23 @@ export function AnalysisView() {
{t("analysis.subtitle")}
+
+
+
+ {t("analysis.live.title")}
+
+
+ {t("analysis.live.subtitle")}
+
+
+
+
+ {t("analysis.live.label")}
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+ total={weekdayTotal}
+ >
+
+
+
+
+
+
+
+
diff --git a/frontend/components/analysis/live-hospital-chart.tsx b/frontend/components/analysis/live-hospital-chart.tsx
new file mode 100644
index 0000000..fc6ca9d
--- /dev/null
+++ b/frontend/components/analysis/live-hospital-chart.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+
+import {
+ LiveLineChart,
+ type LiveLinePoint,
+} from "@/components/charts/live-line-chart";
+import { LiveLine } from "@/components/charts/live-line";
+import { LiveYAxis } from "@/components/charts/live-y-axis";
+import { ChartTooltip } from "@/components/charts/tooltip";
+
+// temetro has no real-time telemetry feed yet, so the "Live" panel simulates a
+// hospital signal (patients currently in the building) as a bounded random walk
+// that updates once a second. Swap `tick()` for a WebSocket/poll when a real
+// feed exists — the chart contract (append { time, value }) stays the same.
+const BASELINE = 48;
+const MIN = 24;
+const MAX = 90;
+const WINDOW_SECONDS = 30;
+
+export function LiveHospitalChart() {
+ const [data, setData] = useState([]);
+ const [value, setValue] = useState(BASELINE);
+ const valueRef = useRef(BASELINE);
+
+ useEffect(() => {
+ // Seed a short history so the line is drawn immediately on mount.
+ const now = Date.now() / 1000;
+ setData(
+ Array.from({ length: WINDOW_SECONDS }, (_, i) => ({
+ time: now - (WINDOW_SECONDS - i),
+ value: BASELINE,
+ })),
+ );
+
+ const id = setInterval(() => {
+ const drift = (Math.random() - 0.5) * 5;
+ const next = Math.max(MIN, Math.min(MAX, valueRef.current + drift));
+ valueRef.current = next;
+ setValue(next);
+ setData((prev) => [
+ ...prev.slice(-500),
+ { time: Date.now() / 1000, value: next },
+ ]);
+ }, 1000);
+ return () => clearInterval(id);
+ }, []);
+
+ return (
+
+
+ String(Math.round(v))}
+ momentumColors={{
+ up: "var(--color-emerald-500)",
+ down: "var(--color-red-500)",
+ flat: "var(--chart-line-primary)",
+ }}
+ />
+ String(Math.round(v))} position="left" />
+
+
+
+ );
+}
diff --git a/frontend/components/charts/animation.ts b/frontend/components/charts/animation.ts
new file mode 100644
index 0000000..0b5569b
--- /dev/null
+++ b/frontend/components/charts/animation.ts
@@ -0,0 +1,36 @@
+import type { Transition } from "motion/react";
+
+/** Default clip-reveal easing for cartesian charts. */
+export const DEFAULT_ANIMATION_EASING = "cubic-bezier(0.85, 0, 0.15, 1)";
+
+export const DEFAULT_ANIMATION_DURATION_MS = 1100;
+
+/** Default enter transition — matches the original line chart reveal. */
+export const DEFAULT_CHART_ENTER_TRANSITION: Transition = {
+ type: "tween",
+ duration: DEFAULT_ANIMATION_DURATION_MS / 1000,
+ ease: [0.85, 0, 0.15, 1],
+};
+
+/**
+ * Clip-path width reveal must use tween — spring does not reliably animate SVG width.
+ */
+export function clipRevealTransition(enterTransition?: Transition): Transition {
+ if (enterTransition?.type === "tween") {
+ return {
+ ...enterTransition,
+ ease: enterTransition.ease ?? DEFAULT_CHART_ENTER_TRANSITION.ease,
+ };
+ }
+
+ const duration =
+ typeof enterTransition?.duration === "number"
+ ? enterTransition.duration
+ : DEFAULT_ANIMATION_DURATION_MS / 1000;
+
+ return {
+ type: "tween",
+ duration,
+ ease: DEFAULT_CHART_ENTER_TRANSITION.ease,
+ };
+}
diff --git a/frontend/components/charts/area-chart-loading.tsx b/frontend/components/charts/area-chart-loading.tsx
new file mode 100644
index 0000000..1294bc7
--- /dev/null
+++ b/frontend/components/charts/area-chart-loading.tsx
@@ -0,0 +1,110 @@
+"use client";
+
+import { curveNatural } from "@visx/curve";
+import { useMemo } from "react";
+import { Area } from "./area";
+import { AreaChart } from "./area-chart";
+import type { Margin } from "./chart-context";
+import {
+ DEFAULT_SKELETON_DATA_KEY,
+ DEFAULT_SKELETON_POINT_COUNT,
+ generateChartSkeletonData,
+} from "./generate-chart-skeleton-data";
+import { Grid } from "./grid";
+
+const LOADING_DATA_KEY = DEFAULT_SKELETON_DATA_KEY;
+const DEFAULT_LOADING_STROKE = "var(--foreground)";
+const DEFAULT_LOADING_GRID_STROKE =
+ "color-mix(in oklch, var(--chart-grid) 50%, transparent)";
+const DEFAULT_LOADING_GRID_SHIMMER_STROKE =
+ "color-mix(in oklch, var(--foreground) 68%, transparent)";
+const DEFAULT_LOADING_STROKE_OPACITY = 0.5;
+
+export interface AreaChartLoadingProps {
+ /** Chart margins */
+ margin?: Partial;
+ /** 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;
+ /** Centered shimmer label text. Default: "Loading" */
+ label?: string;
+ /** Aspect ratio as "width / height". Default: "2 / 1" */
+ aspectRatio?: string;
+ /** Additional class name for the container */
+ className?: string;
+}
+
+export function AreaChartLoading({
+ margin,
+ stroke = DEFAULT_LOADING_STROKE,
+ strokeOpacity = DEFAULT_LOADING_STROKE_OPACITY,
+ gridStroke = DEFAULT_LOADING_GRID_STROKE,
+ gridShimmerStroke = DEFAULT_LOADING_GRID_SHIMMER_STROKE,
+ gridShimmer = true,
+ gridShimmerLength,
+ gridShimmerSpeed,
+ gridShimmerSync = false,
+ label = "Loading",
+ aspectRatio = "2 / 1",
+ className = "",
+}: AreaChartLoadingProps) {
+ const data = useMemo(
+ () =>
+ generateChartSkeletonData({
+ dataKey: DEFAULT_SKELETON_DATA_KEY,
+ pointCount: DEFAULT_SKELETON_POINT_COUNT,
+ }),
+ []
+ );
+
+ return (
+
+
+
+
+ );
+}
+
+export default AreaChartLoading;
diff --git a/frontend/components/charts/area-chart.tsx b/frontend/components/charts/area-chart.tsx
new file mode 100644
index 0000000..51b871e
--- /dev/null
+++ b/frontend/components/charts/area-chart.tsx
@@ -0,0 +1,272 @@
+"use client";
+
+import { ParentSize } from "@visx/responsive";
+import type { Transition } from "motion/react";
+import {
+ Children,
+ type CSSProperties,
+ isValidElement,
+ type ReactNode,
+ useCallback,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { cn } from "@/lib/utils";
+import { Area, type AreaProps } from "./area";
+import type { LineConfig, Margin } from "./chart-context";
+import { ChartLoadingLabel } from "./chart-loading-label";
+import {
+ type ChartPhase,
+ type ChartStatus,
+ DEFAULT_CHART_STATUS,
+ DEFAULT_Y_DOMAIN_TWEEN_MS,
+ resolveRestingChartPhase,
+} from "./chart-phase";
+import { PatternArea } from "./pattern-area";
+import { TimeSeriesChartInner } from "./time-series-chart-shell";
+
+export interface AreaChartProps {
+ /** Data array - each item should have a date field and numeric values */
+ data: Record[];
+ /** Key in data for the x-axis (date). Default: "date" */
+ xDataKey?: string;
+ /** Chart margins */
+ margin?: Partial;
+ /** Animation duration in milliseconds. Default: 1100 */
+ animationDuration?: number;
+ /** CSS easing for clip-reveal. Default: cubic-bezier(0.85, 0, 0.15, 1) */
+ animationEasing?: string;
+ /** Motion enter transition (spring or cubic-bezier tween). */
+ enterTransition?: Transition;
+ /** Signature of motion URL state — triggers reveal replay when it changes. */
+ revealSignature?: string;
+ /** Aspect ratio as "width / height". Default: "2 / 1" */
+ aspectRatio?: string;
+ /** Additional class name for the container */
+ className?: string;
+ /** Loading vs ready — drives chart phase and loading chrome. Default: `"ready"`. */
+ status?: ChartStatus;
+ /** Centered shimmer label while loading. */
+ loadingLabel?: string;
+ /** Animate y-domain over this duration (ms) on status transitions. Default: 500. */
+ yDomainTweenDuration?: number;
+ /** Animate y-domain when status or target domain changes. Default: true */
+ yDomainTween?: boolean;
+ /** Visible x-domain for brush zoom. */
+ xDomain?: [Date, Date];
+ /** Full dataset length for x-scale padding when `xDomain` is set. */
+ xDomainSlotCount?: number;
+ /** Tween y-domain when brush changes the visible x-range. Default: false */
+ tweenYDomainOnXDomainChange?: boolean;
+ /** Inline container styles (e.g. fixed height for brush strip). */
+ style?: CSSProperties;
+ /** Fires when the internal chart phase changes (e.g. OG capture readiness). */
+ onPhaseChange?: (phase: ChartPhase) => void;
+ /** Child components (Area, Grid, ChartTooltip, etc.) */
+ children: ReactNode;
+}
+
+const DEFAULT_MARGIN: Margin = { top: 40, right: 40, bottom: 40, left: 40 };
+
+function extractAreaConfigs(children: ReactNode): LineConfig[] {
+ const configs: LineConfig[] = [];
+
+ Children.forEach(children, (child) => {
+ if (!isValidElement(child)) {
+ return;
+ }
+
+ const childType = child.type as {
+ displayName?: string;
+ name?: string;
+ };
+ const componentName =
+ typeof child.type === "function"
+ ? childType.displayName || childType.name || ""
+ : "";
+
+ const props = child.props as AreaProps | undefined;
+ const isPatternArea =
+ componentName === "PatternArea" || child.type === PatternArea;
+ const isAreaComponent =
+ componentName === "Area" ||
+ child.type === Area ||
+ (props &&
+ typeof props.dataKey === "string" &&
+ props.dataKey.length > 0 &&
+ !isPatternArea);
+
+ if (isAreaComponent && props?.dataKey) {
+ configs.push({
+ dataKey: props.dataKey,
+ stroke: props.stroke || props.fill || "var(--chart-line-primary)",
+ strokeWidth: props.strokeWidth || 2,
+ yAxisId: props.yAxisId,
+ });
+ }
+ });
+
+ return configs;
+}
+
+interface ChartInnerProps {
+ width: number;
+ height: number;
+ data: Record[];
+ 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;
+ onPhaseChange: (phase: ChartPhase) => void;
+}
+
+function ChartInner({
+ width,
+ height,
+ data,
+ xDataKey,
+ margin,
+ animationDuration,
+ animationEasing,
+ enterTransition,
+ revealSignature,
+ chartStatus,
+ loadingLabel,
+ yDomainTweenDuration,
+ yDomainTween,
+ xDomain,
+ xDomainSlotCount,
+ tweenYDomainOnXDomainChange,
+ children,
+ containerRef,
+ onPhaseChange,
+}: ChartInnerProps) {
+ const lines = useMemo(() => extractAreaConfigs(children), [children]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function AreaChart({
+ data,
+ xDataKey = "date",
+ margin: marginProp,
+ animationDuration = 1100,
+ animationEasing,
+ enterTransition,
+ revealSignature,
+ aspectRatio = "2 / 1",
+ className = "",
+ status = DEFAULT_CHART_STATUS,
+ loadingLabel,
+ yDomainTweenDuration = DEFAULT_Y_DOMAIN_TWEEN_MS,
+ yDomainTween = true,
+ xDomain,
+ xDomainSlotCount,
+ tweenYDomainOnXDomainChange = false,
+ style,
+ onPhaseChange,
+ children,
+}: AreaChartProps) {
+ const containerRef = useRef(null);
+ const margin = { ...DEFAULT_MARGIN, ...marginProp };
+ const [chartPhase, setChartPhase] = useState(() =>
+ 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 (
+
+
+ {({ width, height }) => (
+
+ {children}
+
+ )}
+
+ {showLoadingLabel ? (
+
+ ) : null}
+
+ );
+}
+
+export { Area, type AreaProps } from "./area";
+
+export default AreaChart;
diff --git a/frontend/components/charts/area-gradient-defs.tsx b/frontend/components/charts/area-gradient-defs.tsx
new file mode 100644
index 0000000..cb142cb
--- /dev/null
+++ b/frontend/components/charts/area-gradient-defs.tsx
@@ -0,0 +1,113 @@
+import {
+ type FadeEdges,
+ fadeGradientStops,
+ resolveFadeSides,
+ viewportFadeGradientAttrs,
+} from "./fade-edges";
+
+interface AreaGradientDefsProps {
+ gradientId: string;
+ strokeGradientId: string;
+ edgeMaskId: string;
+ edgeGradientId: string;
+ fill: string;
+ fillOpacity: number;
+ gradientToOpacity: number;
+ /** 0–1: where the bottom stop sits (1 = full-height gradient). */
+ gradientSpan?: number;
+ resolvedStroke: string;
+ isPatternFill: boolean;
+ fadeEdges: FadeEdges;
+ innerWidth: number;
+ innerHeight: number;
+}
+
+export function AreaGradientDefs({
+ gradientId,
+ strokeGradientId,
+ edgeMaskId,
+ edgeGradientId,
+ fill,
+ fillOpacity,
+ gradientToOpacity,
+ gradientSpan = 1,
+ resolvedStroke,
+ isPatternFill,
+ fadeEdges,
+ innerWidth,
+ innerHeight,
+}: AreaGradientDefsProps) {
+ const sides = resolveFadeSides(fadeEdges);
+ // Stroke gradient mirrors the area's edge fade so the line doesn't pop in
+ // past the faded fill. Skip emitting it when neither edge fades — the line
+ // can then paint a solid stroke instead of an unnecessary url(#...) ref.
+ const strokeStops = sides.any ? fadeGradientStops(sides) : null;
+ const showEdgeMask = sides.any && !isPatternFill;
+ const edgeStops = showEdgeMask ? fadeGradientStops(sides) : null;
+ const span = Math.min(1, Math.max(0.01, gradientSpan));
+ const midOffset = `${span * 100}%`;
+
+ return (
+
+ {isPatternFill ? null : (
+
+
+
+ {span < 1 ? (
+
+ ) : null}
+
+ )}
+
+ {strokeStops ? (
+
+ {strokeStops.map((stop) => (
+
+ ))}
+
+ ) : null}
+
+ {edgeStops ? (
+ <>
+
+ {edgeStops.map((stop) => (
+
+ ))}
+
+
+
+
+ >
+ ) : null}
+
+ );
+}
diff --git a/frontend/components/charts/area.tsx b/frontend/components/charts/area.tsx
new file mode 100644
index 0000000..9f71b87
--- /dev/null
+++ b/frontend/components/charts/area.tsx
@@ -0,0 +1,351 @@
+"use client";
+
+import { curveMonotoneX } from "@visx/curve";
+import { AreaClosed, LinePath } from "@visx/shape";
+
+// CurveFactory type - simplified version compatible with visx
+// biome-ignore lint/suspicious/noExplicitAny: d3 curve factory type
+type CurveFactory = any;
+
+import { useCallback, useId, useMemo, useRef, useState } from "react";
+import { AreaGradientDefs } from "./area-gradient-defs";
+import { chartCssVars, useChartStable, useYScale } from "./chart-context";
+import type { ChartPhase } from "./chart-phase";
+import { type FadeEdges, resolveFadeSides } from "./fade-edges";
+import {
+ type LineLoadingPulseMode,
+ LineLoadingPulseStroke,
+ resolveLineLoadingPulseMode,
+} from "./line-loading-pulse";
+import { LINE_LOADING_LOOP_PAUSE_MS } from "./line-loading-timing";
+import {
+ resolveDashTailBounds,
+ usePathStrokeMetrics,
+} from "./path-stroke-utils";
+import { SeriesDashTailOverlay } from "./series-dash-tail-overlay";
+import { SeriesHighlightLayer } from "./series-highlight-layer";
+import { SeriesHoverDim } from "./series-hover-dim";
+import { SeriesMarkers } from "./series-markers";
+import type { SeriesPointMarkerStyle } from "./series-point-marker";
+
+export interface AreaProps {
+ /** Key in data to use for y values */
+ dataKey: string;
+ /** Y-scale group id (Recharts `yAxisId`). Default: `"left"`. */
+ yAxisId?: string | number;
+ /** Fill color for the area gradient start. Default: var(--chart-line-primary) */
+ fill?: string;
+ /** Fill opacity at the top of the area. Default: 0.4 */
+ fillOpacity?: number;
+ /** Stroke color for the line. Default: same as fill */
+ stroke?: string;
+ /** Stroke width. Default: 2 */
+ strokeWidth?: number;
+ /** Curve function. Default: curveMonotoneX */
+ curve?: CurveFactory;
+ /** Whether to animate the area. Default: true */
+ animate?: boolean;
+ /** Whether to show the stroke line. Default: true */
+ showLine?: boolean;
+ /** Whether to show highlight segment on hover. Default: true */
+ showHighlight?: boolean;
+ /** Gradient opacity at bottom (0 = fully transparent). Default: 0 */
+ gradientToOpacity?: number;
+ /**
+ * Vertical extent of the fill gradient (0–1). `1` fades across the full
+ * height; lower values compress the gradient toward the top.
+ */
+ gradientSpan?: number;
+ /**
+ * Fade the area fill (and stroke) toward transparent at the chart edges.
+ * - `true` fades both edges, `false` disables the fade entirely.
+ * - `"left"` / `"right"` fades only that side — useful when the opposite
+ * edge butts up against another element you don't want to fade into.
+ * Default: false
+ */
+ fadeEdges?: FadeEdges;
+ /** Render scatter-style circle markers at each data point. Default: false */
+ showMarkers?: boolean;
+ /** Marker styling (same options as Scatter). */
+ markers?: SeriesPointMarkerStyle;
+ /**
+ * Data index from which the line stroke becomes dashed (inclusive).
+ * Useful for projecting incomplete periods, e.g. dashed from yesterday through today.
+ */
+ dashFromIndex?: number;
+ /** Dash pattern for the tail segment when `dashFromIndex` is set. Default: "6,4" */
+ dashArray?: string;
+ /** Pulse stroke color while chart is loading. Default: var(--foreground) */
+ loadingStroke?: string;
+ /** Pulse stroke opacity while chart is loading. Default: 0.5 */
+ loadingStrokeOpacity?: number;
+ /**
+ * Show the loading pulse overlay. Default: follows chart loading phase.
+ * Set `false` to disable even during loading.
+ */
+ loading?: boolean;
+ /** Override pulse animation mode (loop / exit / enter). */
+ loadingPulseMode?: LineLoadingPulseMode;
+}
+
+function useAreaLoadingPulseState(
+ chartPhase: ChartPhase,
+ loading: boolean | undefined,
+ loadingPulseMode: LineLoadingPulseMode | undefined,
+ notifyLoadingPulseComplete?: () => void
+) {
+ const phasePulseMode = resolveLineLoadingPulseMode(chartPhase);
+ const pulseMode =
+ loading === false
+ ? null
+ : (loadingPulseMode ?? (loading === true ? "loop" : phasePulseMode));
+ const showLoadingPulse = pulseMode != null;
+ const showSeriesContent =
+ chartPhase === "revealing" ||
+ chartPhase === "ready" ||
+ chartPhase === "exitingReady";
+ const [pulseEpoch, setPulseEpoch] = useState(0);
+
+ const handleLoadingPulseComplete = useCallback(() => {
+ if (pulseMode === "loop") {
+ window.setTimeout(() => {
+ setPulseEpoch((epoch) => epoch + 1);
+ }, LINE_LOADING_LOOP_PAUSE_MS);
+ return;
+ }
+ notifyLoadingPulseComplete?.();
+ }, [notifyLoadingPulseComplete, pulseMode]);
+
+ return {
+ handleLoadingPulseComplete,
+ pulseMode,
+ pulseEpoch,
+ showLoadingPulse,
+ showSeriesContent,
+ };
+}
+
+// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: mirrors Line series layout (fill, stroke, dash, markers, pulse)
+export function Area({
+ dataKey,
+ yAxisId,
+ fill = chartCssVars.linePrimary,
+ fillOpacity = 0.4,
+ stroke,
+ strokeWidth = 2,
+ curve = curveMonotoneX,
+ animate = true,
+ showLine = true,
+ showHighlight = true,
+ gradientToOpacity = 0,
+ gradientSpan = 1,
+ fadeEdges = false,
+ showMarkers = false,
+ markers,
+ dashFromIndex,
+ dashArray = "6,4",
+ loading,
+ loadingStroke = chartCssVars.foreground,
+ loadingStrokeOpacity = 0.5,
+ loadingPulseMode,
+}: AreaProps) {
+ // Stable slice only: hover state lives inside `` and
+ // `` so this component (and its expensive
+ // child) does not re-render on cursor motion.
+ // The reveal-clip is now a single shared clipPath at the chart-shell
+ // level (`time-series-chart-shell.tsx`); we no longer render a per-area
+ // `` or read `revealEpoch` here.
+ const {
+ data,
+ renderData,
+ xScale,
+ innerHeight,
+ innerWidth,
+ xAccessor,
+ lines,
+ chartPhase,
+ notifyLoadingPulseComplete,
+ } = useChartStable();
+ const yScale = useYScale(yAxisId);
+ const {
+ handleLoadingPulseComplete,
+ pulseMode,
+ pulseEpoch,
+ showLoadingPulse,
+ showSeriesContent,
+ } = useAreaLoadingPulseState(
+ chartPhase,
+ loading,
+ loadingPulseMode,
+ notifyLoadingPulseComplete
+ );
+
+ const seriesIndex = useMemo(() => {
+ const index = lines.findIndex((line) => line.dataKey === dataKey);
+ return index >= 0 ? index : 0;
+ }, [lines, dataKey]);
+
+ const pathRef = useRef(null);
+ const { pathLength, pathD } = usePathStrokeMetrics(pathRef, [
+ renderData,
+ innerWidth,
+ dashFromIndex,
+ showLine,
+ showSeriesContent,
+ showLoadingPulse,
+ ]);
+
+ // Unique IDs for this area
+ const uniqueId = useId();
+ const gradientId = `area-gradient-${dataKey}-${uniqueId}`;
+ const strokeGradientId = `area-stroke-gradient-${dataKey}-${uniqueId}`;
+ const edgeMaskId = `area-edge-mask-${dataKey}-${uniqueId}`;
+ const edgeGradientId = `${edgeMaskId}-gradient`;
+
+ const isPatternFill = fill.startsWith("url(");
+ const showAreaFill = isPatternFill || fillOpacity > 0;
+ const areaFill = isPatternFill ? fill : `url(#${gradientId})`;
+
+ // Resolved stroke color (defaults to fill; pattern URLs need a real color)
+ const resolvedStroke =
+ stroke || (isPatternFill ? chartCssVars.linePrimary : fill);
+
+ const getY = useCallback(
+ (d: Record) => {
+ const value = d[dataKey];
+ return typeof value === "number" ? (yScale(value) ?? 0) : 0;
+ },
+ [dataKey, yScale]
+ );
+
+ const hasDashTail = resolveDashTailBounds(dashFromIndex, data.length);
+ // The stroke gradient is only emitted when at least one edge fades, so fall
+ // back to the resolved solid color otherwise — avoids an invalid url(#...).
+ const fadeSides = resolveFadeSides(fadeEdges);
+ const useViewportEdgeFade = fadeSides.any && !isPatternFill;
+ let strokePaint = resolvedStroke;
+ if (!useViewportEdgeFade && fadeSides.any) {
+ strokePaint = `url(#${strokeGradientId})`;
+ }
+ const highlightEnabled =
+ showHighlight && showLine && !showLoadingPulse && showSeriesContent;
+ const showSeriesStroke = showSeriesContent && showLine;
+ let visibleStroke = "transparent";
+ if (showSeriesStroke && !hasDashTail) {
+ visibleStroke = strokePaint;
+ }
+ const shouldMeasurePath = showLine && (showSeriesContent || showLoadingPulse);
+
+ const seriesLayers = (
+ <>
+ {showSeriesContent && showAreaFill ? (
+ xScale(xAccessor(d)) ?? 0}
+ y={getY}
+ yScale={yScale}
+ />
+ ) : null}
+
+ {shouldMeasurePath ? (
+ <>
+ xScale(xAccessor(d)) ?? 0}
+ y={getY}
+ />
+ {showSeriesStroke ? (
+
+ ) : null}
+ >
+ ) : null}
+ >
+ );
+
+ return (
+ <>
+
+
+
+ {useViewportEdgeFade ? (
+ {seriesLayers}
+ ) : (
+ seriesLayers
+ )}
+
+
+ {/* Highlight segment on hover — isolated hover subscriber. */}
+
+
+ {showMarkers && showSeriesContent ? (
+
+ ) : null}
+
+ {showLoadingPulse && pathD && innerWidth > 0 ? (
+
+ ) : null}
+ >
+ );
+}
+
+Area.displayName = "Area";
+
+export default Area;
diff --git a/frontend/components/charts/bar-chart.tsx b/frontend/components/charts/bar-chart.tsx
new file mode 100644
index 0000000..c550557
--- /dev/null
+++ b/frontend/components/charts/bar-chart.tsx
@@ -0,0 +1,677 @@
+"use client";
+
+import { localPoint } from "@visx/event";
+import { ParentSize } from "@visx/responsive";
+import { scaleBand, scaleLinear } from "@visx/scale";
+import type { Transition } from "motion/react";
+import {
+ Children,
+ isValidElement,
+ 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 {
+ ChartProvider,
+ type LineConfig,
+ type Margin,
+ type TooltipData,
+} from "./chart-context";
+import { isGradientDefComponent, isPatternDefComponent } from "./chart-defs";
+import { shortDateFmt } from "./chart-formatters";
+import { type ChartPhase, DEFAULT_CHART_LIFECYCLE } from "./chart-phase";
+import { useScheduledTooltip } from "./use-scheduled-tooltip";
+import {
+ buildYScalesForLines,
+ getPrimaryYScale,
+ normalizeYAxisId,
+ wrapSingleYScale,
+} from "./y-axis-scales";
+
+export type BarOrientation = "vertical" | "horizontal";
+
+export interface BarChartProps {
+ /** Data array - each item should have an x-axis key and numeric values */
+ data: Record[];
+ /** Key in data for the categorical axis. Default: "name" */
+ xDataKey?: string;
+ /** Chart margins */
+ margin?: Partial;
+ /** 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;
+ /** Child components (Bar, Grid, ChartTooltip, etc.) */
+ children: ReactNode;
+ /** Reports reveal lifecycle for OG screenshots and loading orchestration. */
+ onPhaseChange?: (phase: ChartPhase) => void;
+}
+
+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[] = [];
+
+ Children.forEach(children, (child) => {
+ if (!isValidElement(child)) {
+ return;
+ }
+
+ 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" ||
+ (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;
+}
+
+// Check if a component should render after the mouse overlay
+function isPostOverlayComponent(child: ReactElement): boolean {
+ const childType = child.type as {
+ displayName?: string;
+ name?: string;
+ __isChartMarkers?: boolean;
+ };
+
+ if (childType.__isChartMarkers) {
+ return true;
+ }
+
+ const componentName =
+ typeof child.type === "function"
+ ? childType.displayName || childType.name || ""
+ : "";
+
+ return componentName === "ChartMarkers" || componentName === "MarkerGroup";
+}
+
+interface ChartInnerProps {
+ width: number;
+ height: number;
+ data: Record[];
+ xDataKey: string;
+ margin: Margin;
+ animationDuration: number;
+ animationEasing: string;
+ enterTransition?: Transition;
+ revealSignature?: string;
+ barGap: number;
+ barWidthProp?: number;
+ orientation: BarOrientation;
+ stacked: boolean;
+ stackGap: number;
+ children: ReactNode;
+ containerRef: React.RefObject;
+ onPhaseChange?: (phase: ChartPhase) => void;
+}
+
+function ChartInner(props: ChartInnerProps) {
+ const { width, height } = props;
+ if (width < 10 || height < 10) {
+ return null;
+ }
+ return ;
+}
+
+const ChartCore = memo(function ChartCore({
+ width,
+ height,
+ data,
+ xDataKey,
+ margin,
+ animationDuration,
+ animationEasing,
+ enterTransition,
+ revealSignature = "",
+ barGap,
+ barWidthProp,
+ orientation,
+ stacked,
+ stackGap,
+ children,
+ containerRef,
+ onPhaseChange,
+}: ChartInnerProps) {
+ const { tooltipData, setTooltipData, scheduleTooltip, clearTooltip } =
+ useScheduledTooltip();
+ 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 => {
+ 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): 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({
+ 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>();
+ for (let i = 0; i < data.length; i++) {
+ const d = data[i];
+ if (!d) {
+ continue;
+ }
+ const pointOffsets = new Map();
+ 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);
+ const timer = setTimeout(() => {
+ setIsLoaded(true);
+ }, animationDuration);
+ return () => clearTimeout(timer);
+ }, [animationDuration, revealSignature]);
+
+ useEffect(() => {
+ onPhaseChange?.(isLoaded ? "ready" : "revealing");
+ }, [isLoaded, onPhaseChange]);
+
+ // Mouse move handler
+ const handleMouseMove = useCallback(
+ (event: React.MouseEvent) => {
+ 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 = {};
+ const xPositions: Record = {};
+ 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;
+ yPositions[line.dataKey] = axisScale(value) ?? 0;
+ 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,
+ ]
+ );
+
+ const handleMouseLeave = useCallback(() => {
+ clearTooltip();
+ }, [clearTooltip]);
+
+ const canInteract = isLoaded;
+
+ // Separate children into defs, pre-overlay, and post-overlay
+ const defsChildren: ReactElement[] = [];
+ const preOverlayChildren: ReactElement[] = [];
+ const postOverlayChildren: ReactElement[] = [];
+
+ Children.forEach(children, (child) => {
+ if (!isValidElement(child)) {
+ return;
+ }
+
+ if (isGradientDefComponent(child)) {
+ defsChildren.push(child);
+ } else if (isPatternDefComponent(child)) {
+ preOverlayChildren.push(child);
+ } else if (isPostOverlayComponent(child)) {
+ postOverlayChildren.push(child);
+ } else {
+ preOverlayChildren.push(child);
+ }
+ });
+
+ const contextValue = {
+ ...DEFAULT_CHART_LIFECYCLE,
+ data,
+ renderData: data,
+ xScale: fakeTimeScale as unknown as ReturnType<
+ typeof import("@visx/scale").scaleTime
+ >,
+ yScale: isHorizontal ? valueScale : primaryYScale,
+ yScales,
+ width,
+ height,
+ innerWidth,
+ innerHeight,
+ margin,
+ columnWidth,
+ tooltipData,
+ setTooltipData,
+ containerRef,
+ lines,
+ isLoaded,
+ animationDuration,
+ animationEasing,
+ enterTransition,
+ revealEpoch,
+ xAccessor: xAccessorDate,
+ dateLabels,
+ // Bar-specific properties
+ barScale: categoryScale,
+ bandWidth,
+ hoveredBarIndex,
+ barXAccessor: categoryAccessor,
+ orientation,
+ stacked,
+ stackOffsets,
+ };
+
+ return (
+
+
+
+ );
+});
+
+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,
+ children,
+ onPhaseChange,
+}: BarChartProps) {
+ const containerRef = useRef(null);
+ const margin = { ...DEFAULT_MARGIN, ...marginProp };
+
+ return (
+
+
+ {({ width, height }) => (
+
+ {children}
+
+ )}
+
+
+ );
+}
+
+BarChart.displayName = "BarChart";
+
+export default BarChart;
diff --git a/frontend/components/charts/bar-depth-geometry.ts b/frontend/components/charts/bar-depth-geometry.ts
new file mode 100644
index 0000000..d00743b
--- /dev/null
+++ b/frontend/components/charts/bar-depth-geometry.ts
@@ -0,0 +1,34 @@
+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 };
+}
diff --git a/frontend/components/charts/bar-x-axis.tsx b/frontend/components/charts/bar-x-axis.tsx
new file mode 100644
index 0000000..2fbfa5f
--- /dev/null
+++ b/frontend/components/charts/bar-x-axis.tsx
@@ -0,0 +1,153 @@
+"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 (
+
+
+ {label}
+
+
+ );
+}
+
+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 ;
+}
+
+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(
+
+ {labelsToShow.map((item) => (
+
+ ))}
+
,
+ container
+ );
+});
+
+BarXAxis.displayName = "BarXAxis";
+
+export default BarXAxis;
diff --git a/frontend/components/charts/bar-y-axis.tsx b/frontend/components/charts/bar-y-axis.tsx
new file mode 100644
index 0000000..95400e5
--- /dev/null
+++ b/frontend/components/charts/bar-y-axis.tsx
@@ -0,0 +1,142 @@
+"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 (
+
+
+ {label}
+
+
+ );
+}
+
+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 ;
+}
+
+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(
+
+ {labelsToShow.map((item) => (
+
+ ))}
+
,
+ container
+ );
+});
+
+BarYAxis.displayName = "BarYAxis";
+
+export default BarYAxis;
diff --git a/frontend/components/charts/bar.tsx b/frontend/components/charts/bar.tsx
new file mode 100644
index 0000000..6f3d815
--- /dev/null
+++ b/frontend/components/charts/bar.tsx
@@ -0,0 +1,466 @@
+"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 = ReturnType<
+ typeof scaleBand
+>;
+
+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
+// `` front face lines up exactly with
+// ``'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,
+ bandWidth: number,
+ barXAccessor: (d: Record) => string,
+ innerWidth: number,
+ datum: Record,
+ 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 ``'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
+ * `` when using the 3D surfaces. Default: 0 */
+ minBarHeight?: number;
+}
+
+interface BarInnerProps extends BarProps {
+ barScale: ScaleBand;
+ bandWidth: number;
+ barXAccessor: (d: Record) => 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 (
+
+ );
+ }
+
+ 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 (
+
+
+
+ );
+}
+
+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 `` 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 (
+
+ {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 ``'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 (
+
+ );
+ }
+
+ // Static bar after animation completes
+ return (
+
+ );
+ })}
+
+ );
+});
+
+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 (
+
+ );
+}
+
+Bar.displayName = "Bar";
+
+export default Bar;
diff --git a/frontend/components/charts/chart-child-passthrough.ts b/frontend/components/charts/chart-child-passthrough.ts
new file mode 100644
index 0000000..deed3e0
--- /dev/null
+++ b/frontend/components/charts/chart-child-passthrough.ts
@@ -0,0 +1,23 @@
+import { isValidElement, type ReactElement } 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;
+}
diff --git a/frontend/components/charts/chart-config-context.tsx b/frontend/components/charts/chart-config-context.tsx
new file mode 100644
index 0000000..2faf405
--- /dev/null
+++ b/frontend/components/charts/chart-config-context.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import { createContext, type ReactNode, useContext, useMemo } from "react";
+
+export interface SpringConfig {
+ stiffness: number;
+ damping: number;
+}
+
+export interface ChartConfigValue {
+ /** Crosshair indicator, tooltip dot, date pill. */
+ tooltipSpring: SpringConfig;
+ /** Floating tooltip panel. */
+ tooltipBoxSpring: SpringConfig;
+ /** Line/area hover-highlight band (x + width). */
+ highlightSpring: SpringConfig;
+}
+
+export const DEFAULT_CHART_CONFIG: ChartConfigValue = {
+ tooltipSpring: { stiffness: 300, damping: 30 },
+ tooltipBoxSpring: { stiffness: 100, damping: 20 },
+ highlightSpring: { stiffness: 180, damping: 28 },
+};
+
+const ChartConfigContext = createContext(null);
+
+export interface ChartConfigProviderProps {
+ value?: Partial;
+ children: ReactNode;
+}
+
+export function ChartConfigProvider({
+ value,
+ children,
+}: ChartConfigProviderProps) {
+ const merged = useMemo(
+ () => ({
+ ...DEFAULT_CHART_CONFIG,
+ ...value,
+ }),
+ [value]
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useChartConfig(): ChartConfigValue {
+ return useContext(ChartConfigContext) ?? DEFAULT_CHART_CONFIG;
+}
+
+const DEFAULT_TOOLTIP_BOX_DAMPING =
+ DEFAULT_CHART_CONFIG.tooltipBoxSpring.damping;
+
+/** Maps a damping slider to the floating tooltip panel follow spring. `0` = instant. */
+export function resolveTooltipBoxMotion(damping?: number): {
+ animate: boolean;
+ springConfig: SpringConfig;
+} {
+ if (damping === 0) {
+ return {
+ animate: false,
+ springConfig: DEFAULT_CHART_CONFIG.tooltipBoxSpring,
+ };
+ }
+
+ const effectiveDamping = damping ?? DEFAULT_TOOLTIP_BOX_DAMPING;
+ let stiffness = DEFAULT_CHART_CONFIG.tooltipBoxSpring.stiffness;
+
+ if (effectiveDamping < DEFAULT_TOOLTIP_BOX_DAMPING) {
+ const t =
+ (DEFAULT_TOOLTIP_BOX_DAMPING - effectiveDamping) /
+ DEFAULT_TOOLTIP_BOX_DAMPING;
+ stiffness += t * 400;
+ } else if (effectiveDamping > DEFAULT_TOOLTIP_BOX_DAMPING) {
+ const t =
+ (effectiveDamping - DEFAULT_TOOLTIP_BOX_DAMPING) /
+ (100 - DEFAULT_TOOLTIP_BOX_DAMPING);
+ stiffness -= t * 85;
+ }
+
+ return {
+ animate: true,
+ springConfig: {
+ stiffness: Math.max(12, Math.round(stiffness)),
+ damping: effectiveDamping,
+ },
+ };
+}
diff --git a/frontend/components/charts/chart-context.tsx b/frontend/components/charts/chart-context.tsx
new file mode 100644
index 0000000..b95ad5a
--- /dev/null
+++ b/frontend/components/charts/chart-context.tsx
@@ -0,0 +1,419 @@
+"use client";
+
+import type { scaleBand, scaleLinear, scaleTime } from "@visx/scale";
+
+type ScaleLinear