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,289 @@
import { useLayoutEffect, useRef, useState, type ComponentProps } from "react"
import { cn } from "cn"
import { Label, LabelList, PolarAngleAxis, PolarGrid, PolarRadiusAxis, RadialBar, RadialBarChart } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "./chart"
import { CenterText, type HMDonutDatum } from "./donut-chart"
import { seriesColor, seriesToConfig, type ChartSeries } from "./series"
import { fitText } from "./text-fit"
// Radial charts follow the official shadcn/ui examples (chart-radial-simple, -label, -grid, -text, -shape, -stacked): the same
// angle ranges, radii, background tracks, corner rounding, grid and center text. Radii are pixels, as in the official source.
// Adjustments for HM: values come from `value`/`id` rows with HM series colors; the angle axis domain is explicit (0 to the largest
// value or stack total, the same range Recharts picks automatically) so an all-zero data set cannot produce NaN; names and center
// text are shortened to fit.
// Bar thickness is 1.5 times the earlier one (2026-11-06) in every radial chart. Recharts draws a bar over 80% of its band (10%
// margin each side), so a single bar band 1.5 times as wide gives a 1.5 times thicker bar (text, shape, stacked: the outer radius
// stays and the inner one moves in).
// Ring charts with several bars (simple, label, grid) set the bar thickness (`barSize`) and the gap between rings directly: 18px
// bars and 4px gaps (grid 16.5 and 3, the earlier gaps), and keep the earlier center hole (inner band radius 30), so the outer
// radius grows to inner + bars x (bar + gap): 140 for five simple / label bars, 127.5 for five grid bars (2026-11-06, the outer
// radius and chart height limits were lifted for this). Where the box is too narrow (320px screens) the three charts shrink all
// radii, bar thickness and gaps in proportion (`useFitScale`).
// The legend is drawn under the chart box, not inside it: the chart box is a fixed height that holds the largest ring, however
// many lines the legend wraps to.
const RADIAN = Math.PI / 180
/** Height of the chart box of every radial chart (px): the largest ring (outer radius 140) plus the 5px chart margins. */
const CHART_BOX = "aspect-auto h-[300px] w-full"
const EDGE = 6 // px kept free on both sides of a ring (chart margin 5 + 1)
const MIN_SCALE = 0.5
const LABEL_SIZE = 11
const MIN_LABEL_CHARS = 2
type PolarSector = { cx: number; cy: number; innerRadius: number; outerRadius: number; startAngle: number; endAngle: number }
/** Name inside the start of its bar, curved along the arc like the official "insideStart" label, shortened to the bar length. */
function RadialNameLabel({ viewBox, value }: { viewBox?: unknown; value?: unknown }) {
const sector = viewBox as Partial<PolarSector> | undefined
if (!sector || sector.innerRadius === undefined || sector.outerRadius === undefined || sector.startAngle === undefined || sector.endAngle === undefined) return null
const radius = (sector.innerRadius + sector.outerRadius) / 2
const arcLength = radius * Math.abs(sector.endAngle - sector.startAngle) * RADIAN
const text = fitText(String(value ?? ""), arcLength - 14, LABEL_SIZE)
if ([...text].length < MIN_LABEL_CHARS) return null
return <Label viewBox={viewBox as ComponentProps<typeof Label>["viewBox"]} position="insideStart" value={text} className="hm-radial-name" />
}
/** Legend under the chart box, built from the series (same look as the shadcn legend; it needs no Recharts context). */
function RadialLegend({ series }: { series: readonly ChartSeries[] }) {
return (
<div data-slot="chart-legend-content" className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1 pt-3 text-xs">
{series.map((item, index) => (
<div key={item.key} className="flex items-center gap-1.5">
<div className="h-2 w-2 shrink-0 rounded-[2px]" style={{ backgroundColor: seriesColor(index, item.color) }} />
{item.label}
</div>
))}
</div>
)
}
/** Scale (at most 1) that makes a ring of `radius` px fit the width of the element behind `ref`; measured, so it follows the layout. */
function useFitScale(radius: number) {
const ref = useRef<HTMLDivElement | null>(null)
const [scale, setScale] = useState(1)
useLayoutEffect(() => {
const node = ref.current
if (!node) return
const update = () => {
const width = node.getBoundingClientRect().width
if (width > 0) setScale(Math.min(1, Math.max(MIN_SCALE, Math.floor(((width / 2 - EDGE) / radius) * 100) / 100)))
}
update()
const observer = new ResizeObserver(update)
observer.observe(node)
return () => observer.disconnect()
}, [radius])
return [ref, scale] as const
}
const px = (value: number) => Math.round(value * 10) / 10
function maxOf(values: readonly number[]) {
const max = Math.max(0, ...values.filter(Number.isFinite))
return max > 0 ? max : 1
}
export type HMRadialBarChartProps = {
data: readonly HMDonutDatum[]
/** Official `chart-radial-label`: item names inside the start of each bar. */
labels?: boolean
/** Official `chart-radial-grid`: circular grid lines, no background track. */
grid?: boolean
/** Background track behind each bar (default on, off with `grid`). */
background?: boolean
/** Arc range in degrees. Official: simple/grid 0 to 360, label -90 to 380. */
startAngle?: number
endAngle?: number
/** Pixel radius of the innermost bar band (the center hole). Default 30, as before the thickness change. */
innerRadius?: number
/** Pixel radius of the outermost bar band. Default: `innerRadius` + one `barSize + ringGap` per item (five items: 140, grid 127.5). */
outerRadius?: number
/** Bar thickness in px. Defaults: 18 (grid 16.5), 1.5 times the earlier 12 (grid 11). */
barSize?: number
/** Gap between neighboring bars in px. Defaults: 4 (grid 3), the same as before the thickness change. */
ringGap?: number
/** Accessible name of the chart. */
label: string
className?: string
}
/** Radial bar chart with one bar per item (official simple, label and grid examples). */
export function HMRadialBarChart({
data,
labels = false,
grid = false,
background,
startAngle,
endAngle,
innerRadius = 30,
outerRadius,
barSize = grid ? 16.5 : 18,
ringGap = grid ? 3 : 4,
label,
className,
}: HMRadialBarChartProps) {
const rows = data.map(item => ({ ...item, fill: `var(--color-${item.id})` }))
const series = data.map(item => ({ key: item.id, label: item.label, color: item.color }))
const config = seriesToConfig(series)
const outer = outerRadius ?? innerRadius + Math.max(data.length, 1) * (barSize + ringGap)
const [fitRef, scale] = useFitScale(outer)
return (
<div ref={fitRef} data-slot="chart-radial" className="flex w-full flex-col">
<ChartContainer config={config} role="group" aria-label={label} className={cn(CHART_BOX, className)}>
<RadialBarChart
accessibilityLayer
data={rows}
startAngle={startAngle}
endAngle={endAngle}
innerRadius={px(innerRadius * scale)}
outerRadius={px(outer * scale)}
>
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel nameKey="id" />} />
{grid ? <PolarGrid gridType="circle" /> : null}
<PolarAngleAxis type="number" domain={[0, maxOf(data.map(item => item.value))]} tick={false} axisLine={false} />
<RadialBar dataKey="value" background={background ?? !grid} barSize={px(barSize * scale)}>
{labels ? <LabelList dataKey="label" content={<RadialNameLabel />} /> : null}
</RadialBar>
</RadialBarChart>
</ChartContainer>
<RadialLegend series={series} />
</div>
)
}
export type HMRadialMeterChartProps = {
/** The single value; `label` is the center caption's fallback. */
item: HMDonutDatum
/** Caption under the value in the center. Defaults to the item's name. */
caption?: string
/** Arc range in degrees. Official text: 0 to 250, shape: 0 to 100. */
startAngle?: number
endAngle?: number
/** Pixel radii of the bar band. Text: 75 and 90, shape: 50 and 95 (official 80 / 90 and 65 / 95 were 1.5 times thinner). */
innerRadius?: number
outerRadius?: number
/**
* Pixel radii of the two filled circles that form the ring track behind the bar (official `polarRadius`), the outer one first.
* The track is as thick as the bar and lines up with it: the bar fills 80% of its band, so the radii sit 10% of the band inside
* it. Text: [88.5, 76.5], shape: [90.5, 54.5].
*/
trackRadii?: readonly [number, number]
/** Corner rounding of the bar in px (official text: 10). */
cornerRadius?: number
/** Accessible name of the chart. */
label: string
className?: string
}
/** Single-value radial chart with the value and caption in the center (official text and shape examples). */
export function HMRadialMeterChart({
item,
caption,
startAngle = 0,
endAngle = 250,
innerRadius = 75,
outerRadius = 90,
trackRadii = [88.5, 76.5],
cornerRadius,
label,
className,
}: HMRadialMeterChartProps) {
const row = { ...item, fill: `var(--color-${item.id})` }
const config = seriesToConfig([{ key: item.id, label: item.label, color: item.color }])
return (
<ChartContainer config={config} role="group" aria-label={label} className={cn(CHART_BOX, className)}>
<RadialBarChart data={[row]} startAngle={startAngle} endAngle={endAngle} innerRadius={innerRadius} outerRadius={outerRadius}>
{/* Official: the first circle is the muted track, the second (smaller) one is the surface color that cuts it into a ring. */}
<PolarGrid
gridType="circle"
radialLines={false}
stroke="none"
className="first:fill-muted last:fill-[var(--hm-sem-bg-surface)]"
polarRadius={[...trackRadii]}
/>
<PolarAngleAxis type="number" domain={[0, maxOf([item.value])]} tick={false} axisLine={false} />
<RadialBar dataKey="value" background cornerRadius={cornerRadius} />
<PolarRadiusAxis tick={false} tickLine={false} axisLine={false}>
<Label
content={({ viewBox }) => {
if (!viewBox || !("cx" in viewBox) || !("cy" in viewBox)) return null
return (
<CenterText
cx={Number(viewBox.cx)}
cy={Number(viewBox.cy)}
innerRadius={Math.min(trackRadii[1], innerRadius)}
value={item.value}
caption={caption ?? item.label}
maxSize={36}
/>
)
}}
/>
</PolarRadiusAxis>
</RadialBarChart>
</ChartContainer>
)
}
export type HMRadialStackedChartProps = {
/** Stacked from the first series outwards along the arc. Official order: mobile (570) first, then desktop (1260). */
series: readonly ChartSeries[]
/** One value per series key. */
row: Readonly<Record<string, number>>
/** Caption under the total in the center. */
totalLabel: string
/** Arc range in degrees. Official: 0 to 180 (half circle). */
startAngle?: number
endAngle?: number
innerRadius?: number
outerRadius?: number
/** Accessible name of the chart. */
label: string
className?: string
}
/** Stacked radial chart: one row, each series a segment of the same arc, total in the center (official `chart-radial-stacked`). */
export function HMRadialStackedChart({
series,
row,
totalLabel,
startAngle = 0,
endAngle = 180,
innerRadius = 65,
outerRadius = 110,
label,
className,
}: HMRadialStackedChartProps) {
const total = series.reduce((sum, item) => sum + (Number(row[item.key]) || 0), 0)
return (
<div data-slot="chart-radial" className="flex w-full flex-col">
<ChartContainer config={seriesToConfig(series)} role="group" aria-label={label} className={cn(CHART_BOX, className)}>
{/* The official chart sits at the middle of its square box; the half circle (outer radius 110) is moved down so it is centered in the 300px box. */}
<RadialBarChart accessibilityLayer data={[row]} cy="68%" startAngle={startAngle} endAngle={endAngle} innerRadius={innerRadius} outerRadius={outerRadius}>
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
<PolarAngleAxis type="number" domain={[0, total > 0 ? total : 1]} tick={false} axisLine={false} />
{series.map(item => (
<RadialBar
key={item.key}
dataKey={item.key}
stackId="a"
fill={`var(--color-${item.key})`}
cornerRadius={5}
stroke="var(--hm-sem-bg-surface)"
strokeWidth={2}
/>
))}
<PolarRadiusAxis tick={false} tickLine={false} axisLine={false}>
<Label
content={({ viewBox }) => {
if (!viewBox || !("cx" in viewBox) || !("cy" in viewBox)) return null
return <CenterText cx={Number(viewBox.cx)} cy={Number(viewBox.cy)} innerRadius={innerRadius} value={total} caption={totalLabel} dy={-14} />
}}
/>
</PolarRadiusAxis>
</RadialBarChart>
</ChartContainer>
<RadialLegend series={series} />
</div>
)
}