feat(chart): add solid (thick) pie and donut charts

Add HMSolidPieChart and HMSolidDonutChart: a tilted, SVG-drawn disc with an
outer wall and, for the donut, an inner wall seen through the hole. Same data,
slice angles, order, legend and tooltip as the 2D charts; the tooltip is the
same on the top face and the walls and follows the pointer. The existing 2D
charts and their props are unchanged.

- Side walls use darker HM tokens (--hm-chart-1-side .. --hm-chart-5-side) and
  a color-mix darkening for custom colors; shared chart tokens are untouched.
- Add a "입체형" example to the Pie Chart and Donut Chart stories
  (PieSolidExample, DonutSolidExample); ExampleCard description is now optional.
- Document usage and constraints in the chart package README.
This commit is contained in:
shkim4 committed 2026-11-06 17:44:49 +09:00
1 parent dc9258599c
commit db0bfe2d34
10 files changed
+332 -8

No files matched your search

@@ -0,0 +1,271 @@
import { useId, useState, type MouseEvent, type ReactNode } from "react"
import { cn } from "cn"
import { Pie, PieChart, usePlotArea, type PieSectorShapeProps } from "recharts"
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent } from "./chart"
import { CenterText, type HMDonutDatum } from "./donut-chart"
import { seriesSideColor, seriesToConfig } from "./series"
/*
* Solid (thick) Pie and Donut, drawn as SVG - not a rotatable 3D scene.
*
* The disc is seen from above at a fixed tilt: the top face is an ellipse (rx, ry = rx * TILT) and the walls are that ellipse
* shifted down by the thickness. Only Recharts' sector angles are used (angles, order and tooltip stay the real data); the
* geometry is projected here, so text and tooltips are never skewed.
*
* Draw order inside one slice, which is also the whole occlusion rule (slices are flush, so no slice hides another):
* 1. front part of the outer wall (angles 180-360, the half that faces the viewer; the back wall is hidden by the top face)
* 2. back part of the inner wall (donut only, angles 0-180, the half seen through the hole; clipped to the hole opening so
* it never paints over the front part of the ring)
* 3. top face
* Walls and the top face of different slices never overlap, so the layer order of the slices does not matter. Every part sits in
* the slice's own Recharts layer, so hovering the top or a wall gives the same tooltip.
*/
const RAD = Math.PI / 180
const TILT = 0.6 // ry / rx of the top ellipse
const THICKNESS = 0.2 // wall height / rx
const MIN_THICKNESS = 8
const HOLE = 0.55 // donut hole radius / outer radius
const SIDE_PAD = 4
const MIN_SPAN = 0.01 // degrees; narrower slices are not drawn
const FULL = 359.99
type Projection = { cx: number; cy: number; rx: number; ry: number; h: number }
/** Largest tilted disc (with its wall) that fits the plot area, centered in it. Scales with the chart width on small screens. */
function project(plot: { x: number; y: number; width: number; height: number }): Projection {
const fit = Math.min((plot.width - SIDE_PAD * 2) / 2, (plot.height - SIDE_PAD * 2) / (2 * TILT + THICKNESS), plot.height * 0.55)
const rx = Math.max(fit, 8)
const h = Math.max(rx * THICKNESS, MIN_THICKNESS)
const ry = rx * TILT
const top = plot.y + (plot.height - (2 * ry + h)) / 2
return { cx: plot.x + plot.width / 2, cy: top + ry, rx, ry, h }
}
const num = (value: number) => Math.round(value * 100) / 100
/** Point on the (optionally scaled) top ellipse at a Recharts angle (degrees, counter-clockwise from 3 o'clock). */
const at = (p: Projection, scale: number, angle: number, dy = 0) =>
`${num(p.cx + p.rx * scale * Math.cos(angle * RAD))},${num(p.cy - p.ry * scale * Math.sin(angle * RAD) + dy)}`
/** Elliptical arc between two angles. `forward` follows increasing angles (counter-clockwise on screen). */
function arc(p: Projection, scale: number, from: number, to: number, forward: boolean, dy = 0) {
const span = Math.abs(to - from)
return `A${num(p.rx * scale)},${num(p.ry * scale)} 0 ${span > 180 ? 1 : 0} ${forward ? 0 : 1} ${at(p, scale, to, dy)}`
}
/** Closed outline of a full ellipse (two half arcs, since one arc cannot start and end at the same point). */
const ellipse = (p: Projection, scale: number, dy = 0) =>
`M${at(p, scale, 0, dy)}A${num(p.rx * scale)},${num(p.ry * scale)} 0 1 0 ${at(p, scale, 180, dy)}A${num(p.rx * scale)},${num(p.ry * scale)} 0 1 0 ${at(p, scale, 360, dy)}Z`
/** Top face of one slice: a wedge (pie) or a ring segment (donut). */
function topPath(p: Projection, hole: number, from: number, to: number) {
if (to - from >= FULL) return hole > 0 ? `${ellipse(p, 1)}${ellipse(p, hole)}` : ellipse(p, 1)
if (hole <= 0) return `M${p.cx},${p.cy}L${at(p, 1, from)}${arc(p, 1, from, to, true)}Z`
return `M${at(p, 1, from)}${arc(p, 1, from, to, true)}L${at(p, hole, to)}${arc(p, hole, to, from, false)}Z`
}
/** Wall band along an ellipse between two angles: the top edge, then the same edge `h` lower, walked back. */
function wallPath(p: Projection, scale: number, from: number, to: number) {
return `M${at(p, scale, from)}${arc(p, scale, from, to, true)}L${at(p, scale, to, p.h)}${arc(p, scale, to, from, false, p.h)}Z`
}
type SolidSliceProps = {
sector: PieSectorShapeProps
hole: number
sideColor: string
topColor: string
center?: ReactNode | ((p: Projection) => ReactNode)
}
function SolidSlice({ sector, hole, sideColor, topColor, center }: SolidSliceProps) {
const plot = usePlotArea()
const clipId = `solid-hole-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`
if (!plot || plot.width <= 0 || plot.height <= 0) return null
const p = project(plot)
const from = Math.max(Math.min(sector.startAngle, sector.endAngle), 0)
const to = Math.min(Math.max(sector.startAngle, sector.endAngle), 360)
const drawn = to - from > MIN_SPAN
const frontFrom = Math.max(from, 180)
const frontTo = Math.min(to, 360)
const backFrom = Math.max(from, 0)
const backTo = Math.min(to, 180)
const seam = "var(--hm-sem-bg-surface)"
// The seam between slices is a surface-colored stroke; on a very thin slice it thins out so the slice stays visible.
const arcPx = (to - from) * RAD * p.rx
const seamTop = arcPx < 4 ? 0.5 : arcPx < 10 ? 1 : 2
const seamWall = Math.min(1.5, seamTop)
return (
<g data-slot="chart-solid-slice">
{drawn && frontTo - frontFrom > MIN_SPAN ? (
<path d={wallPath(p, 1, frontFrom, frontTo)} style={{ fill: sideColor }} stroke={seam} strokeWidth={seamWall} strokeLinejoin="round" />
) : null}
{drawn && hole > 0 && backTo - backFrom > MIN_SPAN ? (
<>
<clipPath id={clipId}>
<path d={ellipse(p, hole)} />
</clipPath>
<path d={wallPath(p, hole, backFrom, backTo)} clipPath={`url(#${clipId})`} style={{ fill: sideColor }} stroke={seam} strokeWidth={seamWall} strokeLinejoin="round" />
</>
) : null}
{drawn ? (
<path d={topPath(p, hole, from, to)} fillRule="evenodd" style={{ fill: topColor }} stroke={seam} strokeWidth={seamTop} strokeLinejoin="round" />
) : null}
{typeof center === "function" ? center(p) : center}
</g>
)
}
/**
* Tooltip position that follows the pointer. Recharts places a pie tooltip from the circle it would draw (not from the tilted
* ellipse), which would put it away from the hovered face; the pointer is the same point for the top face and the walls.
* Only the position changes: the tooltip content and style are the regular ones.
*/
function useFollowTooltip() {
const [position, setPosition] = useState<{ x: number; y: number } | undefined>()
const onMouseMove = (_data: unknown, _index: number, event: MouseEvent<SVGElement>) => {
const svg = event.currentTarget.ownerSVGElement
const wrapper = svg?.closest(".recharts-wrapper")
if (!svg || !wrapper) return
const box = svg.getBoundingClientRect()
const tip = wrapper.querySelector<HTMLElement>(".recharts-tooltip-wrapper")
const width = tip?.offsetWidth ?? 0
const height = tip?.offsetHeight ?? 0
const px = event.clientX - box.left
const py = event.clientY - box.top
let x = px + 14
if (x + width > box.width) x = px - 14 - width
let y = py + 14
if (y + height > box.height) y = py - 14 - height
setPosition({ x: Math.max(0, x), y: Math.max(0, y) })
}
return { position, onMouseMove }
}
const sliceColors = (data: readonly HMDonutDatum[]) =>
data.map((item, index) => ({ id: item.id, top: `var(--color-${item.id})`, side: seriesSideColor(index, item.color) }))
/** Neutral stand-in drawn when the total is 0, so an empty chart still shows a disc / ring. */
const EMPTY_TOP = "var(--hm-sem-bg-subtle)"
const EMPTY_SIDE = "var(--hm-sem-stroke-default)"
export type HMSolidPieChartProps = {
data: readonly HMDonutDatum[]
/** Accessible name of the chart. */
label: string
className?: string
}
/**
* Solid pie chart: a thick tilted disc with HM series colors on the top face and a darker tone of the same family on the sides.
* Same data, order, angles, tooltip and legend as `HMPieChart`. Drawn as SVG (no rotation).
*/
export function HMSolidPieChart({ data, label, className }: HMSolidPieChartProps) {
const follow = useFollowTooltip()
const total = data.reduce((sum, item) => sum + Math.max(item.value, 0), 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 colors = sliceColors(data)
return (
<ChartContainer config={config} role="group" aria-label={label} data-solid="pie" className={cn("aspect-auto h-72 w-full sm:h-80", className)}>
<PieChart accessibilityLayer>
<ChartTooltip content={<ChartTooltipContent hideLabel nameKey="id" />} position={follow.position} />
<ChartLegend itemSorter={null} content={<ChartLegendContent nameKey="id" className="flex-wrap gap-x-4 gap-y-1" />} />
{total <= 0 ? (
<Pie
data={[{ value: 1 }]}
dataKey="value"
isAnimationActive={false}
legendType="none"
tooltipType="none"
shape={(sector: PieSectorShapeProps) => <SolidSlice sector={sector} hole={0} topColor={EMPTY_TOP} sideColor={EMPTY_SIDE} />}
/>
) : null}
<Pie
data={slices}
dataKey="value"
nameKey="id"
isAnimationActive={false}
onMouseEnter={follow.onMouseMove}
onMouseMove={follow.onMouseMove}
shape={(sector: PieSectorShapeProps, index: number) => (
<SolidSlice sector={sector} hole={0} topColor={colors[index].top} sideColor={colors[index].side} />
)}
/>
</PieChart>
</ChartContainer>
)
}
export type HMSolidDonutChartProps = {
data: readonly HMDonutDatum[]
/** Caption under the total in the hole. Omit it to hide the center total. */
totalLabel?: string
/** Accessible name of the chart. */
label: string
className?: string
}
/**
* Solid donut chart: a thick tilted ring with an outer wall and an inner wall (the back of the hole). The center total sits in
* the tilted hole. Same data, order, angles, tooltip and legend as `HMDonutChart`. A total of 0 draws an empty ring.
*/
export function HMSolidDonutChart({ data, totalLabel, label, className }: HMSolidDonutChartProps) {
const follow = useFollowTooltip()
const total = data.reduce((sum, item) => sum + Math.max(item.value, 0), 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 colors = sliceColors(data)
// The center text is drawn once, with the first slice that is actually drawn (a 0 slice has no layer).
const centerIndex = data.findIndex(item => item.value > 0)
const centerOf = (p: Projection) => {
if (!totalLabel) return null
const holeRx = p.rx * HOLE
// The wall hides the top `h` of the hole opening, so the text sits lower; its size follows the free height of the opening.
const free = 2 * p.ry * HOLE - p.h
const maxSize = Math.max(14, Math.min(24, Math.floor((free * 0.9 - 26) / 0.75)))
return (
<g pointerEvents="none" data-slot="chart-solid-center">
<CenterText cx={p.cx} cy={p.cy} innerRadius={holeRx * 0.92} value={total} caption={totalLabel} maxSize={maxSize} dy={p.h * 0.62} />
</g>
)
}
return (
<ChartContainer config={config} role="group" aria-label={label} data-solid="donut" className={cn("aspect-auto h-72 w-full sm:h-80", className)}>
<PieChart accessibilityLayer>
<ChartTooltip content={<ChartTooltipContent hideLabel nameKey="id" />} position={follow.position} />
<ChartLegend itemSorter={null} content={<ChartLegendContent nameKey="id" className="flex-wrap gap-x-4 gap-y-1" />} />
{total <= 0 ? (
<Pie
data={[{ value: 1 }]}
dataKey="value"
isAnimationActive={false}
legendType="none"
tooltipType="none"
shape={(sector: PieSectorShapeProps) => (
<SolidSlice sector={sector} hole={HOLE} topColor={EMPTY_TOP} sideColor={EMPTY_SIDE} center={centerOf} />
)}
/>
) : null}
<Pie
data={slices}
dataKey="value"
nameKey="id"
isAnimationActive={false}
onMouseEnter={follow.onMouseMove}
onMouseMove={follow.onMouseMove}
shape={(sector: PieSectorShapeProps, index: number) => (
<SolidSlice
sector={sector}
hole={HOLE}
topColor={colors[index].top}
sideColor={colors[index].side}
center={total > 0 && index === centerIndex ? centerOf : undefined}
/>
)}
/>
</PieChart>
</ChartContainer>
)
}