- new workspace package packages/chart (shadcn/ui Chart + Recharts, HM Theme tokens), examples entry with sample data - Storybook Chart group (src/chart), consumer example (examples/chart), build:chart / build:chart-example scripts - README rewritten as the AI usage guide (install, CSS wiring, chart selection, data rules, verification) - docs: package count/list, Submodule guide (chart workspace, React dedupe), shared AI_GUIDE row, MAINTAINING, story inventory
138 lines
5.9 KiB
TypeScript
138 lines
5.9 KiB
TypeScript
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<Record<string, string | number>>
|
|
/** 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 (
|
|
<g role="img" aria-label={`${name}: ${valueText}`}>
|
|
{barWidth > 0 ? <Rectangle {...props} fill={`url(#${gradientId})`} /> : null}
|
|
{shownName ? (
|
|
<text data-slot="chart-bar-name" x={x + NAME_PADDING} y={centerY} dominantBaseline="central">{shownName}</text>
|
|
) : null}
|
|
<text data-slot="chart-bar-value" x={x + barWidth + VALUE_GAP} y={centerY} dominantBaseline="central">
|
|
{valueText}
|
|
</text>
|
|
{outsideName ? (
|
|
<text data-slot="chart-bar-outside-name" x={outsideX} y={centerY} dominantBaseline="central">{outsideName}</text>
|
|
) : null}
|
|
</g>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<ChartContainer
|
|
config={config}
|
|
role="group"
|
|
aria-label={label}
|
|
// Same default height as the vertical bar charts so cards line up; many rows grow past it via the min-height.
|
|
className={cn("aspect-auto h-64 w-full sm:h-80", className)}
|
|
style={{ minHeight: data.length * ROW_HEIGHT + CHART_PADDING * 2 }}
|
|
>
|
|
<BarChart
|
|
accessibilityLayer
|
|
layout="vertical"
|
|
data={data as Record<string, string | number>[]}
|
|
margin={{ top: CHART_PADDING, right: valueRoom, bottom: CHART_PADDING, left: 0 }}
|
|
>
|
|
<defs>
|
|
<linearGradient id={gradientId} x1="0" y1="0" x2="1" y2="0">
|
|
<stop offset="0%" style={{ stopColor: `var(--color-${valueKey})`, stopOpacity: BAR_GRADIENT_FADED_OPACITY }} />
|
|
<stop offset="100%" style={{ stopColor: `var(--color-${valueKey})`, stopOpacity: BAR_GRADIENT_FULL_OPACITY }} />
|
|
</linearGradient>
|
|
</defs>
|
|
<XAxis type="number" hide domain={[0, "dataMax"]} />
|
|
<YAxis type="category" dataKey={categoryKey} hide />
|
|
<ChartTooltip content={<ChartTooltipContent />} />
|
|
<Bar
|
|
dataKey={valueKey}
|
|
fill={`var(--color-${valueKey})`}
|
|
barSize={BAR_SIZE}
|
|
radius={[0, 4, 4, 0]}
|
|
shape={renderBar}
|
|
// Labels are positioned from the final bar width, so the bars do not animate in.
|
|
isAnimationActive={false}
|
|
/>
|
|
</BarChart>
|
|
</ChartContainer>
|
|
)
|
|
}
|