├── .all-contributorsrc ├── .changeset ├── README.md └── config.json ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── dependabot-auto-merge.yml │ ├── deploy-storybook.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .storybook ├── main.ts └── preview.ts ├── @types ├── index.d.ts ├── schwingbat │ └── relative-angle │ │ └── index.d.ts ├── svg-partial-circle │ └── index.d.ts └── vitest.d.ts ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── docs └── chart.gif ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── Chart │ ├── Chart.tsx │ ├── extendData.ts │ ├── index.tsx │ ├── renderLabels.tsx │ └── renderSegments.tsx ├── Label.tsx ├── Path.tsx ├── commonTypes.ts ├── index.ts └── utils.ts ├── stories ├── Animation.stories.tsx ├── DonutChart.stories.tsx ├── Interaction.stories.tsx ├── Labels.stories.tsx ├── LoadingIndicator.stories.tsx ├── Misc.stories.tsx ├── PartialChart.stories.tsx ├── PieChart.stories.tsx ├── components │ ├── FullOption.tsx │ ├── Interaction.tsx │ ├── InteractionTab.tsx │ ├── LoadingIndicator.tsx │ ├── PartialLoadingIndicator.tsx │ └── Tooltip.tsx └── mocks.ts ├── test-bundles ├── __snapshots__ │ └── bundles-snapshot.test.ts.snap ├── bundles-snapshot.test.ts ├── cjs.vitest.config.mts └── es.vitest.config.mts ├── test ├── Chart.test.tsx ├── Label.test.tsx ├── Path.test.tsx ├── testUtils │ ├── getArcCenter.ts │ ├── getArcInfo.ts │ ├── index.ts │ ├── render.tsx │ └── test │ │ └── getArcCenter.test.ts └── types.tsx ├── tsconfig.json ├── vitest.config.mts └── vitest.setup.ts /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "react-minimal-pie-chart", 3 | "projectOwner": "toomuchdesign", 4 | "files": [ 5 | "README.md" 6 | ], 7 | "imageSize": 100, 8 | "commit": false, 9 | "contributors": [ 10 | { 11 | "login": "toomuchdesign", 12 | "name": "Andrea Carraro", 13 | "avatar_url": "https://avatars3.githubusercontent.com/u/4573549?v=4", 14 | "profile": "http://www.andreacarraro.it", 15 | "contributions": [ 16 | "code", 17 | "doc", 18 | "infra", 19 | "test", 20 | "review" 21 | ] 22 | }, 23 | { 24 | "login": "rufman", 25 | "name": "Stephane Rufer", 26 | "avatar_url": "https://avatars3.githubusercontent.com/u/1128559?v=4", 27 | "profile": "https://github.com/rufman", 28 | "contributions": [ 29 | "bug", 30 | "code" 31 | ] 32 | }, 33 | { 34 | "login": "jaaberg", 35 | "name": "Jørgen Aaberg", 36 | "avatar_url": "https://avatars3.githubusercontent.com/u/1413255?v=4", 37 | "profile": "https://github.com/jaaberg", 38 | "contributions": [ 39 | "code" 40 | ] 41 | }, 42 | { 43 | "login": "TobiahRex", 44 | "name": "Tobiah Rex", 45 | "avatar_url": "https://avatars3.githubusercontent.com/u/16377119?v=4", 46 | "profile": "http://www.tobiahrex.com", 47 | "contributions": [ 48 | "bug" 49 | ] 50 | }, 51 | { 52 | "login": "edwardfhsiao", 53 | "name": "Edward Xiao", 54 | "avatar_url": "https://avatars2.githubusercontent.com/u/11728228?v=4", 55 | "profile": "https://edwardxiao.com", 56 | "contributions": [ 57 | "bug" 58 | ] 59 | }, 60 | { 61 | "login": "konsumer", 62 | "name": "David Konsumer", 63 | "avatar_url": "https://avatars1.githubusercontent.com/u/83857?v=4", 64 | "profile": "https://keybase.io/konsumer", 65 | "contributions": [ 66 | "code", 67 | "doc", 68 | "example", 69 | "ideas" 70 | ] 71 | }, 72 | { 73 | "login": "nehoraigold", 74 | "name": "Ori", 75 | "avatar_url": "https://avatars2.githubusercontent.com/u/44398222?v=4", 76 | "profile": "https://github.com/nehoraigold", 77 | "contributions": [ 78 | "ideas" 79 | ] 80 | }, 81 | { 82 | "login": "manosim", 83 | "name": "Emmanouil Konstantinidis", 84 | "avatar_url": "https://avatars3.githubusercontent.com/u/6333409?v=4", 85 | "profile": "https://www.manos.im/", 86 | "contributions": [ 87 | "bug" 88 | ] 89 | }, 90 | { 91 | "login": "yuruc", 92 | "name": "yuruc", 93 | "avatar_url": "https://avatars0.githubusercontent.com/u/5884342?v=4", 94 | "profile": "https://github.com/yuruc", 95 | "contributions": [ 96 | "code" 97 | ] 98 | }, 99 | { 100 | "login": "luca-esse", 101 | "name": "luca-esse ", 102 | "avatar_url": "https://avatars1.githubusercontent.com/u/16616566?v=4", 103 | "profile": "https://www.linkedin.com/in/luca-schiavone-7270a8138/", 104 | "contributions": [ 105 | "bug" 106 | ] 107 | }, 108 | { 109 | "login": "Osuka42g", 110 | "name": "Oscar Mendoza", 111 | "avatar_url": "https://avatars1.githubusercontent.com/u/5117006?v=4", 112 | "profile": "http://twitter.com/Osuka42", 113 | "contributions": [ 114 | "bug", 115 | "code" 116 | ] 117 | }, 118 | { 119 | "login": "damien-git", 120 | "name": "damien-git", 121 | "avatar_url": "https://avatars0.githubusercontent.com/u/7503971?v=4", 122 | "profile": "https://github.com/damien-git", 123 | "contributions": [ 124 | "bug", 125 | "ideas" 126 | ] 127 | }, 128 | { 129 | "login": "vibl", 130 | "name": "Vianney Stroebel", 131 | "avatar_url": "https://avatars0.githubusercontent.com/u/628818?v=4", 132 | "profile": "https://www.linkedin.com/in/vianneystroebel/", 133 | "contributions": [ 134 | "bug", 135 | "ideas" 136 | ] 137 | }, 138 | { 139 | "login": "xumi", 140 | "name": "Maxime Zielony", 141 | "avatar_url": "https://avatars0.githubusercontent.com/u/204001?v=4", 142 | "profile": "http://xumi.fr", 143 | "contributions": [ 144 | "bug", 145 | "code" 146 | ] 147 | }, 148 | { 149 | "login": "razked", 150 | "name": "Raz Kedem", 151 | "avatar_url": "https://avatars0.githubusercontent.com/u/39411034?v=4", 152 | "profile": "https://github.com/razked", 153 | "contributions": [ 154 | "bug" 155 | ] 156 | }, 157 | { 158 | "login": "slumbering", 159 | "name": "Blocksmith", 160 | "avatar_url": "https://avatars2.githubusercontent.com/u/1186424?v=4", 161 | "profile": "https://github.com/slumbering", 162 | "contributions": [ 163 | "bug" 164 | ] 165 | }, 166 | { 167 | "login": "majelbstoat", 168 | "name": "Jamie Talbot", 169 | "avatar_url": "https://avatars0.githubusercontent.com/u/425787?v=4", 170 | "profile": "http://jamietalbot.com", 171 | "contributions": [ 172 | "bug" 173 | ] 174 | }, 175 | { 176 | "login": "airoscar", 177 | "name": "Oscar Yixuan Chen", 178 | "avatar_url": "https://avatars1.githubusercontent.com/u/22269057?v=4", 179 | "profile": "http://timeslikethese.ca", 180 | "contributions": [ 181 | "bug" 182 | ] 183 | }, 184 | { 185 | "login": "RuiRocha1991", 186 | "name": "RuiRocha1991", 187 | "avatar_url": "https://avatars2.githubusercontent.com/u/29250466?v=4", 188 | "profile": "https://github.com/RuiRocha1991", 189 | "contributions": [ 190 | "bug" 191 | ] 192 | }, 193 | { 194 | "login": "Romaboy", 195 | "name": "Roman Kushyn", 196 | "avatar_url": "https://avatars0.githubusercontent.com/u/42248135?v=4", 197 | "profile": "https://github.com/Romaboy", 198 | "contributions": [ 199 | "bug" 200 | ] 201 | }, 202 | { 203 | "login": "bogas04", 204 | "name": "Divjot Singh", 205 | "avatar_url": "https://avatars.githubusercontent.com/u/6177621?v=4", 206 | "profile": "https://bogas04.github.io/", 207 | "contributions": [ 208 | "code" 209 | ] 210 | } 211 | ], 212 | "repoType": "github", 213 | "commitConvention": "none" 214 | } 215 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.3/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "master", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.{json,yml}] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Do you want to request a _feature_ or report a _bug_? 2 | 3 | ... 4 | 5 | ### What is the current behaviour? 6 | 7 | ... 8 | 9 | ### What is the expected behaviour? 10 | 11 | ... 12 | 13 | ### Steps to Reproduce the Problem 14 | 15 | 1. ... 16 | 2. ... 17 | 3. ... 18 | 19 | ### Specifications 20 | 21 | - Version: 22 | - Platform: 23 | - Subsystem: 24 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### What kind of change does this PR introduce? _(Bug fix, feature, docs update, ...)_ 2 | 3 | ... 4 | 5 | ### What is the current behaviour? _(You can also link to an open issue here)_ 6 | 7 | ... 8 | 9 | ### What is the new behaviour? 10 | 11 | ... 12 | 13 | ### Does this PR introduce a breaking change? _(What changes might users need to make in their application due to this PR?)_ 14 | 15 | ... 16 | 17 | ### Other information: 18 | 19 | ### Please check if the PR fulfills these requirements: 20 | 21 | - [ ] Tests for the changes have been added 22 | - [ ] Docs have been added / updated 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: 'daily' 7 | groups: 8 | rollup: 9 | patterns: 10 | - 'rollup*' 11 | - '@rollup/*' 12 | storybook: 13 | patterns: 14 | - 'storybook' 15 | - '@storybook/*' 16 | testing-library: 17 | patterns: 18 | - '@testing-library/*' 19 | react: 20 | patterns: 21 | - 'react' 22 | - 'react-dom' 23 | - '@types/react' 24 | - '@types/react-dom' 25 | vitest: 26 | patterns: 27 | - 'vitest' 28 | - '@vitest/*' 29 | size-limit: 30 | patterns: 31 | - 'size-limit' 32 | - '@size-limit/*' 33 | 34 | - package-ecosystem: 'github-actions' 35 | directory: '/' 36 | schedule: 37 | interval: 'daily' 38 | groups: 39 | upload-download-artifact: 40 | patterns: 41 | - 'actions/upload-artifact' 42 | - 'actions/download-artifact' 43 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | 8 | env: 9 | CI: true 10 | 11 | jobs: 12 | test-and-build: 13 | name: 'Test and build' 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version-file: '.nvmrc' 22 | 23 | - run: npm ci 24 | 25 | - uses: actions/upload-artifact@v4 26 | with: 27 | name: code-coverage 28 | path: coverage 29 | 30 | upload-code-coverage: 31 | name: 'Upload code coverage' 32 | needs: ['test-and-build'] 33 | runs-on: ubuntu-latest 34 | 35 | steps: 36 | - uses: actions/checkout@v4 37 | 38 | - uses: actions/download-artifact@v4 39 | with: 40 | name: code-coverage 41 | path: coverage 42 | 43 | - uses: coverallsapp/github-action@v2 44 | with: 45 | github-token: ${{ secrets.GITHUB_TOKEN }} 46 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto merge 2 | on: 3 | pull_request: 4 | 5 | jobs: 6 | dependabot-auto-merge: 7 | name: 'Dependabot auto merge' 8 | runs-on: ubuntu-latest 9 | permissions: 10 | pull-requests: write 11 | contents: write 12 | steps: 13 | - uses: fastify/github-action-merge-dependabot@v3 14 | with: 15 | target: minor 16 | use-github-auto-merge: true 17 | -------------------------------------------------------------------------------- /.github/workflows/deploy-storybook.yml: -------------------------------------------------------------------------------- 1 | name: Deploy storybook 2 | on: 3 | push: 4 | branches: 5 | - master 6 | 7 | permissions: 8 | contents: read 9 | pages: write 10 | id-token: write 11 | 12 | jobs: 13 | deploy: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - id: build-publish 17 | uses: bitovi/github-actions-storybook-to-github-pages@v1.0.3 18 | with: 19 | path: storybook-static 20 | build_command: npm run storybook:build 21 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | name: Release new version 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | token: ${{ secrets.GITHUB_TOKEN }} 18 | 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version-file: '.nvmrc' 22 | 23 | - run: npm ci 24 | 25 | - name: Create release pull request 26 | uses: changesets/action@v1 27 | with: 28 | publish: npx changeset tag 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS generated files 2 | .* 3 | *.db 4 | *.log 5 | node_modules 6 | es 7 | lib 8 | dist 9 | types 10 | coverage 11 | storybook-static 12 | *-temp.json 13 | 14 | # Trailing dotted files 15 | !.all-contributorsrc 16 | !.babelrc.js 17 | !.editorconfig 18 | !.github 19 | !.gitignore 20 | !.npmignore 21 | !.npmrc 22 | !.prettierrc 23 | !.prettierignore 24 | !.storybook 25 | !.travis.yml 26 | !.nvmrc 27 | !.changeset 28 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | .tmp/ 3 | coverage/ 4 | dist/ 5 | es/ 6 | lib/ 7 | types/ 8 | package.json 9 | package-lock.json 10 | storybook-static 11 | .all-contributorsrc 12 | CHANGELOG.md 13 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5" 4 | } 5 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from '@storybook/react-vite'; 2 | 3 | const config: StorybookConfig = { 4 | stories: ['../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)'], 5 | addons: ['@storybook/addon-essentials'], 6 | framework: { 7 | name: '@storybook/react-vite', 8 | options: {}, 9 | }, 10 | }; 11 | export default config; 12 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from '@storybook/react'; 2 | import { fn } from '@storybook/test'; 3 | 4 | const preview: Preview = { 5 | parameters: { 6 | // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout 7 | layout: 'centered', 8 | options: { 9 | storySort: { 10 | order: [ 11 | 'Example', 12 | [ 13 | 'Pie Chart', 14 | 'Donut Chart', 15 | 'Partial Chart', 16 | 'Labels', 17 | '', 18 | '*', 19 | 'Misc', 20 | ], 21 | ], 22 | }, 23 | }, 24 | }, 25 | // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs 26 | // tags: ['autodocs'], 27 | // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args 28 | args: { onClick: fn() }, 29 | }; 30 | 31 | export default preview; 32 | -------------------------------------------------------------------------------- /@types/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@schwingbat/relative-angle'; 2 | 3 | type Point = { 4 | x: number; 5 | y: number; 6 | }; 7 | 8 | export function degrees(objectCoords: Point, targetCoords: Point): number; 9 | -------------------------------------------------------------------------------- /@types/schwingbat/relative-angle/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@schwingbat/relative-angle'; 2 | 3 | type Point = { 4 | x: number; 5 | y: number; 6 | }; 7 | 8 | export function degrees(objectCoords: Point, targetCoords: Point): number; 9 | -------------------------------------------------------------------------------- /@types/svg-partial-circle/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'svg-partial-circle'; 2 | 3 | export default function partialCircle( 4 | cx: number, 5 | cy: number, 6 | r: number, 7 | start: number, 8 | end: number 9 | ): [ 10 | ['M', number, number], 11 | ['A', number, number, 0, number, number, number, number], 12 | ]; 13 | -------------------------------------------------------------------------------- /@types/vitest.d.ts: -------------------------------------------------------------------------------- 1 | import 'vitest'; 2 | 3 | interface CustomMatchers { 4 | toEqualWithRoundingError: (expected: number) => R; 5 | } 6 | 7 | declare module 'vitest' { 8 | interface Assertion extends CustomMatchers {} 9 | interface AsymmetricMatchersContaining extends CustomMatchers {} 10 | } 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # react-minimal-pie-chart 2 | 3 | ## 9.1.0 4 | 5 | ### Minor Changes 6 | 7 | - a0ce709: Support react v19 8 | 9 | ## 9.0.0 10 | 11 | ### Major Changes 12 | 13 | - 3bfb426: UMD export removed 14 | - 3bfb426: Package compiled in ES6. Consuming JS engines should be able to interpret features like arrow functions and const. 15 | - 3bfb426: `svg-partial-circle` unbundled and imported via plain import 16 | 17 | ## 8.4.1 18 | 19 | ### Patch Changes 20 | 21 | - d1887ba: Move `@types/svg-path-parser` to dev dependencies 22 | 23 | ## 8.4.0 24 | 25 | ### Minor Changes 26 | 27 | - Improve `labelRenderProps` by using TS generics 28 | - Expose chart `PieChartProps` types 29 | - Rely on useEffect only to trigger initial animation 30 | 31 | ## 8.3.0 32 | 33 | ### Minor Changes 34 | 35 | - Support React 18 36 | 37 | ## 8.2.0 38 | 39 | ### Minor Changes 40 | 41 | - Decrease bundle size of about 2% 42 | 43 | ## 8.1.0 44 | 45 | ### Minor Changes 46 | 47 | - Widen peerDependencies to include React 17 48 | 49 | ## 8.0.1 50 | 51 | ### Patch Changes 52 | 53 | - Change `extractPercentage` implementation to address a rounding issue in `stroke-dashoffset` evaluation (#133) 54 | 55 | ## 8.0.0 56 | 57 | ### Major Changes 58 | 59 | - Chart component exposed as named export instead of default. 60 | - Minimum `react` and `react-dom` peerDependency versions increased to `^16.8.0` 61 | - Minimum `typescript` version increased to `^3.8.0` (due to `import type`) 62 | - `label` prop works as [render prop](https://reactjs.org/docs/render-props.html); drop support for `boolean` and Element values 63 | - `segmentsShift` prop expressed as absolute `viewBox` units instead of radius' percentage 64 | - Event handlers signature updated to: `(event, segmentIndex) => void` 65 | - `rx` and `ry` props replaced by `center` array prop 66 | - `center` and `radius` props expressed as absolute `viewBox` units instead of percentage of it 67 | - `prop-types` dependency and static `PropTypes` declarations dropped 68 | - Dropped support for `data[].style property 69 | - Replaced extendedData startOffset prop with startAngle 70 | - `injectSVG` dropped in favour of native children prop 71 | - requestAnimationFrame existence check removed 72 | - Removed `Object.assign` polyfill 73 | 74 | ### Minor Changes 75 | 76 | - Gzipped size reduced from 2.6kb to 1.9 kb (-27%) 77 | - `segmentsStyle` and `labelStyle` accept both value or function 78 | 79 | ### Patch Changes 80 | 81 | - Default labels vertically aligned with `dominant-baseline: central` (#149) 82 | 83 | ### How to migrate to version 8.0.0 84 | 85 | - Update import declaration to: `import {PieChart} from 'react-minimal-pie-chart'` 86 | - Make sure that installed `react` and `react-dom` version is >= `16.8.0` 87 | - In case `typescript` is used, ensure that installed version is >= `3.8` 88 | - Migrate `label` prop to provide a render function (see docs about labels) 89 | - Replace existing `rx` `ry` props with `center` 90 | - Review existing `center` and `segmentsShift` values (now expressed as `viewBox` values) 91 | - Update `onBlur`, `onClick`, `onFocus`, `onKeyDown`, `onMouseOut`, `onMouseOver`, `segmentsShift` function props to new signatures 92 | - Move existing `injectSVG` prop return value to `children` prop 93 | - Use `segmentsStyle` as function instead of `data[].style` prop 94 | - Mind that the root element is now the SVG itself 95 | - Provide an [`Object.assign` polyfill](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign#Polyfill) to support legacy browser (eg. IE) 96 | 97 | ## 7.3.1 98 | 99 | ### Patch Changes 100 | 101 | - Fix event handler types expecting wrong event as first argument 102 | - Fix `label` prop type declaration when receiving a function (#154) 103 | - Fix wrong label position when segmentsShift enabled (#155) 104 | 105 | ## 7.3.0 106 | 107 | ### Minor Changes 108 | 109 | - Allow `segmentsShift` as function to return `undefined` 110 | 111 | ### Minor Changes 112 | 113 | - Improve `sumValues` performance 114 | 115 | ## 7.2.0 116 | 117 | ### Minor Changes 118 | 119 | - Add `segmentsShift` prop to move segments radially and render exploded charts 120 | 121 | ## 7.1.1 122 | 123 | ### Patch Changes 124 | 125 | - Fix regression introduced in `7.1.0` consisting of `reveal` prop being stuck after initial animation 126 | 127 | ## 7.1.0 128 | 129 | ### Minor Changes 130 | 131 | - Add `onBlur`, `onFocus`, `onKeyDown` callbacks 132 | - Add `segmentsTabIndex` to append a `tabindex` prop to segment paths 133 | 134 | ### Patch Changes 135 | 136 | - Fix regression bug consisting of `stroke-dasharray` and `stroke-dashoffset` attributes being evaluated and appended even when there is no animation 137 | 138 | ## 7.0.0 139 | 140 | ### Warning 141 | 142 | This release introduced a regression bug. Please upgrade to `V7.1.0` 143 | 144 | ### Major Changes 145 | 146 | - Remove `src` from distribution 147 | - `EventHandler` expects `void` as return type 148 | 149 | ### Minor Changes 150 | 151 | - Introduce Typescript natively 152 | - Remove `prop-types` from private components 153 | 154 | ## 6.0.1 155 | 156 | ### Patch Changes 157 | 158 | - Fix chart `cy` being evaluated from `viewBoxSize` width instead of height 159 | 160 | ## 6.0.0 161 | 162 | ### Major Changes 163 | 164 | - `ratio` property removed in favour of `viewBoxSize` 165 | 166 | ## 5.0.2 167 | 168 | ### Patch Changes 169 | 170 | - fix `NaN` being rendered as SVG path attribute when `data.value` sum equals 0 and `totalValue` is undefined 171 | 172 | ## 5.0.1 173 | 174 | ### Patch Changes 175 | 176 | - fix `reveal` direction with negative `lengthAngle` 177 | 178 | ## 5.0.0 179 | 180 | ### Major Changes 181 | 182 | - `reveal` works same direction as `lengthAngle` 183 | - Labels vertically aligned using `dominant-baseline` instead of `alignment-baseline` 184 | 185 | ## 4.2.0 186 | 187 | ### Minor Changes 188 | 189 | - Add `background` prop to draw segment's background 190 | 191 | ### Patch Changes 192 | 193 | - Fix `props.data` types 194 | 195 | ## 4.1.1 196 | 197 | ### Patch Changes 198 | 199 | - Transpile bundled `svg-partial-circle` 200 | 201 | ## 4.1.0 202 | 203 | ### Minor Changes 204 | 205 | - Provide custom segment style via `props.data[i].style` 206 | 207 | ## 4.0.0 208 | 209 | ### Major Changes 210 | 211 | - `ReactMinimalPieChart` extends `React.Component` instead of `React.PureComponent` 212 | - Improve `paddingAngle` implementation to respect the proportions between rendered slices 213 | - Add TypeScript types 214 | 215 | ### Minor Changes 216 | 217 | - Compile with Babel v7 218 | - Update Storybook demo to v5 219 | - Add `sideEffect` flag 220 | 221 | ### Patch Changes 222 | 223 | - Replace `export { default } from` syntax not supported by TS 224 | 225 | ## 3.5.0 226 | 227 | - Add support for labels with props: `label`, `labelPosition` and `labelStyle` 228 | - Minor internal refactoring 229 | 230 | ## 3.4.1 231 | 232 | - Fix chart to render `data.title` value as `` inside `<path>` element 233 | 234 | ## 3.4.0 235 | 236 | - Support `injectSvg` function property to inject any element into rendered `<svg>` element 237 | 238 | ## 3.3.0 239 | 240 | - Support `data.title` property to render `<title>` inside `<path>` element 241 | 242 | ## 3.2.0 243 | 244 | - Add UMD export 245 | 246 | ## 3.1.0 247 | 248 | - Add segments interaction callbacks: `onClick`, `onMouseOver`, `onMouseOut` 249 | - Add `segmentsStyle` prop 250 | - Setup up Prettier 251 | 252 | ## 3.0.2 253 | 254 | - Prevent initial animation when component is unmounted 255 | 256 | ## 3.0.1 257 | 258 | - Update `react`/`react-dom` peer dependency version to accept versions `15` and `16` 259 | 260 | ## 3.0.0 261 | 262 | - Make SVG element `display: block` to remove undesired spacing 263 | 264 | ## 2.0.0 265 | 266 | - Compile with Rollup.js to bundle with a transpiled version of `svg-partial-circle v0.2.0` 267 | 268 | ## 1.1.0 269 | 270 | - Migrate React's prop types to external `prop-types` package 271 | - Swap `react-addons-test-utils` with `react-test-renderer` as `enzyme` required dependencies 272 | 273 | ## 1.0.0 274 | 275 | - Enable negative `lengthAngle` to configure clockwise and counterclockwise charts 276 | - Add `rx` and `ry` props to set custom chart center coordinates 277 | - Add `ratio` prop 278 | - Add `radius` prop 279 | - Re-evaluate segments size when `paddingAngle` is > 0 280 | - Make counterclockwise charts by default 281 | - Make <ReactMinimalPieChart> a pure component 282 | - Add eslint parsing 283 | 284 | ## 0.0.2 285 | 286 | - Incorporate `svg-partial-circle` lib 287 | 288 | ## 0.0.1 289 | 290 | - Fix bad imports 291 | 292 | ## 0.0.0 293 | 294 | - Initial release 295 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) Andrea Carraro 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React minimal pie chart 2 | 3 | [![Build Status][ci-badge]][ci] 4 | [![Npm version][npm-version-badge]][npm] 5 | [![Coveralls][coveralls-badge]][coveralls] 6 | [![Bundle size][bundlephobia-badge]][bundlephobia] 7 | 8 | Lightweight React **SVG pie charts**, with **versatile options** and **CSS animation** included. **~2kB** gzipped. [👏 Demo 👏][storybook]. 9 | 10 | <p align="center"> 11 | <img 12 | width="350px" 13 | src="docs/chart.gif?raw=true" 14 | alt="React minimal pie chart preview" 15 | /> 16 | </p> 17 | 18 | ## Why? 19 | 20 | Because [Recharts][recharts-github] is awesome, but when you just need a simple pie/donought chart, **2kB** are usually enough. 21 | 22 | | | Size<br>by Bundlefobia | Benchmark Size \* | Loading time<br>on a slow 3g \* | 23 | | :----------------------------------------------------: | :-----------------------------------------------------------------------------------------------------: | :---------------: | :-----------------------------: | 24 | | react-minimal-pie-chart (_v9.0.0_) | [![Bundle size: React minimal pie chart][bundlephobia-badge]][bundlephobia] | 1.99 KB | ~40 ms | 25 | | [rechart][recharts-github] (_v1.8.5_) | [![Bundle size: Recharts][recharts-bundlephobia-badge]][recharts-bundlephobia] | 96.9 KB | ~1900 ms | 26 | | [victory-pie][victory-pie-github] (_v34.1.3_) | [![Bundle size: Victory pie][victory-pie-bundlephobia-badge]][victory-pie-bundlephobia] | 50.5 KB | ~1100 ms | 27 | | [react-apexcharts][react-apexcharts-github] (_v1.3.7_) | [![Bundle size: React apec charts][react-apexcharts-bundlephobia-badge]][react-apexcharts-bundlephobia] | 114.6 KB | ~2300 ms | 28 | | [react-vis][react-vis-github] (_v1.11.7_) | [![Bundle size: React vis][react-vis-bundlephobia-badge]][react-vis-bundlephobia] | 78.3 KB | ~1600 ms | 29 | 30 | \* Benchmark carried out with [size-limit](https://github.com/ai/size-limit) with a "real-world" setup: see [benchmark repo](https://github.com/toomuchdesign/react-pie-charts-size). (What matter here are not absolute values but the relation between magnitudes) 31 | 32 | ## Features 33 | 34 | - **< 2kB** gzipped 35 | - Versatile: **Pie**, **Donut**, **Loading**, **Completion** charts (see [Demo][storybook]) 36 | - Customizable chart **labels** and **CSS animations** 37 | - Written in **Typescript** 38 | - No dependencies 39 | 40 | ## Installation 41 | 42 | ```console 43 | npm install react-minimal-pie-chart 44 | ``` 45 | 46 | If you don't use a package manager, `react-minimal-pie-chart` exposes also an `UMD` module ready for the browser. 47 | 48 | ``` 49 | https://unpkg.com/react-minimal-pie-chart/dist/index.js 50 | ``` 51 | 52 | Minimum supported **Typescript** version: >= `3.8` 53 | 54 | ## Usage 55 | 56 | ```js 57 | import { PieChart } from 'react-minimal-pie-chart'; 58 | 59 | <PieChart 60 | data={[ 61 | { title: 'One', value: 10, color: '#E38627' }, 62 | { title: 'Two', value: 15, color: '#C13C37' }, 63 | { title: 'Three', value: 20, color: '#6A2135' }, 64 | ]} 65 | />; 66 | ``` 67 | 68 | ## Options 69 | 70 | <!-- prettier-ignore-start --> 71 | | Property | Type | Description | Default | 72 | | --------------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | 73 | | [**data**][data-props-docs] | `DataEntry[]` | Source data. Each entry represents a chart segment | [] | 74 | | **lineWidth** | `number` (%) | Line width of each segment. Percentage of chart's radius | 100 | 75 | | **startAngle** | `number` | Start angle of first segment | 0 | 76 | | **lengthAngle** | `number` | Total angle taken by the chart _(can be negative to make the chart clockwise!)_ | 360 | 77 | | **totalValue** | `number` | Total value represented by the full chart | - | 78 | | **paddingAngle** | `number` | Angle between two segments | - | 79 | | **rounded** | `boolean` | Round line caps of each segment | - | 80 | | **segmentsShift** | `number`</br>or:</br>`(segmentIndex) => number` | Translates segments radially. If `number` set, provide shift value relative to `viewBoxSize` space. If `function`, return a value for each segment.</br>_(`radius` prop might be adjusted to prevent segments from overflowing chart's boundaries)_ | - | 81 | | **segmentsStyle** | `CSSObject`</br>or:</br>`(segmentIndex) => CSSObject` | Style object assigned to each segment. If `function`, return a value for each segment. *(Warning: SVG only supports [its own CSS props][svg-css])*. | - | 82 | | **segmentsTabIndex** | `number` | [`tabindex` attribute](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/tabindex) assigned to segments | - | 83 | | [**label**][label-props-docs] | `(labelRenderProps) => string \| number \| ReactElement` | A function returning a label value or the [SVG element][svg-elements] to be rendered as label | - | 84 | | **labelPosition** | `number` (%) | Label position from origin. Percentage of chart's radius _(50 === middle point)_ | 50 | 85 | | **labelStyle** | `CSSObject`</br>or:</br>`(segmentIndex) => CSSObject` | Style object assigned to each label. If `function` set, return style for each label. *(Warning: SVG only supports [its own CSS props][svg-css])*. | - | 86 | | **animate** | `boolean` | Animate segments on component mount | - | 87 | | **animationDuration** | `number` | Animation duration in ms | 500 | 88 | | **animationEasing** | `string` | A [CSS easing function](https://developer.mozilla.org/en-US/docs/Web/CSS/transition-timing-function) | ease-out | 89 | | **reveal** | `number` (%) | Turn on CSS animation and reveal just a percentage of each segment | - | 90 | | **background** | `string` | Segments' background color | - | 91 | | **children** | `ReactElement` (svg) | Elements rendered as children of [SVG element][svg-elements] (eg. SVG `defs` and gradient elements) | - | 92 | | **radius** | `number` (user units) | Radius of the pie (relative to `viewBoxSize` space) | 50 | 93 | | **center** | `[number, number]` | x and y coordinates of center (relative to `viewBoxSize` space) | [50, 50] | 94 | | **viewBoxSize** | `[number, number]` | `width` and `height` of SVG `viewBox` attribute | [100, 100] | 95 | | **onBlur** | `(e, segmentIndex) => void` | `onBlur` event handler for each segment | - | 96 | | **onClick** | `(e, segmentIndex) => void` | `onClick` event handler for each segment | - | 97 | | **onFocus** | `(e, segmentIndex) => void` | `onFocus` event handler for each segment | - | 98 | | **onKeyDown** | `(e, segmentIndex) => void` | `onKeyDown` event handler for each segment | - | 99 | | **onMouseOut** | `(e, segmentIndex) => void` | `onMouseOut` event handler for each segment | - | 100 | | **onMouseOver** | `(e, segmentIndex) => void` | `onMouseOver` event handler for each segment | - | 101 | | | `.oOo.oOo.oOo.oOo.oOo.oOo.oOo.` | | | 102 | <!-- prettier-ignore-end --> 103 | 104 | Prop types are exposed for convenience: 105 | 106 | ```ts 107 | import type { PieChartProps } from 'react-minimal-pie-chart'; 108 | ``` 109 | 110 | ### About `data` prop 111 | 112 | `data` prop expects an array of chart entries as follows: 113 | 114 | ```typescript 115 | type Data = { 116 | color: string; 117 | value: number; 118 | key?: string | number; 119 | title?: string | number; 120 | [key: string]: any; 121 | }[]; 122 | ``` 123 | 124 | Each entry accepts any custom property plus the following **optional ones**: 125 | 126 | - **`key`**: custom value to be used as [segments element keys](https://reactjs.org/docs/lists-and-keys.html) 127 | 128 | - **`title`**: [`title` element](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/title) rendered as segment's child 129 | 130 | ### Custom labels with `label` render prop 131 | 132 | `label` prop accepts a function returning the **string, number or element** rendered as label for each segment: 133 | 134 | ```js 135 | <PieChart 136 | label={(labelRenderProps: LabelRenderProps) => 137 | number | string | React.ReactElement | undefined | null 138 | } 139 | /> 140 | ``` 141 | 142 | The function receives `labelRenderProps` object as single **argument**: 143 | 144 | ```typescript 145 | type LabelRenderProps = { 146 | x: number; 147 | y: number; 148 | dx: number; 149 | dy: number; 150 | textAnchor: string; 151 | dataEntry: { 152 | ...props.data[dataIndex] 153 | // props.data entry relative to the label extended with: 154 | startAngle: number; 155 | degrees: number; 156 | percentage: number; 157 | }; 158 | dataIndex: number; 159 | style: React.CSSProperties; 160 | }; 161 | ``` 162 | 163 | #### Label prop, common scenarios 164 | 165 | Render entries' values as labels: 166 | 167 | ```js 168 | label={({ dataEntry }) => dataEntry.value} 169 | ``` 170 | 171 | Render segment's percentage as labels: 172 | 173 | ```js 174 | label={({ dataEntry }) => `${Math.round(dataEntry.percentage)} %`} 175 | ``` 176 | 177 | See examples in the [demo source](https://github.com/toomuchdesign/react-minimal-pie-chart/blob/v8.2.0/stories/index.tsx#L81). 178 | 179 | ## How to 180 | 181 | ### User interactions with the chart 182 | 183 | See [demo](https://toomuchdesign.github.io/react-minimal-pie-chart/index.html?path=/story/example-interaction--click-mouseover-mouseout-callbacks) and relative source [here](https://github.com/toomuchdesign/react-minimal-pie-chart/blob/v8.0.0/stories/InteractionStory.tsx) and [here](https://github.com/toomuchdesign/react-minimal-pie-chart/blob/v8.0.0/stories/InteractionTabStory.tsx). 184 | 185 | ### Custom tooltip 186 | 187 | See [demo](https://toomuchdesign.github.io/react-minimal-pie-chart/index.html?path=/story/example-misc--tooltip-integration) and [relative source](https://github.com/toomuchdesign/react-minimal-pie-chart/blob/master/stories/components/Tooltip.tsx). 188 | 189 | ## Browsers support 190 | 191 | Here is an updated [browsers support list 🔍](https://github.com/toomuchdesign/react-minimal-pie-chart/issues/129). 192 | 193 | The main requirement of this library is an accurate rendering of [SVG Stroke properties](https://www.w3schools.com/graphics/svg_stroking.asp). 194 | 195 | Please consider that [`Math.sign`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/sign) and [`Object.assign`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign) polyfills are required to support legacy browsers. 196 | 197 | ## Misc 198 | 199 | ### How svg arc paths work? 200 | 201 | - http://xahlee.info/js/svg_circle_arc.html 202 | - https://codepen.io/lingtalfi/pen/yaLWJG 203 | 204 | <!-- http://users.ecs.soton.ac.uk/rfp07r/interactive-svg-examples/ --> 205 | 206 | ### How SVG animations work? 207 | 208 | This library uses the `stroke-dasharray` + `stroke-dashoffset` animation strategy [described here](https://css-tricks.com/svg-line-animation-works/). 209 | 210 | ## Todo's 211 | 212 | - Consider moving storybook deployment to CI 213 | - Consider using `transform` to mutate segments/labels positions 214 | - Consider abstracting React bindings to re-use business logic with other frameworks 215 | - Provide a way to supply `svg` element with any extra prop 216 | - Find a better solution to assign default props 217 | 218 | ## Contributors 219 | 220 | Thanks to you all ([emoji key](https://github.com/kentcdodds/all-contributors#emoji-key)): 221 | 222 | <!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section --> 223 | <!-- prettier-ignore-start --> 224 | <!-- markdownlint-disable --> 225 | <table> 226 | <tr> 227 | <td align="center"><a href="http://www.andreacarraro.it"><img src="https://avatars3.githubusercontent.com/u/4573549?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Andrea Carraro</b></sub></a><br /><a href="https://github.com/toomuchdesign/react-minimal-pie-chart/commits?author=toomuchdesign" title="Code">💻</a> <a href="https://github.com/toomuchdesign/react-minimal-pie-chart/commits?author=toomuchdesign" title="Documentation">📖</a> <a href="#infra-toomuchdesign" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/toomuchdesign/react-minimal-pie-chart/commits?author=toomuchdesign" title="Tests">⚠️</a> <a href="https://github.com/toomuchdesign/react-minimal-pie-chart/pulls?q=is%3Apr+reviewed-by%3Atoomuchdesign" title="Reviewed Pull Requests">👀</a></td> 228 | <td align="center"><a href="https://github.com/rufman"><img src="https://avatars3.githubusercontent.com/u/1128559?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Stephane Rufer</b></sub></a><br /><a href="https://github.com/toomuchdesign/react-minimal-pie-chart/issues?q=author%3Arufman" title="Bug reports">🐛</a> <a href="https://github.com/toomuchdesign/react-minimal-pie-chart/commits?author=rufman" title="Code">💻</a></td> 229 | <td align="center"><a href="https://github.com/jaaberg"><img src="https://avatars3.githubusercontent.com/u/1413255?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jørgen Aaberg</b></sub></a><br /><a href="https://github.com/toomuchdesign/react-minimal-pie-chart/commits?author=jaaberg" title="Code">💻</a></td> 230 | <td align="center"><a href="http://www.tobiahrex.com"><img src="https://avatars3.githubusercontent.com/u/16377119?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Tobiah Rex</b></sub></a><br /><a href="https://github.com/toomuchdesign/react-minimal-pie-chart/issues?q=author%3ATobiahRex" title="Bug reports">🐛</a></td> 231 | <td align="center"><a href="https://edwardxiao.com"><img src="https://avatars2.githubusercontent.com/u/11728228?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Edward Xiao</b></sub></a><br /><a href="https://github.com/toomuchdesign/react-minimal-pie-chart/issues?q=author%3Aedwardfhsiao" title="Bug reports">🐛</a></td> 232 | <td align="center"><a href="https://keybase.io/konsumer"><img src="https://avatars1.githubusercontent.com/u/83857?v=4?s=100" width="100px;" alt=""/><br /><sub><b>David Konsumer</b></sub></a><br /><a href="https://github.com/toomuchdesign/react-minimal-pie-chart/commits?author=konsumer" title="Code">💻</a> <a href="https://github.com/toomuchdesign/react-minimal-pie-chart/commits?author=konsumer" title="Documentation">📖</a> <a href="#example-konsumer" title="Examples">💡</a> <a href="#ideas-konsumer" title="Ideas, Planning, & Feedback">🤔</a></td> 233 | <td align="center"><a href="https://github.com/nehoraigold"><img src="https://avatars2.githubusercontent.com/u/44398222?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ori</b></sub></a><br /><a href="#ideas-nehoraigold" title="Ideas, Planning, & Feedback">🤔</a></td> 234 | </tr> 235 | <tr> 236 | <td align="center"><a href="https://www.manos.im/"><img src="https://avatars3.githubusercontent.com/u/6333409?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Emmanouil Konstantinidis</b></sub></a><br /><a href="https://github.com/toomuchdesign/react-minimal-pie-chart/issues?q=author%3Amanosim" title="Bug reports">🐛</a></td> 237 | <td align="center"><a href="https://github.com/yuruc"><img src="https://avatars0.githubusercontent.com/u/5884342?v=4?s=100" width="100px;" alt=""/><br /><sub><b>yuruc</b></sub></a><br /><a href="https://github.com/toomuchdesign/react-minimal-pie-chart/commits?author=yuruc" title="Code">💻</a></td> 238 | <td align="center"><a href="https://www.linkedin.com/in/luca-schiavone-7270a8138/"><img src="https://avatars1.githubusercontent.com/u/16616566?v=4?s=100" width="100px;" alt=""/><br /><sub><b>luca-esse </b></sub></a><br /><a href="https://github.com/toomuchdesign/react-minimal-pie-chart/issues?q=author%3Aluca-esse" title="Bug reports">🐛</a></td> 239 | <td align="center"><a href="http://twitter.com/Osuka42"><img src="https://avatars1.githubusercontent.com/u/5117006?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Oscar Mendoza</b></sub></a><br /><a href="https://github.com/toomuchdesign/react-minimal-pie-chart/issues?q=author%3AOsuka42g" title="Bug reports">🐛</a> <a href="https://github.com/toomuchdesign/react-minimal-pie-chart/commits?author=Osuka42g" title="Code">💻</a></td> 240 | <td align="center"><a href="https://github.com/damien-git"><img src="https://avatars0.githubusercontent.com/u/7503971?v=4?s=100" width="100px;" alt=""/><br /><sub><b>damien-git</b></sub></a><br /><a href="https://github.com/toomuchdesign/react-minimal-pie-chart/issues?q=author%3Adamien-git" title="Bug reports">🐛</a> <a href="#ideas-damien-git" title="Ideas, Planning, & Feedback">🤔</a></td> 241 | <td align="center"><a href="https://www.linkedin.com/in/vianneystroebel/"><img src="https://avatars0.githubusercontent.com/u/628818?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Vianney Stroebel</b></sub></a><br /><a href="https://github.com/toomuchdesign/react-minimal-pie-chart/issues?q=author%3Avibl" title="Bug reports">🐛</a> <a href="#ideas-vibl" title="Ideas, Planning, & Feedback">🤔</a></td> 242 | <td align="center"><a href="http://xumi.fr"><img src="https://avatars0.githubusercontent.com/u/204001?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Maxime Zielony</b></sub></a><br /><a href="https://github.com/toomuchdesign/react-minimal-pie-chart/issues?q=author%3Axumi" title="Bug reports">🐛</a> <a href="https://github.com/toomuchdesign/react-minimal-pie-chart/commits?author=xumi" title="Code">💻</a></td> 243 | </tr> 244 | <tr> 245 | <td align="center"><a href="https://github.com/razked"><img src="https://avatars0.githubusercontent.com/u/39411034?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Raz Kedem</b></sub></a><br /><a href="https://github.com/toomuchdesign/react-minimal-pie-chart/issues?q=author%3Arazked" title="Bug reports">🐛</a></td> 246 | <td align="center"><a href="https://github.com/slumbering"><img src="https://avatars2.githubusercontent.com/u/1186424?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Blocksmith</b></sub></a><br /><a href="https://github.com/toomuchdesign/react-minimal-pie-chart/issues?q=author%3Aslumbering" title="Bug reports">🐛</a></td> 247 | <td align="center"><a href="http://jamietalbot.com"><img src="https://avatars0.githubusercontent.com/u/425787?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jamie Talbot</b></sub></a><br /><a href="https://github.com/toomuchdesign/react-minimal-pie-chart/issues?q=author%3Amajelbstoat" title="Bug reports">🐛</a></td> 248 | <td align="center"><a href="http://timeslikethese.ca"><img src="https://avatars1.githubusercontent.com/u/22269057?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Oscar Yixuan Chen</b></sub></a><br /><a href="https://github.com/toomuchdesign/react-minimal-pie-chart/issues?q=author%3Aairoscar" title="Bug reports">🐛</a></td> 249 | <td align="center"><a href="https://github.com/RuiRocha1991"><img src="https://avatars2.githubusercontent.com/u/29250466?v=4?s=100" width="100px;" alt=""/><br /><sub><b>RuiRocha1991</b></sub></a><br /><a href="https://github.com/toomuchdesign/react-minimal-pie-chart/issues?q=author%3ARuiRocha1991" title="Bug reports">🐛</a></td> 250 | <td align="center"><a href="https://github.com/Romaboy"><img src="https://avatars0.githubusercontent.com/u/42248135?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Roman Kushyn</b></sub></a><br /><a href="https://github.com/toomuchdesign/react-minimal-pie-chart/issues?q=author%3ARomaboy" title="Bug reports">🐛</a></td> 251 | <td align="center"><a href="https://bogas04.github.io/"><img src="https://avatars.githubusercontent.com/u/6177621?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Divjot Singh</b></sub></a><br /><a href="https://github.com/toomuchdesign/react-minimal-pie-chart/commits?author=bogas04" title="Code">💻</a></td> 252 | </tr> 253 | </table> 254 | 255 | <!-- markdownlint-restore --> 256 | <!-- prettier-ignore-end --> 257 | 258 | <!-- ALL-CONTRIBUTORS-LIST:END --> 259 | 260 | [ci-badge]: https://github.com/toomuchdesign/react-minimal-pie-chart/actions/workflows/ci.yml/badge.svg 261 | [ci]: https://github.com/toomuchdesign/react-minimal-pie-chart/actions/workflows/ci.yml 262 | [coveralls-badge]: https://coveralls.io/repos/github/toomuchdesign/react-minimal-pie-chart/badge.svg?branch=master 263 | [coveralls]: https://coveralls.io/github/toomuchdesign/react-minimal-pie-chart?branch=master 264 | [npm]: https://www.npmjs.com/package/react-minimal-pie-chart 265 | [npm-version-badge]: https://img.shields.io/npm/v/react-minimal-pie-chart.svg 266 | [bundlephobia-badge]: https://badgen.net/bundlephobia/minzip/react-minimal-pie-chart 267 | [bundlephobia]: https://bundlephobia.com/result?p=react-minimal-pie-chart 268 | [recharts-bundlephobia-badge]: https://badgen.net/bundlephobia/minzip/recharts 269 | [recharts-bundlephobia]: https://bundlephobia.com/result?p=recharts 270 | [recharts-github]: https://github.com/recharts/recharts 271 | [victory-pie-bundlephobia-badge]: https://badgen.net/bundlephobia/minzip/victory-pie 272 | [victory-pie-bundlephobia]: https://bundlephobia.com/result?p=victory-pie 273 | [victory-pie-github]: https://github.com/FormidableLabs/victory 274 | [react-apexcharts-bundlephobia-badge]: https://badgen.net/bundlephobia/minzip/apexcharts 275 | [react-apexcharts-bundlephobia]: https://bundlephobia.com/result?p=apexcharts 276 | [react-apexcharts-github]: https://github.com/apexcharts/apexcharts.js 277 | [react-vis-bundlephobia-badge]: https://badgen.net/bundlephobia/minzip/react-vis 278 | [react-vis-bundlephobia]: https://bundlephobia.com/result?p=react-vis 279 | [react-vis-github]: https://github.com/uber/react-vis 280 | [storybook]: https://toomuchdesign.github.io/react-minimal-pie-chart/index.html 281 | [data-props-docs]: #about-data-prop 282 | [label-props-docs]: #custom-labels-with-label-render-prop 283 | [svg-elements]: https://developer.mozilla.org/en-US/docs/Web/SVG/Element 284 | [svg-css]: https://css-tricks.com/svg-properties-and-css/#aa-properties-shared-between-css-and-svg 285 | -------------------------------------------------------------------------------- /docs/chart.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toomuchdesign/react-minimal-pie-chart/07ddda9f927def62bcfc5353c0a73969a168a90b/docs/chart.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-minimal-pie-chart", 3 | "version": "9.1.0", 4 | "description": "Lightweight but versatile SVG pie/donut charts for React", 5 | "main": "dist/index.cjs.js", 6 | "module": "dist/index.esm.js", 7 | "types": "dist/index.d.ts", 8 | "files": [ 9 | "dist" 10 | ], 11 | "sideEffects": false, 12 | "scripts": { 13 | "test": "vitest --config ./vitest.config.mts", 14 | "test:bundles": "npm run test:bundles:snapshot && npm run test:bundles:unit && npm run test:bundles:size", 15 | "test:bundles:unit": "vitest --config ./test-bundles/cjs.vitest.config.mts --run && vitest --config ./test-bundles/es.vitest.config.mts --run", 16 | "test:bundles:snapshot": "npm run test -- --run --dir ./test-bundles", 17 | "test:bundles:size": "size-limit", 18 | "test:source": "npm run test:ts && npm t -- --coverage --run", 19 | "test:ts": "tsc --noEmit", 20 | "test:update": "npm run build:source && npm run test:bundles:snapshot -- -u && git add ./jest/__tests__/__snapshots__", 21 | "clean": "rm -rf dist", 22 | "build": "npm run clean && rollup -c --bundleConfigAsCjs", 23 | "build:source": "rollup -c --bundleConfigAsCjs", 24 | "build:types": "tsc -p tsconfig.build.json", 25 | "format:check": "prettier . --check", 26 | "format:fix": "npm run format:check -- --write", 27 | "preversion": "npm run prepare", 28 | "version": "git add package.json", 29 | "postversion": "git push && git push --tags", 30 | "prepare": "npx simple-git-hooks && npm run test -- --run && npm run test:source && npm run build && npm run test:bundles", 31 | "start": "storybook dev", 32 | "storybook:build": "storybook build" 33 | }, 34 | "keywords": [ 35 | "react", 36 | "pie", 37 | "donough", 38 | "arc", 39 | "chart", 40 | "typescript" 41 | ], 42 | "repository": { 43 | "type": "git", 44 | "url": "git+https://github.com/toomuchdesign/react-minimal-pie-chart.git" 45 | }, 46 | "author": "Andrea Carraro <me@andreacarraro.it>", 47 | "license": "MIT", 48 | "peerDependencies": { 49 | "react": "^16.8.0 || ^17 || ^18 || ^19", 50 | "react-dom": "^16.8.0 || ^17 || ^18 || ^19" 51 | }, 52 | "devDependencies": { 53 | "@changesets/cli": "^2.27.9", 54 | "@rollup/plugin-typescript": "^12.1.1", 55 | "@schwingbat/relative-angle": "^1.0.0", 56 | "@size-limit/preset-small-lib": "^11.1.4", 57 | "@storybook/addon-essentials": "^8.1.6", 58 | "@storybook/react": "^8.1.6", 59 | "@storybook/react-vite": "^8.1.6", 60 | "@storybook/test": "^8.1.6", 61 | "@testing-library/jest-dom": "^6.4.5", 62 | "@testing-library/react": "^16.1.0", 63 | "@total-typescript/tsconfig": "^1.0.4", 64 | "@types/node": "^22.8.6", 65 | "@types/react": "^19.0.0", 66 | "@types/react-dom": "^19.0.0", 67 | "@types/svg-path-parser": "^1.1.6", 68 | "@vitest/coverage-v8": "^3.0.1", 69 | "jsdom": "^26.0.0", 70 | "prettier": "^3.2.5", 71 | "react": "^19.0.0", 72 | "react-dom": "^19.0.0", 73 | "react-tooltip": "^4.5.1", 74 | "rollup": "^4.18.0", 75 | "simple-git-hooks": "^2.11.1", 76 | "size-limit": "^11.1.4", 77 | "storybook": "^8.1.6", 78 | "svg-path-parser": "^1.1.0", 79 | "typescript": "^5.4.5", 80 | "vitest": "^3.0.1" 81 | }, 82 | "size-limit": [ 83 | { 84 | "limit": "2.5 KB", 85 | "path": "./dist/index.esm.js", 86 | "ignore": [ 87 | "react" 88 | ] 89 | } 90 | ], 91 | "simple-git-hooks": { 92 | "pre-commit": "npm run format:check && npm run test:source" 93 | }, 94 | "dependencies": { 95 | "svg-partial-circle": "^1.0.0" 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import pkg from './package.json'; 3 | 4 | const external = [] 5 | // Mark dependencies and peerDependencies as external 6 | .concat(Object.keys(pkg.dependencies), Object.keys(pkg.peerDependencies)); 7 | 8 | const plugins = [ 9 | typescript({ 10 | compilerOptions: { 11 | declaration: true, 12 | declarationDir: 'dist', 13 | }, 14 | include: ['src/**'], 15 | }), 16 | ]; 17 | 18 | /** @type {import('rollup').RollupOptions} */ 19 | export default { 20 | input: 'src/index.ts', 21 | plugins: plugins, 22 | external, 23 | output: [ 24 | { 25 | file: pkg.module, 26 | format: 'es', 27 | sourcemap: true, 28 | }, 29 | { 30 | file: pkg.main, 31 | format: 'cjs', 32 | sourcemap: true, 33 | }, 34 | ], 35 | }; 36 | -------------------------------------------------------------------------------- /src/Chart/Chart.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import type { 3 | CSSProperties, 4 | FocusEvent, 5 | KeyboardEvent, 6 | MouseEvent, 7 | ReactNode, 8 | } from 'react'; 9 | import extendData from './extendData'; 10 | import renderLabels from './renderLabels'; 11 | import renderSegments from './renderSegments'; 12 | import type { Data, BaseDataEntry, LabelRenderFunction } from '../commonTypes'; 13 | 14 | export type Props<DataEntry extends BaseDataEntry = BaseDataEntry> = { 15 | animate?: boolean; 16 | animationDuration?: number; 17 | animationEasing?: string; 18 | background?: string; 19 | center?: [number, number]; 20 | children?: ReactNode; 21 | className?: string; 22 | data: Data<DataEntry>; 23 | lengthAngle?: number; 24 | lineWidth?: number; 25 | label?: LabelRenderFunction<DataEntry>; 26 | labelPosition?: number; 27 | labelStyle?: 28 | | CSSProperties 29 | | ((dataIndex: number) => CSSProperties | undefined); 30 | onBlur?: (event: FocusEvent, dataIndex: number) => void; 31 | onClick?: (event: MouseEvent, dataIndex: number) => void; 32 | onFocus?: (event: FocusEvent, dataIndex: number) => void; 33 | onKeyDown?: (event: KeyboardEvent, dataIndex: number) => void; 34 | onMouseOut?: (event: MouseEvent, dataIndex: number) => void; 35 | onMouseOver?: (event: MouseEvent, dataIndex: number) => void; 36 | paddingAngle?: number; 37 | radius?: number; 38 | reveal?: number; 39 | rounded?: boolean; 40 | segmentsShift?: number | ((dataIndex: number) => number | undefined); 41 | segmentsStyle?: 42 | | CSSProperties 43 | | ((dataIndex: number) => CSSProperties | undefined); 44 | segmentsTabIndex?: number; 45 | startAngle?: number; 46 | style?: CSSProperties; 47 | totalValue?: number; 48 | viewBoxSize?: [number, number]; 49 | }; 50 | 51 | export const defaultProps = { 52 | animationDuration: 500, 53 | animationEasing: 'ease-out', 54 | center: [50, 50] as [number, number], 55 | data: [] as Data, 56 | labelPosition: 50, 57 | lengthAngle: 360, 58 | lineWidth: 100, 59 | paddingAngle: 0, 60 | radius: 50, 61 | startAngle: 0, 62 | viewBoxSize: [100, 100] as [number, number], 63 | }; 64 | 65 | export type PropsWithDefaults<DataEntry extends BaseDataEntry = BaseDataEntry> = 66 | Props<DataEntry> & typeof defaultProps; 67 | 68 | function makePropsWithDefaults<DataEntry extends BaseDataEntry>( 69 | props: Props<DataEntry> 70 | ): PropsWithDefaults<DataEntry> { 71 | const result: PropsWithDefaults<DataEntry> = Object.assign( 72 | {}, 73 | defaultProps, 74 | props 75 | ); 76 | 77 | // @NOTE Object.assign doesn't default properties with undefined value (like React defaultProps does) 78 | let key: keyof typeof defaultProps; 79 | for (key in defaultProps) { 80 | if (props[key] === undefined) { 81 | // @ts-expect-error: TS cannot ensure we're assigning the expected props accross abjects 82 | result[key] = defaultProps[key]; 83 | } 84 | } 85 | 86 | return result; 87 | } 88 | 89 | export function ReactMinimalPieChart<DataEntry extends BaseDataEntry>( 90 | originalProps: Props<DataEntry> 91 | ) { 92 | const props = makePropsWithDefaults(originalProps); 93 | const [revealOverride, setRevealOverride] = useState( 94 | props.animate ? 0 : null 95 | ); 96 | 97 | useEffect(() => { 98 | if (props.animate) { 99 | // Trigger initial animation 100 | setRevealOverride(null); 101 | } 102 | }, []); 103 | 104 | const extendedData = extendData(props); 105 | return ( 106 | <svg 107 | viewBox={`0 0 ${props.viewBoxSize[0]} ${props.viewBoxSize[1]}`} 108 | width="100%" 109 | height="100%" 110 | className={props.className} 111 | style={props.style} 112 | > 113 | {renderSegments(extendedData, props, revealOverride)} 114 | {renderLabels(extendedData, props)} 115 | {props.children} 116 | </svg> 117 | ); 118 | } 119 | -------------------------------------------------------------------------------- /src/Chart/extendData.ts: -------------------------------------------------------------------------------- 1 | import { extractPercentage, valueBetween } from '../utils'; 2 | import type { Data, BaseDataEntry, ExtendedData } from '../commonTypes'; 3 | import type { PropsWithDefaults as ChartProps } from './Chart'; 4 | 5 | function sumValues<DataEntry extends BaseDataEntry>( 6 | data: Data<DataEntry> 7 | ): number { 8 | let sum = 0; 9 | for (let i = 0; i < data.length; i++) { 10 | sum += data[i].value; 11 | } 12 | return sum; 13 | } 14 | 15 | // Append "percentage", "degrees" and "startAngle" to each data entry 16 | export default function extendData<DataEntry extends BaseDataEntry>({ 17 | data, 18 | lengthAngle: totalAngle, 19 | totalValue, 20 | paddingAngle, 21 | startAngle: chartStartAngle, 22 | }: ChartProps<DataEntry>): ExtendedData<DataEntry> { 23 | const total = totalValue || sumValues(data); 24 | const normalizedTotalAngle = valueBetween(totalAngle, -360, 360); 25 | const numberOfPaddings = 26 | Math.abs(normalizedTotalAngle) === 360 ? data.length : data.length - 1; 27 | const singlePaddingDegrees = Math.abs(paddingAngle) * Math.sign(totalAngle); 28 | const degreesTakenByPadding = singlePaddingDegrees * numberOfPaddings; 29 | const degreesTakenByPaths = normalizedTotalAngle - degreesTakenByPadding; 30 | let lastSegmentEnd = 0; 31 | const extendedData: ExtendedData<DataEntry> = []; 32 | 33 | // @NOTE: Shall we evaluate percentage accordingly to dataEntry.value's sign? 34 | for (let i = 0; i < data.length; i++) { 35 | const dataEntry = data[i]; 36 | const valueInPercentage = total === 0 ? 0 : (dataEntry.value / total) * 100; 37 | const degrees = extractPercentage(degreesTakenByPaths, valueInPercentage); 38 | const startAngle = lastSegmentEnd + chartStartAngle; 39 | lastSegmentEnd = lastSegmentEnd + degrees + singlePaddingDegrees; 40 | extendedData.push( 41 | Object.assign( 42 | { 43 | percentage: valueInPercentage, 44 | startAngle, 45 | degrees, 46 | }, 47 | dataEntry 48 | ) 49 | ); 50 | } 51 | return extendedData; 52 | } 53 | -------------------------------------------------------------------------------- /src/Chart/index.tsx: -------------------------------------------------------------------------------- 1 | export { 2 | ReactMinimalPieChart as PieChart, 3 | defaultProps as pieChartDefaultProps, 4 | } from './Chart'; 5 | export type { Props as PieChartProps } from './Chart'; 6 | -------------------------------------------------------------------------------- /src/Chart/renderLabels.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Label from '../Label'; 3 | import { 4 | bisectorAngle, 5 | extractPercentage, 6 | functionProp, 7 | shiftVectorAlongAngle, 8 | } from '../utils'; 9 | import type { PropsWithDefaults as ChartProps } from './Chart'; 10 | import type { LabelRenderProps } from '../Label'; 11 | import type { ExtendedData, BaseDataEntry } from '../commonTypes'; 12 | 13 | function round(number: number): number { 14 | const divisor = 1e14; // 14 decimals 15 | return Math.round((number + Number.EPSILON) * divisor) / divisor; 16 | } 17 | 18 | function evaluateTextAnchorPosition({ 19 | labelPosition, 20 | lineWidth, 21 | labelHorizontalShift, 22 | }: { 23 | labelPosition: number; 24 | lineWidth: number; 25 | labelHorizontalShift: number; 26 | }) { 27 | const dx = round(labelHorizontalShift); 28 | // Label in the vertical center 29 | if (dx === 0) { 30 | return 'middle'; 31 | } 32 | // Outward label 33 | if (labelPosition > 100) { 34 | return dx > 0 ? 'start' : 'end'; 35 | } 36 | // Inward label 37 | const innerRadius = 100 - lineWidth; 38 | if (labelPosition < innerRadius) { 39 | return dx > 0 ? 'end' : 'start'; 40 | } 41 | // Overlying label 42 | return 'middle'; 43 | } 44 | 45 | function makeLabelRenderProps<DataEntry extends BaseDataEntry>( 46 | data: ExtendedData<DataEntry>, 47 | props: ChartProps<DataEntry> 48 | ): LabelRenderProps<DataEntry>[] { 49 | return data.map((dataEntry, index) => { 50 | const segmentsShift = functionProp(props.segmentsShift, index) ?? 0; 51 | const distanceFromCenter = 52 | extractPercentage(props.radius, props.labelPosition) + segmentsShift; 53 | const { dx, dy } = shiftVectorAlongAngle( 54 | bisectorAngle(dataEntry.startAngle, dataEntry.degrees), 55 | distanceFromCenter 56 | ); 57 | 58 | // This object is passed as argument to the "label" function prop 59 | const labelRenderProps: LabelRenderProps<DataEntry> = { 60 | x: props.center[0], 61 | y: props.center[1], 62 | dx, 63 | dy, 64 | textAnchor: evaluateTextAnchorPosition({ 65 | labelPosition: props.labelPosition, 66 | lineWidth: props.lineWidth, 67 | labelHorizontalShift: dx, 68 | }), 69 | dataEntry, 70 | dataIndex: index, 71 | style: functionProp(props.labelStyle, index), 72 | }; 73 | 74 | return labelRenderProps; 75 | }); 76 | } 77 | 78 | export default function renderLabels<DataEntry extends BaseDataEntry>( 79 | data: ExtendedData<DataEntry>, 80 | props: ChartProps<DataEntry> 81 | ) { 82 | const { label } = props; 83 | if (label) { 84 | return makeLabelRenderProps(data, props).map((labelRenderProps, index) => ( 85 | <Label 86 | key={`label-${labelRenderProps.dataEntry.key || index}`} 87 | renderLabel={label} 88 | labelProps={labelRenderProps} 89 | /> 90 | )); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Chart/renderSegments.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { CSSProperties, SyntheticEvent } from 'react'; 3 | import Path from '../Path'; 4 | import { extractPercentage, functionProp, isNumber } from '../utils'; 5 | import type { ExtendedData, BaseDataEntry } from '../commonTypes'; 6 | import type { PropsWithDefaults as ChartProps } from './Chart'; 7 | 8 | function combineSegmentTransitionsStyle( 9 | duration: number, 10 | easing: string, 11 | customStyle?: CSSProperties 12 | ): { transition: string } { 13 | // Merge chart's animation CSS transition with "transition" found to customStyle 14 | let transition = `stroke-dashoffset ${duration}ms ${easing}`; 15 | if (customStyle && customStyle.transition) { 16 | transition = `${transition},${customStyle.transition}`; 17 | } 18 | return { 19 | transition, 20 | }; 21 | } 22 | 23 | function getRevealValue({ 24 | reveal, 25 | animate, 26 | }: Pick<ChartProps, 'reveal' | 'animate'>) { 27 | //@NOTE When animation is on, chart has to be fully revealed when reveal is not set 28 | if (animate && !isNumber(reveal)) { 29 | return 100; 30 | } 31 | return reveal; 32 | } 33 | 34 | function makeEventHandler< 35 | Event extends SyntheticEvent, 36 | Payload, 37 | EventHandler extends (event: Event, payload: Payload) => void, 38 | >(eventHandler: undefined | EventHandler, payload: Payload) { 39 | return ( 40 | eventHandler && 41 | ((e: Event) => { 42 | eventHandler(e, payload); 43 | }) 44 | ); 45 | } 46 | 47 | export default function renderSegments<DataEntry extends BaseDataEntry>( 48 | data: ExtendedData<DataEntry>, 49 | props: ChartProps<DataEntry>, 50 | revealOverride?: null | number 51 | ) { 52 | // @NOTE this should go in Path component. Here for performance reasons 53 | const reveal = revealOverride ?? getRevealValue(props); 54 | const { 55 | radius, 56 | center: [cx, cy], 57 | } = props; 58 | const lineWidth = extractPercentage(radius, props.lineWidth); 59 | const paths = data.map((dataEntry, index) => { 60 | const segmentsStyle = functionProp(props.segmentsStyle, index); 61 | return ( 62 | <Path 63 | cx={cx} 64 | cy={cy} 65 | key={dataEntry.key || index} 66 | lengthAngle={dataEntry.degrees} 67 | lineWidth={lineWidth} 68 | radius={radius} 69 | rounded={props.rounded} 70 | reveal={reveal} 71 | shift={functionProp(props.segmentsShift, index)} 72 | startAngle={dataEntry.startAngle} 73 | title={dataEntry.title} 74 | style={Object.assign( 75 | {}, 76 | segmentsStyle, 77 | props.animate && 78 | combineSegmentTransitionsStyle( 79 | props.animationDuration, 80 | props.animationEasing, 81 | segmentsStyle 82 | ) 83 | )} 84 | stroke={dataEntry.color} 85 | tabIndex={props.segmentsTabIndex} 86 | onBlur={makeEventHandler(props.onBlur, index)} 87 | onClick={makeEventHandler(props.onClick, index)} 88 | onFocus={makeEventHandler(props.onFocus, index)} 89 | onKeyDown={makeEventHandler(props.onKeyDown, index)} 90 | onMouseOver={makeEventHandler(props.onMouseOver, index)} 91 | onMouseOut={makeEventHandler(props.onMouseOut, index)} 92 | /> 93 | ); 94 | }); 95 | 96 | if (props.background) { 97 | paths.unshift( 98 | <Path 99 | cx={cx} 100 | cy={cy} 101 | key="bg" 102 | lengthAngle={props.lengthAngle} 103 | lineWidth={lineWidth} 104 | radius={radius} 105 | rounded={props.rounded} 106 | startAngle={props.startAngle} 107 | stroke={props.background} 108 | /> 109 | ); 110 | } 111 | 112 | return paths; 113 | } 114 | -------------------------------------------------------------------------------- /src/Label.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { CSSProperties, SVGProps } from 'react'; 3 | import type { 4 | ExtendedData, 5 | BaseDataEntry, 6 | LabelRenderFunction, 7 | } from './commonTypes'; 8 | 9 | export type LabelRenderProps<DataEntry extends BaseDataEntry> = { 10 | x: number; 11 | y: number; 12 | dx: number; 13 | dy: number; 14 | textAnchor: string; 15 | dataEntry: ExtendedData<DataEntry>[number]; 16 | dataIndex: number; 17 | style?: CSSProperties; 18 | }; 19 | 20 | export default function ReactMinimalPieChartLabel< 21 | DataEntry extends BaseDataEntry, 22 | >({ 23 | renderLabel, 24 | labelProps, 25 | }: { 26 | renderLabel: LabelRenderFunction<DataEntry>; 27 | labelProps: LabelRenderProps<DataEntry>; 28 | }) { 29 | const label = renderLabel(labelProps); 30 | 31 | // Default label 32 | if (typeof label === 'string' || typeof label === 'number') { 33 | const { dataEntry, dataIndex, ...props } = labelProps; 34 | return ( 35 | <text dominantBaseline="central" {...props}> 36 | {label} 37 | </text> 38 | ); 39 | } 40 | 41 | if (React.isValidElement(label)) { 42 | return label; 43 | } 44 | 45 | return null; 46 | } 47 | -------------------------------------------------------------------------------- /src/Path.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { SVGProps } from 'react'; 3 | import partialCircle from 'svg-partial-circle'; 4 | import { 5 | bisectorAngle, 6 | degreesToRadians, 7 | extractPercentage, 8 | isNumber, 9 | shiftVectorAlongAngle, 10 | valueBetween, 11 | } from './utils'; 12 | 13 | export function makePathCommands( 14 | cx: number, 15 | cy: number, 16 | startAngle: number, 17 | lengthAngle: number, 18 | radius: number 19 | ): string { 20 | const patchedLengthAngle = valueBetween(lengthAngle, -359.999, 359.999); 21 | 22 | return partialCircle( 23 | cx, 24 | cy, // center X and Y 25 | radius, 26 | degreesToRadians(startAngle), 27 | degreesToRadians(startAngle + patchedLengthAngle) 28 | ) 29 | .map((command) => command.join(' ')) 30 | .join(' '); 31 | } 32 | 33 | type Props = SVGProps<SVGPathElement> & { 34 | cx: number; 35 | cy: number; 36 | lengthAngle: number; 37 | lineWidth: number; 38 | radius: number; 39 | reveal?: number; 40 | rounded?: boolean; 41 | shift?: number; 42 | startAngle: number; 43 | title?: string | number; 44 | }; 45 | 46 | export default function ReactMinimalPieChartPath({ 47 | cx, 48 | cy, 49 | lengthAngle, 50 | lineWidth, 51 | radius, 52 | shift = 0, 53 | reveal, 54 | rounded, 55 | startAngle, 56 | title, 57 | ...props 58 | }: Props) { 59 | const pathRadius = radius - lineWidth / 2; 60 | //@NOTE This shift might be rendered as a translation in future 61 | const { dx, dy } = shiftVectorAlongAngle( 62 | bisectorAngle(startAngle, lengthAngle), 63 | shift 64 | ); 65 | 66 | const pathCommands = makePathCommands( 67 | cx + dx, 68 | cy + dy, 69 | startAngle, 70 | lengthAngle, 71 | pathRadius 72 | ); 73 | let strokeDasharray; 74 | let strokeDashoffset; 75 | 76 | // Animate/hide paths with "stroke-dasharray" + "stroke-dashoffset" 77 | // https://css-tricks.com/svg-line-animation-works/ 78 | if (isNumber(reveal)) { 79 | const pathLength = degreesToRadians(pathRadius) * lengthAngle; 80 | strokeDasharray = Math.abs(pathLength); 81 | strokeDashoffset = 82 | strokeDasharray - extractPercentage(strokeDasharray, reveal); 83 | } 84 | 85 | return ( 86 | <path 87 | d={pathCommands} 88 | fill="none" 89 | strokeWidth={lineWidth} 90 | strokeDasharray={strokeDasharray} 91 | strokeDashoffset={strokeDashoffset} 92 | strokeLinecap={rounded ? 'round' : undefined} 93 | {...props} 94 | > 95 | {title && <title>{title}} 96 | 97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /src/commonTypes.ts: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | import type { LabelRenderProps } from './Label'; 3 | 4 | export type LabelRenderFunction = ( 5 | labelRenderProps: LabelRenderProps 6 | ) => ReactNode; 7 | 8 | export type BaseDataEntry = { 9 | title?: string | number; 10 | color: string; 11 | value: number; 12 | key?: string | number; 13 | }; 14 | 15 | type BaseExtendedDataEntry = 16 | DataEntry & { 17 | degrees: number; 18 | startAngle: number; 19 | percentage: number; 20 | }; 21 | 22 | export type Data = DataEntry[]; 23 | export type ExtendedData = 24 | BaseExtendedDataEntry[]; 25 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { PieChart, pieChartDefaultProps } from './Chart'; 2 | export type { PieChartProps } from './Chart'; 3 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export function degreesToRadians(degrees: number) { 2 | return (degrees * Math.PI) / 180; 3 | } 4 | 5 | export function valueBetween(value: number, min: number, max: number) { 6 | if (value > max) return max; 7 | if (value < min) return min; 8 | return value; 9 | } 10 | 11 | export function extractPercentage(value: number, percentage: number) { 12 | return (percentage / 100) * value; 13 | } 14 | 15 | export function bisectorAngle(startAngle: number, lengthAngle: number) { 16 | return startAngle + lengthAngle / 2; 17 | } 18 | 19 | export function shiftVectorAlongAngle(angle: number, distance: number) { 20 | const angleRadians = degreesToRadians(angle); 21 | return { 22 | dx: distance * Math.cos(angleRadians), 23 | dy: distance * Math.sin(angleRadians), 24 | }; 25 | } 26 | 27 | export function isNumber(value: unknown): value is number { 28 | return typeof value === 'number'; 29 | } 30 | 31 | /** 32 | * Conditionally return a prop or a function prop result 33 | */ 34 | export function functionProp( 35 | prop: ((args: Payload) => ReturnedProp) | ReturnedProp, 36 | payload: Payload 37 | ): ReturnedProp { 38 | return typeof prop === 'function' 39 | ? // @ts-expect-error: cannot find a way to type 2nd prop arg as anything-but-function 40 | prop(payload) 41 | : prop; 42 | } 43 | -------------------------------------------------------------------------------- /stories/Animation.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { PieChart } from '../src'; 4 | import { dataMock } from './mocks'; 5 | 6 | // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export 7 | const meta: Meta = { 8 | title: 'Example/Animation', 9 | component: PieChart, 10 | // More on argTypes: https://storybook.js.org/docs/api/argtypes 11 | argTypes: {}, 12 | }; 13 | 14 | export default meta; 15 | type Story = StoryObj; 16 | 17 | // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args 18 | export const OnMountClockwise: Story = { 19 | args: { 20 | data: dataMock, 21 | animate: true, 22 | }, 23 | }; 24 | 25 | export const OnMountCounterclockwise: Story = { 26 | args: { 27 | data: dataMock, 28 | animate: true, 29 | lengthAngle: -360, 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /stories/DonutChart.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { PieChart } from '../src'; 4 | import { dataMock } from './mocks'; 5 | 6 | // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export 7 | const meta: Meta = { 8 | title: 'Example/Donut Chart', 9 | component: PieChart, 10 | // More on argTypes: https://storybook.js.org/docs/api/argtypes 11 | argTypes: {}, 12 | }; 13 | 14 | export default meta; 15 | type Story = StoryObj; 16 | 17 | // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args 18 | export const CustomArcsWidth: Story = { 19 | args: { 20 | data: dataMock, 21 | lineWidth: 15, 22 | }, 23 | }; 24 | 25 | export const RoundedArcs: Story = { 26 | args: { 27 | data: dataMock, 28 | lineWidth: 15, 29 | rounded: true, 30 | }, 31 | }; 32 | 33 | export const PaddedArcs: Story = { 34 | args: { 35 | data: dataMock, 36 | lineWidth: 15, 37 | paddingAngle: 5, 38 | }, 39 | }; 40 | -------------------------------------------------------------------------------- /stories/Interaction.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { Meta, StoryObj } from '@storybook/react'; 3 | 4 | import { PieChart } from '../src'; 5 | import Interaction from './components/Interaction'; 6 | import InteractionTab from './components/InteractionTab'; 7 | import { dataMock } from './mocks'; 8 | 9 | // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export 10 | const meta: Meta = { 11 | title: 'Example/Interaction', 12 | component: PieChart, 13 | // More on argTypes: https://storybook.js.org/docs/api/argtypes 14 | argTypes: {}, 15 | }; 16 | 17 | export default meta; 18 | type Story = StoryObj; 19 | 20 | // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args 21 | export const ClickMouseoverMouseoutCallbacks: Story = { 22 | name: 'click, mouseOver, mouseOut callbacks', 23 | args: { 24 | data: dataMock, 25 | animate: true, 26 | }, 27 | parameters: { 28 | controls: { expanded: true, open: true }, 29 | }, 30 | render: (props) => , 31 | }; 32 | 33 | export const TabEnterKeyPress: Story = { 34 | name: 'Tab + Enter key press', 35 | args: { 36 | data: dataMock, 37 | animate: true, 38 | lengthAngle: -360, 39 | }, 40 | parameters: { 41 | controls: { expanded: true, open: true }, 42 | }, 43 | render: (props) => , 44 | }; 45 | -------------------------------------------------------------------------------- /stories/Labels.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { Meta, StoryObj } from '@storybook/react'; 3 | 4 | import { PieChart } from '../src'; 5 | import { dataMock, defaultLabelStyle } from './mocks'; 6 | 7 | // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export 8 | const meta: Meta = { 9 | title: 'Example/Labels', 10 | component: PieChart, 11 | // More on argTypes: https://storybook.js.org/docs/api/argtypes 12 | argTypes: {}, 13 | }; 14 | 15 | export default meta; 16 | type Story = StoryObj; 17 | 18 | // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args 19 | export const DefaultLabels: Story = { 20 | args: { 21 | data: dataMock, 22 | label: ({ dataEntry }) => dataEntry.value, 23 | labelStyle: { 24 | ...defaultLabelStyle, 25 | }, 26 | }, 27 | }; 28 | 29 | export const OuterLabels: Story = { 30 | args: { 31 | data: dataMock, 32 | label: ({ dataEntry }) => dataEntry.value, 33 | labelStyle: (index) => ({ 34 | fill: dataMock[index].color, 35 | fontSize: '5px', 36 | fontFamily: 'sans-serif', 37 | }), 38 | radius: 42, 39 | labelPosition: 112, 40 | }, 41 | }; 42 | 43 | export const InnerLabels: Story = { 44 | args: { 45 | data: dataMock, 46 | lineWidth: 20, 47 | paddingAngle: 18, 48 | rounded: true, 49 | label: ({ dataEntry }) => dataEntry.value, 50 | labelStyle: (index) => ({ 51 | fill: dataMock[index].color, 52 | fontSize: '5px', 53 | fontFamily: 'sans-serif', 54 | }), 55 | labelPosition: 60, 56 | }, 57 | }; 58 | 59 | export const SingleLabel: Story = { 60 | args: { 61 | data: [{ value: 82, color: '#E38627' }], 62 | totalValue: 100, 63 | lineWidth: 20, 64 | label: ({ dataEntry }) => dataEntry.value, 65 | labelStyle: { 66 | fontSize: '25px', 67 | fontFamily: 'sans-serif', 68 | fill: '#E38627', 69 | }, 70 | labelPosition: 0, 71 | }, 72 | }; 73 | 74 | export const Percentage: Story = { 75 | args: { 76 | data: dataMock, 77 | label: ({ dataEntry }) => Math.round(dataEntry.percentage) + '%', 78 | labelStyle: defaultLabelStyle, 79 | }, 80 | }; 81 | 82 | export const CustomLabelElement: Story = { 83 | args: { 84 | data: dataMock, 85 | label: ({ x, y, dx, dy, dataEntry }) => ( 86 | 101 | {Math.round(dataEntry.percentage) + '%'} 102 | 103 | ), 104 | labelStyle: defaultLabelStyle, 105 | }, 106 | }; 107 | -------------------------------------------------------------------------------- /stories/LoadingIndicator.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { Meta, StoryObj } from '@storybook/react'; 3 | 4 | import { PieChart } from '../src'; 5 | import LoadingIndicator from './components/LoadingIndicator'; 6 | import PartialLoadingIndicator from './components/PartialLoadingIndicator'; 7 | import { dataMock } from './mocks'; 8 | 9 | // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export 10 | const meta: Meta = { 11 | title: 'Example/Loading indicator', 12 | component: PieChart, 13 | // More on argTypes: https://storybook.js.org/docs/api/argtypes 14 | argTypes: {}, 15 | }; 16 | 17 | export default meta; 18 | type Story = StoryObj; 19 | 20 | // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args 21 | export const Indicator1: Story = { 22 | name: '360° indicator', 23 | args: { 24 | data: dataMock, 25 | }, 26 | render: (props) => , 27 | }; 28 | 29 | export const Indicator2: Story = { 30 | name: '270° indicator with background', 31 | args: { 32 | data: dataMock, 33 | }, 34 | render: (props) => , 35 | }; 36 | -------------------------------------------------------------------------------- /stories/Misc.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { Meta, StoryObj } from '@storybook/react'; 3 | 4 | import { PieChart } from '../src'; 5 | import Tooltip from './components/Tooltip'; 6 | import { dataMock, defaultLabelStyle } from './mocks'; 7 | 8 | // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export 9 | const meta: Meta = { 10 | title: 'Example/Misc', 11 | component: PieChart, 12 | // More on argTypes: https://storybook.js.org/docs/api/argtypes 13 | argTypes: {}, 14 | }; 15 | 16 | export default meta; 17 | type Story = StoryObj; 18 | 19 | // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args 20 | export const SingleGradient: Story = { 21 | args: { 22 | data: [{ value: 10, color: 'url(#gradient1)' }], 23 | startAngle: -180, 24 | lengthAngle: 180, 25 | lineWidth: 20, 26 | viewBoxSize: [100, 50], 27 | children: [ 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | , 36 | ], 37 | }, 38 | }; 39 | 40 | export const SquaredPie: Story = { 41 | args: { 42 | data: dataMock, 43 | label: ({ dataEntry }) => dataEntry.value, 44 | labelStyle: defaultLabelStyle, 45 | radius: 75, 46 | }, 47 | }; 48 | 49 | export const TooltipIntegration: Story = { 50 | args: { 51 | data: dataMock, 52 | animate: true, 53 | lengthAngle: -360, 54 | }, 55 | render: (props) => , 56 | }; 57 | -------------------------------------------------------------------------------- /stories/PartialChart.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { PieChart } from '../src'; 4 | import { dataMock } from './mocks'; 5 | 6 | // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export 7 | const meta: Meta = { 8 | title: 'Example/Partial Chart', 9 | component: PieChart, 10 | // More on argTypes: https://storybook.js.org/docs/api/argtypes 11 | argTypes: {}, 12 | }; 13 | 14 | export default meta; 15 | type Story = StoryObj; 16 | 17 | // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args 18 | export const Chart180: Story = { 19 | name: '180° chart', 20 | args: { 21 | data: dataMock, 22 | startAngle: 180, 23 | lengthAngle: 180, 24 | viewBoxSize: [100, 50], 25 | }, 26 | }; 27 | 28 | export const Chart90: Story = { 29 | name: '90° chart', 30 | args: { 31 | data: dataMock, 32 | center: [100, 100], 33 | startAngle: -180, 34 | lengthAngle: 90, 35 | radius: 100, 36 | }, 37 | }; 38 | 39 | export const MissingSlice: Story = { 40 | args: { 41 | data: dataMock, 42 | totalValue: 60, 43 | }, 44 | }; 45 | 46 | export const MissingSliceWithBackground: Story = { 47 | args: { 48 | data: dataMock, 49 | totalValue: 60, 50 | background: '#bfbfbf', 51 | }, 52 | }; 53 | -------------------------------------------------------------------------------- /stories/PieChart.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { Meta, StoryObj } from '@storybook/react'; 3 | 4 | import { PieChart, pieChartDefaultProps } from '../src'; 5 | import FullOption from './components/FullOption'; 6 | import { dataMock, defaultLabelStyle } from './mocks'; 7 | 8 | // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export 9 | const meta: Meta = { 10 | title: 'Example/Pie Chart', 11 | component: PieChart, 12 | // More on argTypes: https://storybook.js.org/docs/api/argtypes 13 | argTypes: {}, 14 | }; 15 | 16 | export default meta; 17 | type Story = StoryObj; 18 | 19 | // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args 20 | export const FullOptions: Story = { 21 | args: { 22 | data: dataMock, 23 | }, 24 | render: (props) => , 25 | }; 26 | 27 | export const Default: Story = { 28 | args: { 29 | data: dataMock, 30 | }, 31 | }; 32 | 33 | export const CustomSize: Story = { 34 | args: { 35 | data: dataMock, 36 | style: { height: '100px' }, 37 | }, 38 | }; 39 | 40 | const shiftSize = 7; 41 | export const Exploded: Story = { 42 | args: { 43 | data: dataMock, 44 | radius: pieChartDefaultProps.radius - shiftSize, 45 | segmentsShift: (index) => (index === 0 ? shiftSize : 0.5), 46 | label: ({ dataEntry }) => dataEntry.value, 47 | labelStyle: { 48 | ...defaultLabelStyle, 49 | }, 50 | }, 51 | }; 52 | -------------------------------------------------------------------------------- /stories/components/FullOption.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { PieChart, pieChartDefaultProps, PieChartProps } from '../../src'; 3 | 4 | function FullOption(props: PieChartProps) { 5 | const [selected, setSelected] = useState(0); 6 | const [hovered, setHovered] = useState(undefined); 7 | 8 | const data = props.data.map((entry, i) => { 9 | if (hovered === i) { 10 | return { 11 | ...entry, 12 | color: 'grey', 13 | }; 14 | } 15 | return entry; 16 | }); 17 | 18 | const lineWidth = 60; 19 | 20 | return ( 21 | (index === selected ? 6 : 1)} 32 | animate 33 | label={({ dataEntry }) => Math.round(dataEntry.percentage) + '%'} 34 | labelPosition={100 - lineWidth / 2} 35 | labelStyle={{ 36 | fill: '#fff', 37 | opacity: 0.75, 38 | pointerEvents: 'none', 39 | }} 40 | onClick={(_, index) => { 41 | setSelected(index === selected ? undefined : index); 42 | }} 43 | onMouseOver={(_, index) => { 44 | setHovered(index); 45 | }} 46 | onMouseOut={() => { 47 | setHovered(undefined); 48 | }} 49 | /> 50 | ); 51 | } 52 | 53 | export default FullOption; 54 | -------------------------------------------------------------------------------- /stories/components/Interaction.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { action } from '@storybook/addon-actions'; 3 | import { PieChart, pieChartDefaultProps, PieChartProps } from '../../src'; 4 | 5 | function DemoInteraction(props: PieChartProps) { 6 | const [selected, setSelected] = useState(0); 7 | const [hovered, setHovered] = useState(undefined); 8 | 9 | const data = props.data.map((entry, i) => { 10 | if (hovered === i) { 11 | return { 12 | ...entry, 13 | color: 'grey', 14 | }; 15 | } 16 | return entry; 17 | }); 18 | 19 | return ( 20 | (index === selected ? 6 : 1)} 25 | onClick={(event, index) => { 26 | action('CLICK')(event, index); 27 | console.log('CLICK', { event, index }); 28 | setSelected(index === selected ? undefined : index); 29 | }} 30 | onMouseOver={(_, index) => { 31 | setHovered(index); 32 | }} 33 | onMouseOut={() => { 34 | setHovered(undefined); 35 | }} 36 | /> 37 | ); 38 | } 39 | 40 | export default DemoInteraction; 41 | -------------------------------------------------------------------------------- /stories/components/InteractionTab.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { action } from '@storybook/addon-actions'; 3 | import { PieChart, PieChartProps } from '../../src'; 4 | 5 | function DemoInteractionTab(props: PieChartProps) { 6 | const [selected, setSelected] = useState(0); 7 | const [focused, setFocused] = useState(undefined); 8 | 9 | const data = props.data.map((entry, i) => { 10 | let result = entry; 11 | if (focused === i) { 12 | result = { 13 | ...result, 14 | color: 'grey', 15 | }; 16 | } 17 | return result; 18 | }); 19 | 20 | const segmentsStyle = { transition: 'stroke .3s', cursor: 'pointer' }; 21 | return ( 22 | <> 23 |

24 | Press Tab until focus reaches the Chart or click on the yellow sector 25 | and press Tab and then Enter. 26 |

27 | { 32 | return index === selected 33 | ? { ...segmentsStyle, strokeWidth: 35 } 34 | : segmentsStyle; 35 | }} 36 | segmentsTabIndex={1} 37 | onKeyDown={(event, index) => { 38 | // Enter keypress 39 | if (event.keyCode === 13) { 40 | action('CLICK')(event, index); 41 | console.log('CLICK', { event, index }); 42 | setSelected(selected === index ? undefined : index); 43 | } 44 | }} 45 | onFocus={(_, index) => { 46 | setFocused(index); 47 | }} 48 | onBlur={() => setFocused(undefined)} 49 | /> 50 | 51 | ); 52 | } 53 | 54 | export default DemoInteractionTab; 55 | -------------------------------------------------------------------------------- /stories/components/LoadingIndicator.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { PieChart, PieChartProps } from '../../src'; 3 | 4 | function LoadingIndicatorStory(props: PieChartProps) { 5 | const [percentage, setPercentage] = useState(20); 6 | return ( 7 |
8 | 14 | Reveal: {percentage}% 15 | { 23 | setPercentage(Number(e.target.value)); 24 | }} 25 | /> 26 |
27 | ); 28 | } 29 | 30 | export default LoadingIndicatorStory; 31 | -------------------------------------------------------------------------------- /stories/components/PartialLoadingIndicator.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { PieChart, PieChartProps } from '../../src'; 3 | 4 | function PartialLoadingIndicatorStory(props: PieChartProps) { 5 | const [percentage, setPercentage] = useState(20); 6 | return ( 7 |
8 | 17 | Reveal: {percentage}% 18 | { 26 | setPercentage(Number(e.target.value)); 27 | }} 28 | /> 29 |
30 | ); 31 | } 32 | 33 | export default PartialLoadingIndicatorStory; 34 | -------------------------------------------------------------------------------- /stories/components/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import ReactTooltip from 'react-tooltip'; 3 | import { PieChart, PieChartProps } from '../../src'; 4 | 5 | type BaseData = PieChartProps['data'][number]; 6 | 7 | function makeTooltipContent( 8 | entry: BaseData & { 9 | tooltip: BaseData['title']; 10 | } 11 | ) { 12 | return `Sector ${entry.tooltip} has value ${entry.value}`; 13 | } 14 | 15 | function ToolTip(props: PieChartProps) { 16 | const [hovered, setHovered] = useState(null); 17 | const data = props.data.map(({ title, ...entry }) => { 18 | return { 19 | ...entry, 20 | tooltip: title, 21 | }; 22 | }); 23 | 24 | return ( 25 |
26 | { 29 | setHovered(index); 30 | }} 31 | onMouseOut={() => { 32 | setHovered(null); 33 | }} 34 | /> 35 | 38 | typeof hovered === 'number' ? makeTooltipContent(data[hovered]) : null 39 | } 40 | /> 41 |
42 | ); 43 | } 44 | 45 | export default ToolTip; 46 | -------------------------------------------------------------------------------- /stories/mocks.ts: -------------------------------------------------------------------------------- 1 | export const dataMock = [ 2 | { title: 'One', value: 10, color: '#E38627' }, 3 | { title: 'Two', value: 15, color: '#C13C37' }, 4 | { title: 'Three', value: 20, color: '#6A2135' }, 5 | ]; 6 | 7 | export const defaultLabelStyle = { 8 | fontSize: '5px', 9 | fontFamily: 'sans-serif', 10 | }; 11 | -------------------------------------------------------------------------------- /test-bundles/__snapshots__/bundles-snapshot.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Dist bundle > is unchanged 1`] = ` 4 | "import React, { useState, useEffect } from 'react'; 5 | import partialCircle from 'svg-partial-circle'; 6 | 7 | function degreesToRadians(degrees) { 8 | return (degrees * Math.PI) / 180; 9 | } 10 | function valueBetween(value, min, max) { 11 | if (value > max) 12 | return max; 13 | if (value < min) 14 | return min; 15 | return value; 16 | } 17 | function extractPercentage(value, percentage) { 18 | return (percentage / 100) * value; 19 | } 20 | function bisectorAngle(startAngle, lengthAngle) { 21 | return startAngle + lengthAngle / 2; 22 | } 23 | function shiftVectorAlongAngle(angle, distance) { 24 | const angleRadians = degreesToRadians(angle); 25 | return { 26 | dx: distance * Math.cos(angleRadians), 27 | dy: distance * Math.sin(angleRadians), 28 | }; 29 | } 30 | function isNumber(value) { 31 | return typeof value === 'number'; 32 | } 33 | /** 34 | * Conditionally return a prop or a function prop result 35 | */ 36 | function functionProp(prop, payload) { 37 | return typeof prop === 'function' 38 | ? // @ts-expect-error: cannot find a way to type 2nd prop arg as anything-but-function 39 | prop(payload) 40 | : prop; 41 | } 42 | 43 | function sumValues(data) { 44 | let sum = 0; 45 | for (let i = 0; i < data.length; i++) { 46 | sum += data[i].value; 47 | } 48 | return sum; 49 | } 50 | // Append "percentage", "degrees" and "startAngle" to each data entry 51 | function extendData({ data, lengthAngle: totalAngle, totalValue, paddingAngle, startAngle: chartStartAngle, }) { 52 | const total = totalValue || sumValues(data); 53 | const normalizedTotalAngle = valueBetween(totalAngle, -360, 360); 54 | const numberOfPaddings = Math.abs(normalizedTotalAngle) === 360 ? data.length : data.length - 1; 55 | const singlePaddingDegrees = Math.abs(paddingAngle) * Math.sign(totalAngle); 56 | const degreesTakenByPadding = singlePaddingDegrees * numberOfPaddings; 57 | const degreesTakenByPaths = normalizedTotalAngle - degreesTakenByPadding; 58 | let lastSegmentEnd = 0; 59 | const extendedData = []; 60 | // @NOTE: Shall we evaluate percentage accordingly to dataEntry.value's sign? 61 | for (let i = 0; i < data.length; i++) { 62 | const dataEntry = data[i]; 63 | const valueInPercentage = total === 0 ? 0 : (dataEntry.value / total) * 100; 64 | const degrees = extractPercentage(degreesTakenByPaths, valueInPercentage); 65 | const startAngle = lastSegmentEnd + chartStartAngle; 66 | lastSegmentEnd = lastSegmentEnd + degrees + singlePaddingDegrees; 67 | extendedData.push(Object.assign({ 68 | percentage: valueInPercentage, 69 | startAngle, 70 | degrees, 71 | }, dataEntry)); 72 | } 73 | return extendedData; 74 | } 75 | 76 | function ReactMinimalPieChartLabel({ renderLabel, labelProps, }) { 77 | const label = renderLabel(labelProps); 78 | // Default label 79 | if (typeof label === 'string' || typeof label === 'number') { 80 | const { dataEntry, dataIndex, ...props } = labelProps; 81 | return (React.createElement("text", { dominantBaseline: "central", ...props }, label)); 82 | } 83 | if (React.isValidElement(label)) { 84 | return label; 85 | } 86 | return null; 87 | } 88 | 89 | function round(number) { 90 | const divisor = 1e14; // 14 decimals 91 | return Math.round((number + Number.EPSILON) * divisor) / divisor; 92 | } 93 | function evaluateTextAnchorPosition({ labelPosition, lineWidth, labelHorizontalShift, }) { 94 | const dx = round(labelHorizontalShift); 95 | // Label in the vertical center 96 | if (dx === 0) { 97 | return 'middle'; 98 | } 99 | // Outward label 100 | if (labelPosition > 100) { 101 | return dx > 0 ? 'start' : 'end'; 102 | } 103 | // Inward label 104 | const innerRadius = 100 - lineWidth; 105 | if (labelPosition < innerRadius) { 106 | return dx > 0 ? 'end' : 'start'; 107 | } 108 | // Overlying label 109 | return 'middle'; 110 | } 111 | function makeLabelRenderProps(data, props) { 112 | return data.map((dataEntry, index) => { 113 | const segmentsShift = functionProp(props.segmentsShift, index) ?? 0; 114 | const distanceFromCenter = extractPercentage(props.radius, props.labelPosition) + segmentsShift; 115 | const { dx, dy } = shiftVectorAlongAngle(bisectorAngle(dataEntry.startAngle, dataEntry.degrees), distanceFromCenter); 116 | // This object is passed as argument to the "label" function prop 117 | const labelRenderProps = { 118 | x: props.center[0], 119 | y: props.center[1], 120 | dx, 121 | dy, 122 | textAnchor: evaluateTextAnchorPosition({ 123 | labelPosition: props.labelPosition, 124 | lineWidth: props.lineWidth, 125 | labelHorizontalShift: dx, 126 | }), 127 | dataEntry, 128 | dataIndex: index, 129 | style: functionProp(props.labelStyle, index), 130 | }; 131 | return labelRenderProps; 132 | }); 133 | } 134 | function renderLabels(data, props) { 135 | const { label } = props; 136 | if (label) { 137 | return makeLabelRenderProps(data, props).map((labelRenderProps, index) => (React.createElement(ReactMinimalPieChartLabel, { key: \`label-\${labelRenderProps.dataEntry.key || index}\`, renderLabel: label, labelProps: labelRenderProps }))); 138 | } 139 | } 140 | 141 | function makePathCommands(cx, cy, startAngle, lengthAngle, radius) { 142 | const patchedLengthAngle = valueBetween(lengthAngle, -359.999, 359.999); 143 | return partialCircle(cx, cy, // center X and Y 144 | radius, degreesToRadians(startAngle), degreesToRadians(startAngle + patchedLengthAngle)) 145 | .map((command) => command.join(' ')) 146 | .join(' '); 147 | } 148 | function ReactMinimalPieChartPath({ cx, cy, lengthAngle, lineWidth, radius, shift = 0, reveal, rounded, startAngle, title, ...props }) { 149 | const pathRadius = radius - lineWidth / 2; 150 | //@NOTE This shift might be rendered as a translation in future 151 | const { dx, dy } = shiftVectorAlongAngle(bisectorAngle(startAngle, lengthAngle), shift); 152 | const pathCommands = makePathCommands(cx + dx, cy + dy, startAngle, lengthAngle, pathRadius); 153 | let strokeDasharray; 154 | let strokeDashoffset; 155 | // Animate/hide paths with "stroke-dasharray" + "stroke-dashoffset" 156 | // https://css-tricks.com/svg-line-animation-works/ 157 | if (isNumber(reveal)) { 158 | const pathLength = degreesToRadians(pathRadius) * lengthAngle; 159 | strokeDasharray = Math.abs(pathLength); 160 | strokeDashoffset = 161 | strokeDasharray - extractPercentage(strokeDasharray, reveal); 162 | } 163 | return (React.createElement("path", { d: pathCommands, fill: "none", strokeWidth: lineWidth, strokeDasharray: strokeDasharray, strokeDashoffset: strokeDashoffset, strokeLinecap: rounded ? 'round' : undefined, ...props }, title && React.createElement("title", null, title))); 164 | } 165 | 166 | function combineSegmentTransitionsStyle(duration, easing, customStyle) { 167 | // Merge chart's animation CSS transition with "transition" found to customStyle 168 | let transition = \`stroke-dashoffset \${duration}ms \${easing}\`; 169 | if (customStyle && customStyle.transition) { 170 | transition = \`\${transition},\${customStyle.transition}\`; 171 | } 172 | return { 173 | transition, 174 | }; 175 | } 176 | function getRevealValue({ reveal, animate, }) { 177 | //@NOTE When animation is on, chart has to be fully revealed when reveal is not set 178 | if (animate && !isNumber(reveal)) { 179 | return 100; 180 | } 181 | return reveal; 182 | } 183 | function makeEventHandler(eventHandler, payload) { 184 | return (eventHandler && 185 | ((e) => { 186 | eventHandler(e, payload); 187 | })); 188 | } 189 | function renderSegments(data, props, revealOverride) { 190 | // @NOTE this should go in Path component. Here for performance reasons 191 | const reveal = revealOverride ?? getRevealValue(props); 192 | const { radius, center: [cx, cy], } = props; 193 | const lineWidth = extractPercentage(radius, props.lineWidth); 194 | const paths = data.map((dataEntry, index) => { 195 | const segmentsStyle = functionProp(props.segmentsStyle, index); 196 | return (React.createElement(ReactMinimalPieChartPath, { cx: cx, cy: cy, key: dataEntry.key || index, lengthAngle: dataEntry.degrees, lineWidth: lineWidth, radius: radius, rounded: props.rounded, reveal: reveal, shift: functionProp(props.segmentsShift, index), startAngle: dataEntry.startAngle, title: dataEntry.title, style: Object.assign({}, segmentsStyle, props.animate && 197 | combineSegmentTransitionsStyle(props.animationDuration, props.animationEasing, segmentsStyle)), stroke: dataEntry.color, tabIndex: props.segmentsTabIndex, onBlur: makeEventHandler(props.onBlur, index), onClick: makeEventHandler(props.onClick, index), onFocus: makeEventHandler(props.onFocus, index), onKeyDown: makeEventHandler(props.onKeyDown, index), onMouseOver: makeEventHandler(props.onMouseOver, index), onMouseOut: makeEventHandler(props.onMouseOut, index) })); 198 | }); 199 | if (props.background) { 200 | paths.unshift(React.createElement(ReactMinimalPieChartPath, { cx: cx, cy: cy, key: "bg", lengthAngle: props.lengthAngle, lineWidth: lineWidth, radius: radius, rounded: props.rounded, startAngle: props.startAngle, stroke: props.background })); 201 | } 202 | return paths; 203 | } 204 | 205 | const defaultProps = { 206 | animationDuration: 500, 207 | animationEasing: 'ease-out', 208 | center: [50, 50], 209 | data: [], 210 | labelPosition: 50, 211 | lengthAngle: 360, 212 | lineWidth: 100, 213 | paddingAngle: 0, 214 | radius: 50, 215 | startAngle: 0, 216 | viewBoxSize: [100, 100], 217 | }; 218 | function makePropsWithDefaults(props) { 219 | const result = Object.assign({}, defaultProps, props); 220 | // @NOTE Object.assign doesn't default properties with undefined value (like React defaultProps does) 221 | let key; 222 | for (key in defaultProps) { 223 | if (props[key] === undefined) { 224 | // @ts-expect-error: TS cannot ensure we're assigning the expected props accross abjects 225 | result[key] = defaultProps[key]; 226 | } 227 | } 228 | return result; 229 | } 230 | function ReactMinimalPieChart(originalProps) { 231 | const props = makePropsWithDefaults(originalProps); 232 | const [revealOverride, setRevealOverride] = useState(props.animate ? 0 : null); 233 | useEffect(() => { 234 | if (props.animate) { 235 | // Trigger initial animation 236 | setRevealOverride(null); 237 | } 238 | }, []); 239 | const extendedData = extendData(props); 240 | return (React.createElement("svg", { viewBox: \`0 0 \${props.viewBoxSize[0]} \${props.viewBoxSize[1]}\`, width: "100%", height: "100%", className: props.className, style: props.style }, 241 | renderSegments(extendedData, props, revealOverride), 242 | renderLabels(extendedData, props), 243 | props.children)); 244 | } 245 | 246 | export { ReactMinimalPieChart as PieChart, defaultProps as pieChartDefaultProps }; 247 | //# sourceMappingURL=index.esm.js.map 248 | " 249 | `; 250 | -------------------------------------------------------------------------------- /test-bundles/bundles-snapshot.test.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import { join } from 'path'; 3 | import { describe, it, expect } from 'vitest'; 4 | 5 | describe('Dist bundle', () => { 6 | it('is unchanged', () => { 7 | const bundle = readFileSync( 8 | join(__dirname, '../', 'dist/index.esm.js'), 9 | 'utf8' 10 | ); 11 | expect(bundle).toMatchSnapshot(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /test-bundles/cjs.vitest.config.mts: -------------------------------------------------------------------------------- 1 | import { mergeConfig, defineConfig } from 'vitest/config'; 2 | import { resolve } from 'node:path'; 3 | import config from '../vitest.config.mjs'; 4 | 5 | export default mergeConfig( 6 | config, 7 | defineConfig({ 8 | test: { 9 | alias: [{ find: /.+\/src$/, replacement: resolve('dist/index.cjs.js') }], 10 | }, 11 | }) 12 | ); 13 | -------------------------------------------------------------------------------- /test-bundles/es.vitest.config.mts: -------------------------------------------------------------------------------- 1 | import { mergeConfig, defineConfig } from 'vitest/config'; 2 | import { resolve } from 'node:path'; 3 | import config from '../vitest.config.mjs'; 4 | 5 | export default mergeConfig( 6 | config, 7 | defineConfig({ 8 | test: { 9 | alias: [{ find: /.+\/src$/, replacement: resolve('dist/index.esm.js') }], 10 | }, 11 | }) 12 | ); 13 | -------------------------------------------------------------------------------- /test/Chart.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { describe, it, expect } from 'vitest'; 3 | import { render, dataMock, getArcInfo } from './testUtils'; 4 | import { degreesToRadians, extractPercentage } from '../src/utils'; 5 | import { pieChartDefaultProps } from '../src'; 6 | const rgbGreen = 'rgb(0, 128, 0)'; 7 | 8 | describe('Chart', () => { 9 | describe('SVG root element', () => { 10 | it('receive className, style and children props', () => { 11 | const { container } = render({ 12 | className: 'foo', 13 | style: { color: 'green' }, 14 | children: , 15 | }); 16 | const root = container.firstChild; 17 | 18 | // @ts-expect-error: ChildNode type doesn't have tagName prop 19 | expect(root.tagName).toBe('svg'); 20 | expect(root).toHaveClass('foo'); 21 | expect(root).toHaveStyle({ color: rgbGreen }); 22 | 23 | const children = container.querySelector('svg > defs'); 24 | expect(children).toBeInTheDocument(); 25 | }); 26 | }); 27 | 28 | describe('data prop', () => { 29 | it('renders empty SVG element when is undefined', () => { 30 | const { container } = render({ 31 | data: undefined, 32 | }); 33 | const svg = container.querySelector('svg'); 34 | expect(svg).toBeEmptyDOMElement(); 35 | }); 36 | 37 | it('render a element if data[].title provided', () => { 38 | const { container } = render({ 39 | data: [{ title: 'title-value', value: 10, color: 'blue' }], 40 | }); 41 | 42 | const title = container.querySelector('title'); 43 | expect(title).toHaveTextContent('title-value'); 44 | }); 45 | }); 46 | 47 | describe('viewBoxSize prop', () => { 48 | it.each` 49 | viewBoxSize | expectedSize 50 | ${undefined} | ${[100, 100]} 51 | ${[500, 500]} | ${[500, 500]} 52 | ${[500, 250]} | ${[500, 250]} 53 | `( 54 | 'renders full-width chart in a SVG viewBox of given size', 55 | ({ viewBoxSize, expectedSize }) => { 56 | const [expectedWidth, expectedHeight] = expectedSize; 57 | const { container } = render({ 58 | viewBoxSize, 59 | }); 60 | const svg = container.querySelector('svg'); 61 | expect(svg).toHaveAttribute( 62 | 'viewBox', 63 | `0 0 ${expectedWidth} ${expectedHeight}` 64 | ); 65 | } 66 | ); 67 | }); 68 | 69 | describe('lengthAngle prop', () => { 70 | it.each` 71 | lengthAngle 72 | ${270} 73 | ${-270} 74 | `( 75 | 'render a set of arcs which total length angle equals $lengthAngle', 76 | ({ lengthAngle }) => { 77 | let pathsTotalLengthAngle = 0; 78 | const { container } = render({ 79 | lengthAngle, 80 | }); 81 | container.querySelectorAll('path').forEach((path) => { 82 | pathsTotalLengthAngle += getArcInfo(path).lengthAngle; 83 | }); 84 | expect(pathsTotalLengthAngle).toEqual(lengthAngle); 85 | } 86 | ); 87 | }); 88 | 89 | describe('paddingAngle prop', () => { 90 | it('render a set of arcs which total length angle + paddings equals "lengthAngle"', () => { 91 | const lengthAngle = 300; 92 | let pathsTotalLengthAngle = 0; 93 | const totalPaddingDegrees = 10 * (dataMock.length - 1); 94 | 95 | const { container } = render({ 96 | lengthAngle, 97 | paddingAngle: 10, 98 | }); 99 | container.querySelectorAll('path').forEach((path) => { 100 | pathsTotalLengthAngle += getArcInfo(path).lengthAngle; 101 | }); 102 | expect( 103 | pathsTotalLengthAngle + totalPaddingDegrees 104 | ).toEqualWithRoundingError(lengthAngle); 105 | }); 106 | }); 107 | 108 | describe('background prop', () => { 109 | it('render an extra background segment long as the whole chart', () => { 110 | const { container } = render({ 111 | startAngle: 0, 112 | lengthAngle: 200, 113 | background: 'green', 114 | }); 115 | const paths = Array.from(container.querySelectorAll('path')); 116 | const [background, segment] = paths; 117 | const backgroundInfo = getArcInfo(background); 118 | const segmentInfo = getArcInfo(segment); 119 | 120 | expect(paths.length).toBe(dataMock.length + 1); 121 | expect(backgroundInfo.startAngle).toBe(0); 122 | expect(backgroundInfo.lengthAngle).toBe(200); 123 | expect(backgroundInfo.radius).toEqual(segmentInfo.radius); 124 | expect(backgroundInfo.center).toMatchObject({ 125 | x: expect.toEqualWithRoundingError(segmentInfo.center.x), 126 | y: expect.toEqualWithRoundingError(segmentInfo.center.y), 127 | }); 128 | expect(background).toHaveAttribute('fill', 'none'); 129 | expect(background).toHaveAttribute('stroke', 'green'); 130 | expect(background).not.toHaveAttribute('stroke-linecap'); 131 | expect(background).toHaveAttribute( 132 | 'stroke-width', 133 | segment.getAttribute('stroke-width') 134 | ); 135 | }); 136 | 137 | it('render an extra background segment with expected stroke linecap', () => { 138 | const { container } = render({ 139 | background: 'green', 140 | rounded: true, 141 | }); 142 | const paths = Array.from(container.querySelectorAll('path')); 143 | const [background] = paths; 144 | expect(paths.length).toBe(dataMock.length + 1); 145 | expect(background).toHaveAttribute('stroke-linecap', 'round'); 146 | }); 147 | }); 148 | 149 | describe('animate prop', () => { 150 | describe('Segments "style.transition" prop', () => { 151 | it('receive "stroke-dashoffset" transition prop with custom duration/easing', () => { 152 | const { container } = render({ 153 | animate: true, 154 | animationDuration: 100, 155 | animationEasing: 'ease', 156 | }); 157 | const firstPath = container.querySelector('path'); 158 | expect(firstPath).toHaveStyle({ 159 | transition: 'stroke-dashoffset 100ms ease', 160 | }); 161 | }); 162 | 163 | it('merge autogenerated CSS transition prop with the one optionally provided by "segmentsStyle"', () => { 164 | const { container } = render({ 165 | segmentsStyle: { 166 | transition: 'custom-transition', 167 | }, 168 | animate: true, 169 | animationDuration: 100, 170 | animationEasing: 'ease', 171 | }); 172 | const firstPath = container.querySelector('path'); 173 | expect(firstPath).toHaveStyle({ 174 | transition: 'stroke-dashoffset 100ms ease,custom-transition', 175 | }); 176 | }); 177 | }); 178 | 179 | describe.each` 180 | reveal | expectedRevealedPercentage 181 | ${undefined} | ${100} 182 | `('reveal === $reveal', ({ reveal, expectedRevealedPercentage }) => { 183 | it('re-renders on mount revealing the expected portion of segment', () => { 184 | const segmentRadius = pieChartDefaultProps.radius / 2; 185 | const lengthAngle = pieChartDefaultProps.lengthAngle; 186 | const fullPathLength = degreesToRadians(segmentRadius) * lengthAngle; 187 | let expectedHiddenPercentage; 188 | const initialProps = { 189 | data: [dataMock[0]], 190 | animate: true, 191 | reveal, 192 | }; 193 | const { container, reRender } = render(initialProps); 194 | const path = container.querySelector('path'); 195 | 196 | /** 197 | * @NOTE testing library fires useEffect sync and therefore we can't 198 | * assert against the DOM before useEffect is executed 199 | * 200 | * @TODO Find a way to ensure that segments are hidden on mount: 201 | * 202 | * expectedHiddenPercentage = 100; 203 | * expect(path).toHaveAttribute('stroke-dasharray', `${fullPathLength}`); 204 | * expect(path).toHaveAttribute( 205 | * 'stroke-dashoffset', 206 | * `${extractPercentage(fullPathLength, expectedHiddenPercentage)}` 207 | * ); 208 | */ 209 | 210 | // Paths are revealed 211 | expectedHiddenPercentage = 100 - expectedRevealedPercentage; 212 | expect(path).toHaveAttribute('stroke-dasharray', `${fullPathLength}`); 213 | expect(path).toHaveAttribute( 214 | 'stroke-dashoffset', 215 | `${extractPercentage(fullPathLength, expectedHiddenPercentage)}` 216 | ); 217 | 218 | // Update reveal prop after initial animation 219 | const newReveal = 77; 220 | reRender({ 221 | ...initialProps, 222 | reveal: newReveal, 223 | }); 224 | 225 | expectedHiddenPercentage = 100 - newReveal; 226 | expect(path).toHaveAttribute('stroke-dasharray', `${fullPathLength}`); 227 | expect(path).toHaveAttribute( 228 | 'stroke-dashoffset', 229 | `${extractPercentage(fullPathLength, expectedHiddenPercentage)}` 230 | ); 231 | }); 232 | }); 233 | 234 | describe('stroke-dashoffset attribute', () => { 235 | it("doesn't generate zero rounding issues after animation (GitHub: #133)", () => { 236 | const { container } = render({ 237 | data: [ 238 | { value: 1, color: 'red' }, 239 | { value: 1.6, color: 'blue' }, 240 | ], 241 | animate: true, 242 | }); 243 | 244 | // Expect all segments to be fully exposed 245 | container.querySelectorAll('path').forEach((path) => { 246 | expect(path).toHaveAttribute('stroke-dashoffset', '0'); 247 | }); 248 | }); 249 | }); 250 | }); 251 | }); 252 | -------------------------------------------------------------------------------- /test/Label.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { describe, it, expect, vi } from 'vitest'; 3 | import { render, dataMock, getArcInfo } from './testUtils'; 4 | import { 5 | bisectorAngle, 6 | extractPercentage, 7 | shiftVectorAlongAngle, 8 | } from '../src/utils'; 9 | import { pieChartDefaultProps, PieChartProps } from '../src'; 10 | 11 | function getExpectedLabelRenderProps(dataEntry: PieChartProps['data'][number]) { 12 | return { 13 | x: expect.any(Number), 14 | y: expect.any(Number), 15 | dx: expect.any(Number), 16 | dy: expect.any(Number), 17 | textAnchor: expect.any(String), 18 | dataEntry: { 19 | ...dataEntry, 20 | degrees: expect.any(Number), 21 | startAngle: expect.any(Number), 22 | percentage: expect.any(Number), 23 | }, 24 | dataIndex: expect.any(Number), 25 | }; 26 | } 27 | 28 | describe('Label', () => { 29 | describe('label prop function', () => { 30 | it('gets called with expected arguments', () => { 31 | const labelMock = vi.fn(); 32 | render({ 33 | label: labelMock, 34 | }); 35 | 36 | expect(labelMock).toHaveBeenCalledTimes(dataMock.length); 37 | dataMock.forEach((dataEntry) => { 38 | const expected = getExpectedLabelRenderProps(dataEntry); 39 | expect(labelMock).toHaveBeenCalledWith(expected); 40 | }); 41 | }); 42 | 43 | describe('returning a value', () => { 44 | const labels = [0, null, 'label']; 45 | describe.each` 46 | description | label | expectedLabels 47 | ${'number'} | ${() => -5} | ${[-5, -5, -5]} 48 | ${'number'} | ${({ dataIndex }: { dataIndex: number }) => dataIndex} | ${[0, 1, 2]} 49 | ${'string'} | ${() => 'label'} | ${['label', 'label', 'label']} 50 | ${'null'} | ${() => null} | ${[]} 51 | ${'undefined'} | ${() => undefined} | ${[]} 52 | ${'mixed'} | ${({ dataIndex }: { dataIndex: number }) => labels[dataIndex]} | ${[0, 'label']} 53 | `('$description', ({ label, expectedLabels }) => { 54 | it('renders expected <text> elements with expected content', () => { 55 | const { container } = render({ label }); 56 | const labels = container.querySelectorAll('text'); 57 | expect(labels.length).toBe(expectedLabels.length); 58 | 59 | labels.forEach((label, index) => { 60 | expect(label).toHaveTextContent(String(expectedLabels[index])); 61 | }); 62 | }); 63 | }); 64 | }); 65 | 66 | describe('an element', () => { 67 | it('render returned elements', () => { 68 | const { container } = render({ 69 | label: (props) => ( 70 | <text key={props.dataIndex}>{props.dataIndex}</text> 71 | ), 72 | }); 73 | 74 | container.querySelectorAll('text').forEach((label, index) => { 75 | expect(label).toHaveTextContent(`${index}`); 76 | }); 77 | }); 78 | }); 79 | }); 80 | 81 | describe('labelPosition prop', () => { 82 | const radius = 66; 83 | const labelPosition = 5; 84 | const expectedDistanceFromCenter = extractPercentage(radius, labelPosition); 85 | describe.each` 86 | description | segmentsShift | expectedSegmentsShift 87 | ${'as number'} | ${1} | ${[1, 1, 1]} 88 | ${'as function'} | ${(index: number) => index} | ${[0, 1, 2]} 89 | `( 90 | '+ segmentShift $description', 91 | ({ segmentsShift, expectedSegmentsShift }) => { 92 | it('renders labels translated radially', () => { 93 | const { container, getAllByText } = render({ 94 | radius, 95 | labelPosition, 96 | segmentsShift, 97 | label: () => 'label', 98 | }); 99 | const paths = container.querySelectorAll('path'); 100 | const shiftedLabels = getAllByText('label'); 101 | 102 | shiftedLabels.forEach((label, index) => { 103 | const { startAngle, lengthAngle } = getArcInfo(paths[index]); 104 | const expectedAbsoluteShift = 105 | expectedDistanceFromCenter + expectedSegmentsShift[index]; 106 | 107 | const { dx, dy } = shiftVectorAlongAngle( 108 | bisectorAngle(startAngle, lengthAngle), 109 | expectedAbsoluteShift 110 | ); 111 | 112 | expect(label).toHaveAttribute( 113 | 'x', 114 | `${pieChartDefaultProps.center[0]}` 115 | ); 116 | expect(label).toHaveAttribute( 117 | 'y', 118 | `${pieChartDefaultProps.center[1]}` 119 | ); 120 | expect(label).toHaveAttribute('dx', `${dx}`); 121 | expect(label).toHaveAttribute('dy', `${dy}`); 122 | }); 123 | }); 124 | } 125 | ); 126 | }); 127 | 128 | describe('labelStyle prop', () => { 129 | describe('as object', () => { 130 | it("pass provided value as labels' style", () => { 131 | const { getAllByText } = render({ 132 | label: () => 'label', 133 | labelStyle: { pointerEvents: 'none' }, 134 | }); 135 | 136 | getAllByText('label').forEach((label) => { 137 | expect(label).toHaveStyle({ pointerEvents: 'none' }); 138 | }); 139 | }); 140 | }); 141 | 142 | describe('as function', () => { 143 | it("pass return value as labels' style", () => { 144 | const { getAllByText } = render({ 145 | label: () => 'label', 146 | labelStyle: (index) => ({ stroke: dataMock[index].color }), 147 | }); 148 | 149 | getAllByText('label').forEach((label, index) => { 150 | expect(label).toHaveStyle({ stroke: dataMock[index].color }); 151 | }); 152 | }); 153 | }); 154 | }); 155 | 156 | describe('text-alignment and position', () => { 157 | // @TODO label positioning tests 158 | const lineWidth = 50; 159 | const lengthAngle = 90; 160 | const orientation = { 161 | bottom: lengthAngle / 2, 162 | left: 90 + lengthAngle / 2, 163 | top: 180 + lengthAngle / 2, 164 | right: 270 + lengthAngle / 2, 165 | }; 166 | const labelPosition = { 167 | inside: 25, 168 | outside: 125, 169 | over: 75, 170 | }; 171 | 172 | it.each` 173 | description | labelPosition | startAngle | expectedAlignment 174 | ${'outwards on the left'} | ${labelPosition.outside} | ${orientation.left} | ${'end'} 175 | ${'outwards on the right'} | ${labelPosition.outside} | ${orientation.right} | ${'start'} 176 | ${'outwards on the bottom'} | ${labelPosition.outside} | ${orientation.bottom} | ${'middle'} 177 | ${'inwards on the left'} | ${labelPosition.inside} | ${orientation.left} | ${'start'} 178 | ${'inwards on the right'} | ${labelPosition.inside} | ${orientation.right} | ${'end'} 179 | ${'inwards on the bottom'} | ${labelPosition.inside} | ${orientation.bottom} | ${'middle'} 180 | ${'overlying on the left'} | ${labelPosition.over} | ${orientation.left} | ${'middle'} 181 | ${'overlying on the right'} | ${labelPosition.over} | ${orientation.right} | ${'middle'} 182 | ${'overlying on the bottom'} | ${labelPosition.over} | ${orientation.bottom} | ${'middle'} 183 | `( 184 | 'Label $description', 185 | ({ labelPosition, startAngle, expectedAlignment }) => { 186 | const { getByText } = render({ 187 | data: [{ value: 1, color: 'red' }], 188 | lineWidth, 189 | lengthAngle, 190 | startAngle, 191 | label: () => 'label', 192 | labelPosition, 193 | }); 194 | const label = getByText('label'); 195 | expect(label).toHaveAttribute('text-anchor', expectedAlignment); 196 | } 197 | ); 198 | }); 199 | }); 200 | -------------------------------------------------------------------------------- /test/Path.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { render, dataMock, getArcInfo, fireEvent } from './testUtils'; 3 | import { 4 | bisectorAngle, 5 | degreesToRadians, 6 | extractPercentage, 7 | shiftVectorAlongAngle, 8 | } from '../src/utils'; 9 | const rgbGreen = 'rgb(0, 128, 0)'; 10 | 11 | describe('Path', () => { 12 | it('render one path for each entry in props.data', () => { 13 | const { container } = render(); 14 | const paths = container.querySelectorAll('path'); 15 | expect(paths.length).toEqual(dataMock.length); 16 | paths.forEach((path) => { 17 | expect(path).toHaveAttribute('d', expect.any(String)); 18 | }); 19 | }); 20 | 21 | it('get empty "d" attributes when data values sum equals 0', () => { 22 | const { container } = render({ 23 | data: [ 24 | { value: 0, color: 'red' }, 25 | { value: 0, color: 'green' }, 26 | ], 27 | }); 28 | const paths = container.querySelectorAll('path'); 29 | expect(paths.length).toEqual(2); 30 | paths.forEach((path) => { 31 | expect(path).toHaveAttribute('d', ''); 32 | }); 33 | }); 34 | 35 | it('receive "segmentsTabIndex", "rounded" and "data[].color"props', () => { 36 | const dataMockWithStyle = dataMock.map((entry) => ({ 37 | ...entry, 38 | ...{ 39 | color: 'black', 40 | }, 41 | })); 42 | const { container } = render({ 43 | data: dataMockWithStyle, 44 | segmentsTabIndex: 2, 45 | rounded: true, 46 | }); 47 | const paths = container.querySelectorAll('path'); 48 | 49 | paths.forEach((path) => { 50 | expect(path).toHaveAttribute('stroke', 'black'); 51 | expect(path).toHaveAttribute('tabindex', '2'); 52 | expect(path).toHaveAttribute('stroke-linecap', 'round'); 53 | }); 54 | }); 55 | 56 | describe('segmentsStyle prop', () => { 57 | describe.each` 58 | description | segmentsStyle | expectedStyle 59 | ${'undefined'} | ${undefined} | ${null} 60 | ${'as object'} | ${{ color: 'green' }} | ${{ color: rgbGreen }} 61 | ${'as function'} | ${(index: number) => ({ color: 'green' })} | ${{ color: rgbGreen }} 62 | ${'as function'} | ${vi.fn((i) => undefined)} | ${null} 63 | `('$description', ({ segmentsStyle, expectedStyle }) => { 64 | if (vi.isMockFunction(segmentsStyle)) { 65 | it('gets called with expected arguments', () => { 66 | // @ts-expect-error vi.isMockFunction type guards to MockInstance 67 | render({ segmentsStyle }); 68 | expect(segmentsStyle).toHaveBeenNthCalledWith(1, 0); 69 | expect(segmentsStyle).toHaveBeenNthCalledWith(2, 1); 70 | expect(segmentsStyle).toHaveBeenNthCalledWith(3, 2); 71 | }); 72 | } 73 | 74 | it('segments receives expected style', () => { 75 | const { container } = render({ segmentsStyle }); 76 | container.querySelectorAll('path').forEach((path) => { 77 | if (expectedStyle) { 78 | expect(path).toHaveStyle(expectedStyle); 79 | } else { 80 | expect(path.getAttribute('style')).toEqual(expectedStyle); 81 | } 82 | }); 83 | }); 84 | }); 85 | }); 86 | 87 | describe('segmentsShift prop', () => { 88 | /* 89 | * 1- Render both shifted and non-shifted segments 90 | * 2- Evaluate expected absolute segment's shift 91 | * 3- Compare shifted and non-shifted segments info 92 | */ 93 | describe.each` 94 | description | segmentsShift | expectedSegmentsShift 95 | ${'as number'} | ${1} | ${[1, 1, 1]} 96 | ${'as function'} | ${(index: number) => index} | ${[0, 1, 2]} 97 | ${'as function'} | ${vi.fn()} | ${[0, 0, 0]} 98 | `('$description', ({ segmentsShift, expectedSegmentsShift }) => { 99 | if (vi.isMockFunction(segmentsShift)) { 100 | it('gets called with expected arguments', () => { 101 | // @ts-expect-error vi.isMockFunction type guards to MockInstance 102 | render({ segmentsShift }); 103 | expect(segmentsShift).toHaveBeenNthCalledWith(1, 0); 104 | expect(segmentsShift).toHaveBeenNthCalledWith(2, 1); 105 | expect(segmentsShift).toHaveBeenNthCalledWith(3, 2); 106 | }); 107 | } 108 | 109 | it('renders segments translated radially', () => { 110 | const { container: originalPie } = render(); 111 | const { container: shiftedPie } = render({ 112 | segmentsShift, 113 | }); 114 | const originalPaths = originalPie.querySelectorAll('path'); 115 | const shiftedPaths = shiftedPie.querySelectorAll('path'); 116 | 117 | originalPaths.forEach((path, index) => { 118 | const { startPoint, startAngle, lengthAngle, radius, center } = 119 | getArcInfo(path); 120 | const shiftedPathInfo = getArcInfo(shiftedPaths[index]); 121 | const { dx, dy } = shiftVectorAlongAngle( 122 | bisectorAngle(startAngle, lengthAngle), 123 | expectedSegmentsShift[index] 124 | ); 125 | 126 | const expected = { 127 | startPoint: { 128 | x: expect.toEqualWithRoundingError(startPoint.x + dx), 129 | y: startPoint.y + dy, 130 | }, 131 | startAngle: expect.toEqualWithRoundingError(startAngle), 132 | lengthAngle: expect.toEqualWithRoundingError(lengthAngle), 133 | radius: radius, 134 | center: { 135 | x: expect.toEqualWithRoundingError(center.x + dx), 136 | y: expect.toEqualWithRoundingError(center.y + dy), 137 | }, 138 | }; 139 | 140 | expect(shiftedPathInfo).toEqual(expected); 141 | }); 142 | }); 143 | }); 144 | }); 145 | 146 | describe('lineWidth prop', () => { 147 | it('render path which "stroke-width" attributes equals the provided percentage of radius size', () => { 148 | const radius = 66; 149 | const lineWidth = 5; 150 | const { container } = render({ 151 | radius, 152 | lineWidth, 153 | }); 154 | const expectedStrokeWidth = extractPercentage(radius, lineWidth); 155 | const path = container.querySelector('path'); 156 | expect(path).toHaveAttribute('stroke-width', `${expectedStrokeWidth}`); 157 | }); 158 | }); 159 | 160 | describe('reveal prop', () => { 161 | const pathLength = degreesToRadians(25) * 360; 162 | const singleEntryDataMock = [dataMock[0]]; 163 | 164 | describe('undefined', () => { 165 | it('render a fully revealed path without "strokeDasharray" nor "strokeDashoffset"', () => { 166 | const { container } = render({ 167 | data: singleEntryDataMock, 168 | }); 169 | 170 | const path = container.querySelector('path'); 171 | expect(path).not.toHaveAttribute('stroke-dasharray'); 172 | expect(path).not.toHaveAttribute('stroke-dashoffset'); 173 | }); 174 | }); 175 | 176 | describe('100', () => { 177 | it('render a fully revealed path with "strokeDasharray" === path length & "strokeDashoffset" === 0', () => { 178 | const { container } = render({ 179 | data: singleEntryDataMock, 180 | reveal: 100, 181 | }); 182 | 183 | const path = container.querySelector('path'); 184 | expect(path).toHaveAttribute('stroke-dasharray', `${pathLength}`); 185 | expect(path).toHaveAttribute('stroke-dashoffset', '0'); 186 | }); 187 | }); 188 | 189 | describe('0', () => { 190 | it('render a fully hidden path with "strokeDashoffset" === "strokeDasharray"', () => { 191 | const { container } = render({ 192 | data: singleEntryDataMock, 193 | reveal: 0, 194 | }); 195 | 196 | const path = container.querySelector('path'); 197 | expect(path).toHaveAttribute('stroke-dasharray', `${pathLength}`); 198 | expect(path).toHaveAttribute('stroke-dashoffset', `${pathLength}`); 199 | }); 200 | 201 | describe('with negative "lengthAngle"', () => { 202 | it('Renders same "strokeDashoffset" value', () => { 203 | const { container } = render({ 204 | data: singleEntryDataMock, 205 | lengthAngle: -360, 206 | reveal: 0, 207 | }); 208 | 209 | const path = container.querySelector('path'); 210 | expect(path).toHaveAttribute('stroke-dasharray', `${pathLength}`); 211 | expect(path).toHaveAttribute('stroke-dashoffset', `${pathLength}`); 212 | }); 213 | }); 214 | }); 215 | }); 216 | 217 | describe('Event handlers props', () => { 218 | describe.each([ 219 | ['onBlur', 'blur', fireEvent.blur], 220 | ['onClick', 'click', fireEvent.click], 221 | ['onFocus', 'focus', fireEvent.focus], 222 | ['onKeyDown', 'keydown', fireEvent.keyDown], 223 | ['onMouseOut', 'mouseout', fireEvent.mouseOut], 224 | ['onMouseOver', 'mouseover', fireEvent.mouseOver], 225 | ])('%s', (propName, eventType, event) => { 226 | it('fire callback with expected arguments', () => { 227 | const eventCallbackMock = vi.fn((e) => e.persist()); 228 | const { container } = render({ 229 | [propName]: eventCallbackMock, 230 | }); 231 | const segmentIndex = 0; 232 | const segment = container.querySelectorAll('path')[segmentIndex]; 233 | event(segment); 234 | 235 | expect(eventCallbackMock).toHaveBeenCalledTimes(1); 236 | expect(eventCallbackMock).toHaveBeenLastCalledWith( 237 | expect.objectContaining({ 238 | type: eventType, 239 | }), 240 | segmentIndex 241 | ); 242 | }); 243 | }); 244 | }); 245 | }); 246 | -------------------------------------------------------------------------------- /test/testUtils/getArcCenter.ts: -------------------------------------------------------------------------------- 1 | // https://stackoverflow.com/questions/9017100/calculate-center-of-svg-arc 2 | // https://github.com/Ghostkeeper/SVGToolpathReader/blob/a2bbe90da64e6cd9d54fec553f61ba941001e85d/Parser.py#L493 3 | // @TODO Find a more reliable solution 4 | export function getArcCenter( 5 | x1: number, 6 | y1: number, 7 | rx: number, 8 | ry: number, 9 | phi: number, 10 | fA: number, 11 | fS: number, 12 | x2: number, 13 | y2: number 14 | ): { 15 | x: number; 16 | y: number; 17 | } { 18 | var cx, cy; 19 | 20 | if (rx < 0) { 21 | rx = -rx; 22 | } 23 | if (ry < 0) { 24 | ry = -ry; 25 | } 26 | if (rx == 0.0 || ry == 0.0) { 27 | // invalid arguments 28 | throw Error('rx and ry can not be 0'); 29 | } 30 | 31 | var s_phi = Math.sin(phi); 32 | var c_phi = Math.cos(phi); 33 | var hd_x = (x1 - x2) / 2.0; // half diff of x 34 | var hd_y = (y1 - y2) / 2.0; // half diff of y 35 | var hs_x = (x1 + x2) / 2.0; // half sum of x 36 | var hs_y = (y1 + y2) / 2.0; // half sum of y 37 | 38 | // F6.5.1 39 | var x1_ = c_phi * hd_x + s_phi * hd_y; 40 | var y1_ = c_phi * hd_y - s_phi * hd_x; 41 | 42 | // F.6.6 Correction of out-of-range radii 43 | // Step 3: Ensure radii are large enough 44 | var lambda = (x1_ * x1_) / (rx * rx) + (y1_ * y1_) / (ry * ry); 45 | if (lambda > 1) { 46 | rx = rx * Math.sqrt(lambda); 47 | ry = ry * Math.sqrt(lambda); 48 | } 49 | 50 | var rxry = rx * ry; 51 | var rxy1_ = rx * y1_; 52 | var ryx1_ = ry * x1_; 53 | var sum_of_sq = rxy1_ * rxy1_ + ryx1_ * ryx1_; // sum of square 54 | if (!sum_of_sq) { 55 | throw Error('start point can not be same as end point'); 56 | } 57 | var coe = Math.sqrt(Math.abs((rxry * rxry - sum_of_sq) / sum_of_sq)); 58 | if (fA == fS) { 59 | coe = -coe; 60 | } 61 | 62 | // F6.5.2 63 | var cx_ = (coe * rxy1_) / ry; 64 | var cy_ = (-coe * ryx1_) / rx; 65 | 66 | // F6.5.3 67 | cx = c_phi * cx_ - s_phi * cy_ + hs_x; 68 | cy = s_phi * cx_ + c_phi * cy_ + hs_y; 69 | 70 | return { 71 | x: cx, 72 | y: cy, 73 | }; 74 | } 75 | -------------------------------------------------------------------------------- /test/testUtils/getArcInfo.ts: -------------------------------------------------------------------------------- 1 | import { parseSVG } from 'svg-path-parser'; 2 | import { degrees as getDegrees } from '@schwingbat/relative-angle'; 3 | import { getArcCenter } from './getArcCenter'; 4 | 5 | type Point = { 6 | x: number; 7 | y: number; 8 | }; 9 | 10 | function getAbsoluteAngle(center: Point, point: Point): number { 11 | const relativeAngle = getDegrees(center, point); 12 | if (relativeAngle < 0) { 13 | return 360 + relativeAngle; 14 | } 15 | return relativeAngle; 16 | } 17 | 18 | /* 19 | * Known issues: 20 | * - Paths with non-integer center/startAngle/lengthAngle values 21 | * generate respective values with rounding issues 22 | */ 23 | export function getArcInfo(element: Element) { 24 | const d = element.getAttribute('d'); 25 | 26 | if (!d) { 27 | throw new Error('Provided element must have a "d" attribute'); 28 | } 29 | 30 | const [moveto, arc] = parseSVG(d); 31 | 32 | if (moveto.command !== 'moveto' || arc.command !== 'elliptical arc') { 33 | throw new Error('Provided path is not the section of a circumference'); 34 | } 35 | 36 | if (arc.rx !== arc.ry) { 37 | throw new Error('Provided path is not the section of a circumference'); 38 | } 39 | 40 | const center = getArcCenter( 41 | moveto.x, 42 | moveto.y, 43 | arc.rx, 44 | arc.ry, 45 | arc.xAxisRotation, 46 | arc.largeArc ? 1 : 0, 47 | arc.sweep ? 1 : 0, 48 | arc.x, 49 | arc.y 50 | ); 51 | 52 | const startAngle = getAbsoluteAngle(center, moveto); 53 | let lengthAngle = getAbsoluteAngle(center, arc) - startAngle; 54 | const lengthAngleAbsolute = Math.abs(lengthAngle); 55 | 56 | if ( 57 | (arc.largeArc === true && lengthAngleAbsolute < 180) || 58 | (arc.largeArc === false && lengthAngleAbsolute > 180) 59 | ) { 60 | lengthAngle = (360 - lengthAngleAbsolute) * Math.sign(lengthAngle) * -1; 61 | } 62 | 63 | return { 64 | startPoint: { 65 | x: moveto.x, 66 | y: moveto.y, 67 | }, 68 | startAngle, 69 | lengthAngle, 70 | radius: arc.rx, 71 | center, 72 | }; 73 | } 74 | -------------------------------------------------------------------------------- /test/testUtils/index.ts: -------------------------------------------------------------------------------- 1 | export { act, render, fireEvent, dataMock, PieChart } from './render'; 2 | export { getArcInfo } from './getArcInfo'; 3 | -------------------------------------------------------------------------------- /test/testUtils/render.tsx: -------------------------------------------------------------------------------- 1 | import React, { act } from 'react'; 2 | import { 3 | render as TLRender, 4 | fireEvent, 5 | RenderResult, 6 | } from '@testing-library/react'; 7 | // @NOTE this import must finish with "/src" to allow test runner 8 | // to remap it against bundled artefacts (npm run test:bundles:unit) 9 | import { PieChart, PieChartProps } from '../../src'; 10 | 11 | const dataMock = [ 12 | { value: 10, color: 'blue' }, 13 | { value: 15, color: 'orange' }, 14 | { value: 20, color: 'green' }, 15 | ]; 16 | 17 | function render( 18 | props?: Partial<PieChartProps> 19 | ): RenderResult & { reRender: (props?: Partial<PieChartProps>) => void } { 20 | const defaultProps = { data: dataMock }; 21 | const instance = TLRender(<PieChart {...defaultProps} {...props} />); 22 | 23 | // Uniform rerender to render's API 24 | return { 25 | ...instance, 26 | reRender: (props?: Partial<PieChartProps>) => 27 | instance.rerender(<PieChart {...defaultProps} {...props} />), 28 | }; 29 | } 30 | 31 | export { PieChart, act, dataMock, render, fireEvent }; 32 | -------------------------------------------------------------------------------- /test/testUtils/test/getArcCenter.test.ts: -------------------------------------------------------------------------------- 1 | // @vitest-environment node 2 | import { describe, it, expect } from 'vitest'; 3 | import { getArcInfo } from '../getArcInfo'; 4 | import { makePathCommands } from '../../../src/Path'; 5 | 6 | function getArcInfoFromDAttribute(d: string) { 7 | // @ts-expect-error We are providing getArcInfo with a partial input 8 | return getArcInfo({ 9 | getAttribute: () => d, 10 | }); 11 | } 12 | 13 | describe('getArcInfo test utility', () => { 14 | it.each` 15 | descr | cx | cy | startAngle | lengthAngle | radius 16 | ${'integer values'} | ${50} | ${50} | ${0} | ${90} | ${25} 17 | ${'decimal cx'} | ${222.222} | ${50} | ${0} | ${90} | ${25} 18 | ${'decimal cy'} | ${50} | ${222.222} | ${0} | ${90} | ${25} 19 | ${'decimal startAngle'} | ${50} | ${50} | ${222.222} | ${90} | ${25} 20 | ${'decimal lengthAngle'} | ${50} | ${50} | ${0} | ${222.222} | ${25} 21 | ${'decimal radius'} | ${50} | ${50} | ${0} | ${90} | ${222.222} 22 | `('$descr', ({ cx, cy, startAngle, lengthAngle, radius }) => { 23 | const d = makePathCommands(cx, cy, startAngle, lengthAngle, radius); 24 | const arcInfo = getArcInfoFromDAttribute(d); 25 | const expected = { 26 | startAngle: expect.toEqualWithRoundingError(startAngle), 27 | lengthAngle: expect.toEqualWithRoundingError(lengthAngle), 28 | radius, 29 | center: { 30 | x: expect.toEqualWithRoundingError(cx), 31 | y: expect.toEqualWithRoundingError(cy), 32 | }, 33 | }; 34 | 35 | expect(arcInfo).toMatchObject(expected); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /test/types.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { FocusEvent, KeyboardEvent, MouseEvent } from 'react'; 3 | import { PieChart } from '../src'; 4 | 5 | <PieChart data={[]} />; 6 | <PieChart 7 | data={[ 8 | { value: 10, color: 'blue' }, 9 | { value: 15, color: 'orange' }, 10 | ]} 11 | animate={true} 12 | animationDuration={1} 13 | animationEasing={'string'} 14 | background={'string'} 15 | className={'string'} 16 | center={[100, 100]} 17 | labelPosition={1} 18 | lengthAngle={1} 19 | lineWidth={1} 20 | paddingAngle={1} 21 | radius={1} 22 | reveal={1} 23 | rounded={true} 24 | segmentsStyle={{ color: 'red' }} 25 | segmentsTabIndex={1} 26 | startAngle={1} 27 | style={{ color: 'red' }} 28 | totalValue={1} 29 | viewBoxSize={[1, 1]} 30 | />; 31 | 32 | <PieChart data={[]} label={() => 1} />; 33 | <PieChart data={[]} label={() => 'string'} />; 34 | <PieChart data={[]} label={() => <text />} />; 35 | <PieChart data={[]} label={() => undefined} />; 36 | <PieChart data={[]} label={() => null} />; 37 | <PieChart data={[]} labelStyle={{ color: 'red' }} />; 38 | <PieChart data={[]} labelStyle={(i: number) => ({ color: 'red' })} />; 39 | 40 | <PieChart data={[]} segmentsShift={1} />; 41 | <PieChart data={[]} segmentsShift={() => 1} />; 42 | <PieChart data={[]} segmentsShift={() => undefined} />; 43 | //@ts-ignore 44 | <PieChart data={[]} segmentsShift={() => {}} />; 45 | 46 | <PieChart 47 | data={[]} 48 | onBlur={(e: FocusEvent, index: number) => {}} 49 | onClick={(e: MouseEvent, index: number) => {}} 50 | onFocus={(e: FocusEvent, index: number) => {}} 51 | onKeyDown={(e: KeyboardEvent, index: number) => {}} 52 | onMouseOut={(e: MouseEvent, index: number) => {}} 53 | onMouseOver={(e: MouseEvent, index: number) => {}} 54 | />; 55 | 56 | <PieChart data={[]}>1</PieChart>; 57 | <PieChart data={[]}>'string'</PieChart>; 58 | <PieChart data={[]}> 59 | <defs /> 60 | </PieChart>; 61 | 62 | <PieChart 63 | data={[ 64 | { value: 10, color: 'blue', custom: 'foo' }, 65 | { value: 15, color: 'orange', custom: 'bar' }, 66 | ]} 67 | label={(labelRenderProps) => { 68 | const customProp: string = labelRenderProps.dataEntry.custom; 69 | return null; 70 | }} 71 | />; 72 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@total-typescript/tsconfig/tsc/dom/library", 3 | "compilerOptions": { 4 | "jsx": "react", 5 | "paths": { 6 | "@schwingbat/relative-angle": [ 7 | "./@types/schwingbat/relative-angle/index.d.ts" 8 | ], 9 | "svg-partial-circle": ["./@types/svg-partial-circle/index.d.ts"] 10 | }, 11 | "types": ["@testing-library/jest-dom", "node"], 12 | "noUncheckedIndexedAccess": false, 13 | "verbatimModuleSyntax": false 14 | }, 15 | "include": ["src", "test", "stories", "@types"] 16 | } 17 | -------------------------------------------------------------------------------- /vitest.config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig, defaultInclude } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | dir: 'test', 6 | setupFiles: ['vitest.setup.ts'], 7 | coverage: { 8 | provider: 'v8', 9 | include: ['src/**'], 10 | enabled: false, 11 | reporter: [['lcov', { projectRoot: './' }], ['text']], 12 | }, 13 | environment: 'jsdom', 14 | typecheck: { 15 | enabled: true, 16 | include: defaultInclude, 17 | }, 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import { expect, afterEach } from 'vitest'; 2 | import { cleanup } from '@testing-library/react'; 3 | import '@testing-library/jest-dom/vitest'; 4 | 5 | afterEach(() => { 6 | cleanup(); 7 | }); 8 | 9 | // https://stackoverflow.com/a/53464807/2902821 10 | expect.extend({ 11 | toEqualWithRoundingError(actual: number, expected: number, decimals = 12) { 12 | const pass = Math.abs(expected - actual) < Math.pow(10, -decimals) / 2; 13 | if (pass) { 14 | return { 15 | message: () => 16 | `expected ${actual} not to equal ${expected} (with ${decimals} decimals rounding error)`, 17 | pass: true, 18 | }; 19 | } else { 20 | return { 21 | message: () => 22 | `expected ${actual} to equal ${expected} (with ${decimals} decimals rounding error)`, 23 | pass: false, 24 | }; 25 | } 26 | }, 27 | }); 28 | --------------------------------------------------------------------------------