// --------------------------------------------------------------------------- // HBarChart — horizontal bar chart for ranked lists (e.g. Top Providers). // // Each row gets a label slot on the left, a bar that grows from 0 to // its value on mount, and a trailing value. Bars share a common scale // derived from the max value. Designed for 4–8 rows; collapses to a // list if rows are empty. Paper-friendly: cream background, dark ink. // --------------------------------------------------------------------------- import { useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export interface HBarRow { /** Stable id used as React key. */ id: string; /** Left label (e.g. provider name). */ label: string; /** Sub-label under the row (e.g. NPI). Optional. */ sublabel?: string; /** Bar value. */ value: number; /** Optional pre-formatted trailing value. Defaults to fmt.num(value). */ formatted?: string; /** Optional color override. Defaults to --accent. */ color?: string; /** Optional leading slot (icon, initials avatar, etc). */ leading?: React.ReactNode; } export interface HBarChartProps { rows: HBarRow[]; /** Bar fill colour when a row doesn't override. Defaults to accent. */ defaultColor?: string; /** Pixel height of each row. Default 44. */ rowHeight?: number; /** Compact mode: smaller text, tighter padding. */ compact?: boolean; /** Optional click handler. */ onRowClick?: (id: string) => void; } export function HBarChart({ rows, defaultColor = "hsl(var(--accent))", rowHeight = 44, compact = false, onRowClick, }: HBarChartProps) { const wrapRef = useRef(null); const [labelW, setLabelW] = useState(180); useEffect(() => { const el = wrapRef.current; if (!el) return; const ro = new ResizeObserver((entries) => { const w = entries[0]?.contentRect.width ?? 800; // Reserve ~38% of width for label + leading slot, capped between // 140 and 260 so bars stay readable on wide and narrow layouts. setLabelW(Math.max(140, Math.min(260, Math.floor(w * 0.38)))); }); ro.observe(el); setLabelW(Math.max(140, Math.min(260, Math.floor(el.clientWidth * 0.38)))); return () => ro.disconnect(); }, []); if (rows.length === 0) { return (
No data yet.
); } const max = Math.max(...rows.map((r) => r.value), 1); return (
{rows.map((r, i) => { const pct = (r.value / max) * 100; const color = r.color ?? defaultColor; return (
onRowClick(r.id) : undefined} onKeyDown={ onRowClick ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onRowClick(r.id); } } : undefined } className={cn( "group flex items-center gap-3", compact ? "py-1.5" : "py-2.5", onRowClick ? "cursor-pointer" : "" )} style={{ minHeight: rowHeight }} > {/* Leading slot (e.g. initials avatar) */} {r.leading ? (
{r.leading}
) : null} {/* Label column */}
{r.label}
{r.sublabel ? (
{r.sublabel}
) : null}
{/* Bar */}
{/* Track (hairline) */}
{/* Rank tick on the bar */} 12 ? 1 : 0, }} > {String(i + 1).padStart(2, "0")}
{/* Trailing value */}
{r.formatted ?? r.value.toLocaleString("en-US")}
); })}
); }