import { useId } from "react" import { cn } from "cn" import { Bar, BarChart, Rectangle, XAxis, YAxis, type BarShapeProps } from "recharts" import { ChartContainer, ChartTooltip, ChartTooltipContent } from "./chart" import { BAR_GRADIENT_FADED_OPACITY, BAR_GRADIENT_FULL_OPACITY } from "./bar-gradient" import { seriesToConfig } from "./series" export type HMHorizontalBarChartProps = { data: ReadonlyArray> /** Field of each data row used as the item name (shown inside the bar, full text in the tooltip). */ categoryKey: string /** Numeric field of each data row (bar length, shown after the bar end). */ valueKey: string /** Name of the value, used as the tooltip row label. */ valueLabel: string /** Any CSS color. Defaults to the first HM series color. */ color?: string /** Formats the value shown after the bar end. Defaults to `toLocaleString()`. */ valueFormatter?: (value: number) => string /** Accessible name of the chart. */ label: string className?: string } const FONT_SIZE = 12 const ROW_HEIGHT = 40 const BAR_SIZE = 26 const NAME_PADDING = 10 // space between the bar start / end and the item name const VALUE_GAP = 8 // space between the bar end and the value const RIGHT_SAFE = 6 // extra room so the value never touches the chart edge const CHART_PADDING = 8 // Text is measured by estimate (full-width glyphs 1em, others ~0.6em) so truncation needs no DOM access. function textWidth(text: string) { let width = 0 for (const char of text) width += (char.codePointAt(0) ?? 0) > 0x2e7f ? FONT_SIZE : FONT_SIZE * 0.6 return width } /** Keeps the longest start of `text` (plus an ellipsis) that fits `maxWidth`; "" when not even one character fits. */ function fitText(text: string, maxWidth: number) { if (textWidth(text) <= maxWidth) return text const chars = [...text] for (let count = chars.length - 1; count > 0; count -= 1) { const candidate = `${chars.slice(0, count).join("").trimEnd()}…` if (textWidth(candidate) <= maxWidth) return candidate } return "" } /** * Horizontal bar chart with custom labels (shadcn Chart + Recharts): the item name sits inside the bar at the left, the value * after the bar end. The gradient runs left 30% -> right 100% of the HM color. Names that do not fit the bar are shortened with * an ellipsis; a bar too short for any of its name (including zero) shows the name after the value instead. Names never run into * the value, and the tooltip always shows the full name. */ export function HMHorizontalBarChart({ data, categoryKey, valueKey, valueLabel, color, valueFormatter = value => value.toLocaleString(), label, className, }: HMHorizontalBarChartProps) { const gradientId = `hm-hbar-${useId().replace(/:/g, "")}` const config = seriesToConfig([{ key: valueKey, label: valueLabel, color }]) // Reserve the room the longest value needs on the right so no value is clipped by the chart edge. const valueRoom = Math.ceil( Math.max(0, ...data.map(row => textWidth(valueFormatter(Number(row[valueKey]) || 0)))) + VALUE_GAP + RIGHT_SAFE, ) const renderBar = (props: BarShapeProps) => { const { x, y, width, height, payload, value, parentViewBox } = props const name = String(payload?.[categoryKey] ?? "") const amount = Number(Array.isArray(value) ? value[1] : value) || 0 const barWidth = Math.max(width, 0) const shownName = fitText(name, barWidth - NAME_PADDING * 2) const centerY = y + height / 2 const valueText = valueFormatter(amount) // A bar too short to hold any of its name shows the name after the value instead, shortened to the space left of the plot edge. const outsideX = x + barWidth + VALUE_GAP + textWidth(valueText) + NAME_PADDING const outsideName = shownName ? "" : fitText(name, parentViewBox.x + parentViewBox.width - outsideX) return ( {barWidth > 0 ? : null} {shownName ? ( {shownName} ) : null} {valueText} {outsideName ? ( {outsideName} ) : null} ) } return ( []} margin={{ top: CHART_PADDING, right: valueRoom, bottom: CHART_PADDING, left: 0 }} > } /> ) }