import { useMemo, useState, type ReactNode } from "react"; import { BarRounded } from "@visx/shape"; import { Group } from "@visx/group"; import { scaleBand, scaleLinear } from "@visx/scale"; import { useParentSize } from "@visx/responsive"; import { ChartTooltip } from "./ChartTooltip"; interface Props { data: T[]; getLabel: (d: T) => string; getValue: (d: T) => number; /** Name of an HSL colour token for the bars. */ colorVar: string; /** Direct label at each bar's tip. */ formatValue: (n: number) => string; renderTooltip: (d: T) => ReactNode; onSelect?: (d: T) => void; /** Label of the selected row; the others fade back. */ selected?: string | null; labelWidth?: number; /** Rows shrink (down to 22px) so a long tail fits inside this height. */ maxHeight?: number; } const ROW = 34; const MIN_ROW = 22; const MAX_BAR = 20; const VALUE_GUTTER = 64; function truncate(s: string, max: number) { return s.length > max ? `${s.slice(0, max - 1)}…` : s; } /** * Sorted horizontal bars with the category on the left and the value at the * tip. One hue (length already encodes magnitude), so opacity is free to * carry selection. Rows are keyboard-focusable and Enter selects. */ export function HorizontalBarChart({ data, getLabel, getValue, colorVar, formatValue, renderTooltip, onSelect, selected, labelWidth = 132, maxHeight = 520, }: Props) { const { parentRef, width } = useParentSize({ debounceTime: 30 }); const [active, setActive] = useState(null); const row = Math.max(MIN_ROW, Math.min(ROW, (maxHeight - 8) / Math.max(1, data.length))); const height = Math.max(180, data.length * row + 8); const innerW = Math.max(0, width - labelWidth - VALUE_GUTTER); const yScale = useMemo( () => scaleBand({ domain: data.map((_, i) => i), range: [4, height - 4], paddingInner: 0.3 }), [data, height] ); const xScale = useMemo( () => scaleLinear({ domain: [0, Math.max(0, ...data.map(getValue)) || 1], range: [0, innerW] }), [data, getValue, innerW] ); const barH = Math.min(MAX_BAR, yScale.bandwidth()); // ~6.6px per character at 11px, less the 10px gap before the bar. const maxChars = Math.floor((labelWidth - 12) / 6.6); return (
{width > 0 && ( {data.map((d, i) => { const label = getLabel(d); const v = getValue(d); const rowY = yScale(i) ?? 0; const cy = rowY + yScale.bandwidth() / 2; const w = Math.max(v > 0 ? 2 : 0, xScale(v)); const faded = !!selected && selected !== label; return ( setActive(i)} onPointerLeave={() => setActive(null)} onFocus={() => setActive(i)} onBlur={() => setActive(null)} onClick={() => onSelect?.(d)} onKeyDown={(e) => { if (onSelect && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); onSelect(d); } }} > {/* Full-row hit target, washed on hover. */} {label} {truncate(label, maxChars)} {w > 0 && ( )} {formatValue(v)} ); })} )} {active !== null && data[active] && ( {renderTooltip(data[active])} )}
); }