diff --git a/.changeset/initial-release.md b/.changeset/initial-release.md new file mode 100644 index 0000000..bae87b5 --- /dev/null +++ b/.changeset/initial-release.md @@ -0,0 +1,12 @@ +--- +'@ciscode/ui-chart-kit': minor +--- + +Initial release of @ciscode/ui-chart-kit v0.1.0. + +- ChartDataPoint, ChartDataset, and ChartTheme type contracts +- buildChartConfig utility mapping typed data to Chart.js config +- BarChart component with stacked and horizontal support +- LineChart component with smooth curve support +- AreaChart component with fill at 20% opacity and stacked support +- All components responsive with configurable height diff --git a/README.md b/README.md index 539fe85..d2fcecb 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,170 @@ -# React TypeScript DeveloperKit (Template) +# @ciscode/ui-chart-kit -Template repository for building reusable React TypeScript **npm libraries** -(components + hooks + utilities). +Typed React chart components (Bar, Line, Area) built on Chart.js. +Pass data and a theme — get a fully configured, responsive chart. No raw Chart.js options required. -## What you get +## Installation -- ESM + CJS + Types build (tsup) -- Vitest testing -- ESLint + Prettier (flat config) -- Changesets (manual release flow, no automation PR) -- Husky (pre-commit + pre-push) -- Enforced public API via `src/index.ts` -- Dependency-free styling (Tailwind-compatible by convention only) -- `react` and `react-dom` as peerDependencies +```bash +npm install @ciscode/ui-chart-kit +``` -## Package structure +### Peer dependencies -- `src/components` – reusable UI components -- `src/hooks` – reusable React hooks -- `src/utils` – framework-agnostic utilities -- `src/index.ts` – **only public API** (no deep imports allowed) +| Package | Version | +| ----------- | ------- | +| `react` | ≥ 18 | +| `react-dom` | ≥ 18 | -Anything not exported from `src/index.ts` is considered private. +`chart.js` and `react-chartjs-2` are bundled — you do **not** need to install them separately. -## Scripts +--- -- `npm run build` – build to `dist/` (tsup) -- `npm test` – run tests (vitest) -- `npm run typecheck` – TypeScript typecheck -- `npm run lint` – ESLint -- `npm run format` / `npm run format:write` – Prettier -- `npx changeset` – create a changeset +## Data types -## Release flow (summary) +### `ChartDataPoint` -- Work on a `feature` branch from `develop` -- Merge to `develop` -- Add a changeset for user-facing changes: `npx changeset` -- Promote `develop` → `master` -- Tag `vX.Y.Z` to publish (npm OIDC) +```ts +interface ChartDataPoint { + label: string; + value: number; +} +``` -This repository is a **template**. Teams should clone it and focus only on -library logic, not tooling or release mechanics. +### `ChartDataset` + +```ts +interface ChartDataset { + id: string; + label: string; + data: ChartDataPoint[]; + color?: string; // hex color — falls back to theme.colors when omitted +} +``` + +### `ChartTheme` + +```ts +interface ChartTheme { + colors: string[]; // palette shared across datasets + fontFamily?: string; + fontSize?: number; + grid?: { + color?: string; + display?: boolean; + }; + tooltip?: { + enabled?: boolean; + backgroundColor?: string; + titleColor?: string; + bodyColor?: string; + }; + legend?: { + display?: boolean; + position?: 'top' | 'bottom' | 'left' | 'right'; + }; +} +``` + +--- + +## Components + +### BarChart + +| Prop | Type | Default | Description | +| ------------ | ---------------- | ------- | --------------------------------- | +| `data` | `ChartDataset[]` | — | Datasets to render | +| `theme` | `ChartTheme` | — | Theme (colors, fonts, grid, etc.) | +| `height` | `number` | `300` | Chart height in pixels | +| `stacked` | `boolean` | `false` | Stack bars on top of each other | +| `horizontal` | `boolean` | `false` | Render horizontal bars | + +```tsx +import { BarChart } from '@ciscode/ui-chart-kit'; +import type { ChartDataset, ChartTheme } from '@ciscode/ui-chart-kit'; + +const theme: ChartTheme = { + colors: ['#4F46E5', '#10B981', '#F59E0B'], + fontFamily: 'Inter, sans-serif', + fontSize: 12, + grid: { color: '#E5E7EB', display: true }, + tooltip: { enabled: true, backgroundColor: '#1F2937' }, + legend: { display: true, position: 'top' }, +}; + +const datasets: ChartDataset[] = [ + { + id: 'revenue', + label: 'Revenue', + data: [ + { label: 'Q1', value: 120 }, + { label: 'Q2', value: 180 }, + { label: 'Q3', value: 150 }, + { label: 'Q4', value: 210 }, + ], + }, +]; + +function App() { + return ; +} +``` + +--- + +### LineChart + +| Prop | Type | Default | Description | +| -------- | ---------------- | ------- | --------------------------------------- | +| `data` | `ChartDataset[]` | — | Datasets to render | +| `theme` | `ChartTheme` | — | Theme (colors, fonts, grid, etc.) | +| `height` | `number` | `300` | Chart height in pixels | +| `smooth` | `boolean` | `false` | Curved line interpolation (0.4 tension) | + +```tsx +import { LineChart } from '@ciscode/ui-chart-kit'; + +function App() { + return ; +} +``` + +--- + +### AreaChart + +| Prop | Type | Default | Description | +| --------- | ---------------- | ------- | --------------------------------------- | +| `data` | `ChartDataset[]` | — | Datasets to render | +| `theme` | `ChartTheme` | — | Theme (colors, fonts, grid, etc.) | +| `height` | `number` | `300` | Chart height in pixels | +| `smooth` | `boolean` | `false` | Curved line interpolation (0.4 tension) | +| `stacked` | `boolean` | `false` | Stack areas on top of each other | + +Area fill uses the dataset color at 20 % opacity automatically. + +```tsx +import { AreaChart } from '@ciscode/ui-chart-kit'; + +function App() { + return ; +} +``` + +--- + +## Design decisions + +- **No Chart.js passthrough.** Components expose a curated props API only. + Chart.js configuration is built internally via `buildChartConfig`. + This keeps the public surface small and prevents breaking changes + when Chart.js internals evolve. +- **Colors cycle.** When there are more datasets than `theme.colors` entries, + colors wrap around automatically. +- **Responsive by default.** Every chart renders inside a `div` with + `width: 100%` and the specified `height`. + +## License + +MIT diff --git a/ciscode-reactts-developerkit-1.0.0.tgz b/ciscode-reactts-developerkit-1.0.0.tgz new file mode 100644 index 0000000..47778b6 Binary files /dev/null and b/ciscode-reactts-developerkit-1.0.0.tgz differ diff --git a/package-lock.json b/package-lock.json index 065e5b9..1599bb6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,10 @@ "name": "@ciscode/ui-chart-kit", "version": "0.0.0", "license": "MIT", + "dependencies": { + "chart.js": "^4.5.1", + "react-chartjs-2": "^5.3.1" + }, "devDependencies": { "@changesets/cli": "^2.27.8", "@eslint/js": "^9.39.2", @@ -158,6 +162,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -779,6 +784,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -819,6 +825,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1736,6 +1743,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@manypkg/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@manypkg/find-root/-/find-root-1.1.0.tgz", @@ -2309,8 +2322,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", @@ -2339,6 +2351,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2350,6 +2363,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2389,6 +2403,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -2724,6 +2739,7 @@ "integrity": "sha512-izzd2zmnk8Nl5ECYkW27328RbQ1nKvkm6Bb5DAaz1Gk59EbLkiCMa6OLT0NoaAYTjOFS6N+SMYW1nh4/9ljPiw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "2.1.9", "fflate": "^0.8.2", @@ -2761,6 +2777,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3157,6 +3174,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3319,6 +3337,19 @@ "dev": true, "license": "MIT" }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -3712,8 +3743,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -3982,6 +4012,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -4046,6 +4077,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5838,6 +5870,7 @@ "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.8.1", @@ -6217,7 +6250,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6975,6 +7007,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7059,7 +7092,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -7075,7 +7107,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -7160,6 +7191,16 @@ "node": ">=0.10.0" } }, + "node_modules/react-chartjs-2": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz", + "integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-dom": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", @@ -7178,8 +7219,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/read-yaml-file": { "version": "1.1.0", @@ -7556,8 +7596,7 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/semver": { "version": "7.7.4", @@ -8376,6 +8415,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8662,6 +8702,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8787,6 +8828,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -9300,6 +9342,7 @@ "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", @@ -9710,6 +9753,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 2502981..7ef117a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@ciscode/ui-chart-kit", - "version": "0.0.0", - "description": "React 19 chart library on Chart.js.", + "version": "0.1.0", + "description": "Typed React chart components (Bar, Line, Area) built on Chart.js — no raw Chart.js config required.", "license": "MIT", "private": false, "type": "module", @@ -84,5 +84,9 @@ }, "engines": { "node": ">=20" + }, + "dependencies": { + "chart.js": "^4.5.1", + "react-chartjs-2": "^5.3.1" } } diff --git a/src/components/AreaChart/AreaChart.test.tsx b/src/components/AreaChart/AreaChart.test.tsx new file mode 100644 index 0000000..72fba88 --- /dev/null +++ b/src/components/AreaChart/AreaChart.test.tsx @@ -0,0 +1,184 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import type { ChartDataset, ChartTheme } from '../../types/chart.types'; + +vi.mock('react-chartjs-2', () => ({ + Line: (props: { data: unknown; options: unknown }) => ( + + ), +})); + +vi.mock('chart.js', () => ({ + Chart: { register: vi.fn() }, + CategoryScale: 'CategoryScale', + LinearScale: 'LinearScale', + LineElement: 'LineElement', + PointElement: 'PointElement', + Filler: 'Filler', + Tooltip: 'Tooltip', + Legend: 'Legend', +})); + +import { AreaChart } from './AreaChart'; + +const singleDataset: ChartDataset[] = [ + { + id: 'ds1', + label: 'Sales', + data: [ + { label: 'Jan', value: 10 }, + { label: 'Feb', value: 20 }, + ], + }, +]; + +const multiDatasets: ChartDataset[] = [ + { + id: 'ds1', + label: 'Sales', + data: [ + { label: 'Jan', value: 10 }, + { label: 'Feb', value: 20 }, + ], + }, + { + id: 'ds2', + label: 'Revenue', + data: [ + { label: 'Jan', value: 30 }, + { label: 'Feb', value: 40 }, + ], + color: '#00FF00', + }, + { + id: 'ds3', + label: 'Costs', + data: [ + { label: 'Jan', value: 5 }, + { label: 'Feb', value: 15 }, + ], + }, +]; + +const theme: ChartTheme = { + colors: ['#FF0000', '#00FF00', '#0000FF'], + fontFamily: 'Arial', + fontSize: 14, + grid: { color: '#ccc', display: true }, + tooltip: { enabled: true, backgroundColor: '#333' }, + legend: { display: true, position: 'bottom' }, +}; + +describe('AreaChart', () => { + it('should render with 1 dataset without errors', () => { + render(); + expect(screen.getByTestId('area-canvas')).toBeInTheDocument(); + }); + + it('should render with multiple datasets without errors', () => { + render(); + const canvas = screen.getByTestId('area-canvas'); + const data = JSON.parse(canvas.getAttribute('data-data')!); + expect(data.datasets).toHaveLength(3); + }); + + it('should set fill true on all datasets (area variant)', () => { + render(); + const canvas = screen.getByTestId('area-canvas'); + const data = JSON.parse(canvas.getAttribute('data-data')!); + for (const ds of data.datasets) { + expect(ds.fill).toBe(true); + } + }); + + it('should apply 20% opacity (append 33) to backgroundColor', () => { + render(); + const canvas = screen.getByTestId('area-canvas'); + const data = JSON.parse(canvas.getAttribute('data-data')!); + expect(data.datasets[0].backgroundColor).toBe('#FF000033'); + }); + + it('should use dataset color with 20% opacity when color is provided', () => { + render(); + const canvas = screen.getByTestId('area-canvas'); + const data = JSON.parse(canvas.getAttribute('data-data')!); + expect(data.datasets[1].backgroundColor).toBe('#00FF0033'); + }); + + it('should wrap chart in div with width 100% and default height 300', () => { + const { container } = render(); + const wrapper = container.firstElementChild as HTMLDivElement; + expect(wrapper.style.width).toBe('100%'); + expect(wrapper.style.height).toBe('300px'); + }); + + it('should apply custom height', () => { + const { container } = render(); + const wrapper = container.firstElementChild as HTMLDivElement; + expect(wrapper.style.height).toBe('450px'); + }); + + it('should set tension 0.4 on all datasets when smooth is true', () => { + render(); + const canvas = screen.getByTestId('area-canvas'); + const data = JSON.parse(canvas.getAttribute('data-data')!); + for (const ds of data.datasets) { + expect(ds.tension).toBe(0.4); + } + }); + + it('should not set tension when smooth is false', () => { + render(); + const canvas = screen.getByTestId('area-canvas'); + const data = JSON.parse(canvas.getAttribute('data-data')!); + expect(data.datasets[0].tension).toBeUndefined(); + }); + + it('should set scales.y.stacked true when stacked is true', () => { + render(); + const canvas = screen.getByTestId('area-canvas'); + const options = JSON.parse(canvas.getAttribute('data-options')!); + expect(options.scales.y.stacked).toBe(true); + }); + + it('should not set stacked when stacked prop is false', () => { + render(); + const canvas = screen.getByTestId('area-canvas'); + const options = JSON.parse(canvas.getAttribute('data-options')!); + expect(options.scales.y.stacked).toBeUndefined(); + }); + + it('should support stacked and smooth together', () => { + render(); + const canvas = screen.getByTestId('area-canvas'); + const options = JSON.parse(canvas.getAttribute('data-options')!); + const data = JSON.parse(canvas.getAttribute('data-data')!); + expect(options.scales.y.stacked).toBe(true); + for (const ds of data.datasets) { + expect(ds.tension).toBe(0.4); + expect(ds.fill).toBe(true); + } + }); + + it('should reflect theme settings in options', () => { + render(); + const canvas = screen.getByTestId('area-canvas'); + const options = JSON.parse(canvas.getAttribute('data-options')!); + expect(options.plugins.tooltip.enabled).toBe(true); + expect(options.plugins.legend.position).toBe('bottom'); + expect(options.responsive).toBe(true); + }); + + it('should handle empty data array without crash', () => { + render(); + const canvas = screen.getByTestId('area-canvas'); + const data = JSON.parse(canvas.getAttribute('data-data')!); + expect(data.labels).toEqual([]); + expect(data.datasets).toEqual([]); + }); +}); diff --git a/src/components/AreaChart/AreaChart.tsx b/src/components/AreaChart/AreaChart.tsx new file mode 100644 index 0000000..74c2741 --- /dev/null +++ b/src/components/AreaChart/AreaChart.tsx @@ -0,0 +1,61 @@ +import React, { useMemo } from 'react'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + LineElement, + PointElement, + Filler, + Tooltip, + Legend, +} from 'chart.js'; +import { Line } from 'react-chartjs-2'; +import { buildChartConfig } from '../../utils/buildChartConfig'; +import type { AreaChartProps } from './AreaChart.types'; + +ChartJS.register(CategoryScale, LinearScale, LineElement, PointElement, Filler, Tooltip, Legend); + +/** + * AreaChart renders a filled line chart using react-chartjs-2. + * Uses the 'area' variant from buildChartConfig for fill with 20% opacity. + * Supports smooth curves and stacked mode. + * + * @example + * ```tsx + * + * ``` + */ +export const AreaChart: React.FC = ({ + data, + theme, + height = 300, + smooth = false, + stacked = false, +}) => { + const config = useMemo(() => { + const base = buildChartConfig(data, theme, 'area'); + + if (smooth) { + for (const ds of base.data.datasets) { + ds.tension = 0.4; + } + } + + if (stacked) { + base.options.scales.y.stacked = true; + } + + return base; + }, [data, theme, smooth, stacked]); + + return ( +
+ ['options']} + /> +
+ ); +}; + +AreaChart.displayName = 'AreaChart'; diff --git a/src/components/AreaChart/AreaChart.types.ts b/src/components/AreaChart/AreaChart.types.ts new file mode 100644 index 0000000..096f8b0 --- /dev/null +++ b/src/components/AreaChart/AreaChart.types.ts @@ -0,0 +1,14 @@ +import type { ChartDataset, ChartTheme } from '../../types/chart.types'; + +export interface AreaChartProps { + /** Array of datasets to render */ + data: ChartDataset[]; + /** Theme configuration for colors, fonts, grid, tooltip, and legend */ + theme: ChartTheme; + /** Chart height in pixels @default 300 */ + height?: number; + /** Apply curved line interpolation (tension 0.4) @default false */ + smooth?: boolean; + /** Stack area datasets on top of each other */ + stacked?: boolean; +} diff --git a/src/components/AreaChart/index.ts b/src/components/AreaChart/index.ts new file mode 100644 index 0000000..75803cd --- /dev/null +++ b/src/components/AreaChart/index.ts @@ -0,0 +1,2 @@ +export { AreaChart } from './AreaChart'; +export type { AreaChartProps } from './AreaChart.types'; diff --git a/src/components/BarChart/BarChart.test.tsx b/src/components/BarChart/BarChart.test.tsx new file mode 100644 index 0000000..e845ee3 --- /dev/null +++ b/src/components/BarChart/BarChart.test.tsx @@ -0,0 +1,166 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import type { ChartDataset, ChartTheme } from '../../types/chart.types'; + +// Mock react-chartjs-2 to avoid canvas dependency in jsdom +vi.mock('react-chartjs-2', () => ({ + Bar: (props: { data: unknown; options: unknown }) => ( + + ), +})); + +// Mock chart.js register to avoid side-effects +vi.mock('chart.js', () => ({ + Chart: { register: vi.fn() }, + CategoryScale: 'CategoryScale', + LinearScale: 'LinearScale', + BarElement: 'BarElement', + Tooltip: 'Tooltip', + Legend: 'Legend', +})); + +import { BarChart } from './BarChart'; + +const sampleData: ChartDataset[] = [ + { + id: 'ds1', + label: 'Sales', + data: [ + { label: 'Jan', value: 10 }, + { label: 'Feb', value: 20 }, + ], + }, +]; + +const threeDatasets: ChartDataset[] = [ + { + id: 'ds1', + label: 'Sales', + data: [ + { label: 'Jan', value: 10 }, + { label: 'Feb', value: 20 }, + ], + }, + { + id: 'ds2', + label: 'Revenue', + data: [ + { label: 'Jan', value: 30 }, + { label: 'Feb', value: 40 }, + ], + color: '#00FF00', + }, + { + id: 'ds3', + label: 'Costs', + data: [ + { label: 'Jan', value: 5 }, + { label: 'Feb', value: 15 }, + ], + }, +]; + +const theme: ChartTheme = { + colors: ['#FF0000', '#00FF00', '#0000FF'], + fontFamily: 'Arial', + fontSize: 14, + grid: { color: '#ccc', display: true }, + tooltip: { enabled: true, backgroundColor: '#333' }, + legend: { display: true, position: 'bottom' }, +}; + +describe('BarChart', () => { + it('should render with 1 dataset without errors', () => { + render(); + expect(screen.getByTestId('bar-canvas')).toBeInTheDocument(); + }); + + it('should render with 3 datasets without errors', () => { + render(); + const canvas = screen.getByTestId('bar-canvas'); + const data = JSON.parse(canvas.getAttribute('data-data')!); + expect(data.datasets).toHaveLength(3); + }); + + it('should wrap chart in div with width 100% and default height 300', () => { + const { container } = render(); + const wrapper = container.firstElementChild as HTMLDivElement; + expect(wrapper.style.width).toBe('100%'); + expect(wrapper.style.height).toBe('300px'); + }); + + it('should apply custom height', () => { + const { container } = render(); + const wrapper = container.firstElementChild as HTMLDivElement; + expect(wrapper.style.height).toBe('500px'); + }); + + it('should set stacked on both axes when stacked prop is true', () => { + render(); + const canvas = screen.getByTestId('bar-canvas'); + const options = JSON.parse(canvas.getAttribute('data-options')!); + expect(options.scales.x.stacked).toBe(true); + expect(options.scales.y.stacked).toBe(true); + }); + + it('should not set stacked when stacked prop is false', () => { + render(); + const canvas = screen.getByTestId('bar-canvas'); + const options = JSON.parse(canvas.getAttribute('data-options')!); + expect(options.scales.x.stacked).toBeUndefined(); + expect(options.scales.y.stacked).toBeUndefined(); + }); + + it('should set indexAxis to y when horizontal is true', () => { + render(); + const canvas = screen.getByTestId('bar-canvas'); + const options = JSON.parse(canvas.getAttribute('data-options')!); + expect(options.indexAxis).toBe('y'); + }); + + it('should not set indexAxis when horizontal is false', () => { + render(); + const canvas = screen.getByTestId('bar-canvas'); + const options = JSON.parse(canvas.getAttribute('data-options')!); + expect(options.indexAxis).toBeUndefined(); + }); + + it('should pass animation enabled by default (no explicit disable)', () => { + render(); + const canvas = screen.getByTestId('bar-canvas'); + const options = JSON.parse(canvas.getAttribute('data-options')!); + expect(options.animation).toBeUndefined(); + }); + + it('should reflect theme tooltip and legend settings in options', () => { + render(); + const canvas = screen.getByTestId('bar-canvas'); + const options = JSON.parse(canvas.getAttribute('data-options')!); + expect(options.plugins.tooltip.enabled).toBe(true); + expect(options.plugins.tooltip.backgroundColor).toBe('#333'); + expect(options.plugins.legend.display).toBe(true); + expect(options.plugins.legend.position).toBe('bottom'); + }); + + it('should support stacked and horizontal together', () => { + render(); + const canvas = screen.getByTestId('bar-canvas'); + const options = JSON.parse(canvas.getAttribute('data-options')!); + expect(options.scales.x.stacked).toBe(true); + expect(options.scales.y.stacked).toBe(true); + expect(options.indexAxis).toBe('y'); + }); + + it('should handle empty data array without crash', () => { + render(); + const canvas = screen.getByTestId('bar-canvas'); + const data = JSON.parse(canvas.getAttribute('data-data')!); + expect(data.labels).toEqual([]); + expect(data.datasets).toEqual([]); + }); +}); diff --git a/src/components/BarChart/BarChart.tsx b/src/components/BarChart/BarChart.tsx new file mode 100644 index 0000000..03d7d47 --- /dev/null +++ b/src/components/BarChart/BarChart.tsx @@ -0,0 +1,57 @@ +import React, { useMemo } from 'react'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + BarElement, + Tooltip, + Legend, +} from 'chart.js'; +import { Bar } from 'react-chartjs-2'; +import { buildChartConfig } from '../../utils/buildChartConfig'; +import type { BarChartProps } from './BarChart.types'; + +ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip, Legend); + +/** + * BarChart renders a bar chart using react-chartjs-2. + * Supports stacked and horizontal variants via typed props only. + * + * @example + * ```tsx + * + * ``` + */ +export const BarChart: React.FC = ({ + data, + theme, + height = 300, + stacked = false, + horizontal = false, +}) => { + const config = useMemo(() => { + const base = buildChartConfig(data, theme, 'bar'); + + if (stacked) { + base.options.scales.x.stacked = true; + base.options.scales.y.stacked = true; + } + + if (horizontal) { + base.options.indexAxis = 'y'; + } + + return base; + }, [data, theme, stacked, horizontal]); + + return ( +
+ ['options']} + /> +
+ ); +}; + +BarChart.displayName = 'BarChart'; diff --git a/src/components/BarChart/BarChart.types.ts b/src/components/BarChart/BarChart.types.ts new file mode 100644 index 0000000..0a6db58 --- /dev/null +++ b/src/components/BarChart/BarChart.types.ts @@ -0,0 +1,14 @@ +import type { ChartDataset, ChartTheme } from '../../types/chart.types'; + +export interface BarChartProps { + /** Array of datasets to render */ + data: ChartDataset[]; + /** Theme configuration for colors, fonts, grid, tooltip, and legend */ + theme: ChartTheme; + /** Chart height in pixels @default 300 */ + height?: number; + /** Stack bars on top of each other */ + stacked?: boolean; + /** Render horizontal bars (indexAxis 'y') */ + horizontal?: boolean; +} diff --git a/src/components/BarChart/index.ts b/src/components/BarChart/index.ts new file mode 100644 index 0000000..e9b7e02 --- /dev/null +++ b/src/components/BarChart/index.ts @@ -0,0 +1,2 @@ +export { BarChart } from './BarChart'; +export type { BarChartProps } from './BarChart.types'; diff --git a/src/components/LineChart/LineChart.test.tsx b/src/components/LineChart/LineChart.test.tsx new file mode 100644 index 0000000..6529a5b --- /dev/null +++ b/src/components/LineChart/LineChart.test.tsx @@ -0,0 +1,142 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import type { ChartDataset, ChartTheme } from '../../types/chart.types'; + +vi.mock('react-chartjs-2', () => ({ + Line: (props: { data: unknown; options: unknown }) => ( + + ), +})); + +vi.mock('chart.js', () => ({ + Chart: { register: vi.fn() }, + CategoryScale: 'CategoryScale', + LinearScale: 'LinearScale', + LineElement: 'LineElement', + PointElement: 'PointElement', + Filler: 'Filler', + Tooltip: 'Tooltip', + Legend: 'Legend', +})); + +import { LineChart } from './LineChart'; + +const singleDataset: ChartDataset[] = [ + { + id: 'ds1', + label: 'Sales', + data: [ + { label: 'Jan', value: 10 }, + { label: 'Feb', value: 20 }, + ], + }, +]; + +const multiDatasets: ChartDataset[] = [ + { + id: 'ds1', + label: 'Sales', + data: [ + { label: 'Jan', value: 10 }, + { label: 'Feb', value: 20 }, + ], + }, + { + id: 'ds2', + label: 'Revenue', + data: [ + { label: 'Jan', value: 30 }, + { label: 'Feb', value: 40 }, + ], + color: '#00FF00', + }, + { + id: 'ds3', + label: 'Costs', + data: [ + { label: 'Jan', value: 5 }, + { label: 'Feb', value: 15 }, + ], + }, +]; + +const theme: ChartTheme = { + colors: ['#FF0000', '#00FF00', '#0000FF'], + fontFamily: 'Arial', + fontSize: 14, + grid: { color: '#ccc', display: true }, + tooltip: { enabled: true, backgroundColor: '#333' }, + legend: { display: true, position: 'bottom' }, +}; + +describe('LineChart', () => { + it('should render with 1 dataset without errors', () => { + render(); + expect(screen.getByTestId('line-canvas')).toBeInTheDocument(); + }); + + it('should render with multiple datasets without errors', () => { + render(); + const canvas = screen.getByTestId('line-canvas'); + const data = JSON.parse(canvas.getAttribute('data-data')!); + expect(data.datasets).toHaveLength(3); + }); + + it('should wrap chart in div with width 100% and default height 300', () => { + const { container } = render(); + const wrapper = container.firstElementChild as HTMLDivElement; + expect(wrapper.style.width).toBe('100%'); + expect(wrapper.style.height).toBe('300px'); + }); + + it('should apply custom height', () => { + const { container } = render(); + const wrapper = container.firstElementChild as HTMLDivElement; + expect(wrapper.style.height).toBe('500px'); + }); + + it('should not set tension when smooth is false', () => { + render(); + const canvas = screen.getByTestId('line-canvas'); + const data = JSON.parse(canvas.getAttribute('data-data')!); + expect(data.datasets[0].tension).toBeUndefined(); + }); + + it('should set tension 0.4 on all datasets when smooth is true', () => { + render(); + const canvas = screen.getByTestId('line-canvas'); + const data = JSON.parse(canvas.getAttribute('data-data')!); + for (const ds of data.datasets) { + expect(ds.tension).toBe(0.4); + } + }); + + it('should not set fill for line variant', () => { + render(); + const canvas = screen.getByTestId('line-canvas'); + const data = JSON.parse(canvas.getAttribute('data-data')!); + expect(data.datasets[0].fill).toBeUndefined(); + }); + + it('should reflect theme settings in options', () => { + render(); + const canvas = screen.getByTestId('line-canvas'); + const options = JSON.parse(canvas.getAttribute('data-options')!); + expect(options.plugins.tooltip.enabled).toBe(true); + expect(options.plugins.legend.position).toBe('bottom'); + expect(options.responsive).toBe(true); + }); + + it('should handle empty data array without crash', () => { + render(); + const canvas = screen.getByTestId('line-canvas'); + const data = JSON.parse(canvas.getAttribute('data-data')!); + expect(data.labels).toEqual([]); + expect(data.datasets).toEqual([]); + }); +}); diff --git a/src/components/LineChart/LineChart.tsx b/src/components/LineChart/LineChart.tsx new file mode 100644 index 0000000..b8e2f8d --- /dev/null +++ b/src/components/LineChart/LineChart.tsx @@ -0,0 +1,55 @@ +import React, { useMemo } from 'react'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + LineElement, + PointElement, + Filler, + Tooltip, + Legend, +} from 'chart.js'; +import { Line } from 'react-chartjs-2'; +import { buildChartConfig } from '../../utils/buildChartConfig'; +import type { LineChartProps } from './LineChart.types'; + +ChartJS.register(CategoryScale, LinearScale, LineElement, PointElement, Filler, Tooltip, Legend); + +/** + * LineChart renders a line chart using react-chartjs-2. + * Supports smooth curves via the smooth prop. + * + * @example + * ```tsx + * + * ``` + */ +export const LineChart: React.FC = ({ + data, + theme, + height = 300, + smooth = false, +}) => { + const config = useMemo(() => { + const base = buildChartConfig(data, theme, 'line'); + + if (smooth) { + for (const ds of base.data.datasets) { + ds.tension = 0.4; + } + } + + return base; + }, [data, theme, smooth]); + + return ( +
+ ['options']} + /> +
+ ); +}; + +LineChart.displayName = 'LineChart'; diff --git a/src/components/LineChart/LineChart.types.ts b/src/components/LineChart/LineChart.types.ts new file mode 100644 index 0000000..6e62173 --- /dev/null +++ b/src/components/LineChart/LineChart.types.ts @@ -0,0 +1,12 @@ +import type { ChartDataset, ChartTheme } from '../../types/chart.types'; + +export interface LineChartProps { + /** Array of datasets to render */ + data: ChartDataset[]; + /** Theme configuration for colors, fonts, grid, tooltip, and legend */ + theme: ChartTheme; + /** Chart height in pixels @default 300 */ + height?: number; + /** Apply curved line interpolation (tension 0.4) @default false */ + smooth?: boolean; +} diff --git a/src/components/LineChart/index.ts b/src/components/LineChart/index.ts new file mode 100644 index 0000000..b22f5c7 --- /dev/null +++ b/src/components/LineChart/index.ts @@ -0,0 +1,2 @@ +export { LineChart } from './LineChart'; +export type { LineChartProps } from './LineChart.types'; diff --git a/src/components/index.ts b/src/components/index.ts index 52f8fa8..d5b415c 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -2,3 +2,6 @@ export const __components_placeholder = true; export * from './NoopButton'; +export * from './BarChart'; +export * from './LineChart'; +export * from './AreaChart'; diff --git a/src/index.ts b/src/index.ts index c55977d..14b0c36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export * from './components'; export * from './hooks'; +export * from './types'; export * from './utils'; diff --git a/src/types/chart.types.ts b/src/types/chart.types.ts new file mode 100644 index 0000000..039a100 --- /dev/null +++ b/src/types/chart.types.ts @@ -0,0 +1,70 @@ +export interface ChartDataPoint { + label: string; + value: number; +} + +export interface ChartDataset { + id: string; + label: string; + data: ChartDataPoint[]; + color?: string; +} + +export interface ChartTheme { + colors: string[]; + fontFamily?: string; + fontSize?: number; + grid?: { + color?: string; + display?: boolean; + }; + tooltip?: { + enabled?: boolean; + backgroundColor?: string; + titleColor?: string; + bodyColor?: string; + }; + legend?: { + display?: boolean; + position?: 'top' | 'bottom' | 'left' | 'right'; + }; +} + +export type ChartVariant = 'bar' | 'line' | 'area'; + +export interface ChartConfig { + type: 'bar' | 'line'; + data: { + labels: string[]; + datasets: ChartConfigDataset[]; + }; + options: { + responsive: boolean; + indexAxis?: 'x' | 'y'; + plugins: { + tooltip: ChartTheme['tooltip'] & Record; + legend: ChartTheme['legend'] & Record; + }; + scales: { + x: { + grid: ChartTheme['grid'] & Record; + ticks: Record; + stacked?: boolean; + }; + y: { + grid: ChartTheme['grid'] & Record; + ticks: Record; + stacked?: boolean; + }; + }; + }; +} + +export interface ChartConfigDataset { + label: string; + data: number[]; + backgroundColor: string | string[]; + borderColor: string; + fill?: boolean; + tension?: number; +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..9613f86 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1 @@ +export * from './chart.types'; diff --git a/src/utils/buildChartConfig.test.ts b/src/utils/buildChartConfig.test.ts new file mode 100644 index 0000000..e06023d --- /dev/null +++ b/src/utils/buildChartConfig.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect } from 'vitest'; +import { buildChartConfig } from './buildChartConfig'; +import type { ChartDataset, ChartTheme } from '../types/chart.types'; + +const sampleData = [ + { label: 'Jan', value: 10 }, + { label: 'Feb', value: 20 }, + { label: 'Mar', value: 30 }, +]; + +const baseTheme: ChartTheme = { + colors: ['#FF0000', '#00FF00', '#0000FF'], + fontFamily: 'Arial', + fontSize: 14, + grid: { color: '#E0E0E0', display: true }, + tooltip: { + enabled: true, + backgroundColor: '#333', + titleColor: '#FFF', + bodyColor: '#CCC', + }, + legend: { display: true, position: 'bottom' }, +}; + +const datasets: ChartDataset[] = [ + { id: 'ds1', label: 'Sales', data: sampleData }, + { id: 'ds2', label: 'Revenue', data: sampleData, color: '#ABCDEF' }, +]; + +describe('buildChartConfig', () => { + describe('bar variant', () => { + const config = buildChartConfig(datasets, baseTheme, 'bar'); + + it('should set type to bar', () => { + expect(config.type).toBe('bar'); + }); + + it('should extract labels from the first dataset', () => { + expect(config.data.labels).toEqual(['Jan', 'Feb', 'Mar']); + }); + + it('should map dataset values', () => { + expect(config.data.datasets[0].data).toEqual([10, 20, 30]); + }); + + it('should fall back to theme.colors when dataset.color is undefined', () => { + expect(config.data.datasets[0].backgroundColor).toBe('#FF0000'); + expect(config.data.datasets[0].borderColor).toBe('#FF0000'); + }); + + it('should use dataset.color when provided', () => { + expect(config.data.datasets[1].backgroundColor).toBe('#ABCDEF'); + expect(config.data.datasets[1].borderColor).toBe('#ABCDEF'); + }); + + it('should not set fill for bar variant', () => { + expect(config.data.datasets[0].fill).toBeUndefined(); + }); + }); + + describe('line variant', () => { + const config = buildChartConfig(datasets, baseTheme, 'line'); + + it('should set type to line', () => { + expect(config.type).toBe('line'); + }); + + it('should not set fill for line variant', () => { + expect(config.data.datasets[0].fill).toBeUndefined(); + }); + }); + + describe('area variant', () => { + const config = buildChartConfig(datasets, baseTheme, 'area'); + + it('should set type to line for area variant', () => { + expect(config.type).toBe('line'); + }); + + it('should set fill true for area datasets', () => { + expect(config.data.datasets[0].fill).toBe(true); + expect(config.data.datasets[1].fill).toBe(true); + }); + + it('should append 33 to hex color for 20% opacity background', () => { + expect(config.data.datasets[0].backgroundColor).toBe('#FF000033'); + }); + + it('should append 33 to explicit dataset color for area', () => { + expect(config.data.datasets[1].backgroundColor).toBe('#ABCDEF33'); + }); + + it('should keep borderColor without opacity suffix', () => { + expect(config.data.datasets[0].borderColor).toBe('#FF0000'); + }); + }); + + describe('theme fields reflected in output', () => { + const config = buildChartConfig(datasets, baseTheme, 'bar'); + + it('should reflect tooltip settings', () => { + expect(config.options.plugins.tooltip.enabled).toBe(true); + expect(config.options.plugins.tooltip.backgroundColor).toBe('#333'); + expect(config.options.plugins.tooltip.titleColor).toBe('#FFF'); + expect(config.options.plugins.tooltip.bodyColor).toBe('#CCC'); + }); + + it('should reflect legend settings', () => { + expect(config.options.plugins.legend.display).toBe(true); + expect(config.options.plugins.legend.position).toBe('bottom'); + }); + + it('should reflect grid settings on both axes', () => { + expect(config.options.scales.x.grid.display).toBe(true); + expect(config.options.scales.x.grid.color).toBe('#E0E0E0'); + expect(config.options.scales.y.grid.display).toBe(true); + expect(config.options.scales.y.grid.color).toBe('#E0E0E0'); + }); + + it('should reflect fontFamily and fontSize in tick fonts', () => { + expect(config.options.scales.x.ticks).toEqual({ font: { family: 'Arial', size: 14 } }); + expect(config.options.scales.y.ticks).toEqual({ font: { family: 'Arial', size: 14 } }); + }); + + it('should set responsive true', () => { + expect(config.options.responsive).toBe(true); + }); + }); + + describe('color cycling', () => { + it('should cycle through theme.colors when there are more datasets than colors', () => { + const threeDs: ChartDataset[] = [ + { id: '1', label: 'A', data: sampleData }, + { id: '2', label: 'B', data: sampleData }, + { id: '3', label: 'C', data: sampleData }, + { id: '4', label: 'D', data: sampleData }, + ]; + const config = buildChartConfig(threeDs, baseTheme, 'bar'); + expect(config.data.datasets[3].backgroundColor).toBe('#FF0000'); + }); + }); + + describe('empty datasets', () => { + it('should produce empty labels for empty datasets array', () => { + const config = buildChartConfig([], baseTheme, 'bar'); + expect(config.data.labels).toEqual([]); + expect(config.data.datasets).toEqual([]); + }); + }); + + describe('default theme values', () => { + const minTheme: ChartTheme = { colors: ['#111'] }; + const config = buildChartConfig(datasets, minTheme, 'line'); + + it('should default tooltip.enabled to true', () => { + expect(config.options.plugins.tooltip.enabled).toBe(true); + }); + + it('should default legend.display to true and position to top', () => { + expect(config.options.plugins.legend.display).toBe(true); + expect(config.options.plugins.legend.position).toBe('top'); + }); + + it('should default grid.display to true', () => { + expect(config.options.scales.x.grid.display).toBe(true); + }); + }); +}); diff --git a/src/utils/buildChartConfig.ts b/src/utils/buildChartConfig.ts new file mode 100644 index 0000000..0197b1c --- /dev/null +++ b/src/utils/buildChartConfig.ts @@ -0,0 +1,88 @@ +import type { + ChartConfig, + ChartConfigDataset, + ChartDataset, + ChartTheme, + ChartVariant, +} from '../types/chart.types'; + +function resolveColor(dataset: ChartDataset, index: number, theme: ChartTheme): string { + return dataset.color ?? theme.colors[index % theme.colors.length]; +} + +function applyAreaOpacity(hex: string): string { + return `${hex}33`; +} + +function buildDataset( + dataset: ChartDataset, + index: number, + theme: ChartTheme, + variant: ChartVariant, +): ChartConfigDataset { + const color = resolveColor(dataset, index, theme); + + const base: ChartConfigDataset = { + label: dataset.label, + data: dataset.data.map((point) => point.value), + backgroundColor: variant === 'area' ? applyAreaOpacity(color) : color, + borderColor: color, + }; + + if (variant === 'area') { + base.fill = true; + } + + return base; +} + +export function buildChartConfig( + datasets: ChartDataset[], + theme: ChartTheme, + variant: ChartVariant, +): ChartConfig { + const labels = datasets.length > 0 ? datasets[0].data.map((point) => point.label) : []; + + const tickFont: Record = {}; + if (theme.fontFamily) tickFont.family = theme.fontFamily; + if (theme.fontSize) tickFont.size = theme.fontSize; + + return { + type: variant === 'area' ? 'line' : variant, + data: { + labels, + datasets: datasets.map((ds, i) => buildDataset(ds, i, theme, variant)), + }, + options: { + responsive: true, + plugins: { + tooltip: { + enabled: theme.tooltip?.enabled ?? true, + backgroundColor: theme.tooltip?.backgroundColor, + titleColor: theme.tooltip?.titleColor, + bodyColor: theme.tooltip?.bodyColor, + }, + legend: { + display: theme.legend?.display ?? true, + position: theme.legend?.position ?? 'top', + }, + }, + scales: { + x: { + grid: { + display: theme.grid?.display ?? true, + color: theme.grid?.color, + }, + ticks: { font: tickFont }, + }, + y: { + grid: { + display: theme.grid?.display ?? true, + color: theme.grid?.color, + }, + ticks: { font: tickFont }, + }, + }, + }, + }; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 6211c64..5cdf899 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -2,3 +2,4 @@ export const __utils_placeholder = true; export * from './noop'; +export * from './buildChartConfig';