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

+3 -2
View File
@@ -44,6 +44,7 @@
| 시간 흐름의 양·구성비 | `HMAreaChart` | `data` 행 + `categoryKey` + `series[]` |
| 전체 대비 비중 | `HMDonutChart`, `HMPieChart` | `data: { id, label, value, color? }[]` |
| 두 구성의 동심원 비교 | `HMStackedPieChart` | `rings: [내부, 외부]` |
| 두께가 보이는 입체형 비중 | `HMSolidDonutChart`, `HMSolidPieChart` | 위 Donut 데이터 (`HMSolidDonutChart`는 `totalLabel` 선택) |
| 여러 지표의 균형 | `HMRadarChart` | `data` 행 + `categoryKey` + `series[]` |
| 목표 대비 진행률·항목별 막대 | `HMRadialBarChart`, `HMRadialMeterChart`, `HMRadialStackedChart` | 위 Donut 데이터 / `item` / `series[]`+`row` |
@@ -94,7 +95,7 @@ const received = [{ month: "1월", received: 186, completed: 142 }]
도넛 차트(`HMDonutChart`) 옵션: `totalLabel`(가운데에 전체 합계와 캡션), `activeId`(선택한 조각을 바깥쪽으로 확장하고 가운데에 그 이름·값 표시), `onActiveChange`(조각 클릭 시 id 전달; 상태는 소비 앱이 보관하며 호버로는 바뀌지 않음). 가운데 텍스트는 도넛 안쪽을 넘지 않도록 숫자를 줄이고(`1234.6억` 같은 축약) 이름은 `…`로 줄이며, 합계가 0이면 빈 링과 `0`을 표시합니다. 전체 이름은 범례와 툴팁에서 확인합니다.
파이(`HMPieChart`, `HMStackedPieChart`): `labels`(`"outside"` 조각 바깥 "이름 값"과 연결선, `"inside"` 조각 안 값)는 놓을 자리가 없으면 해당 Label만 생략하고 툴팁에는 전체 정보가 나옵니다. `HMStackedPieChart`는 `rings`(안쪽·바깥쪽 두 시리즈, 같은 `id`는 같은 색)로 동심원 링을 그립니다. 방사형(`HMRadarChart`): `filled`(기본 영역, `false`면 선만), `dots`, `customLabels`(축 이름을 두 줄로 줄이거나 `…`로 줄임), `maxValue`(공통 축 상한, 기본 100). 영역(`HMAreaChart`): `curve`(`monotone`·`linear`·`step`), `dots`, `pointLabels`·`pointColorKey`(Line과 같은 Dot·Label·충돌 처리), `stacked`(값 누적), `expanded`(각 시점 합계를 100%로 정규화, Y축 %, 툴팁에 원래 값과 비율; 합계 0이면 0%). 영역 채움은 시리즈 색의 세로 그라데이션(위 진하게, 아래 옅게)입니다.
파이(`HMPieChart`, `HMStackedPieChart`): `labels`(`"outside"` 조각 바깥 "이름 값"과 연결선, `"inside"` 조각 안 값)는 놓을 자리가 없으면 해당 Label만 생략하고 툴팁에는 전체 정보가 나옵니다. `HMStackedPieChart`는 `rings`(안쪽·바깥쪽 두 시리즈, 같은 `id`는 같은 색)로 동심원 링을 그립니다. 입체형 파이·도넛(`HMSolidPieChart`, `HMSolidDonutChart`): 기존 2D 차트와 같은 `data`(`id`·`label`·`value`·`color?`)·항목 순서·조각 각도·범례·툴팁을 쓰고, 조각을 약간 기울여 내려다본 두께 있는 원판(도넛은 바깥 옆면과 구멍 안쪽 벽)으로 그립니다. 회전 가능한 3D가 아니라 SVG로 두께와 깊이감만 표현하며 기존 2D 컴포넌트·Props는 바뀌지 않습니다. 윗면은 항목 색(`--color-<id>`), 옆면은 같은 계열의 한 단계 어두운 HM 토큰(`--hm-chart-1-side` ~ `--hm-chart-5-side`, 공용 `--hm-chart-N` 토큰은 그대로)을 쓰며, HM 색이 아닌 `color`는 그 색을 어둡게 섞어 옆면에 씁니다. 앞쪽 옆면과 도넛 안쪽 뒤 벽은 서로·윗면과 겹치지 않는 영역만 그리므로 가려지는 면이 비치지 않고 조각 사이에 틈이 없습니다. 두께와 크기는 차트 영역 너비·높이에 맞춰 줄어듭니다(차트 높이 `h-72`/`sm:h-80`은 `className`으로 조정). `HMSolidDonutChart`의 `totalLabel`은 기울어진 구멍 안에 합계와 캡션으로 표시하고 없으면 가운데를 비웁니다. 제약: 조각 클릭·Active(`activeId`)·Label(`labels`)은 지원하지 않으며, 값 0인 항목은 그리지 않고(범례에는 남음), 합계가 0이면 빈 원판·링을 표시합니다. 툴팁은 윗면·옆면 어디에서나 같은 항목을 표시하고 포인터를 따라갑니다. 방사형(`HMRadarChart`): `filled`(기본 영역, `false`면 선만), `dots`, `customLabels`(축 이름을 두 줄로 줄이거나 `…`로 줄임), `maxValue`(공통 축 상한, 기본 100). 영역(`HMAreaChart`): `curve`(`monotone`·`linear`·`step`), `dots`, `pointLabels`·`pointColorKey`(Line과 같은 Dot·Label·충돌 처리), `stacked`(값 누적), `expanded`(각 시점 합계를 100%로 정규화, Y축 %, 툴팁에 원래 값과 비율; 합계 0이면 0%). 영역 채움은 시리즈 색의 세로 그라데이션(위 진하게, 아래 옅게)입니다.
래디얼(`HMRadialBarChart`, `HMRadialMeterChart`, `HMRadialStackedChart`)은 공식 shadcn/ui radial 예제(simple·label·grid·text·shape·stacked)의 각도·반지름·트랙·중앙 Text를 따릅니다(`labels`, `grid`, `startAngle`/`endAngle`, `innerRadius`/`outerRadius`, `trackRadii`, `cornerRadius`). 중앙 숫자는 `CenterText`가 구멍 폭에 맞춰 줄이거나 `1234.6억`처럼 축약합니다. 도넛·래디얼의 링·막대 두께는 공식 예제의 1.5배입니다(2026-11-06): 단일 막대(text·shape·stacked)는 바깥 반지름을 두고 안쪽 반지름을 줄였고, 여러 막대(simple·label·grid)는 `barSize`(18, grid 16.5)·`ringGap`(4, grid 3, 기존 간격)과 기존 중앙 구멍(`innerRadius` 30)을 유지해 `outerRadius`를 `innerRadius + 항목 수 × (barSize + ringGap)`(5개: 140, grid 127.5)로 계산합니다. 래디얼 차트 영역 높이는 300px로 같고 범례는 차트 영역 아래에 따로 놓이며, 폭이 모자라면(320px) simple·label·grid의 반지름·두께·간격이 비례해 줄어듭니다. `HMRadialMeterChart`의 `trackRadii`는 막대와 같은 두께로 맞춥니다. `labels`(항목명 Label)는 막대 안쪽 시작 부분에 호를 따라 그려지며(`insideStart`) 막대 호 길이에 들어가지 않는 긴 이름은 `…`로 줄입니다(전체 이름은 범례와 툴팁).
@@ -115,7 +116,7 @@ npm run storybook # Chart 그룹: Bar / Line / Donut / P
### 예제 컴포넌트
소비 예제(`examples/chart/main.tsx`)와 Storybook은 `@hm-design/chart/examples`의 예제 카드를 사용합니다: Bar(`BarChartExample`, `StackedBarChartExample`, `HorizontalBarChartExample`, `SignedBarChartExample`), Line(`LineChartExample` 외 Curve·Straight·Step 변형), Donut(`DonutChartExample` 외 Plain·Center·Active·Interactive), Pie(`PiePlainExample`, `PieLabelExample`, `PieLabelListExample`, `PieStackedExample`), Radar(`RadarPlainExample`, `RadarDotExample`, `RadarLinesOnlyExample`, `RadarCustomLabelExample`), Area(`AreaCurveExample` 외 Straight·Step·Stacked·Expanded), Radial(`RadialSimpleExample`, `RadialLabelExample`, `RadialGridExample`, `RadialTextExample`, `RadialShapeExample`, `RadialStackedExample`), 묶음 `ChartExamples`. 예시 데이터(`sampleBar`, `samplePie`, `sampleRadial` 등)는 같은 진입점에서 export됩니다. 예제 카드는 `ExampleCard`(내부 컴포넌트)로 감싸며 실제 업무 데이터가 아님을 표시합니다.
소비 예제(`examples/chart/main.tsx`)와 Storybook은 `@hm-design/chart/examples`의 예제 카드를 사용합니다: Bar(`BarChartExample`, `StackedBarChartExample`, `HorizontalBarChartExample`, `SignedBarChartExample`), Line(`LineChartExample` 외 Curve·Straight·Step 변형), Donut(`DonutChartExample` 외 Plain·Center·Active·Interactive·`DonutSolidExample`(입체형)), Pie(`PiePlainExample`, `PieLabelExample`, `PieLabelListExample`, `PieStackedExample`, `PieSolidExample`(입체형)), Radar(`RadarPlainExample`, `RadarDotExample`, `RadarLinesOnlyExample`, `RadarCustomLabelExample`), Area(`AreaCurveExample` 외 Straight·Step·Stacked·Expanded), Radial(`RadialSimpleExample`, `RadialLabelExample`, `RadialGridExample`, `RadialTextExample`, `RadialShapeExample`, `RadialStackedExample`), 묶음 `ChartExamples`. 예시 데이터(`sampleBar`, `samplePie`, `sampleRadial` 등)는 같은 진입점에서 export됩니다. 예제 카드는 `ExampleCard`(내부 컴포넌트)로 감싸며 실제 업무 데이터가 아님을 표시합니다.
## 공식 shadcn Chart와의 차이
+7
View File
@@ -17,6 +17,13 @@
--hm-chart-4-light: var(--hm-sem-palette-brown-light-4);
--hm-chart-5-light: var(--hm-sem-palette-warm-neutral-light);
/* Side walls of the solid (thick) pie and donut: the next palette step darker in the same family as the series color. */
--hm-chart-1-side: var(--hm-sem-palette-green-light-1);
--hm-chart-2-side: var(--hm-sem-palette-brown-light-1);
--hm-chart-3-side: var(--hm-sem-palette-green-light-2);
--hm-chart-4-side: var(--hm-sem-palette-brown-light-3);
--hm-chart-5-side: var(--hm-sem-palette-warm-neutral-dark);
--hm-chart-tooltip-background: var(--hm-sem-bg-surface);
--hm-chart-tooltip-border-color: var(--hm-sem-stroke-default);
--hm-chart-tooltip-radius: var(--hm-radius-container);
+19
View File
@@ -18,6 +18,25 @@ export const chartLightColors = [
"var(--hm-chart-5-light)",
] as const
/** Darker tone of each HM series color, for the side walls of the solid (thick) pie and donut: the same palette family, the next step darker (see chart.css). */
export const chartSideColors = [
"var(--hm-chart-1-side)",
"var(--hm-chart-2-side)",
"var(--hm-chart-3-side)",
"var(--hm-chart-4-side)",
"var(--hm-chart-5-side)",
] as const
/**
* Side-wall color of a solid slice. An HM series color (default or passed explicitly) maps to its darker HM token; any other CSS
* color is darkened where it is drawn, so no shared token changes.
*/
export function seriesSideColor(index: number, color?: string) {
if (color === undefined) return chartSideColors[index % chartSideColors.length]
const known = (chartColors as readonly string[]).indexOf(color)
return known >= 0 ? chartSideColors[known] : `color-mix(in srgb, ${color} 68%, black)`
}
/** One data series. `key` becomes a CSS variable name (`--color-<key>`), so use letters, digits, `-` or `_` only. */
export type ChartSeries = {
key: string
@@ -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>
)
}
+11 -3
View File
@@ -14,7 +14,7 @@ import {
SelectTrigger,
SelectValue,
} from "@hm-design/ui"
import { HMBarChart, HMDonutChart, HMHorizontalBarChart, HMLineChart, HMSignedBarChart } from "../index"
import { HMBarChart, HMDonutChart, HMHorizontalBarChart, HMLineChart, HMSignedBarChart, HMSolidDonutChart } from "../index"
import { sampleBar, sampleDataNotice, sampleDonut, sampleDonutStates, sampleHorizontalBar, sampleLine, sampleLineCompare, sampleLinePointColors, sampleSignedBar } from "./sample-data"
/** Marks a chart as sample data. Use it next to every chart that is fed with `sample-data`. */
@@ -22,13 +22,13 @@ export function SampleDataBadge() {
return <Badge variant="outline" data-slot="chart-sample-badge" title={sampleDataNotice}>예시 데이터</Badge>
}
export function ExampleCard({ title, description, className, toolbar, children }: { title: string; description: string; className?: string; toolbar?: ReactNode; children: ReactNode }) {
export function ExampleCard({ title, description, className, toolbar, children }: { title: string; description?: string; className?: string; toolbar?: ReactNode; children: ReactNode }) {
return (
// h-full lets a card fill its grid cell; the notice sits at the card bottom so cards of one row line up.
<Card className={cn("h-full", className)} data-chart-example={title}>
<CardHeader>
<CardTitle className="break-keep">{title}</CardTitle>
<CardDescription className="break-keep">{description}</CardDescription>
{description ? <CardDescription className="break-keep">{description}</CardDescription> : null}
<CardAction><SampleDataBadge /></CardAction>
</CardHeader>
<CardContent className="flex flex-1 flex-col">
@@ -165,6 +165,14 @@ export function DonutCenterExample({ className }: { className?: string }) {
)
}
export function DonutSolidExample({ className }: { className?: string }) {
return (
<ExampleCard title="입체형" className={className}>
<HMSolidDonutChart data={sampleDonutStates.data} totalLabel={sampleDonutStates.totalLabel} label="업무 상태 입체형 도넛 차트, 가운데 전체 합계(예시 데이터)" />
</ExampleCard>
)
}
export function DonutActiveExample({ className }: { className?: string }) {
const selected = sampleDonutStates.data.find(item => item.id === sampleDonutStates.activeId)
return (
+2 -1
View File
@@ -1,12 +1,13 @@
// Sample data and ready-made example cards. Everything here is illustrative, not business data.
export { sampleBar, sampleLine, sampleDonut, sampleDonutStates, sampleHorizontalBar, sampleSignedBar, sampleLineCompare, sampleLinePointColors, sampleDataNotice } from "./sample-data"
export { BarChartExample, StackedBarChartExample, HorizontalBarChartExample, SignedBarChartExample, LineChartExample, LineCurveExample, LineCurveDotLabelExample, LineStraightExample, LineStraightColorLabelExample, LineStepExample, LineStepDotLabelExample, DonutChartExample, DonutPlainExample, DonutCenterExample, DonutActiveExample, DonutInteractiveExample, ChartExamples, SampleDataBadge } from "./chart-examples"
export { BarChartExample, StackedBarChartExample, HorizontalBarChartExample, SignedBarChartExample, LineChartExample, LineCurveExample, LineCurveDotLabelExample, LineStraightExample, LineStraightColorLabelExample, LineStepExample, LineStepDotLabelExample, DonutChartExample, DonutPlainExample, DonutCenterExample, DonutSolidExample, DonutActiveExample, DonutInteractiveExample, ChartExamples, SampleDataBadge } from "./chart-examples"
export { samplePie, sampleStackedPie, sampleRadar } from "./sample-data"
export {
PiePlainExample,
PieLabelExample,
PieLabelListExample,
PieStackedExample,
PieSolidExample,
RadarPlainExample,
RadarDotExample,
RadarLinesOnlyExample,
@@ -1,4 +1,4 @@
import { HMAreaChart, HMPieChart, HMRadarChart, HMStackedPieChart } from "../index"
import { HMAreaChart, HMPieChart, HMRadarChart, HMSolidPieChart, HMStackedPieChart } from "../index"
import { ExampleCard } from "./chart-examples"
import { sampleLineCompare, sampleLinePointColors, samplePie, sampleRadar, sampleStackedPie } from "./sample-data"
@@ -34,6 +34,14 @@ export function PieStackedExample({ className }: Props) {
)
}
export function PieSolidExample({ className }: Props) {
return (
<ExampleCard title="입체형" className={className}>
<HMSolidPieChart {...samplePie} label="업무 상태 입체형 파이 차트(예시 데이터)" />
</ExampleCard>
)
}
export function RadarPlainExample({ className }: Props) {
return (
<ExampleCard title="Radar" description="방사형 차트 · 영역" className={className}>
+2
View File
@@ -27,6 +27,8 @@ export type { ChartSeries } from "./components/series"
export { HMPieChart, HMStackedPieChart } from "./components/pie-chart"
export type { HMPieChartProps, HMPieRing, HMStackedPieChartProps } from "./components/pie-chart"
export { HMSolidPieChart, HMSolidDonutChart } from "./components/solid-chart"
export type { HMSolidPieChartProps, HMSolidDonutChartProps } from "./components/solid-chart"
export { HMRadarChart } from "./components/radar-chart"
export type { HMRadarChartProps } from "./components/radar-chart"
export { HMAreaChart } from "./components/area-chart"
+4
View File
@@ -4,6 +4,7 @@ import {
DonutCenterExample,
DonutInteractiveExample,
DonutPlainExample,
DonutSolidExample,
} from "@hm-design/chart/examples"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
@@ -34,6 +35,9 @@ export const Donut: Story = {
<StorySection title="Interactive">
<DonutInteractiveExample />
</StorySection>
<StorySection title="입체형">
<DonutSolidExample />
</StorySection>
</div>
</StoryPage>
),
+4 -1
View File
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite"
import { PieLabelExample, PieLabelListExample, PiePlainExample, PieStackedExample } from "@hm-design/chart/examples"
import { PieLabelExample, PieLabelListExample, PiePlainExample, PieSolidExample, PieStackedExample } from "@hm-design/chart/examples"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Sample data only. All pies share one item list and color mapping; the stacked pie draws two series as concentric rings.
@@ -29,6 +29,9 @@ export const Pie: Story = {
<StorySection title="Pie + Stacked">
<PieStackedExample />
</StorySection>
<StorySection title="입체형">
<PieSolidExample />
</StorySection>
</div>
</StoryPage>
),