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
@@ -45,6 +45,8 @@ const preview: Preview = {
|
||||
],
|
||||
"Patterns",
|
||||
["Data", ["Data Table", "Filter Bar"], "Layout", ["GNB", "LNB", "Dashboard Layout", "Loading Screen"]],
|
||||
"Chart",
|
||||
["Bar Chart", "Line Chart", "Donut Chart", "Pie Chart", "Radar Chart", "Area Chart", "Radial Chart"],
|
||||
"OrgChart",
|
||||
["Overview", "Department Colors", "Full Composition", "Expanded OrgChart"],
|
||||
"Center Graphic",
|
||||
|
||||
@@ -6,16 +6,16 @@ This is a classification record. No Story IDs, exports or titles are renamed. HI
|
||||
|
||||
## Summary
|
||||
|
||||
- PRIMARY: 82
|
||||
- PRIMARY: 89
|
||||
- VERIFICATION: 252
|
||||
- UTILITY: 38
|
||||
- LEGACY / DUPLICATE: 0
|
||||
- KEEP: 82
|
||||
- KEEP: 89
|
||||
- HIDE: 290
|
||||
- MERGE_CANDIDATE: 33
|
||||
- DELETE_CANDIDATE: 0
|
||||
- Story files: 82
|
||||
- Stories: 372
|
||||
- Story files: 89
|
||||
- Stories: 379
|
||||
- Hierarchy: Foundation → Components → Patterns → Center Graphic → Theme
|
||||
|
||||
## Full inventory
|
||||
@@ -58,6 +58,48 @@ Source: `src/center/center-title.stories.tsx`; meta.tags: []
|
||||
- Compositions — Title; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- Playground — Playground; tags: ["verification"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
|
||||
### Chart/Area Chart
|
||||
|
||||
Source: `src/chart/area-chart.stories.tsx`; meta.tags: []
|
||||
|
||||
- Area — Area Chart; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
|
||||
### Chart/Bar Chart
|
||||
|
||||
Source: `src/chart/bar-chart.stories.tsx`; meta.tags: []
|
||||
|
||||
- Bar — Bar Chart; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
|
||||
### Chart/Donut Chart
|
||||
|
||||
Source: `src/chart/donut-chart.stories.tsx`; meta.tags: []
|
||||
|
||||
- Donut — Donut Chart; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
|
||||
### Chart/Line Chart
|
||||
|
||||
Source: `src/chart/line-chart.stories.tsx`; meta.tags: []
|
||||
|
||||
- Line — Line Chart; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
|
||||
### Chart/Pie Chart
|
||||
|
||||
Source: `src/chart/pie-chart.stories.tsx`; meta.tags: []
|
||||
|
||||
- Pie — Pie Chart; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
|
||||
### Chart/Radar Chart
|
||||
|
||||
Source: `src/chart/radar-chart.stories.tsx`; meta.tags: []
|
||||
|
||||
- Radar — Radar Chart; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
|
||||
### Chart/Radial Chart
|
||||
|
||||
Source: `src/chart/radial-chart.stories.tsx`; meta.tags: []
|
||||
|
||||
- Radial — Radial Chart; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
|
||||
### Components/Disclosure/Accordion
|
||||
|
||||
Source: `src/components/accordion.stories.tsx`; meta.tags: []
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"summary": {
|
||||
"storyFiles": 82,
|
||||
"stories": 372,
|
||||
"storyFiles": 89,
|
||||
"stories": 379,
|
||||
"categories": {
|
||||
"PRIMARY": 82,
|
||||
"PRIMARY": 89,
|
||||
"VERIFICATION": 252,
|
||||
"UTILITY": 38,
|
||||
"LEGACY / DUPLICATE": 0
|
||||
},
|
||||
"decisions": {
|
||||
"KEEP": 82,
|
||||
"KEEP": 89,
|
||||
"HIDE": 290,
|
||||
"MERGE_CANDIDATE": 33,
|
||||
"DELETE_CANDIDATE": 0
|
||||
@@ -213,6 +213,132 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/chart/area-chart.stories.tsx",
|
||||
"title": "Chart/Area Chart",
|
||||
"metaTags": [],
|
||||
"stories": [
|
||||
{
|
||||
"id": "chart-area-chart--area",
|
||||
"exportName": "Area",
|
||||
"name": "Area Chart",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
"decision": "KEEP",
|
||||
"candidate": null,
|
||||
"reason": "Representative story or distinct reference page."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/chart/bar-chart.stories.tsx",
|
||||
"title": "Chart/Bar Chart",
|
||||
"metaTags": [],
|
||||
"stories": [
|
||||
{
|
||||
"id": "chart-bar-chart--bar",
|
||||
"exportName": "Bar",
|
||||
"name": "Bar Chart",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
"decision": "KEEP",
|
||||
"candidate": null,
|
||||
"reason": "Representative story or distinct reference page."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/chart/donut-chart.stories.tsx",
|
||||
"title": "Chart/Donut Chart",
|
||||
"metaTags": [],
|
||||
"stories": [
|
||||
{
|
||||
"id": "chart-donut-chart--donut",
|
||||
"exportName": "Donut",
|
||||
"name": "Donut Chart",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
"decision": "KEEP",
|
||||
"candidate": null,
|
||||
"reason": "Representative story or distinct reference page."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/chart/line-chart.stories.tsx",
|
||||
"title": "Chart/Line Chart",
|
||||
"metaTags": [],
|
||||
"stories": [
|
||||
{
|
||||
"id": "chart-line-chart--line",
|
||||
"exportName": "Line",
|
||||
"name": "Line Chart",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
"decision": "KEEP",
|
||||
"candidate": null,
|
||||
"reason": "Representative story or distinct reference page."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/chart/pie-chart.stories.tsx",
|
||||
"title": "Chart/Pie Chart",
|
||||
"metaTags": [],
|
||||
"stories": [
|
||||
{
|
||||
"id": "chart-pie-chart--pie",
|
||||
"exportName": "Pie",
|
||||
"name": "Pie Chart",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
"decision": "KEEP",
|
||||
"candidate": null,
|
||||
"reason": "Representative story or distinct reference page."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/chart/radar-chart.stories.tsx",
|
||||
"title": "Chart/Radar Chart",
|
||||
"metaTags": [],
|
||||
"stories": [
|
||||
{
|
||||
"id": "chart-radar-chart--radar",
|
||||
"exportName": "Radar",
|
||||
"name": "Radar Chart",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
"decision": "KEEP",
|
||||
"candidate": null,
|
||||
"reason": "Representative story or distinct reference page."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/chart/radial-chart.stories.tsx",
|
||||
"title": "Chart/Radial Chart",
|
||||
"metaTags": [],
|
||||
"stories": [
|
||||
{
|
||||
"id": "chart-radial-chart--radial",
|
||||
"exportName": "Radial",
|
||||
"name": "Radial Chart",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
"decision": "KEEP",
|
||||
"candidate": null,
|
||||
"reason": "Representative story or distinct reference page."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/components/accordion.stories.tsx",
|
||||
"title": "Components/Disclosure/Accordion",
|
||||
|
||||
@@ -63,7 +63,7 @@ shadcn/ui(Radix, Tailwind v4, CVA) 기반 HM 디자인 시스템 시제품. Reac
|
||||
- Button(`variant`=역할, `appearance`=treatment, `size`), Input(`size`, native 상태 selector), Select(Trigger `size`, Radix 상태 속성)는 ui_viz Confirmed 규격으로 구현했다. Card도 `<Card />`만으로 HM 스타일이 적용된다(ui_viz Content Card, `packages/ui/src/theme/card.css`). Dialog도 `<DialogContent />`만으로 HM 스타일이 적용된다(`packages/ui/src/theme/dialog.css`).
|
||||
- 구조(2026-10-02 패키지 분리, npm workspaces): UI 컴포넌트·Theme 구현은 `packages/ui`(`@hm-design/ui`, 컴포넌트 `src/components/ui/`, Theme `src/theme/*.css`, 진입점 `src/theme.css`·`src/tailwind.css`), 컴포넌트 문서는 `docs/components/`, Storybook·Demo는 루트(`.storybook/`, `src/`; Demo 스타일은 `src/hm-theme.css`), `packages/center-graphic`(`@hm-design/center-graphic`)은 CENTER 구현(제목·정보·노선/공간·시설물), 기준 문서는 `docs/center-graphic/`다.
|
||||
- 작업 순서: 컴포넌트 문서 정리, Card, Dialog, 패키지 분리, CENTER 구현, 1단계 UI 컴포넌트(Textarea, Tabs, Label, Checkbox, Radio Group, Switch, Badge, Alert, Separator)와 2단계 A묶음 Floating(Dropdown Menu, Popover, Tooltip, Hover Card)과 B묶음 Modal(Sheet, Drawer(vaul), Alert Dialog)과 C묶음 Expand(Accordion, Collapsible)과 D묶음 Navigation(Breadcrumb, Pagination, Navigation Menu, Menubar)까지 완료로 2단계 UI가 끝났다. 3단계 A묶음 Data/Feedback(Table, Progress, Skeleton, Avatar, Scroll Area)과 B묶음 Selection Controls(Slider, Toggle, Toggle Group)과 C묶음 Search/Date(Calendar, Command, Date Picker, Combobox)까지 완료(새 dependency: `react-day-picker`, `cmdk`)과 D묶음 Layout/Media(Carousel, Aspect Ratio, Resizable)까지 완료로 3단계 UI가 끝났다(새 dependency: `embla-carousel-react`, `react-resizable-panels`). floating surface Semantic 정합성 작업도 완료했다. 4단계 A묶음 독립 UI(Context Menu, Toast/Sonner)까지 완료(새 dependency: `sonner`). 4단계 B묶음 업무 Pattern(Data Table, Filter Bar)까지 완료(새 dependency: `@tanstack/react-table` v9)로 4단계 핵심이 끝났다. Layout Pattern은 GNB·LNB·Page Header·Dashboard Layout까지 완료(`docs/patterns/gnb.md`, `lnb.md`, `page-header.md`, `dashboard-layout.md`, ui-viz 시각 기준)했다. 각 Navigation Mode는 독립 레이아웃을 소유한다. GNB ONLY는 기존 LNB 메뉴 부품을 재사용하는 내부 Sidebar를 허용하며 외곽 배경만 Green Light 2로 적용한다. DASHBOARD ONLY 운영 화면은 전용 Header(48px)·업무 상태 필터 + Content로 구성하고 Sidebar를 금지한다. 운영 최소 폭 960px·내부 가로 스크롤을 유지한다. 기존 DashboardSidebar Export·sidebar slot·1232px 구성은 호환성·검증용이며 신규 운영 Reference가 아니다. LNB 3뎁스(`LNBGroup` 중첩, 2026-10-06)와 모바일 Sheet 중첩 그룹까지 완료(`docs/patterns/lnb.md` §3a). 현재 LNB 모바일은 독립 LNB Mobile Header와 Sheet를 사용한다(`lnb.md` §3b). 과거 GNB+LNB 조합·Brand ownership·gradient 접점 기준은 Navigation Single Mode Policy로 대체되었다. 한 페이지에는 GNB / LNB / DASHBOARD 중 하나만 사용하며 기존 혼합 Story는 verification / VERIFICATION ONLY 조건의 기술 검증에만 한정한다. 경로: UI primitive `packages/ui/src/components/ui`, Pattern `packages/ui/src/patterns`, Pattern 문서 `docs/patterns`. Field는 코드 없이 `docs/components/field.md` 소비 가이드만 둔다.
|
||||
- 패키지는 5개다: `@hm-design/ui`(Web), `@hm-design/sw`(SW 일반·뷰어 모드, `packages/sw`, Story `src/sw/`, 문서 `docs/sw/`), `@hm-design/org-chart`·`@hm-design/org-chart-react`(OrgChart, Story `src/org-chart/`, 문서 `docs/org-chart/`), `@hm-design/center-graphic`(CENTER). 역할·진입점 표는 `README.md`의 패키지 구성, 외부 설치는 `docs/guidelines/external-install-submodule.md`를 따른다. `org-chart-react`가 Core에 의존하고 `/hm-dialog`만 `@hm-design/ui`를 optional peer로 쓰는 것 외에는 서로 의존하지 않는다. 모두 private이 아니며 compiled dist export(JS·타입·CSS)와 library build가 구현되어 있다. Registry publish 이력은 ZIP 검증 범위 밖이다. Runtime Theme URL Loader와 Theme JSON 독립 산출은 구현되었고 실제 CDN infrastructure/publish pipeline은 미구현이다. CENTER 기준은 `docs/center-graphic/README.md`, `packages/center-graphic/src/`, `src/center/`의 현재 HM 구현과 대표 Story다. 외부 ui_viz owner 자료는 제공된 경우에만 참고하며 필수 실행 경로가 아니다. UI와 CENTER 규격을 섞지 않는다.
|
||||
- 패키지는 6개다: `@hm-design/chart`(Bar·Line·Donut·Pie·Radar·Area·Radial 차트, shadcn Chart + Recharts, Story `src/chart/`, 소비 예제 `examples/chart/`, 문서 `packages/chart/README.md`; `@hm-design/ui`의 Theme 토큰(`theme.css`)을 쓰는 peer 의존이며 `@hm-design/ui` JS는 import하지 않는다), `@hm-design/ui`(Web), `@hm-design/sw`(SW 일반·뷰어 모드, `packages/sw`, Story `src/sw/`, 문서 `docs/sw/`), `@hm-design/org-chart`·`@hm-design/org-chart-react`(OrgChart, Story `src/org-chart/`, 문서 `docs/org-chart/`), `@hm-design/center-graphic`(CENTER). 역할·진입점 표는 `README.md`의 패키지 구성, 외부 설치는 `docs/guidelines/external-install-submodule.md`를 따른다. `org-chart-react`가 Core에 의존하고 `/hm-dialog`만 `@hm-design/ui`를 optional peer로 쓰는 것, `chart`가 `@hm-design/ui` Theme CSS 토큰을 peer로 쓰는 것 외에는 서로 의존하지 않는다. 모두 private이 아니며 compiled dist export(JS·타입·CSS)와 library build가 구현되어 있다. Registry publish 이력은 ZIP 검증 범위 밖이다. Runtime Theme URL Loader와 Theme JSON 독립 산출은 구현되었고 실제 CDN infrastructure/publish pipeline은 미구현이다. CENTER 기준은 `docs/center-graphic/README.md`, `packages/center-graphic/src/`, `src/center/`의 현재 HM 구현과 대표 Story다. 외부 ui_viz owner 자료는 제공된 경우에만 참고하며 필수 실행 경로가 아니다. UI와 CENTER 규격을 섞지 않는다.
|
||||
|
||||
> 위 작업 순서는 구현 이력이다. 현재 API·제약은 코드와 Component/Pattern 문서를 대조한다.
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# HM Design System
|
||||
|
||||
React + TypeScript + Vite와 shadcn/Radix 기반 HM 디자인 시스템입니다. Web UI·SW UI·조직도·CENTER Graphic을 독립 workspace 패키지와 통합 Storybook으로 확인합니다.
|
||||
React + TypeScript + Vite와 shadcn/Radix 기반 HM 디자인 시스템입니다. Web UI·SW UI·조직도·CENTER Graphic·차트를 독립 workspace 패키지와 통합 Storybook으로 확인합니다.
|
||||
|
||||
## 패키지 구성
|
||||
|
||||
npm workspace(`packages/*`)의 패키지 5개입니다. 서로 의존하지 않으며(아래 표의 예외 제외) 각자 `dist/`의 ESM JS·타입·CSS를 `exports`로 공개합니다.
|
||||
npm workspace(`packages/*`)의 패키지 6개입니다. 서로 의존하지 않으며(아래 표의 예외 제외) 각자 `dist/`의 ESM JS·타입·CSS를 `exports`로 공개합니다.
|
||||
|
||||
| 패키지 | 역할 | 공개 진입점 | Storybook | AI 지침 |
|
||||
|---|---|---|---|---|
|
||||
@@ -13,6 +13,7 @@ npm workspace(`packages/*`)의 패키지 5개입니다. 서로 의존하지 않
|
||||
| `@hm-design/org-chart` | **OrgChart** Core(Vanilla, Shadow DOM, 데이터·배치·상호작용) | `.`, `/style.css`, `/engine.css`, `/browser` | OrgChart | `packages/org-chart/ai/` |
|
||||
| `@hm-design/org-chart-react` | OrgChart React Wrapper. Core `@hm-design/org-chart`에 의존하고, 선택 Entry `/hm-dialog`만 `@hm-design/ui`(optional peer)를 사용 | `.`, `/hm-dialog` | OrgChart | `packages/org-chart-react/ai/` |
|
||||
| `@hm-design/center-graphic` | CENTER 그래픽(제목·정보·노선/공간·시설물). 내부 검토 범위, License 미확정 | `.`, `/reference`, `/style.css` | Center Graphic | 없음(README) |
|
||||
| `@hm-design/chart` | **Chart** Bar·Line·Donut·Pie·Radar·Area·Radial 차트(공식 shadcn/ui Chart + Recharts). `@hm-design/ui` Theme 토큰(`theme.css`)을 사용하는 peer 의존(JS import 없음), 예시 데이터는 `/examples`에 분리 | `.`, `/examples`, `/style.css`, `/tailwind.css` | Chart | 패키지 `ai/` 없음 — [README](packages/chart/README.md)가 사용 지침(공통 [`AI_GUIDE`](docs/ai/AI_GUIDE.md)의 "기타" 유형) |
|
||||
|
||||
다른 프로젝트에서의 설치는 [외부 설치 가이드(Git Submodule + npm Workspace)](docs/guidelines/external-install-submodule.md)를 따릅니다.
|
||||
|
||||
@@ -128,7 +129,7 @@ SW는 Tailwind 없이 앱 진입점에서 `import "@hm-design/sw/styles.css"`를
|
||||
|
||||
화면 생성 Reference: [Navigation 선택 근거](docs/guidelines/navigation-single-mode.md#선택-근거), [GNB Desktop·Mobile 구성](docs/patterns/gnb.md#8-desktop--mobile)과 [내부 Sidebar](docs/patterns/gnb.md#15-gnb-only-sidebar-composition-2026-10-08), [FilterBar 조회·초기화 소비 예제](docs/patterns/filter-bar.md#61-조회-버튼으로-적용하는-소비-예제). 일반 업무 목록의 모드를 임의 확정하거나 FilterBar에 조회 API가 있다고 가정하지 않습니다.
|
||||
|
||||
루트 `package.json`의 `workspaces`(`packages/*`)로 연결됩니다. 확인: `npm run typecheck`, `npm run build`. 위 [패키지 구성](#패키지-구성)의 5개 패키지는 모두 private이 아니며 ESM JS·타입 선언·CSS를 `dist`에서 export합니다. `npm run build:packages`로 재빌드할 수 있습니다. HM 패키지는 npm Registry로 배포하지 않으며, 다른 프로젝트는 [외부 프로젝트 설치](docs/guidelines/external-install-submodule.md)의 Git Submodule + npm Workspace 방식으로 연결합니다.
|
||||
루트 `package.json`의 `workspaces`(`packages/*`)로 연결됩니다. 확인: `npm run typecheck`, `npm run build`. 위 [패키지 구성](#패키지-구성)의 6개 패키지는 모두 private이 아니며 ESM JS·타입 선언·CSS를 `dist`에서 export합니다. `npm run build:packages`로 재빌드할 수 있습니다. HM 패키지는 npm Registry로 배포하지 않으며, 다른 프로젝트는 [외부 프로젝트 설치](docs/guidelines/external-install-submodule.md)의 Git Submodule + npm Workspace 방식으로 연결합니다.
|
||||
|
||||
## 파일 위치
|
||||
|
||||
@@ -146,6 +147,7 @@ SW는 Tailwind 없이 앱 진입점에서 `import "@hm-design/sw/styles.css"`를
|
||||
- `docs/ai/`: 패키지 `ai/` 공통 지침 원본(`npm run sync:ai-docs`, `npm run check:ai-docs`)
|
||||
- `packages/center-graphic/` (`@hm-design/center-graphic`): CENTER 그래픽(제목·정보·노선/공간·시설물), `@hm-design/ui`에 의존하지 않음
|
||||
- `src/center/`: CENTER Storybook (`Center Graphic` 그룹)
|
||||
- `packages/chart/` (`@hm-design/chart`): Bar·Line·Donut·Pie·Radar·Area·Radial 차트와 예시 데이터. `src/chart/`: Chart Storybook(`Chart` 그룹), `examples/chart/`: 소비 예제(`npm run dev:chart-example`)
|
||||
- `docs/center-graphic/`: CENTER 구현 기준 문서
|
||||
- `src/App.tsx`: 기본 UI 확인 화면 (`@hm-design/ui` 사용)
|
||||
- `src/hm-theme.css`: App/Storybook 예시 화면 스타일. 장식용 시제품 색상은 고정값으로 남아 있음
|
||||
|
||||
+2
-1
@@ -17,8 +17,9 @@
|
||||
| ② SW UI — 뷰어 모드 | `@hm-design/sw`(같은 패키지) | `node_modules/@hm-design/sw/ai/` (`AI_USAGE.md` §0 → `VIEWER.md`) | `@hm-design/sw` | `@hm-design/sw/styles.css`(같은 파일) |
|
||||
| ③ OrgChart | `@hm-design/org-chart`(Core) + React면 `@hm-design/org-chart-react` | `node_modules/@hm-design/org-chart/ai/` (React 어댑터 안내는 `node_modules/@hm-design/org-chart-react/ai/AI_USAGE.md`) | `@hm-design/org-chart`, `@hm-design/org-chart-react`, 선택 `@hm-design/org-chart-react/hm-dialog` | `@hm-design/org-chart/engine.css`, `@hm-design/org-chart/style.css`(URL로 전달 — Shadow DOM 안에 로드) |
|
||||
| 기타 | `@hm-design/center-graphic` | AI 지침 없음. 패키지 `README.md`만 | `@hm-design/center-graphic`, 선택 `@hm-design/center-graphic/reference`(Storybook 검토용 참고 데모, 컴포넌트 API 아님) | JS 진입점이 자동 import. 명시가 필요하면 `@hm-design/center-graphic/style.css` |
|
||||
| 기타 — 차트 | `@hm-design/chart`(+ Theme 토큰용 `@hm-design/ui` peer) | AI 지침 폴더 없음. 패키지 `README.md`(`node_modules/@hm-design/chart/README.md`)가 설치·차트 선택·Props·검증 지침 | `@hm-design/chart`, 예시 데이터 `@hm-design/chart/examples`(운영 데이터 아님) | Tailwind 앱에서 `@hm-design/chart/tailwind.css` + `@hm-design/ui/tailwind.css` + `@hm-design/ui/theme.css`(순서는 README). 차트 스타일은 JS 진입점이 자동 import |
|
||||
|
||||
모든 패키지는 서로 독립입니다(`@hm-design/org-chart-react`만 Core에 의존, `@hm-design/ui`는 `/hm-dialog`를 쓸 때만 선택 peer). React·React DOM `^19`는 peer입니다.
|
||||
모든 패키지는 서로 독립입니다(`@hm-design/org-chart-react`만 Core에 의존, `@hm-design/ui`는 `/hm-dialog`를 쓸 때만 선택 peer, `@hm-design/chart`는 `@hm-design/ui`의 Theme CSS 토큰만 peer로 사용). React·React DOM `^19`는 peer입니다.
|
||||
|
||||
## 1단계. 사용 목적 확인
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
| `packages/org-chart-react/ai/` | `AI_USAGE`(Core `ai/` 안내) | 포함 |
|
||||
| `scripts/ai-docs.mjs` | 동기화(`--sync`)·검사(`--check`) | — |
|
||||
|
||||
`@hm-design/center-graphic`에는 AI 지침이 없습니다(공통 가이드에 "README만"으로 안내). 추가하려면 같은 4종 구조로 만들고 `scripts/ai-docs.mjs`의 `packages`에 등록합니다.
|
||||
`@hm-design/center-graphic`과 `@hm-design/chart`에는 `ai/` 폴더가 없습니다(공통 가이드에 "README만"으로 안내). `@hm-design/chart`는 `packages/chart/README.md`가 설치·CSS 연결·차트 선택·Props·검증 지침을 겸하므로 차트 공개 Export·Props·기본값이 바뀌면 같은 Commit에서 README를 고칩니다(`check:ai-docs`는 chart README를 검사하지 않음). 추가하려면 같은 4종 구조로 만들고 `scripts/ai-docs.mjs`의 `packages`에 등록합니다.
|
||||
|
||||
저장소 루트 `AGENTS.md`·`CLAUDE.md`는 **이 저장소를 개발하는 AI**용 규칙이고, `packages/*/ai/`는 **패키지를 설치해 쓰는 소비 프로젝트의 AI**용 규칙입니다. 내용이 겹쳐도 서로를 대신하지 않습니다. 패키지 `ai/` 문서는 패키지 밖(`../../docs/…`)을 링크하지 않습니다(설치 후 존재하지 않음).
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 외부 프로젝트 설치 — Git Submodule + npm Workspace
|
||||
|
||||
> 2026-10-20 작성, 2026-10-23 갱신(저장소 주소 `shkim4`, HM 패키지 npm Registry 미사용 명시). 현재 HMDesign을 다른 React 프로젝트에서 쓰는 공식 방식입니다. npm Registry publish 없이 Git 저장소를 Submodule로 연결하고, HMDesign 안에서 패키지를 빌드한 뒤 소비 프로젝트의 npm Workspace로 연결합니다.
|
||||
> 2026-10-20 작성, 2026-10-23 갱신(저장소 주소 `shkim4`, HM 패키지 npm Registry 미사용 명시), 2026-11-06 갱신(`@hm-design/chart` 연결·React 중복 해석 방지 추가). 현재 HMDesign을 다른 React 프로젝트에서 쓰는 공식 방식입니다. npm Registry publish 없이 Git 저장소를 Submodule로 연결하고, HMDesign 안에서 패키지를 빌드한 뒤 소비 프로젝트의 npm Workspace로 연결합니다.
|
||||
> 실제 적용 근거: 외부 DWG/DXF Viewer(React 19, `@hm-design/ui` + `@hm-design/sw`)가 이 절차로 연결되었습니다. 패키지 tarball 소비 검증은 `packages/sw/tests/integrated-consumer.mjs`가 별도로 수행합니다.
|
||||
|
||||
## 1. 전제
|
||||
@@ -39,7 +39,7 @@ cd ..
|
||||
```
|
||||
|
||||
- `npm ci`는 HMDesign 자체 빌드 도구(Vite·TypeScript·Tailwind 등)를 `HMDesign/node_modules`에 설치합니다. 패키지 빌드 스크립트가 이 위치의 도구를 사용하므로 생략할 수 없습니다.
|
||||
- `build:packages`는 `org-chart → ui → center-graphic → org-chart-react → sw` 순서로 5개 패키지의 `dist/`를 만듭니다. 필요한 패키지만 빌드할 때는 `npm run build:ui`, `npm run build:sw`를 쓸 수 있고, `org-chart-react`는 `org-chart`를 먼저 빌드해야 합니다.
|
||||
- `build:packages`는 `org-chart → ui → center-graphic → chart → org-chart-react → sw` 순서로 6개 패키지의 `dist/`를 만듭니다. 필요한 패키지만 빌드할 때는 `npm run build:ui`, `npm run build:chart`, `npm run build:sw`를 쓸 수 있고, `org-chart-react`는 `org-chart`를 먼저 빌드해야 합니다. `@hm-design/chart`는 `@hm-design/ui`의 `theme.css`(토큰·글꼴)를 소비 앱이 연결해야 하므로 `build:ui`도 함께 실행합니다.
|
||||
|
||||
## 4. 소비 프로젝트 Workspace 연결
|
||||
|
||||
@@ -63,6 +63,8 @@ npm install
|
||||
|
||||
- `npm install` 후 `node_modules/@hm-design/<패키지>`는 `HMDesign/packages/<패키지>`를 가리키는 링크입니다. Import는 패키지 `exports`의 공개 진입점만 사용합니다(`dist`·`src` 내부 경로 금지).
|
||||
- OrgChart를 쓰면 `HMDesign/packages/org-chart`(와 React에서는 `HMDesign/packages/org-chart-react`)를 추가합니다.
|
||||
- 차트를 쓰면 `HMDesign/packages/chart`와 **`HMDesign/packages/ui`를 함께** Workspace·dependency에 추가합니다(`@hm-design/chart`의 peer이며 CSS 토큰용입니다). `recharts` 등 차트 dependency는 `npm install`이 설치합니다.
|
||||
- **React 중복 해석 방지(차트에서 확인).** Submodule 안의 `HMDesign/node_modules`에도 `react`가 설치되어 있어, 소비 프로젝트의 Vite가 패키지 `dist`의 `react` import를 그쪽으로 해석하면 React가 둘이 되어 렌더링이 실패합니다(`Cannot read properties of null (reading useId)`). 소비 프로젝트 `vite.config`에 `resolve: { dedupe: ["react", "react-dom"] }`를 지정합니다. 임시 소비 프로젝트(Vite 7)에서 `dedupe` 없이는 차트가 비었고, 지정 후 렌더링됨을 확인했습니다.
|
||||
- Vite 소비 프로젝트는 HMDesign 저장소 안의 예제·Export용 HTML을 의존성 스캔에서 제외하도록 `optimizeDeps.entries: ['index.html']`를 지정합니다(외부 Viewer에서 확인된 설정).
|
||||
|
||||
## 5. CSS Import
|
||||
@@ -71,6 +73,7 @@ npm install
|
||||
|---|---|
|
||||
| Web `@hm-design/ui` | CSS entry에서 `@import "tailwindcss";` → `@import "@hm-design/ui/tailwind.css";` → `@import "@hm-design/ui/theme.css";`. Tailwind 검색 범위는 [Runtime portability](./runtime-portability.md#21-tailwind-v4-소비자-검색-범위)를 따릅니다. |
|
||||
| SW `@hm-design/sw` | `import "@hm-design/sw/styles.css"`를 한 번. JS import만으로 CSS가 들어오지 않습니다. 글꼴 파일은 포함하지 않습니다(기본 Noto Sans KR). |
|
||||
| Chart `@hm-design/chart` | CSS entry에서 `@import "tailwindcss" source("./");` → `@import "@hm-design/ui/tailwind.css";` → `@import "@hm-design/chart/tailwind.css";` → `@import "@hm-design/ui/theme.css";`. 차트 스타일은 JS import가 자동으로 불러옵니다. 상세는 [`packages/chart/README.md`](../../packages/chart/README.md). |
|
||||
| OrgChart | Core CSS 두 개(`@hm-design/org-chart/engine.css`, `@hm-design/org-chart/style.css`)를 URL로 해석해 `stylesheetUrls`에 전달합니다(Shadow DOM). 문서에 import하는 것만으로는 적용되지 않습니다. |
|
||||
|
||||
Web과 SW를 함께 쓰면 두 CSS의 순서는 서로의 계산 스타일을 바꾸지 않습니다(`integrated-consumer` 검증 항목).
|
||||
@@ -79,6 +82,7 @@ Web과 SW를 함께 쓰면 두 CSS의 순서는 서로의 계산 스타일을
|
||||
|
||||
- 소비 프로젝트의 AI 코딩 도구는 `node_modules/@hm-design/<패키지>/ai/`(= `HMDesign/packages/<패키지>/ai/`)를 읽습니다. 진입점은 각 폴더의 `AI_GUIDE.md`이며 사용 유형(Web / SW 일반·뷰어 / OrgChart / 혼합)을 먼저 정합니다.
|
||||
- 루트 `AGENTS.md`·`CLAUDE.md`는 자동으로 연결되지 않습니다. `ai/AGENTS_SNIPPET.md`의 문구를 소비 프로젝트 루트 지침에 붙입니다(방법은 `AI_GUIDE.md`의 "소비 프로젝트 설정").
|
||||
- `@hm-design/chart`에는 `ai/` 폴더가 없습니다. 차트 사용 지침은 `node_modules/@hm-design/chart/README.md`(= `HMDesign/packages/chart/README.md`)이며, 소비 프로젝트 루트 지침에서 이 README를 명시적으로 가리킵니다(`AI_GUIDE.md`의 "소비 프로젝트 설정").
|
||||
- HMDesign 저장소 루트의 `AGENTS.md`·`CLAUDE.md`는 HMDesign을 개발하는 AI용이며 소비 프로젝트 지침이 아닙니다.
|
||||
|
||||
## 7. HMDesign 버전 갱신
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
@import "@hm-design/ui/theme.css";
|
||||
@@ -0,0 +1,9 @@
|
||||
@import "tailwindcss" source("./");
|
||||
@import "@hm-design/ui/tailwind.css";
|
||||
@import "@hm-design/chart/tailwind.css";
|
||||
|
||||
body { margin: 0; background: var(--hm-sem-bg-content); color: var(--hm-sem-fg-primary); font-family: var(--hm-font-family-base); }
|
||||
.chart-example { min-height: 100dvh; }
|
||||
.chart-example-main { display: grid; min-width: 0; gap: 24px; max-width: 1180px; margin-inline: auto; padding: 24px 16px; }
|
||||
.chart-example-main h1 { margin: 0; font-size: 24px; font-weight: var(--hm-font-weight-bold); }
|
||||
.chart-example-main p { margin: 0; color: var(--hm-sem-fg-secondary); }
|
||||
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html lang="ko"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>HMDesign 차트 예제</title><link rel="icon" href="data:," /></head><body><div id="root"></div><script type="module" src="./main.tsx"></script></body></html>
|
||||
@@ -0,0 +1,27 @@
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { AreaStackedExample, ChartExamples, PieLabelExample, RadarPlainExample, RadialTextExample } from "@hm-design/chart/examples"
|
||||
import "./index.css"
|
||||
import "./hm-theme.css"
|
||||
|
||||
// Consumer of the public @hm-design/chart entries only: bar, line and donut sample cards plus one Pie, Radar, Area and Radial example.
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<div className="chart-example">
|
||||
<main className="chart-example-main">
|
||||
<header>
|
||||
<h1>HMDesign 차트 예제</h1>
|
||||
<p>막대·선·도넛 차트와 파이·방사형(Radar)·영역·래디얼 차트 예시의 표시와 툴팁·범례·반응형 동작을 확인하는 화면입니다. 모든 값은 예시 데이터입니다.</p>
|
||||
</header>
|
||||
<ChartExamples />
|
||||
{/* One example each of the Pie, Radar, Area and Radial charts. */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<PieLabelExample className="min-w-0" />
|
||||
<RadarPlainExample className="min-w-0" />
|
||||
<AreaStackedExample className="min-w-0" />
|
||||
<RadialTextExample className="min-w-0" />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.app.json",
|
||||
"compilerOptions": { "composite": false, "noEmit": true, "types": ["vite/client"], "tsBuildInfoFile": "../../_local/chart-example.tsbuildinfo" },
|
||||
"include": ["main.tsx", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
// Consumer example of @hm-design/chart. The fonts come from the repository public folder like the publisher example.
|
||||
export default defineConfig({
|
||||
root: fileURLToPath(new URL("./", import.meta.url)),
|
||||
publicDir: fileURLToPath(new URL("../../public", import.meta.url)),
|
||||
base: "./",
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: { host: "127.0.0.1" },
|
||||
})
|
||||
Generated
+402
-4
@@ -12,6 +12,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@hm-design/center-graphic": "*",
|
||||
"@hm-design/chart": "*",
|
||||
"@hm-design/org-chart": "*",
|
||||
"@hm-design/org-chart-react": "*",
|
||||
"@hm-design/sw": "*",
|
||||
@@ -1211,6 +1212,10 @@
|
||||
"resolved": "packages/center-graphic",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@hm-design/chart": {
|
||||
"resolved": "packages/chart",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@hm-design/org-chart": {
|
||||
"resolved": "packages/org-chart",
|
||||
"link": true
|
||||
@@ -3564,6 +3569,46 @@
|
||||
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.14.0.tgz",
|
||||
"integrity": "sha512-m/PxMwRe0xLL6XUsAgy0YjInklHcfi9uMwFhJk+QINqjxqPvyc89T5cMjszA5bDmTE0gwGc7zr9LRO4gL+mhaw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.2.9",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.2.9",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.2.9.tgz",
|
||||
"integrity": "sha512-IQ4UEtXjZjyefyWjtLEDXUSrF6T2hwjKPi6BIJl3bY/2XXW24d6cNGdXJSBOA/+KNnuiXkZY6DR0C3efolhitA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz",
|
||||
@@ -3918,6 +3963,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@storybook/builder-vite": {
|
||||
"version": "10.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.6.1.tgz",
|
||||
@@ -4608,6 +4665,69 @@
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
@@ -4686,6 +4806,12 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/validate-npm-package-name": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
||||
@@ -5894,6 +6020,127 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.5",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.5.tgz",
|
||||
"integrity": "sha512-Th8qGXoBalmWsstpkuhmBuTsBQIFqYOJtjlBvNVvBfJ8E3uYusBUwUR8HU20U0qKKQkgCvKyR1YjiozWadk5QA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.1.tgz",
|
||||
"integrity": "sha512-lfab8x0Bj0UO7SNsY8dZSbNIA6ipbkE3X0NG9nqjCu6CfCHZseuDnOSJY7hidKrjpP++i1M2qT+FlAdPKYRilA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-B5znKqRmKROAN4oHWQ50W/qisVjA9GXh2WMsVKdFVp4DxWmcrOsweqgUOUVsv5th96kwaZlokU6SlV4J3gak1w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.3.tgz",
|
||||
"integrity": "sha512-+F+/49swqmGuQJmSKnVMIDH/QkEFV5li9QRj0xm/KhyAUBv099O5PrvpQFxBp24PYph17Mnvt9mjKQV0ii0nMA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.2.tgz",
|
||||
"integrity": "sha512-IRzGU62DtxhvsnSTWMQIdnoZ6gA+BCWwBon1EJI6SjoN595799b6z/QRBzXfFhE8iupUPp3042yBNo0STPqnzQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-jsbgV8zIT38ReB9O7X7gcLndTQRONSqNNd+oGIaTBdUug4r5jFmp0N6RY9R5GWbdUDucn0qoTGyfjapT4tDhcg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.3.tgz",
|
||||
"integrity": "sha512-66cWobCNzcMvlfoaDHCxuIv9TBUWG1BLOWdv9kHHFuCr1JdqAWPvTmsaEYDD5NRMyWZ5+7u0GqiCDfMXMqh3hg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.1.tgz",
|
||||
"integrity": "sha512-dm2o+lbKdzg9tY7GEag/dB9hcbodLfwkE/DkvqbELJXaMz0yKzWWhZqhbI7CKubukA7HnRyz0avdtvi7EPxPvw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.1.tgz",
|
||||
"integrity": "sha512-Ze7rjrhMzdeulh0/hjcfFj7XLAHOfowtEMfsoG6DU866HPynQNW7Wfen6hzXcqKTv8XzxvF6Hzr5XD6bMHr2ug==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.1.tgz",
|
||||
"integrity": "sha512-22XaqjyCjpqgda20erhsOjGmC3vvYwTNYniKAd45W5UWLDF43moeOuV9qYpRqobIlsnK0UhnKZjUUJm3VgORUA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-1Il1aliroVSErrv5aulRBxAY+7+DnaoLIWGd1C6SsBTWDeobWiP/V69vwb8vT6abR1kKwaNe1196pmaQ1fK90A==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
|
||||
@@ -5938,6 +6185,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dedent": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
||||
@@ -6286,6 +6539,18 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.53.1",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.53.1.tgz",
|
||||
"integrity": "sha512-VhMc6IXmexu8iYFfL2aaAww6IvJTdHlqBLruLoEUs33YD4IcP5DdZcLOybMsMmmLsiy1c8PsiLsrM0DdIuuRUA==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks",
|
||||
"tests/types",
|
||||
"tests/browser-compat"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
@@ -6386,6 +6651,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eventsource": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||
@@ -6976,6 +7247,16 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -7010,6 +7291,15 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/iobuffer": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
|
||||
@@ -8869,9 +9159,31 @@
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.1.tgz",
|
||||
"integrity": "sha512-vxMLT0lalUjbZPkoTepWyKAZbjIIFssNx/CEL9lTfwQBU6XpkGSmCNdcCIrkOBUgB68uftZMnbXVb6DwGnhRKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||
@@ -8968,6 +9280,36 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
|
||||
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
@@ -8995,6 +9337,21 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
@@ -9012,6 +9369,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
@@ -9859,7 +10222,6 @@
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
@@ -10236,6 +10598,28 @@
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.4.1",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.4.1.tgz",
|
||||
"integrity": "sha512-geV4p2GVfVM9lgm9ZRS3pvsbAPS+BUGhM9W9nPzPHTKi+/+KjNrFXdgP6KkG+XspNZuvcXmchcrtklfuQ4gniw==",
|
||||
"license": "MIT AND ISC AND BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
|
||||
@@ -10700,6 +11084,20 @@
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"packages/chart": {
|
||||
"name": "@hm-design/chart",
|
||||
"version": "0.1.0",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"packages/org-chart": {
|
||||
"name": "@hm-design/org-chart",
|
||||
"version": "0.1.0",
|
||||
@@ -10714,8 +11112,8 @@
|
||||
"@hm-design/org-chart": "0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"@hm-design/ui": "0.1.0"
|
||||
"@hm-design/ui": "0.1.0",
|
||||
"react": "^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@hm-design/ui": {
|
||||
|
||||
+5
-1
@@ -26,6 +26,9 @@
|
||||
"typecheck": "tsc -b && npm run typecheck --workspaces --if-present",
|
||||
"build:ui": "npm run build -w @hm-design/ui",
|
||||
"build:center-graphic": "npm run build -w @hm-design/center-graphic",
|
||||
"build:chart": "npm run build -w @hm-design/chart",
|
||||
"build:chart-example": "vite build --config examples/chart/vite.config.ts",
|
||||
"dev:chart-example": "vite --config examples/chart/vite.config.ts",
|
||||
"build:org-chart": "npm run build -w @hm-design/org-chart",
|
||||
"typecheck:org-chart": "npm run typecheck -w @hm-design/org-chart",
|
||||
"test:org-chart": "npm run test -w @hm-design/org-chart",
|
||||
@@ -39,12 +42,13 @@
|
||||
"build:org-chart-react-example": "vite build --config examples/org-chart-react/vite.config.ts",
|
||||
"dev:org-chart-react-example": "vite --config examples/org-chart-react/vite.config.ts",
|
||||
"test:org-chart-react-browser": "node examples/org-chart-react/verify.mjs",
|
||||
"build:packages": "npm run build:org-chart && npm run build:ui && npm run build:center-graphic && npm run build:org-chart-react && npm run build:sw"
|
||||
"build:packages": "npm run build:org-chart && npm run build:ui && npm run build:center-graphic && npm run build:chart && npm run build:org-chart-react && npm run build:sw"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hm-design/org-chart": "*",
|
||||
"@hm-design/org-chart-react": "*",
|
||||
"@hm-design/center-graphic": "*",
|
||||
"@hm-design/chart": "*",
|
||||
"@hm-design/sw": "*",
|
||||
"@hm-design/ui": "*",
|
||||
"lucide-react": "^1.49.0",
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -17,8 +17,9 @@
|
||||
| ② SW UI — 뷰어 모드 | `@hm-design/sw`(같은 패키지) | `node_modules/@hm-design/sw/ai/` (`AI_USAGE.md` §0 → `VIEWER.md`) | `@hm-design/sw` | `@hm-design/sw/styles.css`(같은 파일) |
|
||||
| ③ OrgChart | `@hm-design/org-chart`(Core) + React면 `@hm-design/org-chart-react` | `node_modules/@hm-design/org-chart/ai/` (React 어댑터 안내는 `node_modules/@hm-design/org-chart-react/ai/AI_USAGE.md`) | `@hm-design/org-chart`, `@hm-design/org-chart-react`, 선택 `@hm-design/org-chart-react/hm-dialog` | `@hm-design/org-chart/engine.css`, `@hm-design/org-chart/style.css`(URL로 전달 — Shadow DOM 안에 로드) |
|
||||
| 기타 | `@hm-design/center-graphic` | AI 지침 없음. 패키지 `README.md`만 | `@hm-design/center-graphic`, 선택 `@hm-design/center-graphic/reference`(Storybook 검토용 참고 데모, 컴포넌트 API 아님) | JS 진입점이 자동 import. 명시가 필요하면 `@hm-design/center-graphic/style.css` |
|
||||
| 기타 — 차트 | `@hm-design/chart`(+ Theme 토큰용 `@hm-design/ui` peer) | AI 지침 폴더 없음. 패키지 `README.md`(`node_modules/@hm-design/chart/README.md`)가 설치·차트 선택·Props·검증 지침 | `@hm-design/chart`, 예시 데이터 `@hm-design/chart/examples`(운영 데이터 아님) | Tailwind 앱에서 `@hm-design/chart/tailwind.css` + `@hm-design/ui/tailwind.css` + `@hm-design/ui/theme.css`(순서는 README). 차트 스타일은 JS 진입점이 자동 import |
|
||||
|
||||
모든 패키지는 서로 독립입니다(`@hm-design/org-chart-react`만 Core에 의존, `@hm-design/ui`는 `/hm-dialog`를 쓸 때만 선택 peer). React·React DOM `^19`는 peer입니다.
|
||||
모든 패키지는 서로 독립입니다(`@hm-design/org-chart-react`만 Core에 의존, `@hm-design/ui`는 `/hm-dialog`를 쓸 때만 선택 peer, `@hm-design/chart`는 `@hm-design/ui`의 Theme CSS 토큰만 peer로 사용). React·React DOM `^19`는 peer입니다.
|
||||
|
||||
## 1단계. 사용 목적 확인
|
||||
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
| ② SW UI — 뷰어 모드 | `@hm-design/sw`(같은 패키지) | `node_modules/@hm-design/sw/ai/` (`AI_USAGE.md` §0 → `VIEWER.md`) | `@hm-design/sw` | `@hm-design/sw/styles.css`(같은 파일) |
|
||||
| ③ OrgChart | `@hm-design/org-chart`(Core) + React면 `@hm-design/org-chart-react` | `node_modules/@hm-design/org-chart/ai/` (React 어댑터 안내는 `node_modules/@hm-design/org-chart-react/ai/AI_USAGE.md`) | `@hm-design/org-chart`, `@hm-design/org-chart-react`, 선택 `@hm-design/org-chart-react/hm-dialog` | `@hm-design/org-chart/engine.css`, `@hm-design/org-chart/style.css`(URL로 전달 — Shadow DOM 안에 로드) |
|
||||
| 기타 | `@hm-design/center-graphic` | AI 지침 없음. 패키지 `README.md`만 | `@hm-design/center-graphic`, 선택 `@hm-design/center-graphic/reference`(Storybook 검토용 참고 데모, 컴포넌트 API 아님) | JS 진입점이 자동 import. 명시가 필요하면 `@hm-design/center-graphic/style.css` |
|
||||
| 기타 — 차트 | `@hm-design/chart`(+ Theme 토큰용 `@hm-design/ui` peer) | AI 지침 폴더 없음. 패키지 `README.md`(`node_modules/@hm-design/chart/README.md`)가 설치·차트 선택·Props·검증 지침 | `@hm-design/chart`, 예시 데이터 `@hm-design/chart/examples`(운영 데이터 아님) | Tailwind 앱에서 `@hm-design/chart/tailwind.css` + `@hm-design/ui/tailwind.css` + `@hm-design/ui/theme.css`(순서는 README). 차트 스타일은 JS 진입점이 자동 import |
|
||||
|
||||
모든 패키지는 서로 독립입니다(`@hm-design/org-chart-react`만 Core에 의존, `@hm-design/ui`는 `/hm-dialog`를 쓸 때만 선택 peer). React·React DOM `^19`는 peer입니다.
|
||||
모든 패키지는 서로 독립입니다(`@hm-design/org-chart-react`만 Core에 의존, `@hm-design/ui`는 `/hm-dialog`를 쓸 때만 선택 peer, `@hm-design/chart`는 `@hm-design/ui`의 Theme CSS 토큰만 peer로 사용). React·React DOM `^19`는 peer입니다.
|
||||
|
||||
## 1단계. 사용 목적 확인
|
||||
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
| ② SW UI — 뷰어 모드 | `@hm-design/sw`(같은 패키지) | `node_modules/@hm-design/sw/ai/` (`AI_USAGE.md` §0 → `VIEWER.md`) | `@hm-design/sw` | `@hm-design/sw/styles.css`(같은 파일) |
|
||||
| ③ OrgChart | `@hm-design/org-chart`(Core) + React면 `@hm-design/org-chart-react` | `node_modules/@hm-design/org-chart/ai/` (React 어댑터 안내는 `node_modules/@hm-design/org-chart-react/ai/AI_USAGE.md`) | `@hm-design/org-chart`, `@hm-design/org-chart-react`, 선택 `@hm-design/org-chart-react/hm-dialog` | `@hm-design/org-chart/engine.css`, `@hm-design/org-chart/style.css`(URL로 전달 — Shadow DOM 안에 로드) |
|
||||
| 기타 | `@hm-design/center-graphic` | AI 지침 없음. 패키지 `README.md`만 | `@hm-design/center-graphic`, 선택 `@hm-design/center-graphic/reference`(Storybook 검토용 참고 데모, 컴포넌트 API 아님) | JS 진입점이 자동 import. 명시가 필요하면 `@hm-design/center-graphic/style.css` |
|
||||
| 기타 — 차트 | `@hm-design/chart`(+ Theme 토큰용 `@hm-design/ui` peer) | AI 지침 폴더 없음. 패키지 `README.md`(`node_modules/@hm-design/chart/README.md`)가 설치·차트 선택·Props·검증 지침 | `@hm-design/chart`, 예시 데이터 `@hm-design/chart/examples`(운영 데이터 아님) | Tailwind 앱에서 `@hm-design/chart/tailwind.css` + `@hm-design/ui/tailwind.css` + `@hm-design/ui/theme.css`(순서는 README). 차트 스타일은 JS 진입점이 자동 import |
|
||||
|
||||
모든 패키지는 서로 독립입니다(`@hm-design/org-chart-react`만 Core에 의존, `@hm-design/ui`는 `/hm-dialog`를 쓸 때만 선택 peer). React·React DOM `^19`는 peer입니다.
|
||||
모든 패키지는 서로 독립입니다(`@hm-design/org-chart-react`만 Core에 의존, `@hm-design/ui`는 `/hm-dialog`를 쓸 때만 선택 peer, `@hm-design/chart`는 `@hm-design/ui`의 Theme CSS 토큰만 peer로 사용). React·React DOM `^19`는 peer입니다.
|
||||
|
||||
## 1단계. 사용 목적 확인
|
||||
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
| ② SW UI — 뷰어 모드 | `@hm-design/sw`(같은 패키지) | `node_modules/@hm-design/sw/ai/` (`AI_USAGE.md` §0 → `VIEWER.md`) | `@hm-design/sw` | `@hm-design/sw/styles.css`(같은 파일) |
|
||||
| ③ OrgChart | `@hm-design/org-chart`(Core) + React면 `@hm-design/org-chart-react` | `node_modules/@hm-design/org-chart/ai/` (React 어댑터 안내는 `node_modules/@hm-design/org-chart-react/ai/AI_USAGE.md`) | `@hm-design/org-chart`, `@hm-design/org-chart-react`, 선택 `@hm-design/org-chart-react/hm-dialog` | `@hm-design/org-chart/engine.css`, `@hm-design/org-chart/style.css`(URL로 전달 — Shadow DOM 안에 로드) |
|
||||
| 기타 | `@hm-design/center-graphic` | AI 지침 없음. 패키지 `README.md`만 | `@hm-design/center-graphic`, 선택 `@hm-design/center-graphic/reference`(Storybook 검토용 참고 데모, 컴포넌트 API 아님) | JS 진입점이 자동 import. 명시가 필요하면 `@hm-design/center-graphic/style.css` |
|
||||
| 기타 — 차트 | `@hm-design/chart`(+ Theme 토큰용 `@hm-design/ui` peer) | AI 지침 폴더 없음. 패키지 `README.md`(`node_modules/@hm-design/chart/README.md`)가 설치·차트 선택·Props·검증 지침 | `@hm-design/chart`, 예시 데이터 `@hm-design/chart/examples`(운영 데이터 아님) | Tailwind 앱에서 `@hm-design/chart/tailwind.css` + `@hm-design/ui/tailwind.css` + `@hm-design/ui/theme.css`(순서는 README). 차트 스타일은 JS 진입점이 자동 import |
|
||||
|
||||
모든 패키지는 서로 독립입니다(`@hm-design/org-chart-react`만 Core에 의존, `@hm-design/ui`는 `/hm-dialog`를 쓸 때만 선택 peer). React·React DOM `^19`는 peer입니다.
|
||||
모든 패키지는 서로 독립입니다(`@hm-design/org-chart-react`만 Core에 의존, `@hm-design/ui`는 `/hm-dialog`를 쓸 때만 선택 peer, `@hm-design/chart`는 `@hm-design/ui`의 Theme CSS 토큰만 peer로 사용). React·React DOM `^19`는 peer입니다.
|
||||
|
||||
## 1단계. 사용 목적 확인
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { build } from "vite"
|
||||
|
||||
const root = fileURLToPath(new URL("../", import.meta.url))
|
||||
const name = process.argv[2]
|
||||
if (!["ui", "center-graphic"].includes(name)) throw new Error("Expected ui or center-graphic")
|
||||
if (!["ui", "center-graphic", "chart"].includes(name)) throw new Error("Expected ui, center-graphic or chart")
|
||||
const packageRoot = path.join(root, "packages", name)
|
||||
const output = path.join(packageRoot, "dist")
|
||||
const manifest = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8"))
|
||||
@@ -26,7 +26,9 @@ await build({
|
||||
lib: {
|
||||
entry: name === "ui"
|
||||
? { index: path.join(packageRoot, "src/index.ts"), "theme/index": path.join(packageRoot, "src/theme/index.ts") }
|
||||
: { index: path.join(packageRoot, "src/index.ts"), reference: path.join(packageRoot, "src/reference.ts") },
|
||||
: name === "chart"
|
||||
? { index: path.join(packageRoot, "src/index.ts"), examples: path.join(packageRoot, "src/examples/index.ts") }
|
||||
: { index: path.join(packageRoot, "src/index.ts"), reference: path.join(packageRoot, "src/reference.ts") },
|
||||
formats: ["es"],
|
||||
fileName: (_format, entry) => `${entry}.js`,
|
||||
cssFileName: "style",
|
||||
@@ -35,7 +37,7 @@ await build({
|
||||
external: id => externals.some(dep => id === dep || id.startsWith(`${dep}/`)),
|
||||
onwarn(warning, warn) {
|
||||
// The UI component entrypoint retains the directive below; source directives are redundant.
|
||||
if (name === "ui" && warning.code === "MODULE_LEVEL_DIRECTIVE") return
|
||||
if ((name === "ui" || name === "chart") && warning.code === "MODULE_LEVEL_DIRECTIVE") return
|
||||
warn(warning)
|
||||
},
|
||||
},
|
||||
@@ -89,6 +91,15 @@ if (name === "ui") {
|
||||
.replace('/fonts/NotoSansKR-variable.ttf', './fonts/NotoSansKR-variable.ttf')
|
||||
await writeFile(path.join(output, "theme.css"), theme)
|
||||
await cp(path.join(packageRoot, "src/patterns/dashboard-status/dashboard-status.css"), path.join(output, "dashboard-status.css"))
|
||||
} else if (name === "chart") {
|
||||
// The chart stylesheet loads with the component entry. Tailwind utilities used by the shadcn Chart markup
|
||||
// are generated in the consumer app, so ship the same @source entry as @hm-design/ui/tailwind.css.
|
||||
// Both entries share one chunk, so each loads the stylesheet and keeps the client directive Rolldown drops.
|
||||
for (const entry of ["index.js", "examples.js"]) {
|
||||
const file = path.join(output, entry)
|
||||
await writeFile(file, `"use client";\nimport "./style.css";\n${await readFile(file, "utf8")}`)
|
||||
}
|
||||
await cp(path.join(packageRoot, "src/tailwind.css"), path.join(output, "tailwind.css"))
|
||||
} else {
|
||||
// Preserve CENTER's existing automatic component CSS behavior for both public entrypoints.
|
||||
for (const entry of ["index.js", "reference.js"]) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import {
|
||||
AreaCurveDotLabelExample,
|
||||
AreaCurveExample,
|
||||
AreaExpandedExample,
|
||||
AreaStackedExample,
|
||||
AreaStepDotLabelExample,
|
||||
AreaStepExample,
|
||||
AreaStraightColorLabelExample,
|
||||
AreaStraightExample,
|
||||
} from "@hm-design/chart/examples"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Sample data only. The eight examples share the Line Chart data: connection styles as a plain area (left) and with Dots and
|
||||
// value Labels (right), then Stacked (running total) and Expanded (each month normalized to 100%).
|
||||
const meta = {
|
||||
title: "Chart/Area Chart",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
} satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Area: Story = {
|
||||
name: "Area Chart",
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Area Chart" />
|
||||
{/* Same grid and order as Line Chart, plus a Stacked | Expanded row. */}
|
||||
<div className="grid grid-cols-1 gap-x-6 lg:auto-rows-fr lg:grid-cols-2 *:flex *:min-w-0 *:flex-col [&_.sb-page-section-body]:flex-1">
|
||||
<StorySection title="Curve">
|
||||
<AreaCurveExample />
|
||||
</StorySection>
|
||||
<StorySection title="Curve · Dot + Label">
|
||||
<AreaCurveDotLabelExample />
|
||||
</StorySection>
|
||||
<StorySection title="Straight">
|
||||
<AreaStraightExample />
|
||||
</StorySection>
|
||||
<StorySection title="Straight · Color Dot + Label">
|
||||
<AreaStraightColorLabelExample />
|
||||
</StorySection>
|
||||
<StorySection title="Step">
|
||||
<AreaStepExample />
|
||||
</StorySection>
|
||||
<StorySection title="Step · Dot + Label">
|
||||
<AreaStepDotLabelExample />
|
||||
</StorySection>
|
||||
<StorySection title="Stacked">
|
||||
<AreaStackedExample />
|
||||
</StorySection>
|
||||
<StorySection title="Expanded">
|
||||
<AreaExpandedExample />
|
||||
</StorySection>
|
||||
</div>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import {
|
||||
BarChartExample,
|
||||
HorizontalBarChartExample,
|
||||
SignedBarChartExample,
|
||||
StackedBarChartExample,
|
||||
} from "@hm-design/chart/examples"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// shadcn/ui Chart (Recharts) with HM tokens, from the public @hm-design/chart entries. All values are sample data.
|
||||
// Narrow widths: use the toolbar Viewport presets (HM Mobile · 390). The chart is fluid.
|
||||
const meta = {
|
||||
title: "Chart/Bar Chart",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
} satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Bar: Story = {
|
||||
name: "Bar Chart",
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Bar Chart" />
|
||||
{/* Two sections per row from 1024px (HM desktop boundary), one below. Source order is the mobile order; on desktop it reads
|
||||
row by row: Grouped | Horizontal, then Stacked | Positive / Negative. On desktop every row has the same height and each
|
||||
card fills its section, so card tops, bottoms and chart areas line up. */}
|
||||
<div className="grid grid-cols-1 gap-x-6 lg:auto-rows-fr lg:grid-cols-2 *:flex *:min-w-0 *:flex-col [&_.sb-page-section-body]:flex-1">
|
||||
<StorySection title="Grouped">
|
||||
<BarChartExample />
|
||||
</StorySection>
|
||||
<StorySection title="Horizontal">
|
||||
<HorizontalBarChartExample />
|
||||
</StorySection>
|
||||
<StorySection title="Stacked">
|
||||
<StackedBarChartExample />
|
||||
</StorySection>
|
||||
<StorySection title="Positive / Negative">
|
||||
<SignedBarChartExample />
|
||||
</StorySection>
|
||||
</div>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import {
|
||||
DonutActiveExample,
|
||||
DonutCenterExample,
|
||||
DonutInteractiveExample,
|
||||
DonutPlainExample,
|
||||
} from "@hm-design/chart/examples"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Sample data only. The four examples share one data set, color mapping and item order.
|
||||
const meta = {
|
||||
title: "Chart/Donut Chart",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
} satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Donut: Story = {
|
||||
name: "Donut Chart",
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Donut Chart" />
|
||||
{/* Same grid as Bar and Line Chart: two per row from 1024px, one below; source order is the mobile order. */}
|
||||
<div className="grid grid-cols-1 gap-x-6 lg:auto-rows-fr lg:grid-cols-2 *:flex *:min-w-0 *:flex-col [&_.sb-page-section-body]:flex-1">
|
||||
<StorySection title="기본형">
|
||||
<DonutPlainExample />
|
||||
</StorySection>
|
||||
<StorySection title="기본형 + 텍스트">
|
||||
<DonutCenterExample />
|
||||
</StorySection>
|
||||
<StorySection title="Active">
|
||||
<DonutActiveExample />
|
||||
</StorySection>
|
||||
<StorySection title="Interactive">
|
||||
<DonutInteractiveExample />
|
||||
</StorySection>
|
||||
</div>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import {
|
||||
LineCurveDotLabelExample,
|
||||
LineCurveExample,
|
||||
LineStepDotLabelExample,
|
||||
LineStepExample,
|
||||
LineStraightColorLabelExample,
|
||||
LineStraightExample,
|
||||
} from "@hm-design/chart/examples"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Sample data only. All six examples share one data set and axis range, so connection styles compare directly: curve, straight
|
||||
// and step, each as a plain line (left) and with Dots and value labels (right).
|
||||
const meta = {
|
||||
title: "Chart/Line Chart",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
} satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Line: Story = {
|
||||
name: "Line Chart",
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Line Chart" />
|
||||
{/* Same grid as Bar Chart: two per row from 1024px, one below. Source order is the mobile order; desktop reads row by row:
|
||||
Curve | Curve + labels, Straight | Straight + colored labels, Step | Step + labels. */}
|
||||
<div className="grid grid-cols-1 gap-x-6 lg:auto-rows-fr lg:grid-cols-2 *:flex *:min-w-0 *:flex-col [&_.sb-page-section-body]:flex-1">
|
||||
<StorySection title="Curve">
|
||||
<LineCurveExample />
|
||||
</StorySection>
|
||||
<StorySection title="Curve · Dot + Label">
|
||||
<LineCurveDotLabelExample />
|
||||
</StorySection>
|
||||
<StorySection title="Straight">
|
||||
<LineStraightExample />
|
||||
</StorySection>
|
||||
<StorySection title="Straight · Color Dot + Label">
|
||||
<LineStraightColorLabelExample />
|
||||
</StorySection>
|
||||
<StorySection title="Step">
|
||||
<LineStepExample />
|
||||
</StorySection>
|
||||
<StorySection title="Step · Dot + Label">
|
||||
<LineStepDotLabelExample />
|
||||
</StorySection>
|
||||
</div>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { PieLabelExample, PieLabelListExample, PiePlainExample, PieStackedExample } from "@hm-design/chart/examples"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Sample data only. All pies share one item list and color mapping; the stacked pie draws two series as concentric rings.
|
||||
const meta = {
|
||||
title: "Chart/Pie Chart",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
} satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Pie: Story = {
|
||||
name: "Pie Chart",
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Pie Chart" />
|
||||
{/* Same grid as Bar, Line and Donut Chart: two per row from 1024px, one below; source order is the mobile order. */}
|
||||
<div className="grid grid-cols-1 gap-x-6 lg:auto-rows-fr lg:grid-cols-2 *:flex *:min-w-0 *:flex-col [&_.sb-page-section-body]:flex-1">
|
||||
<StorySection title="기본 Pie">
|
||||
<PiePlainExample />
|
||||
</StorySection>
|
||||
<StorySection title="Pie + Label">
|
||||
<PieLabelExample />
|
||||
</StorySection>
|
||||
<StorySection title="Pie + Label List">
|
||||
<PieLabelListExample />
|
||||
</StorySection>
|
||||
<StorySection title="Pie + Stacked">
|
||||
<PieStackedExample />
|
||||
</StorySection>
|
||||
</div>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { RadarCustomLabelExample, RadarDotExample, RadarLinesOnlyExample, RadarPlainExample } from "@hm-design/chart/examples"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Sample data only. All radars share one data set (six axes, two series) and the 0-100 axis range.
|
||||
const meta = {
|
||||
title: "Chart/Radar Chart",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
} satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Radar: Story = {
|
||||
name: "Radar Chart",
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Radar Chart" />
|
||||
{/* Same grid as the other Chart pages: two per row from 1024px, one below. */}
|
||||
<div className="grid grid-cols-1 gap-x-6 lg:auto-rows-fr lg:grid-cols-2 *:flex *:min-w-0 *:flex-col [&_.sb-page-section-body]:flex-1">
|
||||
<StorySection title="Radar">
|
||||
<RadarPlainExample />
|
||||
</StorySection>
|
||||
<StorySection title="Radar + Dot">
|
||||
<RadarDotExample />
|
||||
</StorySection>
|
||||
<StorySection title="Radar + Lines Only">
|
||||
<RadarLinesOnlyExample />
|
||||
</StorySection>
|
||||
<StorySection title="Radar + Custom Label">
|
||||
<RadarCustomLabelExample />
|
||||
</StorySection>
|
||||
</div>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import {
|
||||
RadialGridExample,
|
||||
RadialLabelExample,
|
||||
RadialShapeExample,
|
||||
RadialSimpleExample,
|
||||
RadialStackedExample,
|
||||
RadialTextExample,
|
||||
} from "@hm-design/chart/examples"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Sample data only. One example per official shadcn/ui radial chart (radial-simple, -label, -grid, -text, -shape, -stacked).
|
||||
const meta = {
|
||||
title: "Chart/Radial Chart",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
} satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Radial: Story = {
|
||||
name: "Radial Chart",
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Radial Chart" />
|
||||
{/* Same grid as the other Chart pages: two per row from 1024px, one below; source order is the mobile order. */}
|
||||
<div className="grid grid-cols-1 gap-x-6 lg:auto-rows-fr lg:grid-cols-2 *:flex *:min-w-0 *:flex-col [&_.sb-page-section-body]:flex-1">
|
||||
<StorySection title="Radial Chart">
|
||||
<RadialSimpleExample />
|
||||
</StorySection>
|
||||
<StorySection title="Radial Chart + Label">
|
||||
<RadialLabelExample />
|
||||
</StorySection>
|
||||
<StorySection title="Radial Chart - Grid">
|
||||
<RadialGridExample />
|
||||
</StorySection>
|
||||
<StorySection title="Radial Chart + Text">
|
||||
<RadialTextExample />
|
||||
</StorySection>
|
||||
<StorySection title="Radial Chart - Shape">
|
||||
<RadialShapeExample />
|
||||
</StorySection>
|
||||
<StorySection title="Radial Chart + Stacked">
|
||||
<RadialStackedExample />
|
||||
</StorySection>
|
||||
</div>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
@import "tailwindcss" source("./");
|
||||
@import "@hm-design/ui/tailwind.css";
|
||||
@import "@hm-design/chart/tailwind.css";
|
||||
/* Keep utility detection relative to this entry in both the repo and extracted ZIP. */
|
||||
@source "../.storybook";
|
||||
Reference in new issue
Block a user