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:
1 parent
228944a06b
commit
dc9258599c
56 files changed
+3979
-28
No files matched your search
@@ -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는 차트 별칭을 통해 반영됩니다.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
Vendored
+2
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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: "전체(건)",
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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 "./";
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.app.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
||||
"paths": {}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in new issue
Block a user