"use client";
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { ChartBar, ChartColumn } from "lucide-react";
import { cn } from "@/lib/utils";
/* Theme-aware categorical palette (validated for light + dark and colour-vision deficiency). */
const PALETTE =
"[--chart-1:#2a78d6] [--chart-2:#eb6834] [--chart-3:#1baf7a] [--chart-4:#eda100] [--chart-5:#e87ba4] [--chart-6:#008300] [--chart-7:#4a3aa7] [--chart-8:#e34948] " +
"dark:[--chart-1:#3987e5] dark:[--chart-2:#d95926] dark:[--chart-3:#199e70] dark:[--chart-4:#c98500] dark:[--chart-5:#d55181] dark:[--chart-7:#9085e9] dark:[--chart-8:#e66767]";
export type BarSeries = { id: string; name: string; data: number[]; color?: string };
export type BarMode = "grouped" | "stacked";
export type BarOrientation = "vertical" | "horizontal";
export type BarChartProps = {
labels: string[];
series: BarSeries[];
title?: string;
description?: string;
headline?: React.ReactNode;
/** Controlled / initial mode. */
mode?: BarMode;
defaultMode?: BarMode;
onModeChange?: (m: BarMode) => void;
orientation?: BarOrientation;
defaultOrientation?: BarOrientation;
onOrientationChange?: (o: BarOrientation) => void;
/** Show the grouped/stacked + orientation toggles in the header. */
showControls?: boolean;
showLegend?: boolean;
height?: number;
valueFormatter?: (v: number) => string;
actions?: React.ReactNode;
className?: string;
};
const compact = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 });
function useElementWidth<T extends HTMLElement>() {
const ref = React.useRef<T>(null);
const [width, setWidth] = React.useState(0);
React.useEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver((entries) => setWidth(Math.floor(entries[0].contentRect.width)));
ro.observe(el);
return () => ro.disconnect();
}, []);
return [ref, width] as const;
}
function niceTicks(max: number, count = 5) {
const top = max <= 0 ? 1 : max;
const raw = top / count;
const exp = 10 ** Math.floor(Math.log10(raw));
const f = raw / exp;
const step = (f <= 1 ? 1 : f <= 2 ? 2 : f <= 2.5 ? 2.5 : f <= 5 ? 5 : 10) * exp;
const ticks: number[] = [];
for (let v = 0; v <= Math.ceil(top / step) * step + step / 2; v += step) ticks.push(Number(v.toFixed(10)));
return ticks;
}
/**
* Bar path with rounded corners only at the data end. Both orientations emit the same
* command sequence (M L Q L Q L Z) so Motion can morph between them.
*/
function barPath(x: number, y: number, w: number, h: number, r: number, horizontal: boolean) {
const rr = Math.max(0, Math.min(r, horizontal ? h / 2 : w / 2, horizontal ? w : h));
if (horizontal) {
return `M${x},${y}L${x + w - rr},${y}Q${x + w},${y},${x + w},${y + rr}L${x + w},${y + h - rr}Q${x + w},${y + h},${x + w - rr},${y + h}L${x},${y + h}Z`;
}
return `M${x},${y + h}L${x},${y + rr}Q${x},${y},${x + rr},${y}L${x + w - rr},${y}Q${x + w},${y},${x + w},${y + rr}L${x + w},${y + h}Z`;
}
type Rect = { key: string; seriesId: string; cat: number; x: number; y: number; w: number; h: number; r: number; color: string; zero: string };
export function BarChart({
labels,
series,
title,
description,
headline,
mode: modeProp,
defaultMode = "grouped",
onModeChange,
orientation: orientationProp,
defaultOrientation = "vertical",
onOrientationChange,
showControls = true,
showLegend = true,
height = 300,
valueFormatter = (v) => compact.format(v),
actions,
className,
}: BarChartProps) {
const reduce = useReducedMotion();
const [wrapRef, width] = useElementWidth<HTMLDivElement>();
const [modeState, setModeState] = React.useState(defaultMode);
const [orientState, setOrientState] = React.useState(defaultOrientation);
const mode = modeProp ?? modeState;
const orientation = orientationProp ?? orientState;
const setMode = (m: BarMode) => {
setModeState(m);
onModeChange?.(m);
};
const setOrientation = (o: BarOrientation) => {
setOrientState(o);
onOrientationChange?.(o);
};
const [hidden, setHidden] = React.useState<Set<string>>(new Set());
const [active, setActive] = React.useState<number | null>(null);
const [intro, setIntro] = React.useState(true);
React.useEffect(() => {
const t = setTimeout(() => setIntro(false), 1400);
return () => clearTimeout(t);
}, []);
const horizontal = orientation === "horizontal";
const stacked = mode === "stacked";
const colored = series.map((s, i) => ({ ...s, color: s.color ?? `var(--chart-${(i % 8) + 1})` }));
const visible = colored.filter((s) => !hidden.has(s.id));
const n = labels.length;
const totals = labels.map((_, i) => visible.reduce((a, s) => a + Math.max(0, s.data[i] ?? 0), 0));
const maxValue = stacked ? Math.max(0, ...totals) : Math.max(0, ...visible.flatMap((s) => s.data));
const ticks = niceTicks(maxValue, horizontal ? (width < 480 ? 3 : 5) : 5);
const vMax = ticks[ticks.length - 1];
const longestLabel = Math.max(...labels.map((l) => l.length), 1);
const longestTick = Math.max(...ticks.map((t) => valueFormatter(t).length));
const m = horizontal
? { top: 6, right: 16, bottom: 26, left: Math.min(140, longestLabel * 6.8 + 14) }
: { top: 10, right: 8, bottom: 28, left: longestTick * 6.6 + 14 };
const innerW = Math.max(0, width - m.left - m.right);
const innerH = Math.max(0, height - m.top - m.bottom);
const bandSize = (horizontal ? innerH : innerW) / Math.max(n, 1);
const bandPad = bandSize * (stacked ? 0.36 : 0.22);
const inner = bandSize - bandPad;
const scale = (v: number) => (v / (vMax || 1)) * (horizontal ? innerW : innerH);
const GAP = 2;
const rects: Rect[] = [];
labels.forEach((_, ci) => {
const bandStart = (horizontal ? m.top : m.left) + ci * bandSize + bandPad / 2;
if (stacked) {
const thick = Math.min(inner, 56);
const offset = bandStart + (inner - thick) / 2;
let acc = 0;
const lastVisible = [...colored].reverse().find((s) => !hidden.has(s.id) && (s.data[ci] ?? 0) > 0)?.id;
colored.forEach((s) => {
const on = !hidden.has(s.id);
const v = on ? Math.max(0, s.data[ci] ?? 0) : 0;
const len = scale(v);
const start = scale(acc);
acc += v;
const isTop = s.id === lastVisible;
const gap = len > GAP * 2 && !isTop ? GAP : 0;
const segLen = Math.max(0, len - gap);
const r = isTop ? 4 : 0;
if (horizontal) {
rects.push({ key: `${s.id}-${ci}`, seriesId: s.id, cat: ci, x: m.left + start, y: offset, w: segLen, h: thick, r, color: s.color, zero: barPath(m.left, offset, 0, thick, 0, true) });
} else {
const base = m.top + innerH;
rects.push({ key: `${s.id}-${ci}`, seriesId: s.id, cat: ci, x: offset, y: base - start - segLen, w: thick, h: segLen, r, color: s.color, zero: barPath(offset, base, thick, 0, 0, false) });
}
});
} else {
const k = Math.max(visible.length, 1);
const thick = Math.min((inner - GAP * (k - 1)) / k, 40);
const groupThick = thick * k + GAP * (k - 1);
const offset = bandStart + (inner - groupThick) / 2;
let slot = 0;
colored.forEach((s) => {
const on = !hidden.has(s.id);
const pos = offset + (on ? slot : Math.max(0, slot - 1)) * (thick + GAP);
if (on) slot++;
const v = on ? Math.max(0, s.data[ci] ?? 0) : 0;
const len = scale(v);
if (horizontal) {
rects.push({ key: `${s.id}-${ci}`, seriesId: s.id, cat: ci, x: m.left, y: pos, w: len, h: on ? thick : 0, r: 4, color: s.color, zero: barPath(m.left, pos, 0, thick, 0, true) });
} else {
const base = m.top + innerH;
rects.push({ key: `${s.id}-${ci}`, seriesId: s.id, cat: ci, x: pos, y: base - len, w: on ? thick : 0, h: len, r: 4, color: s.color, zero: barPath(pos, base, thick, 0, 0, false) });
}
});
}
});
const toggle = (id: string) =>
setHidden((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else if (colored.length - next.size > 1) next.add(id);
return next;
});
const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const p = horizontal ? e.clientY - rect.top - m.top : e.clientX - rect.left - m.left;
const i = Math.floor(p / bandSize);
setActive(i >= 0 && i < n ? i : null);
};
const onKeyDown = (e: React.KeyboardEvent) => {
const cur = active ?? -1;
const fwd = horizontal ? "ArrowDown" : "ArrowRight";
const back = horizontal ? "ArrowUp" : "ArrowLeft";
let next: number | null = cur;
if (e.key === fwd) next = Math.min(n - 1, cur + 1);
else if (e.key === back) next = Math.max(0, cur < 0 ? n - 1 : cur - 1);
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = n - 1;
else if (e.key === "Escape") next = null;
else return;
e.preventDefault();
setActive(next);
};
const rows = active === null ? [] : visible.map((s) => ({ ...s, value: s.data[active] ?? 0 }));
const announce = active === null ? "" : `${labels[active]}: ${rows.map((r) => `${r.name} ${valueFormatter(r.value)}`).join(", ")}`;
// tooltip anchor
let tipStyle: React.CSSProperties = {};
if (active !== null) {
const center = (horizontal ? m.top : m.left) + active * bandSize + bandSize / 2;
if (horizontal) {
const below = center < height / 2;
tipStyle = below ? { top: center + bandSize / 2, right: 8 } : { bottom: height - center + bandSize / 2, right: 8 };
} else {
tipStyle = center > width * 0.6 ? { right: width - center + bandSize / 2 - 4, top: 0 } : { left: center + bandSize / 2 - 4, top: 0 };
}
}
const ease = [0.22, 1, 0.36, 1] as const;
const labelEvery = horizontal ? 1 : Math.max(1, Math.ceil((n * (longestLabel * 6.5 + 10)) / Math.max(innerW, 1)));
return (
<figure className={cn("w-full rounded-xl border bg-card p-4 text-card-foreground shadow-xs sm:p-5", PALETTE, className)}>
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
{title && <figcaption className="text-sm font-medium text-muted-foreground">{title}</figcaption>}
{headline && <div className="mt-1 text-2xl font-semibold tracking-tight">{headline}</div>}
{description && <p className="mt-0.5 text-xs text-muted-foreground">{description}</p>}
</div>
<div className="flex flex-wrap items-center gap-2">
{actions}
{showControls && (
<>
<div role="radiogroup" aria-label="Bar layout" className="flex rounded-md border p-0.5 text-xs">
{(["grouped", "stacked"] as const).map((v) => (
<button
key={v}
type="button"
role="radio"
aria-checked={mode === v}
onClick={() => setMode(v)}
className={cn(
"rounded px-2 py-1 capitalize outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
mode === v ? "bg-muted font-medium text-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{v}
</button>
))}
</div>
<div role="radiogroup" aria-label="Orientation" className="flex rounded-md border p-0.5">
{(
[
["vertical", ChartColumn],
["horizontal", ChartBar],
] as const
).map(([v, Icon]) => (
<button
key={v}
type="button"
role="radio"
aria-checked={orientation === v}
aria-label={v === "vertical" ? "Vertical bars" : "Horizontal bars"}
title={v === "vertical" ? "Vertical" : "Horizontal"}
onClick={() => setOrientation(v)}
className={cn(
"grid size-6 place-items-center rounded outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
orientation === v ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
<Icon className="size-3.5" aria-hidden />
</button>
))}
</div>
</>
)}
</div>
</div>
{showLegend && colored.length > 1 && (
<div role="group" aria-label="Toggle series" className="-ml-2 mb-2 flex flex-wrap items-center gap-1">
{colored.map((s) => {
const on = !hidden.has(s.id);
return (
<button
key={s.id}
type="button"
aria-pressed={on}
onClick={() => toggle(s.id)}
className={cn(
"inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring",
on ? "text-foreground" : "text-muted-foreground/70 line-through",
)}
>
<span aria-hidden className={cn("size-2 rounded-[3px]", !on && "opacity-30")} style={{ background: s.color }} />
{s.name}
</button>
);
})}
</div>
)}
<div
ref={wrapRef}
tabIndex={0}
role="group"
aria-label={`${title ?? "Bar chart"}. Use arrow keys to inspect categories.`}
onKeyDown={onKeyDown}
onBlur={() => setActive(null)}
className="relative w-full rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4 focus-visible:ring-offset-card"
style={{ height }}
>
{width > 0 && (
<svg width={width} height={height} className="block select-none" aria-hidden onPointerMove={onPointerMove} onPointerLeave={() => setActive(null)}>
{/* hover band */}
{active !== null && (
<rect
x={horizontal ? m.left : m.left + active * bandSize}
y={horizontal ? m.top + active * bandSize : m.top}
width={horizontal ? innerW : bandSize}
height={horizontal ? bandSize : innerH}
rx={6}
className="fill-muted/70"
/>
)}
{/* value axis grid */}
{ticks.map((t) => {
const p = scale(t);
return horizontal ? (
<g key={t}>
<line x1={m.left + p} x2={m.left + p} y1={m.top} y2={m.top + innerH} className={cn("stroke-border", t !== 0 && "[stroke-dasharray:2_4]")} />
<text x={m.left + p} y={height - 8} textAnchor={t === 0 ? "start" : "middle"} className="fill-muted-foreground text-[11px] tabular-nums">
{valueFormatter(t)}
</text>
</g>
) : (
<g key={t}>
<line x1={m.left} x2={m.left + innerW} y1={m.top + innerH - p} y2={m.top + innerH - p} className={cn("stroke-border", t !== 0 && "[stroke-dasharray:2_4]")} />
<text x={m.left - 10} y={m.top + innerH - p} dy="0.32em" textAnchor="end" className="fill-muted-foreground text-[11px] tabular-nums">
{valueFormatter(t)}
</text>
</g>
);
})}
{/* category labels */}
{labels.map((l, i) => {
if (i % labelEvery !== 0) return null;
const c = (horizontal ? m.top : m.left) + i * bandSize + bandSize / 2;
return horizontal ? (
<text key={`${l}-${i}`} x={m.left - 10} y={c} dy="0.32em" textAnchor="end" className={cn("fill-muted-foreground text-[11px]", active === i && "fill-foreground")}>
{l}
</text>
) : (
<text key={`${l}-${i}`} x={c} y={height - 8} textAnchor="middle" className={cn("fill-muted-foreground text-[11px]", active === i && "fill-foreground")}>
{l}
</text>
);
})}
{/* bars */}
{rects.map((r, idx) => (
<motion.path
key={r.key}
initial={reduce ? false : { d: r.zero }}
animate={{ d: barPath(r.x, r.y, r.w, r.h, r.r, horizontal), opacity: active === null || active === r.cat ? 1 : 0.4 }}
transition={{
d: { duration: reduce ? 0 : 0.6, ease, delay: intro && !reduce ? (idx % n) * 0.04 + Math.floor(idx / n) * 0.02 : 0 },
opacity: { duration: 0.15 },
}}
fill={r.color}
/>
))}
</svg>
)}
{active !== null && (
<div className="pointer-events-none absolute z-10 min-w-40 rounded-lg border bg-popover/95 px-3 py-2 text-xs text-popover-foreground shadow-lg backdrop-blur-sm" style={tipStyle}>
<p className="mb-1.5 font-medium">{labels[active]}</p>
<ul className="space-y-1">
{rows.map((r) => (
<li key={r.id} className="flex items-center gap-2">
<span aria-hidden className="size-2 rounded-[3px]" style={{ background: r.color }} />
<span className="text-muted-foreground">{r.name}</span>
<span className="ml-auto pl-4 font-medium tabular-nums">{valueFormatter(r.value)}</span>
</li>
))}
{stacked && rows.length > 1 && (
<li className="mt-1 flex items-center gap-2 border-t pt-1.5">
<span className="text-muted-foreground">Total</span>
<span className="ml-auto pl-4 font-semibold tabular-nums">{valueFormatter(totals[active])}</span>
</li>
)}
</ul>
</div>
)}
<p aria-live="polite" className="sr-only">
{announce}
</p>
</div>
<table className="sr-only">
<caption>{title ?? "Bar chart data"}</caption>
<thead>
<tr>
<th scope="col">Category</th>
{colored.map((s) => (
<th key={s.id} scope="col">
{s.name}
</th>
))}
</tr>
</thead>
<tbody>
{labels.map((l, i) => (
<tr key={`${l}-${i}`}>
<th scope="row">{l}</th>
{colored.map((s) => (
<td key={s.id}>{valueFormatter(s.data[i] ?? 0)}</td>
))}
</tr>
))}
</tbody>
</table>
</figure>
);
}