feat(chart): add @hm-design/chart package with Bar, Line, Donut, Pie, Radar, Area and Radial charts

- 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
This commit is contained in:
shkim4 committed 2026-11-06 15:52:47 +09:00
1 parent 228944a06b
commit dc9258599c
56 files changed
+3979 -28

No files matched your search

+128
View File
@@ -0,0 +1,128 @@
# @hm-design/chart
공식 shadcn/ui Chart(`recharts@3.8.0`) 기반의 HM 차트 패키지입니다. Bar(세로·가로·부호)·Line·Donut·Pie·Radar·Area·Radial 차트와 shadcn Chart 기본 부품을 제공하며, 색상·카드·글꼴은 `@hm-design/ui`의 HM Theme 토큰을 그대로 사용합니다. 색상값(HEX)은 이 패키지에 정의하지 않습니다.
> 이 패키지에는 별도 `ai/` 폴더가 없고 이 README가 AI 코딩 도구용 사용 지침을 겸합니다(공통 `AI_GUIDE.md`의 "기타" 유형). 아래 순서대로 따릅니다: **① 설치·연결 → ② 차트 고르기 → ③ 데이터 구성 → ④ 렌더링 → ⑤ 검증**.
## 1. 설치·연결
- 이 저장소를 Git Submodule로 포함한 프로젝트는 [외부 설치 가이드](../../docs/guidelines/external-install-submodule.md)의 절차를 따릅니다. **HMDesign 안에서 `npm ci` 후 `npm run build:ui`와 `npm run build:chart`(또는 `npm run build:packages`)를 먼저 실행**해야 `dist/`가 생깁니다. `dist/`는 Git에 없습니다.
- 소비 프로젝트의 npm Workspace에는 **`HMDesign/packages/ui`와 `HMDesign/packages/chart`를 함께** 추가합니다. `@hm-design/chart`는 `@hm-design/ui`(`0.1.0`), `react`·`react-dom`(`^19`), `tailwindcss`(`^4`)를 peer로 요구하며, 차트가 직접 쓰는 것은 `@hm-design/ui`의 **Theme CSS 토큰**(`theme.css`)뿐입니다(`@hm-design/ui`의 JS는 import하지 않음).
- 이 저장소 안에서는 루트 workspace(`packages/*`)로 이미 연결되어 있습니다.
### CSS 연결 (Tailwind v4 앱)
앱의 CSS 진입점(예: `src/index.css`)에서 `@import` 순서대로 연결합니다.
```css
@import "tailwindcss" source("./");
@import "@hm-design/ui/tailwind.css";
@import "@hm-design/chart/tailwind.css";
@import "@hm-design/ui/theme.css";
```
- `@hm-design/chart/tailwind.css`는 차트 마크업의 Tailwind 유틸리티를 소비 앱 빌드가 만들도록 하는 `@source` 한 줄입니다. 연결하지 않으면 그 유틸리티 class가 소비 앱 CSS에 생성되지 않습니다.
- `@hm-design/ui/theme.css`는 차트 색 별칭(`--hm-chart-*`)이 가리키는 `--hm-sem-*` 토큰과 Noto Sans KR 글꼴을 제공합니다.
- 차트 스타일(`@hm-design/chart/style.css`)은 JS 진입점(`@hm-design/chart`, `@hm-design/chart/examples`)이 자동으로 불러오므로 따로 import하지 않습니다.
- `<html>`·`<body>`에 배경·글꼴을 정하는 것은 소비 앱의 몫입니다(차트는 전역 규칙을 넣지 않습니다).
## 2. 진입점과 차트 고르기
| Entry | 내용 |
| --- | --- |
| `@hm-design/chart` | 차트 컴포넌트(아래 표), shadcn Chart 부품(`ChartContainer`, `ChartTooltip(Content)`, `ChartLegend(Content)`, `ChartStyle`, `ChartConfig`), `chartColors`, 타입 |
| `@hm-design/chart/examples` | **예시 데이터**와 예제 카드(`ChartExamples` 및 `*Example` 컴포넌트, `sample*` 데이터, `SampleDataBadge`). 실제 업무 데이터가 아니며 운영 화면에 그대로 쓰지 않습니다. |
| `@hm-design/chart/tailwind.css` | Tailwind 유틸리티 스캔용 `@source` |
| `@hm-design/chart/style.css` | 차트 토큰·스타일(JS 진입점이 자동으로 불러옴) |
| 보여 줄 것 | 컴포넌트 | 데이터 모양 |
| --- | --- | --- |
| 카테고리별 값 비교(그룹·누적) | `HMBarChart` | `data` 행 + `categoryKey` + `series[]` |
| 항목명이 긴 순위·값 비교 | `HMHorizontalBarChart` | `data` 행 + `categoryKey` + `valueKey` + `valueLabel` |
| 증감(양수·음수) | `HMSignedBarChart` | `data` 행 + `categoryKey` + `valueKey` + `valueLabel` |
| 추이 | `HMLineChart` | `data` 행 + `categoryKey` + `series[]` |
| 시간 흐름의 양·구성비 | `HMAreaChart` | `data` 행 + `categoryKey` + `series[]` |
| 전체 대비 비중 | `HMDonutChart`, `HMPieChart` | `data: { id, label, value, color? }[]` |
| 두 구성의 동심원 비교 | `HMStackedPieChart` | `rings: [내부, 외부]` |
| 여러 지표의 균형 | `HMRadarChart` | `data` 행 + `categoryKey` + `series[]` |
| 목표 대비 진행률·항목별 막대 | `HMRadialBarChart`, `HMRadialMeterChart`, `HMRadialStackedChart` | 위 Donut 데이터 / `item` / `series[]`+`row` |
## 3. 데이터 구성 규칙
```ts
type ChartSeries = { key: string; label: string; color?: string; lightColor?: string }
type HMDonutDatum = { id: string; label: string; value: number; color?: string }
```
- 실제 데이터는 소비 앱이 props로 전달합니다. 이 패키지는 API·DB에 연결하지 않습니다.
- `series.key`와 `HMDonutDatum.id`는 CSS 변수 이름(`--color-<key>`)으로 쓰이므로 **영문·숫자·`-`·`_`만** 사용합니다(한글·공백 금지). 화면에 보이는 이름은 `label`에 씁니다.
- 색상을 지정하지 않으면 `--hm-chart-1`~`5`(HM 그린·브라운·웜 뉴트럴 팔레트 별칭)를 순서대로 사용합니다. 다른 색은 `color`에 CSS 색상/토큰을 전달합니다.
- 범례 라벨은 짧게 유지합니다. Bar·Line·Radar·Area의 범례는 한 줄로 두며, 직접 `flex-wrap`으로 줄바꿈시키면 Recharts가 범례 높이를 과대 측정해 차트 아래에 빈 공간이 생길 수 있습니다. Donut·Pie·Stacked Pie는 컴포넌트 내부에서 줄바꿈 범례를 쓰도록 구성되어 있고, Radial 계열은 범례를 차트 박스 밖 아래에 따로 놓습니다.
- 모든 차트의 `label`은 필수 접근성 이름(`role="group"`의 `aria-label`)입니다.
- 차트는 부모 폭을 따라 줄어듭니다(`w-full`, 높이 `h-64` → `sm:h-80`). `className`으로 높이를 바꿀 수 있습니다. 부모에 `min-width: 0`이 없으면 flex·grid 안에서 넘칠 수 있습니다.
## 4. 렌더링
```tsx
import { HMBarChart, HMLineChart, HMDonutChart } from "@hm-design/chart"
const received = [{ month: "1월", received: 186, completed: 142 }]
<HMBarChart
label="월별 접수·완료 건수"
data={received}
categoryKey="month"
series={[{ key: "received", label: "접수" }, { key: "completed", label: "완료" }]}
/>
<HMLineChart label="처리 시간" data={rows} categoryKey="month" series={[{ key: "average", label: "평균" }]} />
<HMDonutChart label="상태 비중" totalLabel="전체(건)" data={[{ id: "done", label: "완료", value: 118 }]} />
```
가로형 라벨 막대(`HMHorizontalBarChart`)는 항목명을 막대 안쪽 왼쪽에, 값을 막대 끝 바깥에 표시합니다. 그라데이션은 왼쪽 30% → 오른쪽 100%이고(세로형은 상단 100% → 하단 30%), 막대에 들어가지 않는 긴 항목명은 `…`로 줄이며 막대가 너무 짧거나 0이면 값 뒤에 항목명을 보여 줍니다. 전체 항목명은 툴팁에서 확인합니다. 선택 props는 `color`, `valueFormatter`입니다. `HMSignedBarChart`는 양수는 0선 위로, 음수는 아래로 그립니다. Stacked 세로형 막대(`HMBarChart stacked`)는 눈금선이 비치지 않도록 각 세그먼트를 불투명한 두 색(시리즈 색 → 같은 계열의 한 단계 밝은 HM 팔레트 색, `--hm-chart-N-light`)으로 칠합니다. 사용자 지정 `color`는 `lightColor`를 함께 주지 않으면 단색 세그먼트가 됩니다.
```tsx
<HMHorizontalBarChart
label="항목별 처리 건수"
data={rows}
categoryKey="item"
valueKey="count"
valueLabel="처리 건수"
/>
```
선 차트(`HMLineChart`) 옵션: `curve`(`"monotone"` 기본 | `"linear"` | `"step"`, 모든 시리즈에 적용), `dots`(기본 `true`; `false`면 기본 Dot 없이 선만 그리고 호버 시 활성 포인트·툴팁은 유지), `pointLabels`(큰 Dot + 값 라벨), `pointColorKey`(포인트별 색상 필드 — 문자열은 첫 시리즈, 객체는 `{ 시리즈키: 필드명 }`; 선·범례는 시리즈 색, Dot·라벨·툴팁 표시는 포인트 색). 시리즈가 둘 이상이면 같은 카테고리에서 값이 큰 쪽 라벨은 위, 작은 쪽은 아래에 두고, 선·Dot·다른 라벨·축 라벨과 닿으면 가까운 빈 자리(더 띄우기, 반대편, 좌우)로 옮깁니다. 점 간격이 라벨 폭보다 좁으면 라벨을 n개마다 표시하고 툴팁에는 모든 값이 나옵니다.
도넛 차트(`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%). 영역 채움은 시리즈 색의 세로 그라데이션(위 진하게, 아래 옅게)입니다.
래디얼(`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`) 막대 호 길이에 들어가지 않는 긴 이름은 `…`로 줄입니다(전체 이름은 범례와 툴팁).
> 위 설명은 코드(`src/components/*.tsx`의 Props 타입·기본값)와 Storybook(`Chart` 그룹)에서 확인된 동작만 적었습니다. 아래 "예제 컴포넌트"의 `*Example` 이름은 `@hm-design/chart/examples`에서 확인합니다.
## 5. 검증
```sh
npm run build:ui && npm run build:chart # HMDesign 안: packages/ui/dist, packages/chart/dist
npm run typecheck -w @hm-design/chart
npm run build:chart-example # examples/chart (소비 예제 빌드)
npm run dev:chart-example # 소비 예제 실행
npm run storybook # Chart 그룹: Bar / Line / Donut / Pie / Radar / Area / Radial
```
- 소비 프로젝트에서는 앱의 `tsc --noEmit`·`vite build` 후 브라우저에서 차트가 렌더링되고(SVG 표시) 툴팁·범례가 보이는지, 320px에서 가로 스크롤이 없는지 확인합니다.
- 애니메이션이 끝난 뒤(약 1.5초) 화면을 확인합니다. 애니메이션 중에는 막대·Label이 일부만 그려집니다.
### 예제 컴포넌트
소비 예제(`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`(내부 컴포넌트)로 감싸며 실제 업무 데이터가 아님을 표시합니다.
## 공식 shadcn Chart와의 차이
`src/components/chart.tsx`는 `npx shadcn view chart`(radix-nova)의 공식 소스입니다. 차이는 세 가지뿐입니다: Dark 테마 항목 제거(HM은 Dark 없음), 툴팁·범례에 `data-slot` 추가, 툴팁 값의 `font-mono` 제거.
## 제한
- 차트 전용 HM 디자인 규격은 아직 없습니다. 색은 기존 HM 팔레트 Semantic 토큰의 별칭이며(`src/chart.css`), 차트 Token은 이 패키지 안에서만 정의합니다.
- `@hm-design/chart/examples`의 `ExampleCard`는 공개 Export가 아닙니다(예제 카드를 통해서만 사용).
- Dark 테마 없음. Runtime Theme(`@hm-design/ui/theme`)의 팔레트 override는 차트 별칭을 통해 반영됩니다.
+46
View File
@@ -0,0 +1,46 @@
{
"name": "@hm-design/chart",
"version": "0.1.0",
"private": false,
"description": "HM Design System charts (shadcn/ui Chart + Recharts): bar, line, donut, pie, radar, area and radial. Reuses the @hm-design/ui theme tokens.",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./examples": {
"types": "./dist/examples/index.d.ts",
"import": "./dist/examples.js",
"default": "./dist/examples.js"
},
"./style.css": "./dist/style.css",
"./tailwind.css": "./dist/tailwind.css"
},
"files": [
"dist"
],
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"build": "node ../../scripts/build-package.mjs chart",
"prepack": "npm run build"
},
"sideEffects": [
"**/*.css",
"./dist/index.js",
"./dist/examples.js"
],
"dependencies": {
"cn": "^0.4.0",
"recharts": "3.8.0"
},
"peerDependencies": {
"@hm-design/ui": "0.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwindcss": "^4.0.0"
}
}
+152
View File
@@ -0,0 +1,152 @@
/* Chart mapping: the shadcn/ui Chart (Recharts) wired to the HM Theme. HM has no separate chart spec yet, so series colors are
derived from existing HM palette Semantic tokens (green / brown / warm neutral); no HEX value is defined here.
Requires @hm-design/ui/theme.css (the --hm-sem-* tokens). Runtime Theme overrides of the palette flow through these aliases. */
:root, [data-hm-theme] {
/* Series order alternates green and brown so neighbouring series stay distinguishable (bar, line and donut slices). */
--hm-chart-1: var(--hm-sem-palette-green-light-2);
--hm-chart-2: var(--hm-sem-palette-brown-light-2);
--hm-chart-3: var(--hm-sem-palette-green-light-3);
--hm-chart-4: var(--hm-sem-palette-brown-light-4);
--hm-chart-5: var(--hm-sem-palette-warm-neutral-mid);
/* Light end of a stacked segment: the next palette step(s) lighter in the same family as the series color.
Brown Light 4 is the lightest brown token, so series 4 keeps its own color (a flat segment) instead of borrowing another family. */
--hm-chart-1-light: var(--hm-sem-palette-green-light-3);
--hm-chart-2-light: var(--hm-sem-palette-brown-light-3);
--hm-chart-3-light: var(--hm-sem-palette-green-light-4);
--hm-chart-4-light: var(--hm-sem-palette-brown-light-4);
--hm-chart-5-light: var(--hm-sem-palette-warm-neutral-light);
--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);
--hm-chart-tooltip-shadow: var(--hm-sem-floating-shadow);
--hm-chart-tooltip-text: var(--hm-sem-fg-primary);
--hm-chart-tooltip-label: var(--hm-sem-fg-secondary);
--hm-chart-grid-color: var(--hm-sem-stroke-default);
--hm-chart-axis-text: var(--hm-sem-fg-secondary);
--hm-chart-center-value: var(--hm-sem-fg-primary);
--hm-chart-font-family: var(--hm-font-family-base);
}
[data-slot="chart"] {
font-family: var(--hm-chart-font-family);
/* The container may sit in a flex row or a narrow card: let it shrink instead of overflowing. */
min-width: 0;
max-width: 100%;
}
[data-slot="chart"] .recharts-cartesian-axis-tick text {
fill: var(--hm-chart-axis-text);
}
[data-slot="chart"] .recharts-cartesian-grid line {
stroke: var(--hm-chart-grid-color);
stroke-opacity: 0.6;
}
[data-slot="chart-tooltip-content"] {
border: 1px solid var(--hm-chart-tooltip-border-color);
border-radius: var(--hm-chart-tooltip-radius);
background: var(--hm-chart-tooltip-background);
color: var(--hm-chart-tooltip-text);
box-shadow: var(--hm-chart-tooltip-shadow);
}
[data-slot="chart-tooltip-content"] .text-muted-foreground {
color: var(--hm-chart-tooltip-label);
}
[data-slot="chart-legend-content"] {
color: var(--hm-chart-tooltip-label);
}
/* Donut center total. */
[data-slot="chart-center-value"] {
fill: var(--hm-chart-center-value);
font-size: 24px;
font-weight: var(--hm-font-weight-bold);
}
[data-slot="chart-center-label"] {
fill: var(--hm-chart-axis-text);
font-size: 12px;
}
/* "Sample data" marker used by the @hm-design/chart/examples entry. */
[data-slot="chart-sample-notice"] {
color: var(--hm-chart-axis-text);
font-size: 12px;
}
/* Horizontal bar chart labels. The item name sits on a gradient that starts at 30% of the series color, so the dark text gets
a light halo (the card surface) to stay readable from the faint left end to the opaque right end. */
:root, [data-hm-theme] {
--hm-chart-bar-name-text: var(--hm-sem-fg-primary);
--hm-chart-bar-name-halo: var(--hm-sem-bg-surface);
--hm-chart-bar-value-text: var(--hm-sem-fg-primary);
}
[data-slot="chart-bar-name"],
[data-slot="chart-bar-value"] {
font-size: 12px;
font-weight: var(--hm-font-weight-medium);
pointer-events: none;
}
[data-slot="chart-bar-name"] {
fill: var(--hm-chart-bar-name-text);
stroke: var(--hm-chart-bar-name-halo);
stroke-width: 3px;
stroke-linejoin: round;
paint-order: stroke;
}
[data-slot="chart-bar-value"] {
fill: var(--hm-chart-bar-value-text);
}
/* Name of a bar too short to hold it: shown after the value on the card surface, so it uses the secondary text color. */
[data-slot="chart-bar-outside-name"] {
fill: var(--hm-chart-axis-text);
font-size: 12px;
pointer-events: none;
}
/* 0 line of the positive / negative bar chart: clearly darker than the regular gridlines. */
:root, [data-hm-theme] {
--hm-chart-zero-line: var(--hm-sem-fg-secondary);
}
/* Value label above a line chart Dot; its color is set per point (series color or the point's own color). */
[data-slot="chart-point-label"] {
font-size: 12px;
font-weight: var(--hm-font-weight-medium);
pointer-events: none;
}
/* Pie: outside "name value" labels, value labels inside slices, and the ring key of the stacked pie. */
[data-slot="chart-pie-label"] {
fill: var(--hm-chart-axis-text);
font-size: 12px;
pointer-events: none;
}
[data-slot="chart-slice-label"] {
fill: var(--hm-chart-bar-name-text);
stroke: var(--hm-chart-bar-name-halo);
stroke-width: 3px;
stroke-linejoin: round;
paint-order: stroke;
font-size: 12px;
font-weight: var(--hm-font-weight-medium);
pointer-events: none;
}
/* Radar: custom axis names. */
[data-slot="chart-axis-label"] {
fill: var(--hm-chart-axis-text);
font-size: 12px;
}
/* Radial: item names curved along the start of each bar. The halo keeps the text readable on pale bar colors. */
.hm-radial-name {
fill: var(--hm-chart-bar-name-text);
stroke: var(--hm-chart-bar-name-halo);
stroke-width: 2px;
stroke-linejoin: round;
paint-order: stroke;
font-size: 11px;
font-weight: var(--hm-font-weight-medium);
pointer-events: none;
}
@@ -0,0 +1,144 @@
import { useId, type ComponentProps } from "react"
import { cn } from "cn"
import { Area, AreaChart, CartesianGrid, LabelList, XAxis, YAxis } from "recharts"
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent } from "./chart"
import { POINT_AXIS_PADDING, PointColorTooltip, PointDot, PointValueLabel } from "./line-points"
import { seriesToConfig, type ChartSeries } from "./series"
// Area fill: the series color is strongest at the top of each area and fades towards the bottom. The overlap of two plain areas stays
// readable because each fill is translucent; stacked areas are a bit denser because they never overlap.
const FILL_TOP = 0.5
const FILL_BOTTOM = 0.05
const STACKED_FILL_TOP = 0.65
const STACKED_FILL_BOTTOM = 0.2
export type HMAreaChartProps = {
data: ReadonlyArray<Record<string, string | number>>
/** Field of each data row used for the category (X) axis. */
categoryKey: string
series: readonly ChartSeries[]
/** Line interpolation for every series: `monotone` (default), `linear` or `step`. */
curve?: "monotone" | "linear" | "step"
/** Small Dot on every point (default off; the active point and tooltip always appear on hover). Ignored when `pointLabels` or `pointColorKey` draw their own Dots. */
dots?: boolean
/** Larger Dot and value Label on every point, placed like the Line chart labels (omitted where they would collide). Not for stacked or expanded areas. */
pointLabels?: boolean
/** Per-point colors for Dots, Labels and tooltip indicators; same as `HMLineChart`. */
pointColorKey?: string | Readonly<Record<string, string>>
/** Stack the series: each area sits on the previous one, so the top edge is the running total. */
stacked?: boolean
/** Stack and normalize each category to 100% (Recharts `stackOffset="expand"`). The Y axis shows percent; the tooltip shows the original value and its share. A total of 0 shows 0%. */
expanded?: boolean
/** Accessible name of the chart. */
label: string
className?: string
}
/** Area chart (shadcn Chart + Recharts): vertical gradient fills in HM series colors, plain, stacked or expanded to 100%. */
export function HMAreaChart({
data,
categoryKey,
series,
curve = "monotone",
dots = false,
pointLabels = false,
pointColorKey,
stacked = false,
expanded = false,
label,
className,
}: HMAreaChartProps) {
const scope = `hm-area-${useId().replace(/:/g, "")}`
const gradientId = (key: string) => `${scope}-${key}`
const stack = stacked || expanded
const colorKeys: Readonly<Record<string, string>> =
typeof pointColorKey === "string" ? (series[0] ? { [series[0].key]: pointColorKey } : {}) : (pointColorKey ?? {})
const customPoints = !stack && (pointLabels || Object.keys(colorKeys).length > 0)
const seriesKeys = series.map(item => item.key)
const share: ComponentProps<typeof ChartTooltipContent>["formatter"] = (_value, _name, item) => {
const row = (item?.payload ?? {}) as Record<string, unknown>
const key = String(item?.dataKey ?? "")
const total = seriesKeys.reduce((sum, k) => sum + (Number(row[k]) || 0), 0)
const value = Number(row[key]) || 0
const percent = total > 0 ? Math.round((value / total) * 100) : 0
return (
<div className="flex w-full items-center gap-2">
<div className="h-2.5 w-2.5 shrink-0 rounded-[2px]" style={{ backgroundColor: item?.color }} />
<span className="text-muted-foreground">{series.find(s => s.key === key)?.label ?? key}</span>
<span className="ml-auto font-medium text-foreground tabular-nums">{value.toLocaleString()} · {percent}%</span>
</div>
)
}
return (
<ChartContainer
config={seriesToConfig(series)}
role="group"
aria-label={label}
className={cn("aspect-auto h-64 w-full sm:h-80", className)}
>
<AreaChart
accessibilityLayer
data={data as Record<string, string | number>[]}
margin={{ top: 24, right: 12, left: 0, bottom: 0 }}
stackOffset={expanded ? "expand" : undefined}
>
<defs>
{series.map(item => (
<linearGradient key={item.key} id={gradientId(item.key)} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" style={{ stopColor: `var(--color-${item.key})`, stopOpacity: stack ? STACKED_FILL_TOP : FILL_TOP }} />
<stop offset="100%" style={{ stopColor: `var(--color-${item.key})`, stopOpacity: stack ? STACKED_FILL_BOTTOM : FILL_BOTTOM }} />
</linearGradient>
))}
</defs>
<CartesianGrid vertical={false} />
{/* Inner padding keeps the first and last Dot, Label and active point from being clipped at the plot edge. */}
<XAxis dataKey={categoryKey} tickLine={false} axisLine={false} tickMargin={8} padding={{ left: POINT_AXIS_PADDING, right: POINT_AXIS_PADDING }} />
<YAxis
tickLine={false}
axisLine={false}
tickMargin={4}
width={expanded ? 44 : 40}
domain={expanded ? [0, 1] : undefined}
tickFormatter={expanded ? (value: number) => `${Math.round(value * 100)}%` : undefined}
/>
<ChartTooltip
cursor
content={
expanded
? <ChartTooltipContent indicator="line" formatter={share} />
: Object.keys(colorKeys).length > 0 && customPoints
? <PointColorTooltip indicator="line" colorKeys={colorKeys} />
: <ChartTooltipContent indicator="line" />
}
/>
<ChartLegend itemSorter={null} content={<ChartLegendContent />} />
{series.map((item, index) => {
const color = `var(--color-${item.key})`
const colorKey = colorKeys[item.key]
return (
<Area
key={item.key}
dataKey={item.key}
type={curve}
stroke={color}
strokeWidth={2}
fill={`url(#${gradientId(item.key)})`}
stackId={stack ? "stack" : undefined}
dot={customPoints ? <PointDot colorKey={colorKey} fallback={color} /> : dots ? { r: 3, fill: color, strokeWidth: 0 } : false}
activeDot={customPoints ? <PointDot colorKey={colorKey} fallback={color} radius={6} /> : { r: 5 }}
>
{customPoints && pointLabels ? (
<LabelList
dataKey={item.key}
content={<PointValueLabel data={data} seriesKeys={seriesKeys} seriesIndex={index} curve={curve} colorKey={colorKey} fallback={color} />}
/>
) : null}
</Area>
)
})}
</AreaChart>
</ChartContainer>
)
}
@@ -0,0 +1,74 @@
import { useId } from "react"
import { cn } from "cn"
import { Bar, BarChart, CartesianGrid, Rectangle, XAxis, YAxis, type BarShapeProps } from "recharts"
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent } from "./chart"
import { BAR_GRADIENT_FADED_OPACITY, BAR_GRADIENT_FULL_OPACITY } from "./bar-gradient"
import { seriesLightColor, seriesToConfig, type ChartSeries } from "./series"
export type HMBarChartProps = {
data: ReadonlyArray<Record<string, string | number>>
/** Field of each data row used for the category (X) axis. */
categoryKey: string
series: readonly ChartSeries[]
/** Stack the series instead of grouping them. */
stacked?: boolean
/** Accessible name of the chart. */
label: string
className?: string
}
/**
* Vertical bar chart (shadcn Chart + Recharts) with HM series colors and a vertical gradient per bar, tooltip and legend.
* Each bar (each segment when stacked) paints its own `linearGradient` in objectBoundingBox units, so the gradient follows
* that bar's height. The `Bar` itself keeps the solid HM color, which the legend and tooltip indicators use.
*/
export function HMBarChart({ data, categoryKey, series, stacked = false, label, className }: HMBarChartProps) {
// Unique per chart instance so several charts on one page never share gradient ids.
const gradientScope = `hm-bar-${useId().replace(/:/g, "")}`
const gradientId = (key: string) => `${gradientScope}-${key}`
return (
<ChartContainer
config={seriesToConfig(series)}
role="group"
aria-label={label}
className={cn("aspect-auto h-64 w-full sm:h-80", className)}
>
<BarChart accessibilityLayer data={data as Record<string, string | number>[]} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid vertical={false} />
<XAxis dataKey={categoryKey} tickLine={false} axisLine={false} tickMargin={8} />
<YAxis tickLine={false} axisLine={false} tickMargin={4} width={40} />
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
<ChartLegend itemSorter={null} content={<ChartLegendContent />} />
<defs>
{series.map((item, index) => (
<linearGradient key={item.key} id={gradientId(item.key)} x1="0" y1="0" x2="0" y2="1">
{stacked ? (
<>
{/* Stacked segments stay opaque at both ends so gridlines never show through: the series color at the top, the
lighter palette color of the same family at the bottom. */}
<stop offset="0%" style={{ stopColor: `var(--color-${item.key})`, stopOpacity: 1 }} />
<stop offset="100%" style={{ stopColor: seriesLightColor(index, item.color, item.lightColor), stopOpacity: 1 }} />
</>
) : (
<>
<stop offset="0%" style={{ stopColor: `var(--color-${item.key})`, stopOpacity: BAR_GRADIENT_FULL_OPACITY }} />
<stop offset="100%" style={{ stopColor: `var(--color-${item.key})`, stopOpacity: BAR_GRADIENT_FADED_OPACITY }} />
</>
)}
</linearGradient>
))}
</defs>
{series.map((item, index) => (
<Bar
key={item.key}
dataKey={item.key}
fill={`var(--color-${item.key})`}
shape={(props: BarShapeProps) => <Rectangle {...props} fill={`url(#${gradientId(item.key)})`} />}
stackId={stacked ? "stack" : undefined}
radius={stacked ? (index === series.length - 1 ? [4, 4, 0, 0] : 0) : [4, 4, 0, 0]}
/>
))}
</BarChart>
</ChartContainer>
)
}
@@ -0,0 +1,4 @@
// Shared by the bar charts: the gradient runs from a fully opaque HM color to 30% of it.
// Vertical bars: top 100% -> bottom 30%. Horizontal bars: left 30% -> right 100%.
export const BAR_GRADIENT_FULL_OPACITY = 1
export const BAR_GRADIENT_FADED_OPACITY = 0.3
+380
View File
@@ -0,0 +1,380 @@
"use client"
/* Official shadcn/ui Chart (registry radix-nova, `npx shadcn view chart`, recharts@3.8.0), kept as published.
HM adaptations (the only differences):
- THEMES has no `dark` entry: HM has no Dark theme (docs/guidelines/테마-토큰-가이드.md).
- `data-slot="chart-tooltip-content"` / `"chart-legend-content"` hooks so chart.css can map HM tokens.
- Tooltip values drop `font-mono` so numbers use the HM base font. */
import * as React from "react"
import { cn } from "cn"
import * as RechartsPrimitive from "recharts"
import type { TooltipValueType } from "recharts"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "" } as const
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
type TooltipNameType = number | string
export type ChartConfig = Record<
string,
{
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
>
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
initialDimension = INITIAL_DIMENSION,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
initialDimension?: {
width: number
height: number
}
}) {
const uniqueId = React.useId()
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer
initialDimension={initialDimension}
>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme ?? config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
} & Omit<
RechartsPrimitive.DefaultTooltipContentProps<
TooltipValueType,
TooltipNameType
>,
"accessibilityLayer"
>) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? (config[label]?.label ?? label)
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
data-slot="chart-tooltip-content"
className={cn(
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color ?? item.payload?.fill ?? item.color
return (
<div
key={index}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label ?? item.name}
</span>
</div>
{item.value != null && (
<span className="font-medium text-foreground tabular-nums">
{typeof item.value === "number"
? item.value.toLocaleString()
: String(item.value)}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> & {
hideIcon?: boolean
nameKey?: string
} & RechartsPrimitive.DefaultLegendContentProps) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
data-slot="chart-legend-content"
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={index}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config ? config[configLabelKey] : config[key]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
@@ -0,0 +1,185 @@
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-<id>`): 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 (
<text x={cx} y={baseline} textAnchor="middle">
<title>{caption ? `${caption} ${full}` : full}</title>
<tspan data-slot="chart-center-value" x={cx} y={baseline} style={{ fontSize: size }}>
{text}
</tspan>
{shownCaption ? (
<tspan data-slot="chart-center-label" x={cx} y={baseline + 20}>
{shownCaption}
</tspan>
) : null}
</text>
)
}
/**
* 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 ? (
<CenterText
cx={props.cx}
cy={props.cy}
innerRadius={props.innerRadius}
value={active ? active.value : total}
caption={active ? active.label : totalLabel}
/>
) : null
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" />} />
{total <= 0 ? (
<Pie
data={[{ value: 1 }]}
dataKey="value"
innerRadius={ring.inner}
outerRadius={ring.outer}
fill="var(--hm-sem-bg-subtle)"
stroke="none"
isAnimationActive={false}
legendType="none"
tooltipType="none"
shape={(props: PieSectorShapeProps, index: number) => (
<>
<Sector {...props} />
{centerText(props, index)}
</>
)}
/>
) : null}
<Pie
data={slices}
dataKey="value"
nameKey="id"
innerRadius={ring.inner}
// Room for the active slice to grow outwards without leaving the chart.
outerRadius={ring.outer}
paddingAngle={paddingAngle(selectable)}
stroke="var(--hm-sem-bg-surface)"
strokeWidth={2}
onClick={onActiveChange ? (_slice, index) => onActiveChange(data[index].id) : undefined}
shape={(props: PieSectorShapeProps, index: number) => (
<>
<Sector
{...props}
outerRadius={props.payload?.id === activeId ? props.outerRadius + EXPAND : props.outerRadius}
cursor={onActiveChange ? "pointer" : undefined}
/>
{total > 0 ? centerText(props, index) : null}
</>
)}
/>
</PieChart>
</ChartContainer>
)
}
@@ -0,0 +1,137 @@
import { useId } from "react"
import { cn } from "cn"
import { Bar, BarChart, Rectangle, XAxis, YAxis, type BarShapeProps } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "./chart"
import { BAR_GRADIENT_FADED_OPACITY, BAR_GRADIENT_FULL_OPACITY } from "./bar-gradient"
import { seriesToConfig } from "./series"
export type HMHorizontalBarChartProps = {
data: ReadonlyArray<Record<string, string | number>>
/** Field of each data row used as the item name (shown inside the bar, full text in the tooltip). */
categoryKey: string
/** Numeric field of each data row (bar length, shown after the bar end). */
valueKey: string
/** Name of the value, used as the tooltip row label. */
valueLabel: string
/** Any CSS color. Defaults to the first HM series color. */
color?: string
/** Formats the value shown after the bar end. Defaults to `toLocaleString()`. */
valueFormatter?: (value: number) => string
/** Accessible name of the chart. */
label: string
className?: string
}
const FONT_SIZE = 12
const ROW_HEIGHT = 40
const BAR_SIZE = 26
const NAME_PADDING = 10 // space between the bar start / end and the item name
const VALUE_GAP = 8 // space between the bar end and the value
const RIGHT_SAFE = 6 // extra room so the value never touches the chart edge
const CHART_PADDING = 8
// Text is measured by estimate (full-width glyphs 1em, others ~0.6em) so truncation needs no DOM access.
function textWidth(text: string) {
let width = 0
for (const char of text) width += (char.codePointAt(0) ?? 0) > 0x2e7f ? FONT_SIZE : FONT_SIZE * 0.6
return width
}
/** Keeps the longest start of `text` (plus an ellipsis) that fits `maxWidth`; "" when not even one character fits. */
function fitText(text: string, maxWidth: number) {
if (textWidth(text) <= maxWidth) return text
const chars = [...text]
for (let count = chars.length - 1; count > 0; count -= 1) {
const candidate = `${chars.slice(0, count).join("").trimEnd()}…`
if (textWidth(candidate) <= maxWidth) return candidate
}
return ""
}
/**
* Horizontal bar chart with custom labels (shadcn Chart + Recharts): the item name sits inside the bar at the left, the value
* after the bar end. The gradient runs left 30% -> right 100% of the HM color. Names that do not fit the bar are shortened with
* an ellipsis; a bar too short for any of its name (including zero) shows the name after the value instead. Names never run into
* the value, and the tooltip always shows the full name.
*/
export function HMHorizontalBarChart({
data,
categoryKey,
valueKey,
valueLabel,
color,
valueFormatter = value => value.toLocaleString(),
label,
className,
}: HMHorizontalBarChartProps) {
const gradientId = `hm-hbar-${useId().replace(/:/g, "")}`
const config = seriesToConfig([{ key: valueKey, label: valueLabel, color }])
// Reserve the room the longest value needs on the right so no value is clipped by the chart edge.
const valueRoom = Math.ceil(
Math.max(0, ...data.map(row => textWidth(valueFormatter(Number(row[valueKey]) || 0)))) + VALUE_GAP + RIGHT_SAFE,
)
const renderBar = (props: BarShapeProps) => {
const { x, y, width, height, payload, value, parentViewBox } = props
const name = String(payload?.[categoryKey] ?? "")
const amount = Number(Array.isArray(value) ? value[1] : value) || 0
const barWidth = Math.max(width, 0)
const shownName = fitText(name, barWidth - NAME_PADDING * 2)
const centerY = y + height / 2
const valueText = valueFormatter(amount)
// A bar too short to hold any of its name shows the name after the value instead, shortened to the space left of the plot edge.
const outsideX = x + barWidth + VALUE_GAP + textWidth(valueText) + NAME_PADDING
const outsideName = shownName ? "" : fitText(name, parentViewBox.x + parentViewBox.width - outsideX)
return (
<g role="img" aria-label={`${name}: ${valueText}`}>
{barWidth > 0 ? <Rectangle {...props} fill={`url(#${gradientId})`} /> : null}
{shownName ? (
<text data-slot="chart-bar-name" x={x + NAME_PADDING} y={centerY} dominantBaseline="central">{shownName}</text>
) : null}
<text data-slot="chart-bar-value" x={x + barWidth + VALUE_GAP} y={centerY} dominantBaseline="central">
{valueText}
</text>
{outsideName ? (
<text data-slot="chart-bar-outside-name" x={outsideX} y={centerY} dominantBaseline="central">{outsideName}</text>
) : null}
</g>
)
}
return (
<ChartContainer
config={config}
role="group"
aria-label={label}
// Same default height as the vertical bar charts so cards line up; many rows grow past it via the min-height.
className={cn("aspect-auto h-64 w-full sm:h-80", className)}
style={{ minHeight: data.length * ROW_HEIGHT + CHART_PADDING * 2 }}
>
<BarChart
accessibilityLayer
layout="vertical"
data={data as Record<string, string | number>[]}
margin={{ top: CHART_PADDING, right: valueRoom, bottom: CHART_PADDING, left: 0 }}
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style={{ stopColor: `var(--color-${valueKey})`, stopOpacity: BAR_GRADIENT_FADED_OPACITY }} />
<stop offset="100%" style={{ stopColor: `var(--color-${valueKey})`, stopOpacity: BAR_GRADIENT_FULL_OPACITY }} />
</linearGradient>
</defs>
<XAxis type="number" hide domain={[0, "dataMax"]} />
<YAxis type="category" dataKey={categoryKey} hide />
<ChartTooltip content={<ChartTooltipContent />} />
<Bar
dataKey={valueKey}
fill={`var(--color-${valueKey})`}
barSize={BAR_SIZE}
radius={[0, 4, 4, 0]}
shape={renderBar}
// Labels are positioned from the final bar width, so the bars do not animate in.
isAnimationActive={false}
/>
</BarChart>
</ChartContainer>
)
}
@@ -0,0 +1,104 @@
import { cn } from "cn"
import { CartesianGrid, LabelList, Line, LineChart, XAxis, YAxis } from "recharts"
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent } from "./chart"
import { POINT_AXIS_PADDING, PointColorTooltip, PointDot, PointValueLabel } from "./line-points"
import { seriesToConfig, type ChartSeries } from "./series"
export type HMLineChartProps = {
data: ReadonlyArray<Record<string, string | number>>
/** Field of each data row used for the category (X) axis. */
categoryKey: string
series: readonly ChartSeries[]
/** Line interpolation for every series. `monotone` (default) is a smooth curve that never overshoots the data; `linear` joins the points with straight segments; `step` runs horizontal then vertical between points. */
curve?: "monotone" | "linear" | "step"
/** Show the small default Dot on every point (default). `false` draws only the line; the active point and tooltip still appear on hover. Ignored when `pointLabels` or `pointColorKey` draw their own Dots. */
dots?: boolean
/** Draw a larger Dot on every point and its value next to it. With several series the higher value is labelled above its Dot and the lower
* one below, and labels are thinned on narrow charts (the tooltip always shows every value). */
pointLabels?: boolean
/** Per-point colors (CSS colors, e.g. HM palette tokens) for Dots, labels and tooltip indicators; lines and the legend keep the series color.
* A string names the row field for the first series; an object maps a series key to its row field. Implies the larger Dot. */
pointColorKey?: string | Readonly<Record<string, string>>
/** Accessible name of the chart. */
label: string
className?: string
}
/** Line chart (shadcn Chart + Recharts) with HM series colors, line-cursor tooltip and legend. */
export function HMLineChart({
data,
categoryKey,
series,
curve = "monotone",
dots = true,
pointLabels = false,
pointColorKey,
label,
className,
}: HMLineChartProps) {
const colorKeys: Readonly<Record<string, string>> =
typeof pointColorKey === "string" ? (series[0] ? { [series[0].key]: pointColorKey } : {}) : (pointColorKey ?? {})
const customPoints = pointLabels || Object.keys(colorKeys).length > 0
const seriesKeys = series.map(item => item.key)
// A line without Dots keeps the same inner room as the Dot variants, so both align when shown side by side.
const roomy = customPoints || !dots
return (
<ChartContainer
config={seriesToConfig(series)}
role="group"
aria-label={label}
className={cn("aspect-auto h-64 w-full sm:h-80", className)}
>
<LineChart
accessibilityLayer
data={data as Record<string, string | number>[]}
margin={{ top: roomy ? 24 : 8, right: 12, left: 0, bottom: 0 }}
>
<CartesianGrid vertical={false} />
{/* Inner padding keeps the first and last Dot, label and active point from being clipped at the plot edge. */}
<XAxis
dataKey={categoryKey}
tickLine={false}
axisLine={false}
tickMargin={8}
padding={roomy ? { left: POINT_AXIS_PADDING, right: POINT_AXIS_PADDING } : undefined}
/>
<YAxis tickLine={false} axisLine={false} tickMargin={4} width={40} />
<ChartTooltip
cursor
content={
Object.keys(colorKeys).length > 0
? <PointColorTooltip indicator="line" colorKeys={colorKeys} />
: <ChartTooltipContent indicator="line" />
}
/>
<ChartLegend itemSorter={null} content={<ChartLegendContent />} />
{series.map((item, index) => {
const color = `var(--color-${item.key})`
const colorKey = colorKeys[item.key]
return (
<Line
key={item.key}
dataKey={item.key}
type={curve}
stroke={color}
strokeWidth={2}
dot={customPoints ? <PointDot colorKey={colorKey} fallback={color} /> : dots ? { r: 3, fill: color, strokeWidth: 0 } : false}
activeDot={customPoints ? <PointDot colorKey={colorKey} fallback={color} radius={6} /> : { r: 5 }}
>
{pointLabels ? (
<LabelList
dataKey={item.key}
content={
<PointValueLabel data={data} seriesKeys={seriesKeys} seriesIndex={index} curve={curve} colorKey={colorKey} fallback={color} />
}
/>
) : null}
</Line>
)
})}
</LineChart>
</ChartContainer>
)
}
@@ -0,0 +1,254 @@
import type { ComponentProps } from "react"
import { usePlotArea, useYAxisScale } from "recharts"
import { ChartTooltipContent } from "./chart"
// Custom Dot, value label and tooltip for HMLineChart. They are rendered inside the chart, so they can read the plot area and scales.
/** Space kept between the plot edge and the first / last point so their Dot and label are not clipped. */
export const POINT_AXIS_PADDING = 22
const DOT_RADIUS = 5
const LABEL_GAP = 7 // between the Dot edge and the label
const FAR_LABEL_GAP = 18 // same, for the second try when the near spot touches a line
const CHAR_WIDTH = 7.2 // estimated 12px digit width
const LABEL_ASCENT = 10
const LABEL_DESCENT = 2
type Row = Record<string, unknown>
type Curve = "monotone" | "linear" | "step"
type Point = { x: number; y: number }
type Box = { left: number; right: number; top: number; bottom: number }
type Placement = { x: number; y: number; anchor: "start" | "middle" | "end"; box: Box }
function pointColor(row: Row | undefined, colorKey: string | undefined, fallback: string) {
const own = colorKey ? row?.[colorKey] : undefined
return typeof own === "string" && own ? own : fallback
}
/** Dot with a surface-colored ring: the series color, or the row's own color when `colorKey` is set. */
export function PointDot({
cx,
cy,
payload,
colorKey,
fallback,
radius = DOT_RADIUS,
}: {
cx?: number | string
cy?: number | string
payload?: Row
colorKey?: string
fallback: string
radius?: number
}) {
const x = Number(cx)
const y = Number(cy)
if (!Number.isFinite(x) || !Number.isFinite(y)) return null
return (
<circle
data-slot="chart-point-dot"
cx={x}
cy={y}
r={radius}
fill={pointColor(payload, colorKey, fallback)}
stroke="var(--hm-sem-bg-surface)"
strokeWidth={2}
/>
)
}
function inflate(box: Box, by: number): Box {
return { left: box.left - by, right: box.right + by, top: box.top - by, bottom: box.bottom + by }
}
const inside = (box: Box, x: number, y: number) => x >= box.left && x <= box.right && y >= box.top && y <= box.bottom
/** Number of sample points (about every 2px) of the segment that fall inside the box. */
function segmentHits(box: Box, a: Point, b: Point) {
const steps = Math.max(1, Math.ceil(Math.hypot(b.x - a.x, b.y - a.y) / 2))
let hits = 0
for (let i = 0; i <= steps; i += 1) {
const t = i / steps
if (inside(box, a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)) hits += 1
}
return hits
}
function circleTouchesBox(box: Box, c: Point, radius: number) {
const dx = Math.max(box.left - c.x, 0, c.x - box.right)
const dy = Math.max(box.top - c.y, 0, c.y - box.bottom)
return Math.hypot(dx, dy) < radius
}
const boxesOverlap = (a: Box, b: Box) => !(a.right <= b.left || b.right <= a.left || a.bottom <= b.top || b.bottom <= a.top)
/** Line segments a series draws between two neighbouring points (a curve is approximated by its chord). */
function connection(a: Point, b: Point, curve: Curve): Array<[Point, Point]> {
if (curve !== "step") return [[a, b]]
const mid = (a.x + b.x) / 2
return [
[a, { x: mid, y: a.y }],
[{ x: mid, y: a.y }, { x: mid, y: b.y }],
[{ x: mid, y: b.y }, b],
]
}
function candidateBoxes(p: Point, width: number, below: boolean): Placement[] {
const above = (gap: number): Placement => {
const baseline = p.y - DOT_RADIUS - gap
return { x: p.x, y: baseline, anchor: "middle", box: { left: p.x - width / 2, right: p.x + width / 2, top: baseline - LABEL_ASCENT, bottom: baseline + LABEL_DESCENT } }
}
const under = (gap: number): Placement => {
const top = p.y + DOT_RADIUS + gap
const baseline = top + LABEL_ASCENT
return { x: p.x, y: baseline, anchor: "middle", box: { left: p.x - width / 2, right: p.x + width / 2, top, bottom: baseline + LABEL_DESCENT } }
}
const side = (dir: 1 | -1): Placement => {
const x = p.x + dir * (DOT_RADIUS + 4)
return {
x,
y: p.y + 4,
anchor: dir === 1 ? "start" : "end",
box: { left: dir === 1 ? x : x - width, right: dir === 1 ? x + width : x, top: p.y - 6, bottom: p.y + 6 },
}
}
const [first, second] = below ? [under, above] : [above, under]
return [first(LABEL_GAP), first(FAR_LABEL_GAP), second(LABEL_GAP), second(FAR_LABEL_GAP), side(1), side(-1)]
}
/**
* Value label of a Dot, in the Dot's color.
* - Default side: one series is labelled above its Dot. With several series the higher value at a category goes above and the lower
* below (a tie keeps the series order).
* - The label then takes the first free spot of: default side near, default side farther, the other side near and farther, right,
* left. A spot is free when it touches no line (curve, straight or step), no Dot, no earlier series' label and is neither above
* the plot top nor on the axis labels. With no free spot the label is omitted; the Dot and the tooltip remain.
* - When points are closer than a label is wide (narrow screens), only every n-th label is drawn; the tooltip shows every value.
*/
export function PointValueLabel({
x,
y,
index,
data,
seriesKeys,
seriesIndex,
curve,
colorKey,
fallback,
}: {
x?: number | string
y?: number | string
index?: number
data: ReadonlyArray<Row>
seriesKeys: readonly string[]
seriesIndex: number
curve: Curve
colorKey?: string
fallback: string
}) {
const plot = usePlotArea()
const yScale = useYAxisScale()
const px = Number(x)
const py = Number(y)
const at = index ?? 0
const row = data[at]
if (!Number.isFinite(px) || !Number.isFinite(py) || !row) return null
const textOf = (key: string) => Number(row[key]).toLocaleString()
const spacing = plot && data.length > 1 ? (plot.width - 2 * POINT_AXIS_PADDING) / (data.length - 1) : Infinity
const widest = Math.max(...seriesKeys.flatMap(key => data.map(item => Number(item[key]).toLocaleString().length)))
const step = Math.max(1, Math.ceil((widest * CHAR_WIDTH + 6) / spacing))
if (at % step !== 0) return null
// Pixel position of any series' point at any category, from this point and the evenly spaced category axis.
const scaleOk = typeof yScale === "function" && Number.isFinite(Number(yScale(Number(row[seriesKeys[seriesIndex]])))) &&
Math.abs(Number(yScale(Number(row[seriesKeys[seriesIndex]]))) - py) < 1.5
const pointOf = (seriesNo: number, i: number): Point | undefined => {
if (!scaleOk || !Number.isFinite(spacing)) return undefined
const value = Number(data[i]?.[seriesKeys[seriesNo]])
const py2 = Number(yScale?.(value))
return Number.isFinite(value) && Number.isFinite(py2) ? { x: px + (i - at) * spacing, y: py2 } : undefined
}
const first = Math.max(0, at - 2)
const last = Math.min(data.length - 1, at + 2)
const bottomLimit = plot ? plot.y + plot.height + 4 : Infinity
const topLimit = 2
const cost = (candidate: Placement, taken: Box[]) => {
const box = inflate(candidate.box, 1.5)
let total = 0
if (candidate.box.bottom > bottomLimit || candidate.box.top < topLimit) total += 200
for (const other of taken) if (boxesOverlap(candidate.box, other)) total += 30
for (let s = 0; s < seriesKeys.length; s += 1) {
for (let i = first; i <= last; i += 1) {
const here = pointOf(s, i)
if (!here) continue
if (circleTouchesBox(box, here, DOT_RADIUS + 1)) total += 8
const next = i < last ? pointOf(s, i + 1) : undefined
if (next) for (const [a, b] of connection(here, next, curve)) total += segmentHits(box, a, b)
}
}
return total
}
const place = (seriesNo: number): Placement | null => {
const own = seriesNo === seriesIndex ? { x: px, y: py } : pointOf(seriesNo, at)
if (!own) return null
const taken: Box[] = []
for (let earlier = 0; earlier < seriesNo; earlier += 1) {
const placed = place(earlier)
if (placed) taken.push(placed.box)
}
const mine = Number(row[seriesKeys[seriesNo]])
const below = seriesKeys.some((key, i) => i !== seriesNo && (Number(row[key]) > mine || (Number(row[key]) === mine && i < seriesNo)))
const width = textOf(seriesKeys[seriesNo]).length * CHAR_WIDTH + 2
let best: Placement | null = null
let bestCost = Infinity
for (const candidate of candidateBoxes(own, width, below)) {
const c = cost(candidate, taken)
if (c < bestCost) {
best = candidate
bestCost = c
}
if (c === 0) break
}
// No free spot: omit this label (its Dot and the tooltip stay) rather than touch a line, a Dot or another label.
return bestCost === 0 ? best : null
}
// Without a usable scale (it never happens inside HMLineChart) fall back to "first series above, others below".
const fallbackPlacement = (): Placement => {
const below = seriesIndex > 0
return candidateBoxes({ x: px, y: py }, textOf(seriesKeys[seriesIndex]).length * CHAR_WIDTH + 2, below)[0]
}
const placement = scaleOk ? place(seriesIndex) : fallbackPlacement()
if (!placement) return null
return (
<text
data-slot="chart-point-label"
x={placement.x}
y={placement.y}
textAnchor={placement.anchor}
fill={pointColor(row, colorKey, fallback)}
>
{textOf(seriesKeys[seriesIndex])}
</text>
)
}
/**
* Tooltip whose row indicators use each series' per-point color (`colorKeys`: series key -> row field). The legend and the
* line keep the series color.
*/
export function PointColorTooltip({
colorKeys,
payload,
...props
}: ComponentProps<typeof ChartTooltipContent> & { colorKeys: Readonly<Record<string, string>> }) {
const colored = payload?.map(item => {
const field = colorKeys[String(item.dataKey)]
const color = field ? (item.payload as Row | undefined)?.[field] : undefined
return typeof color === "string" && color ? { ...item, payload: { ...(item.payload as Row), fill: color } } : item
})
return <ChartTooltipContent {...props} payload={colored} />
}
+193
View File
@@ -0,0 +1,193 @@
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>
)
}
@@ -0,0 +1,92 @@
import { cn } from "cn"
import { PolarAngleAxis, PolarGrid, PolarRadiusAxis, Radar, RadarChart, useChartWidth } from "recharts"
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent } from "./chart"
import { seriesToConfig, type ChartSeries } from "./series"
import { fitText, textWidth } from "./text-fit"
const LABEL_SIZE = 12
const LABEL_LINE = 14
/** Up to two lines: words fill the first line, the rest goes on the second line, shortened with an ellipsis if still too long. */
function wrapLabel(text: string, maxWidth: number): string[] {
if (maxWidth <= 0) return []
if (textWidth(text, LABEL_SIZE) <= maxWidth) return [text]
const words = text.split(/\s+/).filter(Boolean)
let count = 0
let first = ""
while (count < words.length) {
const next = first ? `${first} ${words[count]}` : words[count]
if (textWidth(next, LABEL_SIZE) > maxWidth) break
first = next
count += 1
}
if (!first || count >= words.length) return [fitText(text, maxWidth, LABEL_SIZE)].filter(Boolean)
return [first, fitText(words.slice(count).join(" "), maxWidth, LABEL_SIZE)].filter(Boolean)
}
type TickProps = { x?: number | string; y?: number | string; textAnchor?: "start" | "middle" | "end"; payload?: { value?: string | number } }
/** Axis name as a custom label: wrapped to two lines and shortened to the room between the axis end and the chart edge. */
function RadarAxisLabel({ x, y, textAnchor = "middle", payload }: TickProps) {
const width = useChartWidth()
const px = Number(x)
const py = Number(y)
if (!Number.isFinite(px) || !Number.isFinite(py) || !width) return null
const room = textAnchor === "start" ? width - px - 4 : textAnchor === "end" ? px - 4 : Math.min(px, width - px) * 2 - 8
const lines = wrapLabel(String(payload?.value ?? ""), room)
if (lines.length === 0) return null
// Lines grow away from the chart: upwards above the plot, downwards below, centered beside it.
const first = py - ((lines.length - 1) * LABEL_LINE) / 2
return (
<text data-slot="chart-axis-label" x={px} y={first} textAnchor={textAnchor}>
<title>{String(payload?.value ?? "")}</title>
{lines.map((line, i) => (
<tspan key={i} x={px} dy={i === 0 ? 0 : LABEL_LINE}>{line}</tspan>
))}
</text>
)
}
export type HMRadarChartProps = {
data: ReadonlyArray<Record<string, string | number>>
/** Field of each data row used as the axis name. */
categoryKey: string
series: readonly ChartSeries[]
/** Fill each series area (default). `false` draws only the lines. */
filled?: boolean
/** Dot on every axis of every series. */
dots?: boolean
/** Axis names as custom labels: wrapped to two lines, shortened to the room at the chart edge (full name in the label's title and the tooltip). */
customLabels?: boolean
/** Upper end of the value axis, shared by every series (the axis starts at 0). Default 100. */
maxValue?: number
/** Accessible name of the chart. */
label: string
className?: string
}
/** Radar chart (shadcn Chart + Recharts): semi-transparent areas, outlines, optional dots, lines-only mode and custom axis labels. */
export function HMRadarChart({ data, categoryKey, series, filled = true, dots = false, customLabels = false, maxValue = 100, label, className }: HMRadarChartProps) {
return (
<ChartContainer config={seriesToConfig(series)} role="group" aria-label={label} className={cn("aspect-auto h-72 w-full sm:h-80", className)}>
<RadarChart accessibilityLayer data={data as Record<string, string | number>[]} outerRadius="62%">
<ChartTooltip cursor={false} content={<ChartTooltipContent indicator="line" />} />
<PolarGrid />
<PolarAngleAxis dataKey={categoryKey} tick={customLabels ? <RadarAxisLabel /> : undefined} />
<PolarRadiusAxis domain={[0, maxValue]} tick={false} axisLine={false} />
<ChartLegend itemSorter={null} content={<ChartLegendContent />} />
{series.map(item => (
<Radar
key={item.key}
dataKey={item.key}
fill={`var(--color-${item.key})`}
fillOpacity={filled ? 0.25 : 0}
stroke={`var(--color-${item.key})`}
strokeWidth={2}
dot={dots ? { r: 3, fillOpacity: 1 } : false}
/>
))}
</RadarChart>
</ChartContainer>
)
}
@@ -0,0 +1,289 @@
import { useLayoutEffect, useRef, useState, type ComponentProps } from "react"
import { cn } from "cn"
import { Label, LabelList, PolarAngleAxis, PolarGrid, PolarRadiusAxis, RadialBar, RadialBarChart } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "./chart"
import { CenterText, type HMDonutDatum } from "./donut-chart"
import { seriesColor, seriesToConfig, type ChartSeries } from "./series"
import { fitText } from "./text-fit"
// Radial charts follow the official shadcn/ui examples (chart-radial-simple, -label, -grid, -text, -shape, -stacked): the same
// angle ranges, radii, background tracks, corner rounding, grid and center text. Radii are pixels, as in the official source.
// Adjustments for HM: values come from `value`/`id` rows with HM series colors; the angle axis domain is explicit (0 to the largest
// value or stack total, the same range Recharts picks automatically) so an all-zero data set cannot produce NaN; names and center
// text are shortened to fit.
// Bar thickness is 1.5 times the earlier one (2026-11-06) in every radial chart. Recharts draws a bar over 80% of its band (10%
// margin each side), so a single bar band 1.5 times as wide gives a 1.5 times thicker bar (text, shape, stacked: the outer radius
// stays and the inner one moves in).
// Ring charts with several bars (simple, label, grid) set the bar thickness (`barSize`) and the gap between rings directly: 18px
// bars and 4px gaps (grid 16.5 and 3, the earlier gaps), and keep the earlier center hole (inner band radius 30), so the outer
// radius grows to inner + bars x (bar + gap): 140 for five simple / label bars, 127.5 for five grid bars (2026-11-06, the outer
// radius and chart height limits were lifted for this). Where the box is too narrow (320px screens) the three charts shrink all
// radii, bar thickness and gaps in proportion (`useFitScale`).
// The legend is drawn under the chart box, not inside it: the chart box is a fixed height that holds the largest ring, however
// many lines the legend wraps to.
const RADIAN = Math.PI / 180
/** Height of the chart box of every radial chart (px): the largest ring (outer radius 140) plus the 5px chart margins. */
const CHART_BOX = "aspect-auto h-[300px] w-full"
const EDGE = 6 // px kept free on both sides of a ring (chart margin 5 + 1)
const MIN_SCALE = 0.5
const LABEL_SIZE = 11
const MIN_LABEL_CHARS = 2
type PolarSector = { cx: number; cy: number; innerRadius: number; outerRadius: number; startAngle: number; endAngle: number }
/** Name inside the start of its bar, curved along the arc like the official "insideStart" label, shortened to the bar length. */
function RadialNameLabel({ viewBox, value }: { viewBox?: unknown; value?: unknown }) {
const sector = viewBox as Partial<PolarSector> | undefined
if (!sector || sector.innerRadius === undefined || sector.outerRadius === undefined || sector.startAngle === undefined || sector.endAngle === undefined) return null
const radius = (sector.innerRadius + sector.outerRadius) / 2
const arcLength = radius * Math.abs(sector.endAngle - sector.startAngle) * RADIAN
const text = fitText(String(value ?? ""), arcLength - 14, LABEL_SIZE)
if ([...text].length < MIN_LABEL_CHARS) return null
return <Label viewBox={viewBox as ComponentProps<typeof Label>["viewBox"]} position="insideStart" value={text} className="hm-radial-name" />
}
/** Legend under the chart box, built from the series (same look as the shadcn legend; it needs no Recharts context). */
function RadialLegend({ series }: { series: readonly ChartSeries[] }) {
return (
<div data-slot="chart-legend-content" className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1 pt-3 text-xs">
{series.map((item, index) => (
<div key={item.key} className="flex items-center gap-1.5">
<div className="h-2 w-2 shrink-0 rounded-[2px]" style={{ backgroundColor: seriesColor(index, item.color) }} />
{item.label}
</div>
))}
</div>
)
}
/** Scale (at most 1) that makes a ring of `radius` px fit the width of the element behind `ref`; measured, so it follows the layout. */
function useFitScale(radius: number) {
const ref = useRef<HTMLDivElement | null>(null)
const [scale, setScale] = useState(1)
useLayoutEffect(() => {
const node = ref.current
if (!node) return
const update = () => {
const width = node.getBoundingClientRect().width
if (width > 0) setScale(Math.min(1, Math.max(MIN_SCALE, Math.floor(((width / 2 - EDGE) / radius) * 100) / 100)))
}
update()
const observer = new ResizeObserver(update)
observer.observe(node)
return () => observer.disconnect()
}, [radius])
return [ref, scale] as const
}
const px = (value: number) => Math.round(value * 10) / 10
function maxOf(values: readonly number[]) {
const max = Math.max(0, ...values.filter(Number.isFinite))
return max > 0 ? max : 1
}
export type HMRadialBarChartProps = {
data: readonly HMDonutDatum[]
/** Official `chart-radial-label`: item names inside the start of each bar. */
labels?: boolean
/** Official `chart-radial-grid`: circular grid lines, no background track. */
grid?: boolean
/** Background track behind each bar (default on, off with `grid`). */
background?: boolean
/** Arc range in degrees. Official: simple/grid 0 to 360, label -90 to 380. */
startAngle?: number
endAngle?: number
/** Pixel radius of the innermost bar band (the center hole). Default 30, as before the thickness change. */
innerRadius?: number
/** Pixel radius of the outermost bar band. Default: `innerRadius` + one `barSize + ringGap` per item (five items: 140, grid 127.5). */
outerRadius?: number
/** Bar thickness in px. Defaults: 18 (grid 16.5), 1.5 times the earlier 12 (grid 11). */
barSize?: number
/** Gap between neighboring bars in px. Defaults: 4 (grid 3), the same as before the thickness change. */
ringGap?: number
/** Accessible name of the chart. */
label: string
className?: string
}
/** Radial bar chart with one bar per item (official simple, label and grid examples). */
export function HMRadialBarChart({
data,
labels = false,
grid = false,
background,
startAngle,
endAngle,
innerRadius = 30,
outerRadius,
barSize = grid ? 16.5 : 18,
ringGap = grid ? 3 : 4,
label,
className,
}: HMRadialBarChartProps) {
const rows = data.map(item => ({ ...item, fill: `var(--color-${item.id})` }))
const series = data.map(item => ({ key: item.id, label: item.label, color: item.color }))
const config = seriesToConfig(series)
const outer = outerRadius ?? innerRadius + Math.max(data.length, 1) * (barSize + ringGap)
const [fitRef, scale] = useFitScale(outer)
return (
<div ref={fitRef} data-slot="chart-radial" className="flex w-full flex-col">
<ChartContainer config={config} role="group" aria-label={label} className={cn(CHART_BOX, className)}>
<RadialBarChart
accessibilityLayer
data={rows}
startAngle={startAngle}
endAngle={endAngle}
innerRadius={px(innerRadius * scale)}
outerRadius={px(outer * scale)}
>
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel nameKey="id" />} />
{grid ? <PolarGrid gridType="circle" /> : null}
<PolarAngleAxis type="number" domain={[0, maxOf(data.map(item => item.value))]} tick={false} axisLine={false} />
<RadialBar dataKey="value" background={background ?? !grid} barSize={px(barSize * scale)}>
{labels ? <LabelList dataKey="label" content={<RadialNameLabel />} /> : null}
</RadialBar>
</RadialBarChart>
</ChartContainer>
<RadialLegend series={series} />
</div>
)
}
export type HMRadialMeterChartProps = {
/** The single value; `label` is the center caption's fallback. */
item: HMDonutDatum
/** Caption under the value in the center. Defaults to the item's name. */
caption?: string
/** Arc range in degrees. Official text: 0 to 250, shape: 0 to 100. */
startAngle?: number
endAngle?: number
/** Pixel radii of the bar band. Text: 75 and 90, shape: 50 and 95 (official 80 / 90 and 65 / 95 were 1.5 times thinner). */
innerRadius?: number
outerRadius?: number
/**
* Pixel radii of the two filled circles that form the ring track behind the bar (official `polarRadius`), the outer one first.
* The track is as thick as the bar and lines up with it: the bar fills 80% of its band, so the radii sit 10% of the band inside
* it. Text: [88.5, 76.5], shape: [90.5, 54.5].
*/
trackRadii?: readonly [number, number]
/** Corner rounding of the bar in px (official text: 10). */
cornerRadius?: number
/** Accessible name of the chart. */
label: string
className?: string
}
/** Single-value radial chart with the value and caption in the center (official text and shape examples). */
export function HMRadialMeterChart({
item,
caption,
startAngle = 0,
endAngle = 250,
innerRadius = 75,
outerRadius = 90,
trackRadii = [88.5, 76.5],
cornerRadius,
label,
className,
}: HMRadialMeterChartProps) {
const row = { ...item, fill: `var(--color-${item.id})` }
const config = seriesToConfig([{ key: item.id, label: item.label, color: item.color }])
return (
<ChartContainer config={config} role="group" aria-label={label} className={cn(CHART_BOX, className)}>
<RadialBarChart data={[row]} startAngle={startAngle} endAngle={endAngle} innerRadius={innerRadius} outerRadius={outerRadius}>
{/* Official: the first circle is the muted track, the second (smaller) one is the surface color that cuts it into a ring. */}
<PolarGrid
gridType="circle"
radialLines={false}
stroke="none"
className="first:fill-muted last:fill-[var(--hm-sem-bg-surface)]"
polarRadius={[...trackRadii]}
/>
<PolarAngleAxis type="number" domain={[0, maxOf([item.value])]} tick={false} axisLine={false} />
<RadialBar dataKey="value" background cornerRadius={cornerRadius} />
<PolarRadiusAxis tick={false} tickLine={false} axisLine={false}>
<Label
content={({ viewBox }) => {
if (!viewBox || !("cx" in viewBox) || !("cy" in viewBox)) return null
return (
<CenterText
cx={Number(viewBox.cx)}
cy={Number(viewBox.cy)}
innerRadius={Math.min(trackRadii[1], innerRadius)}
value={item.value}
caption={caption ?? item.label}
maxSize={36}
/>
)
}}
/>
</PolarRadiusAxis>
</RadialBarChart>
</ChartContainer>
)
}
export type HMRadialStackedChartProps = {
/** Stacked from the first series outwards along the arc. Official order: mobile (570) first, then desktop (1260). */
series: readonly ChartSeries[]
/** One value per series key. */
row: Readonly<Record<string, number>>
/** Caption under the total in the center. */
totalLabel: string
/** Arc range in degrees. Official: 0 to 180 (half circle). */
startAngle?: number
endAngle?: number
innerRadius?: number
outerRadius?: number
/** Accessible name of the chart. */
label: string
className?: string
}
/** Stacked radial chart: one row, each series a segment of the same arc, total in the center (official `chart-radial-stacked`). */
export function HMRadialStackedChart({
series,
row,
totalLabel,
startAngle = 0,
endAngle = 180,
innerRadius = 65,
outerRadius = 110,
label,
className,
}: HMRadialStackedChartProps) {
const total = series.reduce((sum, item) => sum + (Number(row[item.key]) || 0), 0)
return (
<div data-slot="chart-radial" className="flex w-full flex-col">
<ChartContainer config={seriesToConfig(series)} role="group" aria-label={label} className={cn(CHART_BOX, className)}>
{/* The official chart sits at the middle of its square box; the half circle (outer radius 110) is moved down so it is centered in the 300px box. */}
<RadialBarChart accessibilityLayer data={[row]} cy="68%" startAngle={startAngle} endAngle={endAngle} innerRadius={innerRadius} outerRadius={outerRadius}>
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
<PolarAngleAxis type="number" domain={[0, total > 0 ? total : 1]} tick={false} axisLine={false} />
{series.map(item => (
<RadialBar
key={item.key}
dataKey={item.key}
stackId="a"
fill={`var(--color-${item.key})`}
cornerRadius={5}
stroke="var(--hm-sem-bg-surface)"
strokeWidth={2}
/>
))}
<PolarRadiusAxis tick={false} tickLine={false} axisLine={false}>
<Label
content={({ viewBox }) => {
if (!viewBox || !("cx" in viewBox) || !("cy" in viewBox)) return null
return <CenterText cx={Number(viewBox.cx)} cy={Number(viewBox.cy)} innerRadius={innerRadius} value={total} caption={totalLabel} dy={-14} />
}}
/>
</PolarRadiusAxis>
</RadialBarChart>
</ChartContainer>
<RadialLegend series={series} />
</div>
)
}
+44
View File
@@ -0,0 +1,44 @@
import type { ChartConfig } from "./chart"
/** HM series colors. They resolve to existing HM palette tokens (see chart.css), never to HEX values. */
export const chartColors = [
"var(--hm-chart-1)",
"var(--hm-chart-2)",
"var(--hm-chart-3)",
"var(--hm-chart-4)",
"var(--hm-chart-5)",
] as const
/** Light end of each HM series color (stacked bar segments): the same palette family, lighter (see chart.css). */
export const chartLightColors = [
"var(--hm-chart-1-light)",
"var(--hm-chart-2-light)",
"var(--hm-chart-3-light)",
"var(--hm-chart-4-light)",
"var(--hm-chart-5-light)",
] as const
/** One data series. `key` becomes a CSS variable name (`--color-<key>`), so use letters, digits, `-` or `_` only. */
export type ChartSeries = {
key: string
label: string
/** Any CSS color. Defaults to the HM series color for the series position. */
color?: string
/** Light end of a stacked bar segment (any CSS color). Defaults to the HM light color of the series position.
* A series with a custom `color` and no `lightColor` is drawn as a flat segment. */
lightColor?: string
}
export function seriesColor(index: number, color?: string) {
return color ?? chartColors[index % chartColors.length]
}
export function seriesLightColor(index: number, color?: string, lightColor?: string) {
return lightColor ?? color ?? chartLightColors[index % chartLightColors.length]
}
export function seriesToConfig(series: readonly ChartSeries[]): ChartConfig {
return Object.fromEntries(
series.map((item, index) => [item.key, { label: item.label, color: seriesColor(index, item.color) }]),
)
}
@@ -0,0 +1,86 @@
import { useId } from "react"
import { cn } from "cn"
import { Bar, BarChart, CartesianGrid, Rectangle, ReferenceLine, XAxis, YAxis, type BarShapeProps } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "./chart"
import { chartColors, chartLightColors } from "./series"
export type HMSignedBarChartProps = {
data: ReadonlyArray<Record<string, string | number>>
/** Field of each data row used for the category (X) axis. */
categoryKey: string
/** Numeric field of each data row. Positive values rise above the 0 line, negative values fall below it. */
valueKey: string
/** Name of the value, used as the tooltip row label. */
valueLabel: string
/** Accessible name of the chart. */
label: string
className?: string
}
const POSITIVE = 0 // HM series 1 (green)
const NEGATIVE = 1 // HM series 2 (brown)
/**
* Vertical bar chart for positive and negative values (shadcn Chart + Recharts) around a clearly marked 0 line.
* Color follows the sign only: positive uses the first HM series color, negative the second. Each bar is a gradient that is
* light at the 0 line and full color at the outer end (opaque at both ends, light end = the palette's lighter step of the same
* family), and only the outer end is rounded. The tooltip indicator uses the same solid color as the bar.
*/
export function HMSignedBarChart({ data, categoryKey, valueKey, valueLabel, label, className }: HMSignedBarChartProps) {
const scope = `hm-signed-${useId().replace(/:/g, "")}`
const positiveGradient = `${scope}-positive`
const negativeGradient = `${scope}-negative`
const config: ChartConfig = {
[valueKey]: { label: valueLabel, color: chartColors[POSITIVE] },
positive: { label: "양수", color: chartColors[POSITIVE] },
negative: { label: "음수", color: chartColors[NEGATIVE] },
}
// `fill` on the row feeds the tooltip indicator, so it always matches the bar's solid color.
const rows = data.map(row => ({ ...row, fill: Number(row[valueKey]) < 0 ? "var(--color-negative)" : "var(--color-positive)" }))
const isNegative = (value: BarShapeProps["value"]) => Number(Array.isArray(value) ? value[1] : value) < 0
return (
<ChartContainer
config={config}
role="group"
aria-label={label}
className={cn("aspect-auto h-64 w-full sm:h-80", className)}
>
<BarChart accessibilityLayer data={rows} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<defs>
{/* Positive: full color at the top (outer end), light at the bottom (0 line). */}
<linearGradient id={positiveGradient} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" style={{ stopColor: "var(--color-positive)", stopOpacity: 1 }} />
<stop offset="100%" style={{ stopColor: chartLightColors[POSITIVE], stopOpacity: 1 }} />
</linearGradient>
{/* Negative: light at the top (0 line), full color at the bottom (outer end). */}
<linearGradient id={negativeGradient} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" style={{ stopColor: chartLightColors[NEGATIVE], stopOpacity: 1 }} />
<stop offset="100%" style={{ stopColor: "var(--color-negative)", stopOpacity: 1 }} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis dataKey={categoryKey} tickLine={false} axisLine={false} tickMargin={8} />
<YAxis tickLine={false} axisLine={false} tickMargin={4} width={44} />
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
<ReferenceLine y={0} stroke="var(--hm-chart-zero-line)" strokeWidth={1.5} />
<Bar
dataKey={valueKey}
fill="var(--color-positive)"
shape={(props: BarShapeProps) => {
const negative = isNegative(props.value)
return (
<Rectangle
{...props}
fill={`url(#${negative ? negativeGradient : positiveGradient})`}
// Flat at the 0 line, rounded only at the outer end. Recharts gives a negative bar a negative height, which
// mirrors the corner order, so the same [4, 4, 0, 0] rounds the outer (bottom) end of a negative bar too.
radius={[4, 4, 0, 0]}
/>
)
}}
/>
</BarChart>
</ChartContainer>
)
}
+18
View File
@@ -0,0 +1,18 @@
// SVG text cannot ellipsize itself, so labels are measured by estimate (full-width glyphs 1em, others ~0.6em) and shortened here.
export function textWidth(text: string, fontSize: number) {
let width = 0
for (const char of text) width += (char.codePointAt(0) ?? 0) > 0x2e7f ? fontSize : fontSize * 0.6
return width
}
/** The longest start of `text` plus an ellipsis that fits `maxWidth`; "" when not even one character fits. */
export function fitText(text: string, maxWidth: number, fontSize: number) {
if (textWidth(text, fontSize) <= maxWidth) return text
const chars = [...text]
for (let count = chars.length - 1; count > 0; count -= 1) {
const candidate = `${chars.slice(0, count).join("").trimEnd()}…`
if (textWidth(candidate, fontSize) <= maxWidth) return candidate
}
return ""
}
+2
View File
@@ -0,0 +1,2 @@
// Side-effect stylesheet imports (chart.css) have no exports.
declare module "*.css" {}
@@ -0,0 +1,205 @@
import { useState, type ReactNode } from "react"
import { cn } from "cn"
import {
Badge,
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@hm-design/ui"
import { HMBarChart, HMDonutChart, HMHorizontalBarChart, HMLineChart, HMSignedBarChart } 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`. */
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 }) {
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>
<CardAction><SampleDataBadge /></CardAction>
</CardHeader>
<CardContent className="flex flex-1 flex-col">
{toolbar !== undefined ? <div data-slot="chart-example-toolbar" className="mb-3 flex min-h-[34px] items-center text-sm text-muted-foreground">{toolbar}</div> : null}
{children}
<p data-slot="chart-sample-notice" className="mt-auto pt-3">{sampleDataNotice}</p>
</CardContent>
</Card>
)
}
export function BarChartExample({ className }: { className?: string }) {
return (
<ExampleCard title="월별 접수·완료 현황" description="막대 차트 · 상반기 월별 건수" className={className}>
<HMBarChart {...sampleBar} label="월별 접수·완료 건수 막대 차트(예시 데이터)" />
</ExampleCard>
)
}
export function StackedBarChartExample({ className }: { className?: string }) {
return (
<ExampleCard title="월별 접수·완료 현황(누적)" description="막대 차트 · stacked" className={className}>
<HMBarChart {...sampleBar} stacked label="월별 접수·완료 건수 누적 막대 차트(예시 데이터)" />
</ExampleCard>
)
}
export function HorizontalBarChartExample({ className }: { className?: string }) {
return (
<ExampleCard title="항목별 처리 건수" description="가로 막대 차트 · 항목별 건수" className={className}>
<HMHorizontalBarChart {...sampleHorizontalBar} label="항목별 처리 건수 가로 막대 차트(예시 데이터)" />
</ExampleCard>
)
}
export function SignedBarChartExample({ className }: { className?: string }) {
return (
<ExampleCard title="월별 순증감" description="막대 차트 · 0 기준 양수·음수" className={className}>
<HMSignedBarChart {...sampleSignedBar} label="월별 순증감 양수·음수 막대 차트(예시 데이터)" />
</ExampleCard>
)
}
export function LineChartExample({ className }: { className?: string }) {
return (
<ExampleCard title="월별 처리 시간 추이" description="선 차트 · 단위: 시간" className={className}>
<HMLineChart {...sampleLine} label="월별 평균 처리 시간 선 차트(예시 데이터)" />
</ExampleCard>
)
}
export function LineCurveExample({ className }: { className?: string }) {
return (
<ExampleCard title="곡선형" description="monotone · Dot 없음" className={className}>
<HMLineChart {...sampleLineCompare} curve="monotone" dots={false} label="월별 접수·완료 곡선형 선 차트(예시 데이터)" />
</ExampleCard>
)
}
export function LineCurveDotLabelExample({ className }: { className?: string }) {
return (
<ExampleCard title="곡선형 · Dot + Label" description="monotone · Dot과 값 Label" className={className}>
<HMLineChart {...sampleLineCompare} curve="monotone" pointLabels label="월별 접수·완료 곡선형 Dot·값 라벨 선 차트(예시 데이터)" />
</ExampleCard>
)
}
export function LineStraightExample({ className }: { className?: string }) {
return (
<ExampleCard title="직선형" description="linear · Dot 없음" className={className}>
<HMLineChart {...sampleLineCompare} curve="linear" dots={false} label="월별 접수·완료 직선형 선 차트(예시 데이터)" />
</ExampleCard>
)
}
export function LineStraightColorLabelExample({ className }: { className?: string }) {
return (
<ExampleCard title="직선형 · Color Dot" description="linear · Dot 개별 색상 + 값 Label" className={className}>
<HMLineChart {...sampleLinePointColors} curve="linear" pointLabels label="월별 접수·완료 직선형 포인트별 색상 선 차트(예시 데이터)" />
</ExampleCard>
)
}
export function LineStepExample({ className }: { className?: string }) {
return (
<ExampleCard title="계단형" description="step · Dot 없음" className={className}>
<HMLineChart {...sampleLineCompare} curve="step" dots={false} label="월별 접수·완료 계단형 선 차트(예시 데이터)" />
</ExampleCard>
)
}
export function LineStepDotLabelExample({ className }: { className?: string }) {
return (
<ExampleCard title="계단형 · Dot + Label" description="step · Dot과 값 Label" className={className}>
<HMLineChart {...sampleLineCompare} curve="step" pointLabels label="월별 접수·완료 계단형 Dot·값 라벨 선 차트(예시 데이터)" />
</ExampleCard>
)
}
export function DonutChartExample({ className }: { className?: string }) {
return (
<ExampleCard title="업무 상태 비중" description="도넛 차트 · 상태별 건수" className={className}>
<HMDonutChart {...sampleDonut} label="업무 상태별 비중 도넛 차트(예시 데이터)" />
</ExampleCard>
)
}
/** Bar, line and donut examples: two per row from 1024px (HM desktop boundary), one per row below. With an odd count the last card keeps one column's width. */
export function ChartExamples() {
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2" data-chart-examples="">
<BarChartExample className="min-w-0" />
<LineChartExample className="min-w-0" />
<DonutChartExample className="min-w-0" />
</div>
)
}
// Four donut states on one data set: plain, center total, active (fixed selection) and interactive (Select + slice click).
// The toolbar row has the Select height in every card, so the donuts of a row start at the same height.
export function DonutPlainExample({ className }: { className?: string }) {
return (
<ExampleCard title="기본형" description="도넛 차트 · 기본" className={className} toolbar="중앙 표시 없음">
<HMDonutChart data={sampleDonutStates.data} label="업무 상태 도넛 차트, 가운데 값 없음(예시 데이터)" />
</ExampleCard>
)
}
export function DonutCenterExample({ className }: { className?: string }) {
return (
<ExampleCard title="기본형 + 텍스트" description="도넛 차트 · 전체 합계" className={className} toolbar="중앙 표시: 전체 합계">
<HMDonutChart 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 (
<ExampleCard title="Active 상태" description="도넛 차트 · 선택된 항목" className={className} toolbar={`선택 항목: ${selected?.label ?? ""}`}>
<HMDonutChart data={sampleDonutStates.data} activeId={sampleDonutStates.activeId} label="업무 상태 도넛 차트, 선택된 항목 강조(예시 데이터)" />
</ExampleCard>
)
}
export function DonutInteractiveExample({ className }: { className?: string }) {
const [activeId, setActiveId] = useState(sampleDonutStates.activeId)
return (
<ExampleCard
title="Interactive"
description="도넛 차트 · 선택 또는 클릭"
className={className}
toolbar={
<Select value={activeId} onValueChange={setActiveId}>
<SelectTrigger size="medium" aria-label="강조할 업무 상태 선택" className="w-full max-w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sampleDonutStates.data.map(item => (
<SelectItem key={item.id} value={item.id}>{item.label}</SelectItem>
))}
</SelectContent>
</Select>
}
>
<HMDonutChart
data={sampleDonutStates.data}
activeId={activeId}
onActiveChange={setActiveId}
label="업무 상태 도넛 차트, 항목을 선택하거나 조각을 클릭해 강조(예시 데이터)"
/>
</ExampleCard>
)
}
+31
View File
@@ -0,0 +1,31 @@
// 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 { samplePie, sampleStackedPie, sampleRadar } from "./sample-data"
export {
PiePlainExample,
PieLabelExample,
PieLabelListExample,
PieStackedExample,
RadarPlainExample,
RadarDotExample,
RadarLinesOnlyExample,
RadarCustomLabelExample,
AreaCurveExample,
AreaCurveDotLabelExample,
AreaStraightExample,
AreaStraightColorLabelExample,
AreaStepExample,
AreaStepDotLabelExample,
AreaStackedExample,
AreaExpandedExample,
} from "./pie-radar-area-examples"
export { sampleRadial, sampleRadialText, sampleRadialShape, sampleRadialStacked } from "./sample-data"
export {
RadialSimpleExample,
RadialLabelExample,
RadialGridExample,
RadialTextExample,
RadialShapeExample,
RadialStackedExample,
} from "./radial-examples"
@@ -0,0 +1,121 @@
import { HMAreaChart, HMPieChart, HMRadarChart, HMStackedPieChart } from "../index"
import { ExampleCard } from "./chart-examples"
import { sampleLineCompare, sampleLinePointColors, samplePie, sampleRadar, sampleStackedPie } from "./sample-data"
// Pie, Radar and Area examples on sample data. Titles follow the Line and Donut examples.
type Props = { className?: string }
export function PiePlainExample({ className }: Props) {
return (
<ExampleCard title="기본 Pie" description="파이 차트 · 기본" className={className}>
<HMPieChart {...samplePie} label="업무 상태 파이 차트(예시 데이터)" />
</ExampleCard>
)
}
export function PieLabelExample({ className }: Props) {
return (
<ExampleCard title="Pie + Label" description="파이 차트 · 이름과 값" className={className}>
<HMPieChart {...samplePie} labels="outside" label="업무 상태 파이 차트, 조각 바깥 이름·값 Label(예시 데이터)" />
</ExampleCard>
)
}
export function PieLabelListExample({ className }: Props) {
return (
<ExampleCard title="Pie + Label List" description="파이 차트 · 조각 안 값" className={className}>
<HMPieChart {...samplePie} labels="inside" label="업무 상태 파이 차트, 조각 안 값 Label(예시 데이터)" />
</ExampleCard>
)
}
export function PieStackedExample({ className }: Props) {
return (
<ExampleCard title="Pie + Stacked" description="파이 차트 · 두 시리즈 링" className={className}>
<HMStackedPieChart {...sampleStackedPie} label="분기별 업무 상태 Stacked 파이 차트(예시 데이터)" />
</ExampleCard>
)
}
export function RadarPlainExample({ className }: Props) {
return (
<ExampleCard title="Radar" description="방사형 차트 · 영역" className={className}>
<HMRadarChart {...sampleRadar} label="단계별 처리 수준 방사형 차트(예시 데이터)" />
</ExampleCard>
)
}
export function RadarDotExample({ className }: Props) {
return (
<ExampleCard title="Radar + Dot" description="방사형 차트 · 축별 Dot" className={className}>
<HMRadarChart {...sampleRadar} dots label="단계별 처리 수준 방사형 차트, Dot(예시 데이터)" />
</ExampleCard>
)
}
export function RadarLinesOnlyExample({ className }: Props) {
return (
<ExampleCard title="Radar + Lines Only" description="방사형 차트 · 선만" className={className}>
<HMRadarChart {...sampleRadar} filled={false} label="단계별 처리 수준 방사형 차트, 선만(예시 데이터)" />
</ExampleCard>
)
}
export function RadarCustomLabelExample({ className }: Props) {
return (
<ExampleCard title="Radar + Custom Label" description="방사형 차트 · 축 Label" className={className}>
<HMRadarChart {...sampleRadar} customLabels label="단계별 처리 수준 방사형 차트, 축 Label(예시 데이터)" />
</ExampleCard>
)
}
export function AreaCurveExample({ className }: Props) {
return (
<ExampleCard title="곡선형" description="영역 차트 · monotone" className={className}>
<HMAreaChart {...sampleLineCompare} curve="monotone" label="월별 접수·완료 곡선형 영역 차트(예시 데이터)" />
</ExampleCard>
)
}
export function AreaCurveDotLabelExample({ className }: Props) {
return (
<ExampleCard title="곡선형 · Dot + Label" description="monotone · Dot과 값 Label" className={className}>
<HMAreaChart {...sampleLineCompare} curve="monotone" pointLabels label="월별 접수·완료 곡선형 Dot·값 Label 영역 차트(예시 데이터)" />
</ExampleCard>
)
}
export function AreaStraightExample({ className }: Props) {
return (
<ExampleCard title="직선형" description="영역 차트 · linear" className={className}>
<HMAreaChart {...sampleLineCompare} curve="linear" label="월별 접수·완료 직선형 영역 차트(예시 데이터)" />
</ExampleCard>
)
}
export function AreaStraightColorLabelExample({ className }: Props) {
return (
<ExampleCard title="직선형 · Color Dot" description="linear · Dot 개별 색상 + 값 Label" className={className}>
<HMAreaChart {...sampleLinePointColors} curve="linear" pointLabels label="월별 접수·완료 직선형 포인트별 색상 영역 차트(예시 데이터)" />
</ExampleCard>
)
}
export function AreaStepExample({ className }: Props) {
return (
<ExampleCard title="계단형" description="영역 차트 · step" className={className}>
<HMAreaChart {...sampleLineCompare} curve="step" label="월별 접수·완료 계단형 영역 차트(예시 데이터)" />
</ExampleCard>
)
}
export function AreaStepDotLabelExample({ className }: Props) {
return (
<ExampleCard title="계단형 · Dot + Label" description="step · Dot과 값 Label" className={className}>
<HMAreaChart {...sampleLineCompare} curve="step" pointLabels label="월별 접수·완료 계단형 Dot·값 Label 영역 차트(예시 데이터)" />
</ExampleCard>
)
}
export function AreaStackedExample({ className }: Props) {
return (
<ExampleCard title="Stacked" description="영역 차트 · 값 누적" className={className}>
<HMAreaChart {...sampleLineCompare} stacked label="월별 접수·완료 Stacked 영역 차트(예시 데이터)" />
</ExampleCard>
)
}
export function AreaExpandedExample({ className }: Props) {
return (
<ExampleCard title="Expanded" description="영역 차트 · 합계 100%" className={className}>
<HMAreaChart {...sampleLineCompare} expanded label="월별 접수·완료 Expanded 영역 차트(예시 데이터)" />
</ExampleCard>
)
}
@@ -0,0 +1,49 @@
import { HMRadialBarChart, HMRadialMeterChart, HMRadialStackedChart } from "../index"
import { ExampleCard } from "./chart-examples"
import { sampleRadial, sampleRadialShape, sampleRadialStacked, sampleRadialText } from "./sample-data"
// Radial examples on sample data, one per official shadcn/ui radial example.
type Props = { className?: string }
export function RadialSimpleExample({ className }: Props) {
return (
<ExampleCard title="Radial Chart" description="radial-simple · 항목별 막대" className={className}>
<HMRadialBarChart {...sampleRadial} label="업무 상태 방사형 막대 차트(예시 데이터)" />
</ExampleCard>
)
}
export function RadialLabelExample({ className }: Props) {
return (
<ExampleCard title="Radial Chart + Label" description="radial-label · 막대 안 항목명" className={className}>
<HMRadialBarChart {...sampleRadial} labels startAngle={-90} endAngle={380} label="업무 상태 방사형 막대 차트, 항목명 Label(예시 데이터)" />
</ExampleCard>
)
}
export function RadialGridExample({ className }: Props) {
return (
<ExampleCard title="Radial Chart - Grid" description="radial-grid · 원형 Grid" className={className}>
<HMRadialBarChart {...sampleRadial} grid label="업무 상태 방사형 막대 차트, Grid(예시 데이터)" />
</ExampleCard>
)
}
export function RadialTextExample({ className }: Props) {
return (
<ExampleCard title="Radial Chart + Text" description="radial-text · 중앙 Text" className={className}>
<HMRadialMeterChart {...sampleRadialText} startAngle={0} endAngle={250} innerRadius={75} outerRadius={90} trackRadii={[88.5, 76.5]} cornerRadius={10} label="진행 중 건수 방사형 차트, 중앙 Text(예시 데이터)" />
</ExampleCard>
)
}
export function RadialShapeExample({ className }: Props) {
return (
<ExampleCard title="Radial Chart - Shape" description="radial-shape · 링 트랙" className={className}>
<HMRadialMeterChart {...sampleRadialShape} startAngle={0} endAngle={100} innerRadius={50} outerRadius={95} trackRadii={[90.5, 54.5]} label="진행 중 건수 방사형 차트, Shape(예시 데이터)" />
</ExampleCard>
)
}
export function RadialStackedExample({ className }: Props) {
return (
<ExampleCard title="Radial Chart + Stacked" description="radial-stacked · 반원 누적" className={className}>
<HMRadialStackedChart {...sampleRadialStacked} label="업무 상태 방사형 Stacked 차트(예시 데이터)" />
</ExampleCard>
)
}
+189
View File
@@ -0,0 +1,189 @@
import { chartColors, type ChartSeries, type HMDonutDatum, type HMPieRing } from "../index"
// SAMPLE DATA ONLY. These values are invented to show the charts; they are not business data and must not be reported as such.
// Real data belongs to the consuming app and is passed to HMBarChart / HMLineChart / HMDonutChart as props.
export const sampleDataNotice = "예시 데이터 · 실제 업무 데이터가 아닙니다"
export const sampleBar = {
categoryKey: "month",
series: [
{ key: "received", label: "접수" },
{ key: "completed", label: "완료" },
] satisfies ChartSeries[],
data: [
{ month: "1월", received: 186, completed: 142 },
{ month: "2월", received: 305, completed: 238 },
{ month: "3월", received: 237, completed: 221 },
{ month: "4월", received: 173, completed: 190 },
{ month: "5월", received: 209, completed: 196 },
{ month: "6월", received: 264, completed: 231 },
],
}
export const sampleLine = {
categoryKey: "month",
series: [
{ key: "average", label: "평균 처리 시간" },
{ key: "target", label: "목표" },
] satisfies ChartSeries[],
data: [
{ month: "1월", average: 4.8, target: 4 },
{ month: "2월", average: 4.4, target: 4 },
{ month: "3월", average: 4.1, target: 4 },
{ month: "4월", average: 3.7, target: 4 },
{ month: "5월", average: 3.9, target: 4 },
{ month: "6월", average: 3.4, target: 4 },
],
}
export const sampleDonut = {
totalLabel: "전체(건)",
data: [
{ id: "done", label: "완료", value: 118 },
{ id: "inProgress", label: "진행 중", value: 42 },
{ id: "delayed", label: "지연", value: 12 },
{ id: "onHold", label: "보류", value: 8 },
] satisfies HMDonutDatum[],
}
// Item names are deliberately uneven: one very long name, short bars and a zero value exercise the label rules.
export const sampleHorizontalBar = {
categoryKey: "item",
valueKey: "count",
valueLabel: "처리 건수",
data: [
{ item: "종합 민원 접수 및 처리 현황 보고서 작성", count: 186 },
{ item: "시설 점검", count: 142 },
{ item: "예산 집행 검토", count: 96 },
{ item: "계약 변경 승인 요청 건", count: 38 },
{ item: "보류 항목", count: 3 },
{ item: "반려", count: 0 },
],
}
// Mixed signs with a zero value. A positive-only or negative-only list works the same way.
export const sampleSignedBar = {
categoryKey: "month",
valueKey: "change",
valueLabel: "순증감",
data: [
{ month: "1월", change: 120 },
{ month: "2월", change: -80 },
{ month: "3월", change: 45 },
{ month: "4월", change: -150 },
{ month: "5월", change: 0 },
{ month: "6월", change: 95 },
],
}
// One data set for all line examples (curve, straight, dots, per-point colors, step), so the connection styles compare directly on the same axis range.
// It is built to exercise the label rules: 3월 is a tie (40 = 40), 2월 and 4월 are close, the lines cross between 5월 and 6월, and 완료 drops to 0 in 5월.
const lineCompareRows = [
{ month: "1월", received: 48, completed: 30 },
{ month: "2월", received: 72, completed: 60 },
{ month: "3월", received: 40, completed: 40 },
{ month: "4월", received: 85, completed: 70 },
{ month: "5월", received: 20, completed: 0 },
{ month: "6월", received: 56, completed: 62 },
]
export const sampleLineCompare = {
categoryKey: "month",
series: [
{ key: "received", label: "접수" },
{ key: "completed", label: "완료" },
] satisfies ChartSeries[],
data: lineCompareRows,
}
// Per-point colors are HM palette tokens by value, one family per series: 접수 in greens, 완료 in browns, and the orange token below 30
// (all dark enough for the value label to read on the card).
const receivedColor = (value: number) =>
value >= 60 ? "var(--hm-sem-palette-green-light-1)"
: value >= 30 ? "var(--hm-sem-palette-green-tone-1)"
: "var(--hm-sem-palette-orange-default)"
const completedColor = (value: number) =>
value >= 60 ? "var(--hm-sem-palette-brown-light-1)"
: value >= 30 ? "var(--hm-sem-palette-brown-light-2)"
: "var(--hm-sem-palette-orange-default)"
export const sampleLinePointColors = {
...sampleLineCompare,
pointColorKey: { received: "receivedColor", completed: "completedColor" },
data: lineCompareRows.map(row => ({
...row,
receivedColor: receivedColor(row.received),
completedColor: completedColor(row.completed),
})),
}
// One data set for the four donut states (plain, center total, active, interactive), so they compare directly.
// It includes a long name (shortened in the center, complete in the legend and tooltip) and a zero item.
export const sampleDonutStates = {
totalLabel: "전체(건)",
/** The selected slice of the active and interactive examples. */
activeId: "inProgress",
data: [
{ id: "done", label: "완료", value: 118 },
{ id: "inProgress", label: "진행 중", value: 42 },
{ id: "review", label: "관계 기관 협의 및 보완 요청 대기", value: 24 },
{ id: "delayed", label: "지연", value: 12 },
{ id: "rejected", label: "반려", value: 0 },
] satisfies HMDonutDatum[],
}
// Pie examples. Items keep one color by id (the order of this list), in every pie and in both rings of the stacked pie.
// It includes a long name (omitted or shortened where it does not fit) and a very small slice.
const pieMaster = [
{ id: "done", label: "완료" },
{ id: "inProgress", label: "진행 중" },
{ id: "review", label: "관계 기관 협의 및 보완 요청 대기" },
{ id: "delayed", label: "지연" },
{ id: "onHold", label: "보류" },
]
const pieItem = (id: string, value: number): HMDonutDatum => {
const index = pieMaster.findIndex(item => item.id === id)
return { id, label: pieMaster[index].label, value, color: chartColors[index % chartColors.length] }
}
export const samplePie = {
data: [pieItem("done", 118), pieItem("inProgress", 42), pieItem("review", 24), pieItem("delayed", 12), pieItem("onHold", 3)],
}
export const sampleStackedPie = {
rings: [
{ id: "q1", label: "1분기", data: [pieItem("done", 80), pieItem("inProgress", 30), pieItem("delayed", 8)] },
{ id: "q2", label: "2분기", data: [pieItem("done", 118), pieItem("inProgress", 42), pieItem("review", 24), pieItem("delayed", 12)] },
] satisfies [HMPieRing, HMPieRing],
}
// Radar examples: six axes, two series on one 0-100 range. The first axis (top) has a long name to exercise the custom label.
export const sampleRadar = {
categoryKey: "area",
maxValue: 100,
series: [
{ key: "current", label: "이번 분기" },
{ key: "previous", label: "지난 분기" },
] satisfies ChartSeries[],
data: [
{ area: "관계 기관 협의 및 보완 요청", current: 62, previous: 48 },
{ area: "접수", current: 88, previous: 72 },
{ area: "검토", current: 70, previous: 82 },
{ area: "승인", current: 55, previous: 60 },
{ area: "배정", current: 92, previous: 65 },
{ area: "완료", current: 76, previous: 58 },
],
}
// Radial examples. Same items and color mapping as the pies (by id). Values are on the scale of the official examples so the arcs read
// the same way: five bars (simple, label, grid), one value (text: 200, shape: 1260) and one stacked row (570 + 1260).
const radialItem = (id: string, value: number) => pieItem(id, value)
export const sampleRadial = {
data: [radialItem("done", 275), radialItem("inProgress", 200), radialItem("review", 187), radialItem("delayed", 173), radialItem("onHold", 90)],
}
export const sampleRadialText = { item: radialItem("inProgress", 200), caption: "진행 중 건수" }
export const sampleRadialShape = { item: radialItem("inProgress", 1260), caption: "진행 중 건수" }
export const sampleRadialStacked = {
series: [
{ key: "inProgress", label: "진행 중", color: chartColors[1] },
{ key: "done", label: "완료", color: chartColors[0] },
] satisfies ChartSeries[],
row: { inProgress: 570, done: 1260 },
totalLabel: "전체(건)",
}
+36
View File
@@ -0,0 +1,36 @@
import "./chart.css"
// shadcn/ui Chart primitives (official source) for custom Recharts compositions.
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
} from "./components/chart"
export type { ChartConfig } from "./components/chart"
// Ready-made HM charts. Sample data lives in the separate "@hm-design/chart/examples" entry.
export { HMBarChart } from "./components/bar-chart"
export type { HMBarChartProps } from "./components/bar-chart"
export { HMHorizontalBarChart } from "./components/horizontal-bar-chart"
export type { HMHorizontalBarChartProps } from "./components/horizontal-bar-chart"
export { HMSignedBarChart } from "./components/signed-bar-chart"
export type { HMSignedBarChartProps } from "./components/signed-bar-chart"
export { HMLineChart } from "./components/line-chart"
export type { HMLineChartProps } from "./components/line-chart"
export { HMDonutChart } from "./components/donut-chart"
export type { HMDonutChartProps, HMDonutDatum } from "./components/donut-chart"
export { chartColors } from "./components/series"
export type { ChartSeries } from "./components/series"
export { HMPieChart, HMStackedPieChart } from "./components/pie-chart"
export type { HMPieChartProps, HMPieRing, HMStackedPieChartProps } from "./components/pie-chart"
export { HMRadarChart } from "./components/radar-chart"
export type { HMRadarChartProps } from "./components/radar-chart"
export { HMAreaChart } from "./components/area-chart"
export type { HMAreaChartProps } from "./components/area-chart"
export { HMRadialBarChart, HMRadialMeterChart, HMRadialStackedChart } from "./components/radial-chart"
export type { HMRadialBarChartProps, HMRadialMeterChartProps, HMRadialStackedChartProps } from "./components/radial-chart"
+3
View File
@@ -0,0 +1,3 @@
/* Lets the consumer's Tailwind scan the utility classes of the shadcn Chart markup in this package.
Import it after "tailwindcss" and "@hm-design/ui/tailwind.css". The dist copy scans its own folder. */
@source "./";
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": false,
"declaration": true,
"emitDeclarationOnly": true,
"rootDir": "src",
"outDir": "dist",
"types": ["react", "react-dom"]
},
"include": ["src"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.app.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
"paths": {}
},
"include": ["src"]
}