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

+193
View File
@@ -0,0 +1,193 @@
import type { ComponentProps } from "react"
import { cn } from "cn"
import { Pie, PieChart, usePlotArea, useChartWidth } from "recharts"
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent } from "./chart"
import type { HMDonutDatum } from "./donut-chart"
import { seriesToConfig } from "./series"
import { fitText, textWidth } from "./text-fit"
const RADIAN = Math.PI / 180
const LABEL_SIZE = 12
const LINE_ELBOW = 16 // radial length of the connecting line
const LINE_RUN = 10 // horizontal length of the connecting line
const MIN_SHOWN_CHARS = 3
type Bounds = { left: number; right: number; top: number; bottom: number }
type Box = { left: number; right: number; top: number; bottom: number }
type OutsidePlacement = { path: string; x: number; y: number; anchor: "start" | "end"; text: string }
/** Middle angle of every slice (degrees, Recharts order: from 0° counter-clockwise), assuming the default start at 0° and no padding. */
function midAngles(values: readonly number[]) {
const total = values.reduce((sum, value) => sum + Math.max(value, 0), 0)
let acc = 0
return values.map(value => {
const v = Math.max(value, 0)
const mid = total > 0 ? ((acc + v / 2) / total) * 360 : 0
acc += v
return mid
})
}
/**
* Where the "name value" labels go outside the slices, with a connecting line from each slice edge.
* Labels are placed in slice order; a label is omitted when it would leave the chart, run into an earlier label, or
* not even `MIN_SHOWN_CHARS` characters fit. Its slice and the tooltip still carry the full information.
*/
function layoutOutside(items: readonly HMDonutDatum[], cx: number, cy: number, outerRadius: number, bounds: Bounds): Array<OutsidePlacement | null> {
const mids = midAngles(items.map(item => item.value))
const placed: Box[] = []
return items.map((item, index) => {
if (!(item.value > 0)) return null
const cos = Math.cos(-mids[index] * RADIAN)
const sin = Math.sin(-mids[index] * RADIAN)
const edge = { x: cx + outerRadius * cos, y: cy + outerRadius * sin }
const elbow = { x: cx + (outerRadius + LINE_ELBOW) * cos, y: cy + (outerRadius + LINE_ELBOW) * sin }
const side = cos >= 0 ? 1 : -1
const end = { x: elbow.x + side * LINE_RUN, y: elbow.y }
const available = side > 0 ? bounds.right - end.x - 6 : end.x - bounds.left - 6
const text = fitText(`${item.label} ${item.value.toLocaleString()}`, available, LABEL_SIZE)
if ([...text].length < MIN_SHOWN_CHARS) return null
const width = textWidth(text, LABEL_SIZE)
const box: Box = {
left: side > 0 ? end.x + 3 : end.x - 3 - width,
right: side > 0 ? end.x + 3 + width : end.x - 3,
top: end.y - 10,
bottom: end.y + 4,
}
if (box.top < bounds.top || box.bottom > bounds.bottom) return null
if (placed.some(other => !(box.right <= other.left || other.right <= box.left || box.bottom <= other.top || other.bottom <= box.top))) return null
placed.push(box)
return { path: `M${edge.x},${edge.y}L${elbow.x},${elbow.y}L${end.x},${end.y}`, x: end.x + side * 3, y: end.y + 4, anchor: side > 0 ? "start" : "end", text }
})
}
type PieLabelProps = { cx?: number; cy?: number; outerRadius?: number; index?: number; midAngle?: number }
function PieOutsideLabel({ cx, cy, outerRadius, index, items }: PieLabelProps & { items: readonly HMDonutDatum[] }) {
const plot = usePlotArea()
const width = useChartWidth()
if (cx === undefined || cy === undefined || outerRadius === undefined || index === undefined || !plot || !width) return null
const bounds: Bounds = { left: 2, right: width - 2, top: plot.y + 2, bottom: plot.y + plot.height - 2 }
const placement = layoutOutside(items, cx, cy, outerRadius, bounds)[index]
if (!placement) return null
return (
<g>
<path d={placement.path} fill="none" stroke="var(--hm-chart-axis-text)" strokeWidth={1} />
<text data-slot="chart-pie-label" x={placement.x} y={placement.y} textAnchor={placement.anchor}>{placement.text}</text>
</g>
)
}
/** Value inside its slice; omitted when the slice is too narrow at that radius (its name is in the legend, everything in the tooltip). */
function PieSliceLabel({ cx, cy, outerRadius, index, midAngle, items }: PieLabelProps & { items: readonly HMDonutDatum[] }) {
if (cx === undefined || cy === undefined || outerRadius === undefined || index === undefined || midAngle === undefined) return null
const item = items[index]
if (!item || !(item.value > 0)) return null
const total = items.reduce((sum, entry) => sum + Math.max(entry.value, 0), 0)
const radius = outerRadius * 0.62
const text = item.value.toLocaleString()
const arc = radius * (item.value / total) * 2 * Math.PI
if (arc < textWidth(text, LABEL_SIZE) + 8) return null
return (
<text
data-slot="chart-slice-label"
x={cx + radius * Math.cos(-midAngle * RADIAN)}
y={cy + radius * Math.sin(-midAngle * RADIAN)}
textAnchor="middle"
dominantBaseline="central"
>
{text}
</text>
)
}
export type HMPieChartProps = {
data: readonly HMDonutDatum[]
/** `"outside"`: "name value" outside each slice with a connecting line; `"inside"`: the value inside each slice (names in the legend).
* Labels that find no free spot are omitted; the tooltip always has the full information. Omit for a plain pie. */
labels?: "outside" | "inside"
/** Accessible name of the chart. */
label: string
className?: string
}
/** Pie chart (shadcn Chart + Recharts): no center hole, HM series colors, tooltip, legend and optional slice labels. */
export function HMPieChart({ data, labels, label, className }: HMPieChartProps) {
const slices = data.map(item => ({ ...item, fill: `var(--color-${item.id})` }))
const config = seriesToConfig(data.map(item => ({ key: item.id, label: item.label, color: item.color })))
const outside = labels === "outside"
return (
<ChartContainer config={config} role="group" aria-label={label} className={cn("aspect-auto h-72 w-full sm:h-80", className)}>
<PieChart accessibilityLayer>
<ChartTooltip content={<ChartTooltipContent hideLabel nameKey="id" />} />
<ChartLegend itemSorter={null} content={<ChartLegendContent nameKey="id" className="flex-wrap gap-x-4 gap-y-1" />} />
<Pie
data={slices}
dataKey="value"
nameKey="id"
outerRadius={outside ? "58%" : "80%"}
stroke="var(--hm-sem-bg-surface)"
strokeWidth={2}
label={
outside ? <PieOutsideLabel items={data} /> : labels === "inside" ? <PieSliceLabel items={data} /> : false
}
labelLine={false}
/>
</PieChart>
</ChartContainer>
)
}
export type HMPieRing = {
/** Series id of the ring (not used as a CSS variable). */
id: string
/** Series name shown in the ring key and the tooltip. */
label: string
data: readonly HMDonutDatum[]
}
export type HMStackedPieChartProps = {
/** Inner ring first, outer ring second. Items with the same `id` share one color in both rings. */
rings: readonly [HMPieRing, HMPieRing]
/** Accessible name of the chart. */
label: string
className?: string
}
/** Tooltip row label: the series (ring) name, then the item name and value of the hovered slice. */
function RingTooltip(props: ComponentProps<typeof ChartTooltipContent>) {
const ring = (props.payload?.[0]?.payload as { ringLabel?: string } | undefined)?.ringLabel
return <ChartTooltipContent {...props} label={ring} nameKey="id" indicator="line" />
}
/** Legend: which ring is which series, then the shared item legend (each item once). */
function RingLegend({ rings, items, ...props }: ComponentProps<typeof ChartLegendContent> & { rings: readonly HMPieRing[]; items: readonly HMDonutDatum[] }) {
const payload = items.map(item => ({ id: item.id, value: item.label, color: `var(--color-${item.id})`, type: "square" as const }))
return (
<div data-slot="chart-ring-legend" className="grid gap-1.5">
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-0.5 text-xs text-muted-foreground">
<span>안쪽 링 · {rings[0].label}</span>
<span>바깥쪽 링 · {rings[1].label}</span>
</div>
<ChartLegendContent {...props} payload={payload} nameKey="id" className="flex-wrap gap-x-4 gap-y-1" />
</div>
)
}
/** Two data series as concentric rings (shadcn "pie stacked"): inner disc and outer ring, one color per item across both. */
export function HMStackedPieChart({ rings, label, className }: HMStackedPieChartProps) {
const items: HMDonutDatum[] = []
for (const ring of rings) for (const item of ring.data) if (!items.some(known => known.id === item.id)) items.push(item)
const config = seriesToConfig(items.map(item => ({ key: item.id, label: item.label, color: item.color })))
const slices = rings.map(ring => ring.data.map(item => ({ ...item, fill: `var(--color-${item.id})`, ringLabel: ring.label })))
return (
<ChartContainer config={config} role="group" aria-label={label} className={cn("aspect-auto h-72 w-full sm:h-80", className)}>
<PieChart accessibilityLayer>
<ChartTooltip content={<RingTooltip />} />
<ChartLegend itemSorter={null} content={<RingLegend rings={rings} items={items} />} />
<Pie data={slices[0]} dataKey="value" nameKey="id" outerRadius="46%" stroke="var(--hm-sem-bg-surface)" strokeWidth={2} />
<Pie data={slices[1]} dataKey="value" nameKey="id" innerRadius="54%" outerRadius="76%" stroke="var(--hm-sem-bg-surface)" strokeWidth={2} />
</PieChart>
</ChartContainer>
)
}