feat(chart): add @hm-design/chart package with Bar, Line, Donut, Pie, Radar, Area and Radial charts

- 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
This commit is contained in:
shkim4 committed 2026-11-06 15:52:47 +09:00
1 parent 228944a06b
commit dc9258599c
56 files changed
+3979 -28

No files matched your search

@@ -0,0 +1,137 @@
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>
)
}