Files
HMDesign/packages/chart/src/components/donut-chart.tsx
T
shkim4 dc9258599c 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
2026-11-06 15:52:47 +09:00

186 lines
7.6 KiB
TypeScript

import { cn } from "cn"
import { Pie, PieChart, Sector, usePlotArea, type PieSectorShapeProps } from "recharts"
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent } from "./chart"
import { seriesToConfig } from "./series"
import { fitText, textWidth } from "./text-fit"
/** One donut slice. `id` becomes a CSS variable name (`--color-<id>`): letters, digits, `-` or `_` only. */
export type HMDonutDatum = {
id: string
label: string
value: number
/** Any CSS color. Defaults to the HM series color for the slice position. */
color?: string
}
export type HMDonutChartProps = {
data: readonly HMDonutDatum[]
/** Caption under the total in the donut center. Omit it to hide the center total. */
totalLabel?: string
/** Id of the selected slice. It is drawn pushed outwards and the center shows its name and value instead of the total.
* The chart is controlled: pair it with `onActiveChange` to let clicks change it. Hovering never changes it. */
activeId?: string
/** Called with the slice id when a slice is clicked (makes the slices clickable). */
onActiveChange?: (id: string) => void
/** Accessible name of the chart. */
label: string
className?: string
}
// Ring thickness is 1.5 times the earlier one (2026-11-06): the outer radius stays, the inner radius moves in.
// Earlier: inner 58%, outer 82% (76% when slices are selectable) of the plot radius, i.e. 24% / 18% thick.
const THICKNESS_SCALE = 1.5
const LEGACY_INNER_PERCENT = 58
const ringRadii = (selectable: boolean) => {
const outer = selectable ? 76 : 82
return { outer: `${outer}%`, inner: `${outer - (outer - LEGACY_INNER_PERCENT) * THICKNESS_SCALE}%` }
}
// The gap between slices is the 1 degree padding plus the 2px stroke. A smaller inner radius shortens the arc of that padding at
// the middle of the ring, so the padding grows by the same ratio and the gap stays what it was (about 3.7px at the ring middle).
const LEGACY_PADDING_ANGLE = 1
const paddingAngle = (selectable: boolean) => {
const outer = selectable ? 76 : 82
const inner = outer - (outer - LEGACY_INNER_PERCENT) * THICKNESS_SCALE
return Math.round(LEGACY_PADDING_ANGLE * ((outer + LEGACY_INNER_PERCENT) / (outer + inner)) * 100) / 100
}
const INNER_RATIO = 0.46// plain ring: inner 46% of the plot radius (used only when the label viewBox carries no radius)
const EXPAND = 10 // how far the active slice grows outwards (px)
const CENTER_MAX_SIZE = 24
const CENTER_MIN_SIZE = 14
const compact = new Intl.NumberFormat("ko", { notation: "compact", maximumFractionDigits: 1 })
/** Center text that always stays inside the hole: the value shrinks first, then switches to a compact number, then is cut. */
export function CenterText({
cx,
cy,
innerRadius,
value,
caption,
maxSize = CENTER_MAX_SIZE,
dy = 0,
}: {
cx: number
cy: number
innerRadius?: number
value: number
caption?: string
/** Largest value size in px (default 24). */
maxSize?: number
/** Vertical shift of the whole text, for centers that are not the middle of the hole. */
dy?: number
}) {
const plot = usePlotArea()
// The Label viewBox may not carry the radius; the hole is then INNER_RATIO of the plot radius.
const hole = innerRadius !== undefined && innerRadius > 0 ? innerRadius : plot ? (Math.min(plot.width, plot.height) / 2) * INNER_RATIO : 40
const maxWidth = Math.max(0, hole * 1.5)
const full = value.toLocaleString()
let text = full
let size = maxSize
while (size > CENTER_MIN_SIZE && textWidth(text, size) > maxWidth) size -= 1
if (textWidth(text, size) > maxWidth) {
text = compact.format(value)
size = maxSize
while (size > CENTER_MIN_SIZE && textWidth(text, size) > maxWidth) size -= 1
if (textWidth(text, size) > maxWidth) text = fitText(text, maxWidth, size)
}
const shownCaption = caption ? fitText(caption, maxWidth, 12) : ""
const baseline = (shownCaption ? cy - 2 : cy + size * 0.35) + dy
return (
<text x={cx} y={baseline} textAnchor="middle">
<title>{caption ? `${caption} ${full}` : full}</title>
<tspan data-slot="chart-center-value" x={cx} y={baseline} style={{ fontSize: size }}>
{text}
</tspan>
{shownCaption ? (
<tspan data-slot="chart-center-label" x={cx} y={baseline + 20}>
{shownCaption}
</tspan>
) : null}
</text>
)
}
/**
* Donut chart (shadcn Chart + Recharts) with HM series colors, per-slice tooltip, legend and an optional center.
* Center: the total with `totalLabel`, or the selected slice's name and value with `activeId`. A total of 0 draws an empty ring.
*/
export function HMDonutChart({ data, totalLabel, activeId, onActiveChange, label, className }: HMDonutChartProps) {
const total = data.reduce((sum, item) => sum + item.value, 0)
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 active = activeId === undefined ? undefined : data.find(item => item.id === activeId)
const selectable = activeId !== undefined || onActiveChange !== undefined
const showCenter = active !== undefined || Boolean(totalLabel)
const ring = ringRadii(selectable)
// Drawn with the first slice (or the empty ring when the total is 0), from the slice's own center. The Label viewBox center
// sits half a legend height below the ring center, which a hole as thick as the earlier ones hid but a thicker ring shows
// (the caption touched the ring at 320px).
const centerText = (props: PieSectorShapeProps, index: number) =>
showCenter && index === 0 ? (
<CenterText
cx={props.cx}
cy={props.cy}
innerRadius={props.innerRadius}
value={active ? active.value : total}
caption={active ? active.label : totalLabel}
/>
) : null
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" />} />
{total <= 0 ? (
<Pie
data={[{ value: 1 }]}
dataKey="value"
innerRadius={ring.inner}
outerRadius={ring.outer}
fill="var(--hm-sem-bg-subtle)"
stroke="none"
isAnimationActive={false}
legendType="none"
tooltipType="none"
shape={(props: PieSectorShapeProps, index: number) => (
<>
<Sector {...props} />
{centerText(props, index)}
</>
)}
/>
) : null}
<Pie
data={slices}
dataKey="value"
nameKey="id"
innerRadius={ring.inner}
// Room for the active slice to grow outwards without leaving the chart.
outerRadius={ring.outer}
paddingAngle={paddingAngle(selectable)}
stroke="var(--hm-sem-bg-surface)"
strokeWidth={2}
onClick={onActiveChange ? (_slice, index) => onActiveChange(data[index].id) : undefined}
shape={(props: PieSectorShapeProps, index: number) => (
<>
<Sector
{...props}
outerRadius={props.payload?.id === activeId ? props.outerRadius + EXPAND : props.outerRadius}
cursor={onActiveChange ? "pointer" : undefined}
/>
{total > 0 ? centerText(props, index) : null}
</>
)}
/>
</PieChart>
</ChartContainer>
)
}