- 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
194 lines
9.4 KiB
TypeScript
194 lines
9.4 KiB
TypeScript
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>
|
|
)
|
|
}
|