import { useMemo, useState, type ReactNode } from "react"; import { BarRounded } from "@visx/shape"; import { AxisBottom, AxisLeft } from "@visx/axis"; import { GridRows } from "@visx/grid"; import { Group } from "@visx/group"; import { scaleBand, scaleLinear } from "@visx/scale"; import { useParentSize } from "@visx/responsive"; import { ChartTooltip } from "./ChartTooltip"; export interface BarSeries { key: K; label: string; /** Name of an HSL colour token, e.g. `--series-income`. */ colorVar: string; } interface Props { data: T[]; series: BarSeries[]; /** Category label for each datum (x axis). */ getLabel: (d: T) => string; getValue: (d: T, key: K) => number; height: number; formatAxisValue: (n: number) => string; renderTooltip: (d: T) => ReactNode; yAxisWidth?: number; } const MARGIN = { top: 8, right: 8, bottom: 28 }; const MAX_BAR = 22; const BAR_GAP = 2; const axisText = { fill: "hsl(var(--muted-foreground))", fontSize: 10 } as const; /** * Side-by-side columns per category (income vs expenses per month). Columns * are capped at 22px with a 2px gap inside each pair, rounded only at the * data end, and the whole category band is the hover target. */ export function GroupedBarChart({ data, series, getLabel, getValue, height, formatAxisValue, renderTooltip, yAxisWidth = 52, }: Props) { const { parentRef, width } = useParentSize({ debounceTime: 30 }); const [active, setActive] = useState(null); const innerW = Math.max(0, width - yAxisWidth - MARGIN.right); const innerH = Math.max(0, height - MARGIN.top - MARGIN.bottom); const xScale = useMemo( () => scaleBand({ domain: data.map((_, i) => i), range: [0, innerW], paddingInner: 0.35, paddingOuter: 0.2 }), [data, innerW] ); const yScale = useMemo(() => { const max = Math.max(0, ...data.flatMap((d) => series.map((s) => getValue(d, s.key)))); return scaleLinear({ domain: [0, max || 1], range: [innerH, 0], nice: 4 }); }, [data, series, getValue, innerH]); const band = xScale.bandwidth(); const barW = Math.max(1, Math.min(MAX_BAR, (band - BAR_GAP * (series.length - 1)) / series.length)); const groupW = barW * series.length + BAR_GAP * (series.length - 1); return (
{width > 0 && ( {data.map((d, i) => { const bx = xScale(i) ?? 0; const gx = bx + (band - groupW) / 2; return ( {active === i && ( )} {series.map((s, si) => { const v = getValue(d, s.key); const top = yScale(v); const h = innerH - top; if (h <= 0) return null; return ( ); })} `${s.label} ${formatAxisValue(getValue(d, s.key))}`).join(", ")}`} className="outline-none" onPointerEnter={() => setActive(i)} onPointerLeave={() => setActive(null)} onFocus={() => setActive(i)} onBlur={() => setActive(null)} /> ); })} formatAxisValue(Number(v))} tickLabelProps={() => ({ ...axisText, textAnchor: "end", dx: -6, dy: 3 })} /> getLabel(data[Number(i)])} tickLabelProps={() => ({ ...axisText, textAnchor: "middle", dy: 4 })} /> )} {active !== null && data[active] && ( {renderTooltip(data[active])} )}
); }