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-`): 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 ( {caption ? `${caption} ${full}` : full} {text} {shownCaption ? ( {shownCaption} ) : null} ) } /** * 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 ? ( ) : null return ( } /> } /> {total <= 0 ? ( ( <> {centerText(props, index)} )} /> ) : null} onActiveChange(data[index].id) : undefined} shape={(props: PieSectorShapeProps, index: number) => ( <> {total > 0 ? centerText(props, index) : null} )} /> ) }