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/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..2279f0b --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @CISCODE-MA/devops diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..8d34dee --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: '/' + schedule: + interval: monthly + open-pull-requests-limit: 1 + groups: + npm-dependencies: + patterns: + - '*' + assignees: + - CISCODE-MA/cloud-devops + labels: + - 'dependencies' + - 'npm' + commit-message: + prefix: 'chore(deps)' + include: 'scope' + rebase-strategy: auto diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index e69de29..c8ac0d3 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -0,0 +1,41 @@ +name: CI - PR Validation + +on: + pull_request: + branches: [develop] + +permissions: + contents: read + +jobs: + validate: + name: CI - PR Validation + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install + run: npm ci + + - name: Format (check) + run: npm run format + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm test + + - name: Build + run: npm run build diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8465462..ffe4408 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,30 +20,52 @@ jobs: with: fetch-depth: 0 - - name: Validate tag exists on this push + - name: Validate version tag and package.json run: | - TAG=$(git describe --exact-match --tags HEAD 2>/dev/null || echo "") - if [[ -z "$TAG" ]]; then - echo "❌ No tag found on HEAD. This push did not include a version tag." - echo "To publish, merge to master with a tag: git tag v1.0.0 && git push origin master --tags" + PKG_VERSION=$(grep '"version"' package.json | head -1 | sed 's/.*"version": "\([^"]*\)".*/\1/') + TAG="v${PKG_VERSION}" + + if [[ -z "$PKG_VERSION" ]]; then + echo "❌ ERROR: Could not read version from package.json" exit 1 fi + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "❌ Invalid tag format: $TAG. Expected: v*.*.*" + echo "❌ ERROR: Invalid version format in package.json: '$PKG_VERSION'" + echo "Expected format: x.y.z (e.g., 1.0.0, 0.2.3)" + exit 1 + fi + + if ! git rev-parse "$TAG" >/dev/null 2>&1; then + echo "❌ ERROR: Tag $TAG not found!" + echo "" + echo "This typically happens when:" + echo " 1. You forgot to run 'npm version patch|minor|major' on your feature branch" + echo " 2. You didn't push the tag: git push origin --tags" + echo " 3. The tag was created locally but never pushed to remote" + echo "" + echo "📋 Correct workflow:" + echo " 1. On feat/** or feature/**: npm version patch (or minor/major)" + echo " 2. Push branch + tag: git push origin feat/your-feature --tags" + echo " 3. PR feat/** → develop, then PR develop → master" + echo " 4. Workflow automatically triggers on master push" + echo "" exit 1 fi - echo "✅ Valid tag found: $TAG" + + echo "✅ package.json version: $PKG_VERSION" + echo "✅ Tag $TAG exists in repo" echo "TAG_VERSION=$TAG" >> $GITHUB_ENV - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' registry-url: 'https://registry.npmjs.org' - cache: npm + cache: 'npm' - name: Install dependencies - run: npm install + run: npm ci - name: Build run: npm run build --if-present @@ -55,6 +77,6 @@ jobs: run: npm test --if-present 2>/dev/null || true - name: Publish to NPM - run: npm publish --access public --no-git-checks + run: npm publish --access public --provenance env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index 1ef2a3c..9b17249 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -3,54 +3,43 @@ name: CI - Release Check on: pull_request: branches: [master] - workflow_dispatch: - inputs: - sonar: - description: 'Run SonarCloud analysis' - required: true - default: 'false' - type: choice - options: - - 'false' - - 'true' concurrency: group: ci-release-${{ github.ref }} cancel-in-progress: true +env: + SONAR_HOST_URL: 'https://sonarcloud.io' + SONAR_ORGANIZATION: 'ciscode' + SONAR_PROJECT_KEY: 'CISCODE-MA_ChartKit-UI' + NODE_VERSION: '22' + +# ─── Job 1: Static checks (fast feedback, runs in parallel with test) ────────── jobs: - ci: - name: release checks + quality: + name: Quality Checks runs-on: ubuntu-latest - timeout-minutes: 25 + timeout-minutes: 10 permissions: contents: read - # Update these values for your package: - # - SONAR_PROJECT_KEY: "CISCODE-MA_YourPackageName" - env: - SONAR_HOST_URL: 'https://sonarcloud.io' - SONAR_ORGANIZATION: 'ciscode' - SONAR_PROJECT_KEY: 'CISCODE-MA_PACKAGE_NAME_TEMPLATE' - steps: - name: Checkout uses: actions/checkout@v4 - with: - fetch-depth: 0 - name: Setup Node uses: actions/setup-node@v4 with: - node-version: '20' - cache: npm + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' - name: Install - run: npm install + run: npm ci - - name: Audit - run: npm audit --prod + - name: Security Audit + # Only fail on high/critical — moderate noise in dev deps is expected + run: npm audit --production --audit-level=high - name: Format run: npm run format @@ -61,29 +50,149 @@ jobs: - name: Lint run: npm run lint + # ─── Job 2: Tests + Coverage (artifact passed to Sonar) ──────────────────────── + test: + name: Test & Coverage + runs-on: ubuntu-latest + timeout-minutes: 15 + + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install + run: npm ci + - name: Test (with coverage) run: npm run test:cov + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage/ + retention-days: 1 + + # ─── Job 3: Build ────────────────────────────────────────────────────────────── + build: + name: Build + runs-on: ubuntu-latest + needs: [quality, test] + timeout-minutes: 10 + + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install + run: npm ci + - name: Build run: npm run build + # ─── Job 4: SonarCloud (depends on test for coverage data) ───────────────────── + sonar: + name: SonarCloud Analysis + runs-on: ubuntu-latest + needs: [test] + timeout-minutes: 15 + + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # Full history required for accurate blame & new code detection + fetch-depth: 0 + + - name: Download coverage report + uses: actions/download-artifact@v4 + with: + name: coverage-report + path: coverage/ + + - name: Cache SonarCloud packages + uses: actions/cache@v4 + with: + path: ~/.sonar/cache + key: sonar-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} + restore-keys: sonar-${{ runner.os }}- + - name: SonarCloud Scan - if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.sonar == 'true' }} uses: SonarSource/sonarqube-scan-action@v6 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_HOST_URL: ${{ env.SONAR_HOST_URL }} with: args: > - -Dsonar.organization=${{ env.SONAR_ORGANIZATION }} \ - -Dsonar.projectKey=${{ env.SONAR_PROJECT_KEY }} \ - -Dsonar.sources=src \ + -Dsonar.organization=${{ env.SONAR_ORGANIZATION }} + -Dsonar.projectKey=${{ env.SONAR_PROJECT_KEY }} + -Dsonar.sources=src + -Dsonar.tests=src + -Dsonar.test.inclusions=**/*.spec.ts,**/*.spec.tsx,**/*.test.ts,**/*.test.tsx + -Dsonar.exclusions=**/node_modules/**,**/dist/**,**/coverage/**,**/*.d.ts + -Dsonar.coverage.exclusions=**/*.spec.ts,**/*.spec.tsx,**/*.test.ts,**/*.test.tsx,**/index.ts -Dsonar.javascript.lcov.reportPaths=coverage/lcov.info + -Dsonar.typescript.tsconfigPath=tsconfig.json + -Dsonar.qualitygate.wait=true + -Dsonar.qualitygate.timeout=300 - - name: SonarCloud Quality Gate - if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.sonar == 'true' }} - uses: SonarSource/sonarqube-quality-gate-action@v1 - timeout-minutes: 10 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - SONAR_HOST_URL: ${{ env.SONAR_HOST_URL }} + # ─── Job 5: Final status report (always runs) ────────────────────────────────── + report: + name: Report CI Status + runs-on: ubuntu-latest + needs: [quality, test, build, sonar] + # Run even if upstream jobs failed + if: always() + timeout-minutes: 5 + + permissions: + contents: read + statuses: write + + steps: + - name: Resolve overall result + id: result + run: | + results="${{ needs.quality.result }} ${{ needs.test.result }} ${{ needs.build.result }} ${{ needs.sonar.result }}" + if echo "$results" | grep -qE "failure|cancelled"; then + echo "state=failure" >> $GITHUB_OUTPUT + echo "desc=One or more CI checks failed" >> $GITHUB_OUTPUT + else + echo "state=success" >> $GITHUB_OUTPUT + echo "desc=All CI checks passed" >> $GITHUB_OUTPUT + fi + + - name: Post commit status + uses: actions/github-script@v7 + with: + script: | + await github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.sha, + state: '${{ steps.result.outputs.state }}', + context: 'CI / Release Check', + description: '${{ steps.result.outputs.desc }}', + target_url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` + }) diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 0000000..93ca411 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,8 @@ +{ + "servers": { + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"] + } + } +} 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/ciscode-ui-chart-kit-0.1.0.tgz b/ciscode-ui-chart-kit-0.1.0.tgz new file mode 100644 index 0000000..36feeed Binary files /dev/null and b/ciscode-ui-chart-kit-0.1.0.tgz differ diff --git a/package-lock.json b/package-lock.json index 30cfbb5..f8e20c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,17 @@ { - "name": "@ciscode/reactts-developerkit", - "version": "1.0.0", + "name": "@ciscode/ui-chart-kit", + "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@ciscode/reactts-developerkit", - "version": "1.0.0", + "name": "@ciscode/ui-chart-kit", + "version": "0.0.1", "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", @@ -1736,6 +1740,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", @@ -3319,6 +3329,18 @@ "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", + "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", @@ -7160,6 +7182,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", diff --git a/package.json b/package.json index e1abcf6..3bfc1fc 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,14 @@ { - "name": "@ciscode/reactts-developerkit", - "version": "1.0.0", - "description": "React TypeScript hybrid library template (components + hooks + utils).", + "name": "@ciscode/ui-chart-kit", + "version": "0.0.1", + "description": "Typed React chart components (Bar, Line, Area) built on Chart.js — no raw Chart.js config required.", "license": "MIT", "private": false, "type": "module", + "repository": { + "type": "git", + "url": "https://github.com/CISCODE-MA/ChartKit-UI" + }, "sideEffects": false, "files": [ "dist" @@ -80,5 +84,9 @@ }, "engines": { "node": ">=20" + }, + "dependencies": { + "chart.js": "^4.5.1", + "react-chartjs-2": "^5.3.1" } } diff --git a/src/__tests__/chart-test-utils.ts b/src/__tests__/chart-test-utils.ts new file mode 100644 index 0000000..0e17d99 --- /dev/null +++ b/src/__tests__/chart-test-utils.ts @@ -0,0 +1,113 @@ +import { render, screen } from '@testing-library/react'; +import { expect, it } from 'vitest'; +import React from 'react'; +import type { ChartDataset, ChartTheme } from '../types/chart.types'; + +export const singleDataset: ChartDataset[] = [ + { + id: 'ds1', + label: 'Sales', + data: [ + { label: 'Jan', value: 10 }, + { label: 'Feb', value: 20 }, + ], + }, +]; + +export 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 }, + ], + }, +]; + +export const defaultTheme: ChartTheme = { + colors: ['#FF0000', '#00FF00', '#0000FF'], + fontFamily: 'Arial', + fontSize: 14, + grid: { color: '#ccc', display: true }, + tooltip: { enabled: true, backgroundColor: '#333' }, + legend: { display: true, position: 'bottom' }, +}; + +export function getCanvasData(testId: string) { + const canvas = screen.getByTestId(testId); + return { + canvas, + data: JSON.parse(canvas.getAttribute('data-data')!), + options: JSON.parse(canvas.getAttribute('data-options')!), + }; +} + +export function describeCommonChartBehavior( + ChartComponent: React.ComponentType<{ data: ChartDataset[]; theme: ChartTheme; height?: number }>, + canvasTestId: string, +) { + it('should render with 1 dataset without errors', () => { + render(React.createElement(ChartComponent, { data: singleDataset, theme: defaultTheme })); + expect(screen.getByTestId(canvasTestId)).toBeInTheDocument(); + }); + + it('should render with multiple datasets without errors', () => { + render(React.createElement(ChartComponent, { data: multiDatasets, theme: defaultTheme })); + const { data } = getCanvasData(canvasTestId); + expect(data.datasets).toHaveLength(3); + }); + + it('should wrap chart in div with width 100% and default height 300', () => { + const { container } = render( + React.createElement(ChartComponent, { data: singleDataset, theme: defaultTheme }), + ); + 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( + React.createElement(ChartComponent, { + data: singleDataset, + theme: defaultTheme, + height: 500, + }), + ); + const wrapper = container.firstElementChild as HTMLDivElement; + expect(wrapper.style.height).toBe('500px'); + }); + + it('should reflect theme settings in options', () => { + render(React.createElement(ChartComponent, { data: singleDataset, theme: defaultTheme })); + const { options } = getCanvasData(canvasTestId); + 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(React.createElement(ChartComponent, { data: [], theme: defaultTheme })); + const { data } = getCanvasData(canvasTestId); + expect(data.labels).toEqual([]); + expect(data.datasets).toEqual([]); + }); +} diff --git a/src/components/AreaChart/AreaChart.test.tsx b/src/components/AreaChart/AreaChart.test.tsx new file mode 100644 index 0000000..b23a4b0 --- /dev/null +++ b/src/components/AreaChart/AreaChart.test.tsx @@ -0,0 +1,95 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { + singleDataset, + multiDatasets, + defaultTheme, + getCanvasData, + describeCommonChartBehavior, +} from '../../__tests__/chart-test-utils'; + +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 CANVAS_TEST_ID = 'area-canvas'; + +describe('AreaChart', () => { + describeCommonChartBehavior(AreaChart, CANVAS_TEST_ID); + + it('should set fill true on all datasets (area variant)', () => { + render(); + const { data } = getCanvasData(CANVAS_TEST_ID); + for (const ds of data.datasets) { + expect(ds.fill).toBe(true); + } + }); + + it('should apply 20% opacity (append 33) to backgroundColor', () => { + render(); + const { data } = getCanvasData(CANVAS_TEST_ID); + expect(data.datasets[0].backgroundColor).toBe('#FF000033'); + }); + + it('should use dataset color with 20% opacity when color is provided', () => { + render(); + const { data } = getCanvasData(CANVAS_TEST_ID); + expect(data.datasets[1].backgroundColor).toBe('#00FF0033'); + }); + + it('should set tension 0.4 on all datasets when smooth is true', () => { + render(); + const { data } = getCanvasData(CANVAS_TEST_ID); + for (const ds of data.datasets) { + expect(ds.tension).toBe(0.4); + } + }); + + it('should not set tension when smooth is false', () => { + render(); + const { data } = getCanvasData(CANVAS_TEST_ID); + expect(data.datasets[0].tension).toBeUndefined(); + }); + + it('should set scales.y.stacked true when stacked is true', () => { + render(); + const { options } = getCanvasData(CANVAS_TEST_ID); + expect(options.scales.y.stacked).toBe(true); + }); + + it('should not set stacked when stacked prop is false', () => { + render(); + const { options } = getCanvasData(CANVAS_TEST_ID); + expect(options.scales.y.stacked).toBeUndefined(); + }); + + it('should support stacked and smooth together', () => { + render(); + const { options, data } = getCanvasData(CANVAS_TEST_ID); + expect(options.scales.y.stacked).toBe(true); + for (const ds of data.datasets) { + expect(ds.tension).toBe(0.4); + expect(ds.fill).toBe(true); + } + }); +}); 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..7891754 --- /dev/null +++ b/src/components/BarChart/BarChart.test.tsx @@ -0,0 +1,92 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { + singleDataset, + defaultTheme, + getCanvasData, + describeCommonChartBehavior, +} from '../../__tests__/chart-test-utils'; + +vi.mock('react-chartjs-2', () => ({ + Bar: (props: { data: unknown; options: unknown }) => ( + + ), +})); + +vi.mock('chart.js', () => ({ + Chart: { register: vi.fn() }, + CategoryScale: 'CategoryScale', + LinearScale: 'LinearScale', + BarElement: 'BarElement', + Tooltip: 'Tooltip', + Legend: 'Legend', +})); + +import { BarChart } from './BarChart'; + +const CANVAS_TEST_ID = 'bar-canvas'; + +describe('BarChart', () => { + describeCommonChartBehavior(BarChart, CANVAS_TEST_ID); + + it('should set stacked on both axes when stacked prop is true', () => { + render(); + const { options } = getCanvasData(CANVAS_TEST_ID); + 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 { options } = getCanvasData(CANVAS_TEST_ID); + expect(options.scales.x.stacked).toBeUndefined(); + expect(options.scales.y.stacked).toBeUndefined(); + }); + + it('should set indexAxis to y when horizontal is true', () => { + render(); + const { options } = getCanvasData(CANVAS_TEST_ID); + expect(options.indexAxis).toBe('y'); + }); + + it('should not set indexAxis when horizontal is false', () => { + render(); + const { options } = getCanvasData(CANVAS_TEST_ID); + expect(options.indexAxis).toBeUndefined(); + }); + + it('should pass animation enabled by default (no explicit disable)', () => { + render(); + const { options } = getCanvasData(CANVAS_TEST_ID); + expect(options.animation).toBeUndefined(); + }); + + it('should reflect theme tooltip and legend settings in options', () => { + render(); + const { options } = getCanvasData(CANVAS_TEST_ID); + 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 { options } = getCanvasData(CANVAS_TEST_ID); + 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 { data } = getCanvasData(CANVAS_TEST_ID); + 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..e034c77 --- /dev/null +++ b/src/components/LineChart/LineChart.test.tsx @@ -0,0 +1,59 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { + singleDataset, + multiDatasets, + defaultTheme, + getCanvasData, + describeCommonChartBehavior, +} from '../../__tests__/chart-test-utils'; + +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 CANVAS_TEST_ID = 'line-canvas'; + +describe('LineChart', () => { + describeCommonChartBehavior(LineChart, CANVAS_TEST_ID); + + it('should not set tension when smooth is false', () => { + render(); + const { data } = getCanvasData(CANVAS_TEST_ID); + expect(data.datasets[0].tension).toBeUndefined(); + }); + + it('should set tension 0.4 on all datasets when smooth is true', () => { + render(); + const { data } = getCanvasData(CANVAS_TEST_ID); + for (const ds of data.datasets) { + expect(ds.tension).toBe(0.4); + } + }); + + it('should not set fill for line variant', () => { + render(); + const { data } = getCanvasData(CANVAS_TEST_ID); + expect(data.datasets[0].fill).toBeUndefined(); + }); +}); 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';