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 { 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 ( {placement.text} ) } /** 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} ) } 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 ( } /> } /> : labels === "inside" ? : false } labelLine={false} /> ) } 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) { const ring = (props.payload?.[0]?.payload as { ringLabel?: string } | undefined)?.ringLabel return } /** Legend: which ring is which series, then the shared item legend (each item once). */ function RingLegend({ rings, items, ...props }: ComponentProps & { 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 (
안쪽 링 · {rings[0].label} 바깥쪽 링 · {rings[1].label}
) } /** 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 ( } /> } /> ) }