From dc9258599ceaa92ca1b28046260664eb5bd21d8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=88=98=ED=98=84?= Date: Fri, 6 Nov 2026 15:52:47 +0900 Subject: [PATCH] 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 --- .storybook/preview.tsx | 2 + .storybook/qa/STORY-INVENTORY.md | 50 ++- .storybook/qa/story-inventory.json | 134 +++++- CLAUDE.md | 2 +- README.md | 8 +- docs/ai/AI_GUIDE.md | 3 +- docs/ai/MAINTAINING.md | 2 +- docs/guidelines/external-install-submodule.md | 8 +- examples/chart/hm-theme.css | 1 + examples/chart/index.css | 9 + examples/chart/index.html | 2 + examples/chart/main.tsx | 27 ++ examples/chart/tsconfig.json | 5 + examples/chart/vite.config.ts | 13 + package-lock.json | 406 +++++++++++++++++- package.json | 6 +- packages/chart/README.md | 128 ++++++ packages/chart/package.json | 46 ++ packages/chart/src/chart.css | 152 +++++++ packages/chart/src/components/area-chart.tsx | 144 +++++++ packages/chart/src/components/bar-chart.tsx | 74 ++++ packages/chart/src/components/bar-gradient.ts | 4 + packages/chart/src/components/chart.tsx | 380 ++++++++++++++++ packages/chart/src/components/donut-chart.tsx | 185 ++++++++ .../src/components/horizontal-bar-chart.tsx | 137 ++++++ packages/chart/src/components/line-chart.tsx | 104 +++++ packages/chart/src/components/line-points.tsx | 254 +++++++++++ packages/chart/src/components/pie-chart.tsx | 193 +++++++++ packages/chart/src/components/radar-chart.tsx | 92 ++++ .../chart/src/components/radial-chart.tsx | 289 +++++++++++++ packages/chart/src/components/series.ts | 44 ++ .../chart/src/components/signed-bar-chart.tsx | 86 ++++ packages/chart/src/components/text-fit.ts | 18 + packages/chart/src/env.d.ts | 2 + .../chart/src/examples/chart-examples.tsx | 205 +++++++++ packages/chart/src/examples/index.ts | 31 ++ .../src/examples/pie-radar-area-examples.tsx | 121 ++++++ .../chart/src/examples/radial-examples.tsx | 49 +++ packages/chart/src/examples/sample-data.ts | 189 ++++++++ packages/chart/src/index.ts | 36 ++ packages/chart/src/tailwind.css | 3 + packages/chart/tsconfig.build.json | 17 + packages/chart/tsconfig.json | 8 + packages/org-chart-react/ai/AI_GUIDE.md | 3 +- packages/org-chart/ai/AI_GUIDE.md | 3 +- packages/sw/ai/AI_GUIDE.md | 3 +- packages/ui/ai/AI_GUIDE.md | 3 +- scripts/build-package.mjs | 17 +- src/chart/area-chart.stories.tsx | 57 +++ src/chart/bar-chart.stories.tsx | 43 ++ src/chart/donut-chart.stories.tsx | 40 ++ src/chart/line-chart.stories.tsx | 50 +++ src/chart/pie-chart.stories.tsx | 35 ++ src/chart/radar-chart.stories.tsx | 35 ++ src/chart/radial-chart.stories.tsx | 48 +++ src/index.css | 1 + 56 files changed, 3979 insertions(+), 28 deletions(-) create mode 100644 examples/chart/hm-theme.css create mode 100644 examples/chart/index.css create mode 100644 examples/chart/index.html create mode 100644 examples/chart/main.tsx create mode 100644 examples/chart/tsconfig.json create mode 100644 examples/chart/vite.config.ts create mode 100644 packages/chart/README.md create mode 100644 packages/chart/package.json create mode 100644 packages/chart/src/chart.css create mode 100644 packages/chart/src/components/area-chart.tsx create mode 100644 packages/chart/src/components/bar-chart.tsx create mode 100644 packages/chart/src/components/bar-gradient.ts create mode 100644 packages/chart/src/components/chart.tsx create mode 100644 packages/chart/src/components/donut-chart.tsx create mode 100644 packages/chart/src/components/horizontal-bar-chart.tsx create mode 100644 packages/chart/src/components/line-chart.tsx create mode 100644 packages/chart/src/components/line-points.tsx create mode 100644 packages/chart/src/components/pie-chart.tsx create mode 100644 packages/chart/src/components/radar-chart.tsx create mode 100644 packages/chart/src/components/radial-chart.tsx create mode 100644 packages/chart/src/components/series.ts create mode 100644 packages/chart/src/components/signed-bar-chart.tsx create mode 100644 packages/chart/src/components/text-fit.ts create mode 100644 packages/chart/src/env.d.ts create mode 100644 packages/chart/src/examples/chart-examples.tsx create mode 100644 packages/chart/src/examples/index.ts create mode 100644 packages/chart/src/examples/pie-radar-area-examples.tsx create mode 100644 packages/chart/src/examples/radial-examples.tsx create mode 100644 packages/chart/src/examples/sample-data.ts create mode 100644 packages/chart/src/index.ts create mode 100644 packages/chart/src/tailwind.css create mode 100644 packages/chart/tsconfig.build.json create mode 100644 packages/chart/tsconfig.json create mode 100644 src/chart/area-chart.stories.tsx create mode 100644 src/chart/bar-chart.stories.tsx create mode 100644 src/chart/donut-chart.stories.tsx create mode 100644 src/chart/line-chart.stories.tsx create mode 100644 src/chart/pie-chart.stories.tsx create mode 100644 src/chart/radar-chart.stories.tsx create mode 100644 src/chart/radial-chart.stories.tsx diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 7c4abd9..c90981f 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -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", diff --git a/.storybook/qa/STORY-INVENTORY.md b/.storybook/qa/STORY-INVENTORY.md index c3ff92f..1f8b092 100644 --- a/.storybook/qa/STORY-INVENTORY.md +++ b/.storybook/qa/STORY-INVENTORY.md @@ -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: [] diff --git a/.storybook/qa/story-inventory.json b/.storybook/qa/story-inventory.json index 7fe872f..eca3520 100644 --- a/.storybook/qa/story-inventory.json +++ b/.storybook/qa/story-inventory.json @@ -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", diff --git a/CLAUDE.md b/CLAUDE.md index 6d875c3..8a243e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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도 ``만으로 HM 스타일이 적용된다(ui_viz Content Card, `packages/ui/src/theme/card.css`). Dialog도 ``만으로 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 문서를 대조한다. diff --git a/README.md b/README.md index 58e4ad0..66dae6e 100644 --- a/README.md +++ b/README.md @@ -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 예시 화면 스타일. 장식용 시제품 색상은 고정값으로 남아 있음 diff --git a/docs/ai/AI_GUIDE.md b/docs/ai/AI_GUIDE.md index 459a740..d57ab5e 100644 --- a/docs/ai/AI_GUIDE.md +++ b/docs/ai/AI_GUIDE.md @@ -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단계. 사용 목적 확인 diff --git a/docs/ai/MAINTAINING.md b/docs/ai/MAINTAINING.md index be337b2..5c6d0f2 100644 --- a/docs/ai/MAINTAINING.md +++ b/docs/ai/MAINTAINING.md @@ -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/…`)을 링크하지 않습니다(설치 후 존재하지 않음). diff --git a/docs/guidelines/external-install-submodule.md b/docs/guidelines/external-install-submodule.md index 9e58b0c..e2b7a0a 100644 --- a/docs/guidelines/external-install-submodule.md +++ b/docs/guidelines/external-install-submodule.md @@ -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 버전 갱신 diff --git a/examples/chart/hm-theme.css b/examples/chart/hm-theme.css new file mode 100644 index 0000000..e490cde --- /dev/null +++ b/examples/chart/hm-theme.css @@ -0,0 +1 @@ +@import "@hm-design/ui/theme.css"; diff --git a/examples/chart/index.css b/examples/chart/index.css new file mode 100644 index 0000000..11ec302 --- /dev/null +++ b/examples/chart/index.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); } diff --git a/examples/chart/index.html b/examples/chart/index.html new file mode 100644 index 0000000..de1879d --- /dev/null +++ b/examples/chart/index.html @@ -0,0 +1,2 @@ + +HMDesign 차트 예제
diff --git a/examples/chart/main.tsx b/examples/chart/main.tsx new file mode 100644 index 0000000..4556731 --- /dev/null +++ b/examples/chart/main.tsx @@ -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( + +
+
+
+

HMDesign 차트 예제

+

막대·선·도넛 차트와 파이·방사형(Radar)·영역·래디얼 차트 예시의 표시와 툴팁·범례·반응형 동작을 확인하는 화면입니다. 모든 값은 예시 데이터입니다.

+
+ + {/* One example each of the Pie, Radar, Area and Radial charts. */} +
+ + + + +
+
+
+
, +) diff --git a/examples/chart/tsconfig.json b/examples/chart/tsconfig.json new file mode 100644 index 0000000..ef9ee34 --- /dev/null +++ b/examples/chart/tsconfig.json @@ -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"] +} diff --git a/examples/chart/vite.config.ts b/examples/chart/vite.config.ts new file mode 100644 index 0000000..2d01a8f --- /dev/null +++ b/examples/chart/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" }, +}) diff --git a/package-lock.json b/package-lock.json index e615788..059a8b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 231a25c..768ed32 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/chart/README.md b/packages/chart/README.md new file mode 100644 index 0000000..66e4293 --- /dev/null +++ b/packages/chart/README.md @@ -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하지 않습니다. +- ``·``에 배경·글꼴을 정하는 것은 소비 앱의 몫입니다(차트는 전역 규칙을 넣지 않습니다). + +## 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-`)으로 쓰이므로 **영문·숫자·`-`·`_`만** 사용합니다(한글·공백 금지). 화면에 보이는 이름은 `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 }] + + + + +``` + +가로형 라벨 막대(`HMHorizontalBarChart`)는 항목명을 막대 안쪽 왼쪽에, 값을 막대 끝 바깥에 표시합니다. 그라데이션은 왼쪽 30% → 오른쪽 100%이고(세로형은 상단 100% → 하단 30%), 막대에 들어가지 않는 긴 항목명은 `…`로 줄이며 막대가 너무 짧거나 0이면 값 뒤에 항목명을 보여 줍니다. 전체 항목명은 툴팁에서 확인합니다. 선택 props는 `color`, `valueFormatter`입니다. `HMSignedBarChart`는 양수는 0선 위로, 음수는 아래로 그립니다. Stacked 세로형 막대(`HMBarChart stacked`)는 눈금선이 비치지 않도록 각 세그먼트를 불투명한 두 색(시리즈 색 → 같은 계열의 한 단계 밝은 HM 팔레트 색, `--hm-chart-N-light`)으로 칠합니다. 사용자 지정 `color`는 `lightColor`를 함께 주지 않으면 단색 세그먼트가 됩니다. + +```tsx + +``` + +선 차트(`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는 차트 별칭을 통해 반영됩니다. diff --git a/packages/chart/package.json b/packages/chart/package.json new file mode 100644 index 0000000..847370a --- /dev/null +++ b/packages/chart/package.json @@ -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" + } +} diff --git a/packages/chart/src/chart.css b/packages/chart/src/chart.css new file mode 100644 index 0000000..9d09a40 --- /dev/null +++ b/packages/chart/src/chart.css @@ -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; +} diff --git a/packages/chart/src/components/area-chart.tsx b/packages/chart/src/components/area-chart.tsx new file mode 100644 index 0000000..6091b4b --- /dev/null +++ b/packages/chart/src/components/area-chart.tsx @@ -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> + /** 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> + /** 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> = + 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["formatter"] = (_value, _name, item) => { + const row = (item?.payload ?? {}) as Record + 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 ( +
+
+ {series.find(s => s.key === key)?.label ?? key} + {value.toLocaleString()} · {percent}% +
+ ) + } + + return ( + + []} + margin={{ top: 24, right: 12, left: 0, bottom: 0 }} + stackOffset={expanded ? "expand" : undefined} + > + + {series.map(item => ( + + + + + ))} + + + {/* Inner padding keeps the first and last Dot, Label and active point from being clipped at the plot edge. */} + + `${Math.round(value * 100)}%` : undefined} + /> + + : Object.keys(colorKeys).length > 0 && customPoints + ? + : + } + /> + } /> + {series.map((item, index) => { + const color = `var(--color-${item.key})` + const colorKey = colorKeys[item.key] + return ( + : dots ? { r: 3, fill: color, strokeWidth: 0 } : false} + activeDot={customPoints ? : { r: 5 }} + > + {customPoints && pointLabels ? ( + } + /> + ) : null} + + ) + })} + + + ) +} diff --git a/packages/chart/src/components/bar-chart.tsx b/packages/chart/src/components/bar-chart.tsx new file mode 100644 index 0000000..f46e0bf --- /dev/null +++ b/packages/chart/src/components/bar-chart.tsx @@ -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> + /** 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 ( + + []} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}> + + + + } /> + } /> + + {series.map((item, index) => ( + + {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. */} + + + + ) : ( + <> + + + + )} + + ))} + + {series.map((item, index) => ( + } + stackId={stacked ? "stack" : undefined} + radius={stacked ? (index === series.length - 1 ? [4, 4, 0, 0] : 0) : [4, 4, 0, 0]} + /> + ))} + + + ) +} diff --git a/packages/chart/src/components/bar-gradient.ts b/packages/chart/src/components/bar-gradient.ts new file mode 100644 index 0000000..0ce3bb8 --- /dev/null +++ b/packages/chart/src/components/bar-gradient.ts @@ -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 diff --git a/packages/chart/src/components/chart.tsx b/packages/chart/src/components/chart.tsx new file mode 100644 index 0000000..d5acfed --- /dev/null +++ b/packages/chart/src/components/chart.tsx @@ -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 } + ) +> + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + 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 ( + +
+ + + {children} + +
+
+ ) +} + +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 ( +