- 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
186 lines
7.6 KiB
TypeScript
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>
|
|
)
|
|
}
|