├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ ├── feature_request.md │ └── question.md ├── dependabot.yml ├── release-drafter.yml └── workflows │ ├── ci.yml │ ├── create_release.yml │ ├── dependabot_automerge.yml │ ├── deploy_website.yml │ └── release_helper.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.cjs ├── .vscode └── settings.json ├── .yarn └── releases │ └── yarn-4.5.1.cjs ├── .yarnrc.yml ├── LICENSE ├── README.md ├── docs ├── .vitepress │ ├── config.ts │ └── theme │ │ └── index.ts ├── examples │ ├── boxplot.ts │ ├── datalimits.md │ ├── datalimits.ts │ ├── datastructures.md │ ├── datastructures.ts │ ├── datastructuresViolin.ts │ ├── fivenum.md │ ├── fivenum.ts │ ├── horizontal.md │ ├── horizontalBoxPlot.ts │ ├── horizontalViolin.ts │ ├── hybrid.md │ ├── hybrid.ts │ ├── index.md │ ├── items.md │ ├── items.ts │ ├── large_number.md │ ├── large_number.ts │ ├── logarithm.md │ ├── logarithm.ts │ ├── styling.md │ ├── styling.ts │ └── violin.ts ├── getting-started.md ├── index.md └── related.md ├── eslint.config.mjs ├── package.json ├── rollup.config.js ├── src ├── __tests__ │ └── createChart.ts ├── animation.ts ├── controllers │ ├── BoxPlotController.spec.ts │ ├── BoxPlotController.ts │ ├── StatsBase.ts │ ├── ViolinController.spec.ts │ ├── ViolinController.ts │ ├── __image_snapshots__ │ │ ├── box-plot-controller-spec-ts-boxplot-data-structures-1-snap.png │ │ ├── box-plot-controller-spec-ts-boxplot-datalimits-1-snap.png │ │ ├── box-plot-controller-spec-ts-boxplot-datastructures-1-snap.png │ │ ├── box-plot-controller-spec-ts-boxplot-default-1-snap.png │ │ ├── box-plot-controller-spec-ts-boxplot-empty-1-snap.png │ │ ├── box-plot-controller-spec-ts-boxplot-hybrid-1-snap.png │ │ ├── box-plot-controller-spec-ts-boxplot-items-1-snap.png │ │ ├── box-plot-controller-spec-ts-boxplot-logarithmic-1-snap.png │ │ ├── box-plot-controller-spec-ts-boxplot-mediancolor-1-snap.png │ │ ├── box-plot-controller-spec-ts-boxplot-minmax-1-snap.png │ │ ├── box-plot-controller-spec-ts-boxplot-quantiles-fivenum-1-snap.png │ │ ├── box-plot-controller-spec-ts-boxplot-quantiles-types-7-1-snap.png │ │ └── violin-controller-spec-ts-violin-default-1-snap.png │ ├── __tests__ │ │ └── utils.ts │ ├── index.ts │ └── patchController.ts ├── data.ts ├── elements │ ├── BoxAndWiskers.ts │ ├── Violin.ts │ ├── base.ts │ └── index.ts ├── index.ts ├── index.umd.ts └── tooltip.ts ├── tsconfig.c.json ├── tsconfig.json ├── typedoc.json ├── vitest.config.ts └── yarn.lock /.gitattributes: -------------------------------------------------------------------------------- 1 | # These settings are for any web project 2 | 3 | # Handle line endings automatically for files detected as text 4 | # and leave all files detected as binary untouched. 5 | * text=auto eol=lf 6 | 7 | # 8 | # The above will handle all files NOT found below 9 | # 10 | 11 | # 12 | ## These files are text and should be normalized (Convert crlf => lf) 13 | # 14 | 15 | # source code 16 | *.php text 17 | *.css text 18 | *.sass text 19 | *.scss text 20 | *.less text 21 | *.styl text 22 | *.js text 23 | *.jsx text 24 | *.ts text 25 | *.tsx text 26 | *.coffee text 27 | *.json text 28 | *.htm text 29 | *.html text 30 | *.xml text 31 | *.txt text 32 | *.ini text 33 | *.inc text 34 | *.pl text 35 | *.rb text 36 | *.py text 37 | *.scm text 38 | *.sql text 39 | *.sh text eof=LF 40 | *.bat text 41 | 42 | # templates 43 | *.hbt text 44 | *.jade text 45 | *.haml text 46 | *.hbs text 47 | *.dot text 48 | *.tmpl text 49 | *.phtml text 50 | 51 | # server config 52 | .htaccess text 53 | 54 | # git config 55 | .gitattributes text 56 | .gitignore text 57 | 58 | # code analysis config 59 | .jshintrc text 60 | .jscsrc text 61 | .jshintignore text 62 | .csslintrc text 63 | 64 | # misc config 65 | *.yaml text 66 | *.yml text 67 | .editorconfig text 68 | 69 | # build config 70 | *.npmignore text 71 | *.bowerrc text 72 | Dockerfile text eof=LF 73 | 74 | # Heroku 75 | Procfile text 76 | .slugignore text 77 | 78 | # Documentation 79 | *.md text 80 | LICENSE text 81 | AUTHORS text 82 | 83 | 84 | # 85 | ## These files are binary and should be left untouched 86 | # 87 | 88 | # (binary is a macro for -text -diff) 89 | *.png binary 90 | *.jpg binary 91 | *.jpeg binary 92 | *.gif binary 93 | *.ico binary 94 | *.mov binary 95 | *.mp4 binary 96 | *.mp3 binary 97 | *.flv binary 98 | *.fla binary 99 | *.swf binary 100 | *.gz binary 101 | *.zip binary 102 | *.7z binary 103 | *.ttf binary 104 | *.pyc binary 105 | *.pdf binary 106 | 107 | # Source files 108 | # ============ 109 | *.pxd text 110 | *.py text 111 | *.py3 text 112 | *.pyw text 113 | *.pyx text 114 | *.sh text eol=lf 115 | *.json text 116 | 117 | # Binary files 118 | # ============ 119 | *.db binary 120 | *.p binary 121 | *.pkl binary 122 | *.pyc binary 123 | *.pyd binary 124 | *.pyo binary 125 | 126 | # Note: .db, .p, and .pkl files are associated 127 | # with the python modules ``pickle``, ``dbm.*``, 128 | # ``shelve``, ``marshal``, ``anydbm``, & ``bsddb`` 129 | # (among others). 130 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [sgratzl] 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Bug report 3 | about: If something isn't working as expected 🤔. 4 | title: '' 5 | labels: 'bug' 6 | assignees: '' 7 | --- 8 | 9 | 10 | 11 | When I... 12 | 13 | **To Reproduce** 14 | 15 | 17 | 18 | 1. 19 | 20 | **Expected behavior** 21 | 22 | 23 | 24 | **Screenshots** 25 | 26 | 27 | 28 | **Context** 29 | 30 | - Version: 31 | - Browser: 32 | 33 | **Additional context** 34 | 35 | 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | # contact_links: 3 | # - name: Samuel Gratzl 4 | # url: https://www.sgratzl.com 5 | # about: Please ask and answer questions here. 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🚀 Feature Request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: 'enhancement' 6 | assignees: '' 7 | --- 8 | 9 | 10 | 11 | It would be great if ... 12 | 13 | **User story** 14 | 15 | 16 | 17 | **Additional context** 18 | 19 | 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🤗 Question 3 | about: ask question about the library (usage, features,...) 4 | title: '' 5 | labels: 'question' 6 | assignees: '' 7 | --- 8 | 9 | 13 | 14 | I'm having the following question... 15 | 16 | **Screenshots / Sketches** 17 | 18 | 19 | 20 | **Context** 21 | 22 | - Version: 23 | - Browser: 24 | 25 | **Additional context** 26 | 27 | 28 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: 'github-actions' 5 | directory: '/' 6 | schedule: 7 | interval: 'monthly' 8 | target-branch: 'dev' 9 | labels: 10 | - 'dependencies' 11 | - 'chore' 12 | - package-ecosystem: 'npm' 13 | directory: '/' 14 | schedule: 15 | interval: 'monthly' 16 | target-branch: 'dev' 17 | labels: 18 | - 'dependencies' 19 | - 'chore' 20 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$RESOLVED_VERSION' 2 | tag-template: 'v$RESOLVED_VERSION' 3 | categories: 4 | - title: '🚀 Features' 5 | labels: 6 | - 'enhancement' 7 | - 'feature' 8 | - title: '🐛 Bugs Fixes' 9 | labels: 10 | - 'bug' 11 | - title: 'Documentation' 12 | labels: 13 | - 'documentation' 14 | - title: '🧰 Development' 15 | labels: 16 | - 'chore' 17 | change-template: '- #$NUMBER $TITLE' 18 | change-title-escapes: '\<*_&`#@' 19 | template: | 20 | $CHANGES 21 | 22 | Thanks to $CONTRIBUTORS 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-node@v4 13 | with: 14 | node-version: 20 15 | - run: npm i -g yarn 16 | - run: yarn config set checksumBehavior ignore 17 | - name: Cache Node.js modules 18 | uses: actions/cache@v4 19 | with: 20 | path: | 21 | ./.yarn/cache 22 | ./.yarn/unplugged 23 | key: ${{ runner.os }}-yarn2-v5-${{ hashFiles('**/yarn.lock') }} 24 | restore-keys: | 25 | ${{ runner.os }}-yarn2-v5 26 | - run: yarn install 27 | - run: yarn build 28 | - run: yarn lint 29 | - run: yarn test 30 | - uses: actions/upload-artifact@v4 31 | if: failure() 32 | with: 33 | name: diff outputs 34 | path: src/**/__diff_output__/*.png 35 | - run: yarn docs:build 36 | -------------------------------------------------------------------------------- /.github/workflows/create_release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | versionName: 6 | description: 'Semantic Version Number (i.e., 5.5.0 or patch, minor, major, prepatch, preminor, premajor, prerelease)' 7 | required: true 8 | default: patch 9 | preid: 10 | description: 'Pre Release Identifier (i.e., alpha, beta)' 11 | required: true 12 | default: alpha 13 | jobs: 14 | create_release: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Check out code 18 | uses: actions/checkout@v4 19 | with: 20 | ref: main 21 | ssh-key: ${{ secrets.PRIVATE_SSH_KEY }} 22 | - name: Reset main branch 23 | run: | 24 | git fetch origin dev:dev 25 | git reset --hard origin/dev 26 | - name: Change version number 27 | id: version 28 | run: | 29 | echo "next_tag=$(npm version --no-git-tag-version ${{ github.event.inputs.versionName }} --preid ${{ github.event.inputs.preid }})" >> $GITHUB_OUTPUT 30 | - name: Create pull request into main 31 | uses: peter-evans/create-pull-request@v7 32 | with: 33 | branch: release/${{ steps.version.outputs.next_tag }} 34 | commit-message: 'chore: release ${{ steps.version.outputs.next_tag }}' 35 | base: main 36 | title: Release ${{ steps.version.outputs.next_tag }} 37 | labels: chore 38 | assignees: sgratzl 39 | body: | 40 | Releasing ${{ steps.version.outputs.next_tag }}. 41 | -------------------------------------------------------------------------------- /.github/workflows/dependabot_automerge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | on: 3 | pull_request: 4 | branches: 5 | - 'dev' # only into dev 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | dependabot: 13 | runs-on: ubuntu-latest 14 | if: github.event.pull_request.user.login == 'dependabot[bot]' && github.repository == 'sgratzl/chartjs-chart-boxplot' 15 | steps: 16 | - name: Dependabot metadata 17 | id: metadata 18 | uses: dependabot/fetch-metadata@v2 19 | with: 20 | github-token: '${{ secrets.GITHUB_TOKEN }}' 21 | - name: Enable auto-merge for Dependabot PRs 22 | # patch only 23 | if: steps.metadata.outputs.update-type == 'version-update:semver-patch' 24 | run: gh pr merge --auto --merge "$PR_URL" 25 | env: 26 | PR_URL: ${{github.event.pull_request.html_url}} 27 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} 28 | -------------------------------------------------------------------------------- /.github/workflows/deploy_website.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Website 2 | on: 3 | workflow_dispatch: {} 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | pages: write 12 | id-token: write 13 | environment: 14 | name: github-pages 15 | url: ${{ steps.deployment.outputs.page_url }} 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version: 20 23 | cache: npm 24 | - run: npm i -g yarn 25 | - run: yarn config set checksumBehavior ignore 26 | - name: Cache Node.js modules 27 | uses: actions/cache@v4 28 | with: 29 | path: | 30 | ./.yarn/cache 31 | ./.yarn/unplugged 32 | key: ${{ runner.os }}-yarn2-v5-${{ hashFiles('**/yarn.lock') }} 33 | restore-keys: | 34 | ${{ runner.os }}-yarn2-v5 35 | - run: yarn install 36 | - run: yarn docs:build 37 | - uses: actions/configure-pages@v5 38 | - uses: actions/upload-pages-artifact@v3 39 | with: 40 | path: docs/.vitepress/dist 41 | - name: Deploy 42 | id: deployment 43 | uses: actions/deploy-pages@v4 44 | -------------------------------------------------------------------------------- /.github/workflows/release_helper.yml: -------------------------------------------------------------------------------- 1 | name: Release Helper 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | correct_repository: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: fail on fork 12 | if: github.repository_owner != 'sgratzl' 13 | run: exit 1 14 | 15 | create_release: 16 | needs: correct_repository 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Check out code 20 | uses: actions/checkout@v4 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: 20 24 | - name: Extract version 25 | id: extract_version 26 | run: | 27 | node -pe "'version=' + require('./package.json').version" >> $GITHUB_OUTPUT 28 | node -pe "'npm_tag=' + (require('./package.json').version.includes('-') ? 'next' : 'latest')" >> $GITHUB_OUTPUT 29 | - name: Print version 30 | run: | 31 | echo "releasing ${{ steps.extract_version.outputs.version }} with tag ${{ steps.extract_version.outputs.npm_tag }}" 32 | - name: Create Release 33 | id: create_release 34 | uses: release-drafter/release-drafter@v6 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | with: 38 | name: v${{ steps.extract_version.outputs.version }} 39 | tag: v${{ steps.extract_version.outputs.version }} 40 | version: ${{ steps.extract_version.outputs.version }} 41 | prerelease: ${{ needs.create_release.outputs.tag_name == 'next' }} 42 | publish: true 43 | outputs: 44 | version: ${{ steps.extract_version.outputs.version }} 45 | npm_tag: ${{ steps.extract_version.outputs.npm_tag }} 46 | upload_url: ${{ steps.create_release.outputs.upload_url }} 47 | tag_name: ${{ steps.create_release.outputs.tag_name }} 48 | 49 | build_assets: 50 | needs: create_release 51 | runs-on: ubuntu-latest 52 | steps: 53 | - name: Check out code 54 | uses: actions/checkout@v4 55 | - uses: actions/setup-node@v4 56 | with: 57 | node-version: 20 58 | - run: npm i -g yarn 59 | - run: yarn config set checksumBehavior ignore 60 | - name: Cache Node.js modules 61 | uses: actions/cache@v4 62 | with: 63 | path: | 64 | ./.yarn/cache 65 | ./.yarn/unplugged 66 | key: ${{ runner.os }}-yarn2-v5-${{ hashFiles('**/yarn.lock') }} 67 | restore-keys: | 68 | ${{ runner.os }}-yarn2-v5 69 | - run: yarn install 70 | - run: yarn build 71 | - run: yarn pack 72 | - name: Upload Release Asset 73 | uses: AButler/upload-release-assets@v3.0 74 | with: 75 | files: 'package.tgz' 76 | repo-token: ${{ secrets.GITHUB_TOKEN }} 77 | release-tag: ${{ needs.create_release.outputs.tag_name }} 78 | - name: Pack Publish 79 | run: | 80 | yarn config set npmAuthToken "${{ secrets.NPM_TOKEN }}" 81 | yarn pack 82 | yarn npm publish --tag "${{ needs.create_release.outputs.npm_tag }}" 83 | 84 | sync_dev: 85 | needs: correct_repository 86 | runs-on: ubuntu-latest 87 | steps: 88 | - name: Check out code 89 | uses: actions/checkout@v4 90 | with: 91 | ref: dev 92 | ssh-key: ${{ secrets.PRIVATE_SSH_KEY }} 93 | - name: Reset dev branch 94 | run: | 95 | git fetch origin main:main 96 | git merge main 97 | git push 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | /coverage 7 | /node_modules 8 | .npm 9 | .yarn/* 10 | !.yarn/patches 11 | !.yarn/releases 12 | !.yarn/plugins 13 | !.yarn/versions 14 | .pnp.* 15 | 16 | # Build files 17 | /.tmp 18 | /build 19 | 20 | *.tgz 21 | /.vscode/extensions.json 22 | *.tsbuildinfo 23 | .eslintcache 24 | __diff_output__ 25 | 26 | docs/.vitepress/dist 27 | docs/.vitepress/cache 28 | docs/.vitepress/config.ts.timestamp* 29 | docs/api/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /.pnp* 2 | /.yarnrc.yml 3 | /.yarn 4 | /build 5 | /docs/.vitepress/cache 6 | /docs/.vitepress/dist 7 | /docs/.vitepress/config.ts.timestamp* 8 | /docs/api 9 | /coverage 10 | /.gitattributes 11 | /.gitignore 12 | /.prettierignore 13 | /LICENSE 14 | /yarn.lock 15 | /.vscode 16 | *.png 17 | *.tgz 18 | *.tsbuildinfo 19 | .eslintcache 20 | .nojekyll 21 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 120, 3 | semi: true, 4 | singleQuote: true, 5 | trailingComma: 'es5', 6 | }; 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.formatOnType": true, 4 | "[javascript]": { 5 | "editor.defaultFormatter": "esbenp.prettier-vscode" 6 | }, 7 | "[typescript]": { 8 | "editor.defaultFormatter": "esbenp.prettier-vscode" 9 | }, 10 | "[json]": { 11 | "editor.defaultFormatter": "esbenp.prettier-vscode" 12 | }, 13 | "[yaml]": { 14 | "editor.defaultFormatter": "esbenp.prettier-vscode" 15 | }, 16 | "npm.packageManager": "yarn", 17 | "eslint.nodePath": ".yarn/sdks", 18 | "prettier.prettierPath": ".yarn/sdks/prettier/index.cjs", 19 | "files.eol": "\n", 20 | "typescript.tsdk": ".yarn/sdks/typescript/lib", 21 | "typescript.enablePromptUseWorkspaceTsdk": true, 22 | "editor.detectIndentation": false, 23 | "editor.tabSize": 2, 24 | "search.exclude": { 25 | "**/.yarn": true, 26 | "**/.pnp.*": true 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | yarnPath: .yarn/releases/yarn-4.5.1.cjs 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 datavisyn GmbH 4 | Copyright (c) 2019-2023 Samuel Gratzl 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chart.js Box and Violin Plot 2 | 3 | [![License: MIT][mit-image]][mit-url] [![NPM Package][npm-image]][npm-url] [![Github Actions][github-actions-image]][github-actions-url] 4 | 5 | Chart.js module for charting box and violin plots. This is a maintained fork of [@datavisyn/chartjs-chart-box-and-violin-plot](https://github.com/datavisyn/chartjs-chart-box-and-violin-plot), which I originally developed during my time at datavisyn. 6 | 7 | ![Box Plot](https://user-images.githubusercontent.com/4129778/42724341-9a6ec554-8770-11e8-99b5-626e34dafdb3.png) 8 | ![Violin Plot](https://user-images.githubusercontent.com/4129778/42724342-9a8dbb58-8770-11e8-9a30-3e69d07d3b79.png) 9 | 10 | ## Install 11 | 12 | ```bash 13 | npm install chart.js @sgratzl/chartjs-chart-boxplot 14 | ``` 15 | 16 | ## Usage 17 | 18 | see [Examples](https://www.sgratzl.com/chartjs-chart-boxplot/examples/) 19 | 20 | and [![Open in CodePen][codepen]](https://codepen.io/sgratzl/pen/QxoLoY) 21 | 22 | ## Chart 23 | 24 | four new types: `boxplot` and `violin`. 25 | 26 | ## Config 27 | 28 | The config can be done on a per dataset `.data.datasets[0].minStats` or for all datasets under the controllers name. e.g., `.options.boxplot.datasets.minStats`. 29 | 30 | see https://github.com/sgratzl/chartjs-chart-boxplot/blob/dev/src/data.ts#L100-L147 31 | 32 | ## Data structure 33 | 34 | Both types support that the data is given as an array of numbers `number[]`. The statistics will be automatically computed. In addition, specific summary data structures are supported: 35 | 36 | see https://github.com/sgratzl/chartjs-chart-boxplot/blob/dev/src/data.ts#L24-L49 37 | 38 | ## Tooltips 39 | 40 | In order to simplify the customization of the tooltips the tooltip item given to the tooltip callbacks was improved. The default `toString()` behavior should be fine in most cases. The tooltip item has the following structure: 41 | 42 | ```ts 43 | interface ITooltipItem { 44 | label: string; // original 45 | value: { 46 | raw: IBoxPlotItem | IViolinItem; 47 | /** 48 | * in case an outlier is hovered this will contains its index 49 | * @default -1 50 | */ 51 | hoveredOutlierRadius: number; 52 | /** 53 | * toString function with a proper default implementation, which is used implicitly 54 | */ 55 | toString(): string; 56 | 57 | min: string; 58 | median: string; 59 | max: string; 60 | items?: string[]; 61 | 62 | //... the formatted version of different attributes IBoxPlotItem or ViolinItem 63 | }; 64 | } 65 | ``` 66 | 67 | ## ESM and Tree Shaking 68 | 69 | The ESM build of the library supports tree shaking thus having no side effects. As a consequence the chart.js library won't be automatically manipulated nor new controllers automatically registered. One has to manually import and register them. 70 | 71 | Variant A: 72 | 73 | ```js 74 | import { Chart, LinearScale, CategoryScale } from 'chart.js'; 75 | import { BoxPlotController, BoxAndWiskers } from '@sgratzl/chartjs-chart-boxplot'; 76 | 77 | // register controller in chart.js and ensure the defaults are set 78 | Chart.register(BoxPlotController, BoxAndWiskers, LinearScale, CategoryScale); 79 | ... 80 | 81 | new Chart(ctx, { 82 | type: 'boxplot', 83 | data: [...], 84 | }); 85 | ``` 86 | 87 | Variant B: 88 | 89 | ```js 90 | import { BoxPlotChart } from '@sgratzl/chartjs-chart-boxplot'; 91 | 92 | new BoxPlotChart(ctx, { 93 | data: [...], 94 | }); 95 | ``` 96 | 97 | ## Related Plugins 98 | 99 | Check out also my other chart.js plugins: 100 | 101 | - [chartjs-chart-error-bars](https://github.com/sgratzl/chartjs-chart-error-bars) for rendering errors bars to bars and line charts 102 | - [chartjs-chart-geo](https://github.com/sgratzl/chartjs-chart-geo) for rendering map, bubble maps, and choropleth charts 103 | - [chartjs-chart-graph](https://github.com/sgratzl/chartjs-chart-graph) for rendering graphs, trees, and networks 104 | - [chartjs-chart-pcp](https://github.com/sgratzl/chartjs-chart-pcp) for rendering parallel coordinate plots 105 | - [chartjs-chart-venn](https://github.com/sgratzl/chartjs-chart-venn) for rendering venn and euler diagrams 106 | - [chartjs-chart-wordcloud](https://github.com/sgratzl/chartjs-chart-wordcloud) for rendering word clouds 107 | - [chartjs-plugin-hierarchical](https://github.com/sgratzl/chartjs-plugin-hierarchical) for rendering hierarchical categorical axes which can be expanded and collapsed 108 | 109 | ## Development Environment 110 | 111 | ```sh 112 | npm i -g yarn 113 | yarn install 114 | yarn sdks vscode 115 | ``` 116 | 117 | ### Common commands 118 | 119 | ```sh 120 | yarn compile 121 | yarn test 122 | yarn lint 123 | yarn fix 124 | yarn build 125 | yarn docs 126 | ``` 127 | 128 | ## Credits 129 | 130 | Original credits belong to [@datavisyn](https://www.datavisyn.io). 131 | 132 | [mit-image]: https://img.shields.io/badge/License-MIT-yellow.svg 133 | [mit-url]: https://opensource.org/licenses/MIT 134 | [npm-image]: https://badge.fury.io/js/%40sgratzl%2Fchartjs-chart-boxplot.svg 135 | [npm-url]: https://npmjs.org/package/@sgratzl/chartjs-chart-boxplot 136 | [github-actions-image]: https://github.com/sgratzl/chartjs-chart-boxplot/workflows/ci/badge.svg 137 | [github-actions-url]: https://github.com/sgratzl/chartjs-chart-boxplot/actions 138 | [codepen]: https://img.shields.io/badge/CodePen-open-blue?logo=codepen 139 | -------------------------------------------------------------------------------- /docs/.vitepress/config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress'; 2 | import { name, description, repository, license, author } from '../../package.json'; 3 | import typedocSidebar from '../api/typedoc-sidebar.json'; 4 | 5 | const cleanName = name.replace('@sgratzl/', ''); 6 | 7 | // https://vitepress.dev/reference/site-config 8 | export default defineConfig({ 9 | title: cleanName, 10 | description, 11 | base: `/${cleanName}/`, 12 | useWebFonts: false, 13 | themeConfig: { 14 | // https://vitepress.dev/reference/default-theme-config 15 | nav: [ 16 | { text: 'Home', link: '/' }, 17 | { text: 'Getting Started', link: '/getting-started' }, 18 | { text: 'Examples', link: '/examples/' }, 19 | { text: 'API', link: '/api/' }, 20 | { text: 'Related Plugins', link: '/related' }, 21 | ], 22 | 23 | sidebar: [ 24 | { 25 | text: 'Examples', 26 | items: [ 27 | { text: 'Basic', link: '/examples/' }, 28 | { text: 'Data Limits', link: '/examples/datalimits' }, 29 | { text: 'Data Structures', link: '/examples/datastructures' }, 30 | { text: 'Quantile Computation', link: '/examples/fivenum' }, 31 | { text: 'Horizontal Plots', link: '/examples/horizontal' }, 32 | { text: 'Large Numbers', link: '/examples/large_number' }, 33 | { text: 'Logarithmic Scale', link: '/examples/logarithm' }, 34 | { text: 'Hybrid Chart', link: '/examples/hybrid' }, 35 | { text: 'Data Points', link: '/examples/items' }, 36 | { text: 'Styling', link: '/examples/styling' }, 37 | ], 38 | }, 39 | { 40 | text: 'API', 41 | collapsed: true, 42 | items: typedocSidebar, 43 | }, 44 | ], 45 | 46 | socialLinks: [{ icon: 'github', link: repository.url.replace('.git', '') }], 47 | 48 | footer: { 49 | message: `Released under the ${license} license.`, 53 | copyright: `Copyright © 2019-present ${author.name}`, 54 | }, 55 | 56 | editLink: { 57 | pattern: `${repository.url.replace('.git', '')}/edit/main/docs/:path`, 58 | }, 59 | 60 | search: { 61 | provider: 'local', 62 | }, 63 | }, 64 | }); 65 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | import Theme from 'vitepress/theme'; 2 | import { createTypedChart } from 'vue-chartjs'; 3 | import { 4 | LinearScale, 5 | CategoryScale, 6 | LogarithmicScale, 7 | Tooltip, 8 | Title, 9 | Legend, 10 | Colors, 11 | BarElement, 12 | LineElement, 13 | PointElement, 14 | BarController, 15 | LineController, 16 | } from 'chart.js'; 17 | import { BoxAndWiskers, BoxPlotController, Violin, ViolinController } from '../../../src'; 18 | 19 | export default { 20 | ...Theme, 21 | enhanceApp({ app }) { 22 | app.component( 23 | 'BoxplotChart', 24 | createTypedChart('boxplot', [ 25 | LinearScale, 26 | CategoryScale, 27 | LogarithmicScale, 28 | BoxAndWiskers, 29 | BoxPlotController, 30 | Tooltip, 31 | Legend, 32 | Colors, 33 | Title, 34 | BarElement, 35 | LineElement, 36 | PointElement, 37 | BarController, 38 | LineController, 39 | ]) 40 | ); 41 | app.component( 42 | 'ViolinChart', 43 | createTypedChart('violin', [ 44 | LinearScale, 45 | CategoryScale, 46 | LogarithmicScale, 47 | Violin, 48 | ViolinController, 49 | Tooltip, 50 | Legend, 51 | Colors, 52 | Title, 53 | ]) 54 | ); 55 | }, 56 | }; 57 | -------------------------------------------------------------------------------- /docs/examples/boxplot.ts: -------------------------------------------------------------------------------- 1 | import type { ChartConfiguration } from 'chart.js'; 2 | import type {} from '../../src'; 3 | 4 | // #region data 5 | function randomValues(count: number, min: number, max: number, extra: number[] = []): number[] { 6 | const delta = max - min; 7 | return [...Array.from({ length: count }).map(() => Math.random() * delta + min), ...extra]; 8 | } 9 | 10 | export const data: ChartConfiguration<'boxplot'>['data'] = { 11 | labels: ['A', 'B', 'C', 'D'], 12 | datasets: [ 13 | { 14 | label: 'Dataset 1', 15 | data: [ 16 | randomValues(100, 0, 100), 17 | randomValues(100, 0, 20, [110]), 18 | randomValues(100, 20, 70), 19 | // empty data 20 | [], 21 | ], 22 | }, 23 | { 24 | label: 'Dataset 2', 25 | data: [ 26 | randomValues(100, 60, 100, [5, 10]), 27 | randomValues(100, 0, 100), 28 | randomValues(100, 0, 20), 29 | randomValues(100, 20, 40), 30 | ], 31 | }, 32 | ], 33 | }; 34 | // #endregion data 35 | // #region config 36 | export const config: ChartConfiguration<'boxplot'> = { 37 | type: 'boxplot', 38 | data, 39 | }; 40 | // #endregion config 41 | -------------------------------------------------------------------------------- /docs/examples/datalimits.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Data Limits 3 | --- 4 | 5 | # Data Limits 6 | 7 | You can customize the scale limit the the minimal and maximal values independently. There are three common choices: 8 | 9 | - data limits (default) ... the minimal and maximal values of the data are the scale limits 10 | - whiskers ... the minimal and maximal values are the whisker endpoints 11 | - box ... the minimal and maximal values are the box endpoints q1 (25% quantile) and q3 (75% quantile) 12 | 13 | 16 | 17 | ## Data Limits 18 | 19 | 23 | 24 | ### Code 25 | 26 | :::code-group 27 | 28 | <<< ./datalimits.ts#minmax [config] 29 | 30 | <<< ./boxplot.ts#data [data] 31 | 32 | ::: 33 | 34 | ## Whiskers 35 | 36 | 40 | 41 | ### Code 42 | 43 | :::code-group 44 | 45 | <<< ./datalimits.ts#whiskers [config] 46 | 47 | <<< ./boxplot.ts#data [data] 48 | 49 | ::: 50 | 51 | ## Box 52 | 53 | 57 | 58 | ### Code 59 | 60 | :::code-group 61 | 62 | <<< ./datalimits.ts#box [config] 63 | 64 | <<< ./boxplot.ts#data [data] 65 | 66 | ::: 67 | -------------------------------------------------------------------------------- /docs/examples/datalimits.ts: -------------------------------------------------------------------------------- 1 | import type { ChartConfiguration } from 'chart.js'; 2 | import type {} from '../../src'; 3 | import { data } from './boxplot'; 4 | 5 | // #region minmax 6 | export const minmax: ChartConfiguration<'boxplot'> = { 7 | type: 'boxplot', 8 | data, 9 | options: { 10 | minStats: 'min', 11 | maxStats: 'max', 12 | }, 13 | }; 14 | // #endregion minmax 15 | 16 | // #region whiskers 17 | export const whiskers: ChartConfiguration<'boxplot'> = { 18 | type: 'boxplot', 19 | data, 20 | options: { 21 | minStats: 'whiskerMin', 22 | maxStats: 'whiskerMax', 23 | }, 24 | }; 25 | // #endregion whiskers 26 | 27 | // #region box 28 | export const box: ChartConfiguration<'boxplot'> = { 29 | type: 'boxplot', 30 | data, 31 | options: { 32 | minStats: 'q1', 33 | maxStats: 'q3', 34 | }, 35 | }; 36 | // #endregion box 37 | -------------------------------------------------------------------------------- /docs/examples/datastructures.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Data Structures 3 | --- 4 | 5 | # Data Structures 6 | 7 | BoxPlot and Violin Plots can be defined in two ways: Either given a raw array of values or as an object of precomputed values. 8 | 9 | 13 | 14 | ## BoxPlot 15 | 16 | 20 | 21 | ### Code 22 | 23 | :::code-group 24 | 25 | <<< ./datastructures.ts#config [config] 26 | 27 | <<< ./datastructures.ts#data [data] 28 | 29 | ::: 30 | 31 | ## Violin Plot 32 | 33 | 37 | 38 | ### Code 39 | 40 | :::code-group 41 | 42 | <<< ./datastructuresViolin.ts#config [config] 43 | 44 | <<< ./datastructuresViolin.ts#data [data] 45 | 46 | ::: 47 | -------------------------------------------------------------------------------- /docs/examples/datastructures.ts: -------------------------------------------------------------------------------- 1 | import type { ChartConfiguration } from 'chart.js'; 2 | import type {} from '../../src'; 3 | 4 | // #region data 5 | 6 | const data: ChartConfiguration<'boxplot'>['data'] = { 7 | labels: ['array', '{boxplot values}', 'with items', 'as outliers'], 8 | datasets: [ 9 | { 10 | label: 'Dataset 1', 11 | borderWidth: 1, 12 | itemRadius: 2, 13 | itemStyle: 'circle', 14 | itemBackgroundColor: '#000', 15 | outlierBackgroundColor: '#000', 16 | data: [ 17 | [1, 2, 3, 4, 5, 11], 18 | { 19 | min: 1, 20 | q1: 2, 21 | median: 3, 22 | q3: 4, 23 | max: 5, 24 | }, 25 | { 26 | min: 1, 27 | q1: 2, 28 | median: 3, 29 | q3: 4, 30 | max: 5, 31 | items: [1, 2, 3, 4, 5], 32 | }, 33 | { 34 | min: 1, 35 | q1: 2, 36 | median: 3, 37 | q3: 4, 38 | max: 5, 39 | outliers: [11], 40 | }, 41 | ], 42 | }, 43 | ], 44 | }; 45 | // #endregion data 46 | 47 | // #region config 48 | export const config: ChartConfiguration<'boxplot'> = { 49 | type: 'boxplot', 50 | data, 51 | }; 52 | // #endregion config 53 | -------------------------------------------------------------------------------- /docs/examples/datastructuresViolin.ts: -------------------------------------------------------------------------------- 1 | import type { ChartConfiguration } from 'chart.js'; 2 | import type {} from '../../src'; 3 | 4 | // #region data 5 | 6 | const data: ChartConfiguration<'violin'>['data'] = { 7 | labels: ['array', '{violin values}', 'with items', 'as outliers'], 8 | datasets: [ 9 | { 10 | label: 'Dataset 1', 11 | borderWidth: 1, 12 | itemRadius: 2, 13 | itemStyle: 'circle', 14 | itemBackgroundColor: '#000', 15 | outlierBackgroundColor: '#000', 16 | data: [ 17 | [1, 2, 3, 4, 5, 11], 18 | { 19 | median: 4, 20 | maxEstimate: 10, 21 | coords: [ 22 | { v: 0, estimate: 0 }, 23 | { v: 2, estimate: 10 }, 24 | { v: 4, estimate: 6 }, 25 | { v: 6, estimate: 7 }, 26 | { v: 8, estimate: 0 }, 27 | ], 28 | }, 29 | { 30 | median: 4, 31 | maxEstimate: 10, 32 | coords: [ 33 | { v: 0, estimate: 0 }, 34 | { v: 2, estimate: 10 }, 35 | { v: 4, estimate: 6 }, 36 | { v: 6, estimate: 7 }, 37 | { v: 8, estimate: 0 }, 38 | ], 39 | items: [1, 2, 3, 4, 5], 40 | }, 41 | { 42 | median: 4, 43 | maxEstimate: 10, 44 | coords: [ 45 | { v: 0, estimate: 0 }, 46 | { v: 2, estimate: 10 }, 47 | { v: 4, estimate: 6 }, 48 | { v: 6, estimate: 7 }, 49 | { v: 8, estimate: 0 }, 50 | ], 51 | outliers: [11], 52 | }, 53 | ], 54 | }, 55 | ], 56 | }; 57 | // #endregion data 58 | 59 | // #region config 60 | export const config: ChartConfiguration<'violin'> = { 61 | type: 'violin', 62 | data, 63 | }; 64 | // #endregion config 65 | -------------------------------------------------------------------------------- /docs/examples/fivenum.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Quantile Computation 3 | --- 4 | 5 | # Quantile Computation 6 | 7 | Quantiles can be computed in different ways. Different statistical software has different logic by default. 8 | 9 | see [quantiles](/api/interfaces/IBaseOptions.html#quantiles) option for available options 10 | 11 | 14 | 15 | ## Quantiles (Type 7) 16 | 17 | 21 | 22 | ### Code 23 | 24 | :::code-group 25 | 26 | <<< ./fivenum.ts#config [config] 27 | 28 | <<< ./fivenum.ts#data [data] 29 | 30 | ::: 31 | 32 | ## Fivenum 33 | 34 | 38 | 39 | ### Code 40 | 41 | :::code-group 42 | 43 | <<< ./fivenum.ts#fivenum [config] 44 | 45 | <<< ./fivenum.ts#fivenum [data] 46 | 47 | ::: 48 | -------------------------------------------------------------------------------- /docs/examples/fivenum.ts: -------------------------------------------------------------------------------- 1 | import type { ChartConfiguration } from 'chart.js'; 2 | import type {} from '../../src'; 3 | 4 | // #region data 5 | export const data: ChartConfiguration<'boxplot'>['data'] = { 6 | labels: ['A'], 7 | datasets: [ 8 | { 9 | itemRadius: 2, 10 | borderColor: 'black', 11 | data: [[18882.492, 7712.077, 5830.748, 7206.05]], 12 | }, 13 | ], 14 | }; 15 | // #endregion data 16 | // #region config 17 | export const configType7: ChartConfiguration<'boxplot'> = { 18 | type: 'boxplot', 19 | data, 20 | options: { 21 | quantiles: 'quantiles', 22 | plugins: { 23 | legend: { 24 | display: false, 25 | }, 26 | }, 27 | }, 28 | }; 29 | // #endregion config 30 | // #region fivenum 31 | export const configFiveNum: ChartConfiguration<'boxplot'> = { 32 | type: 'boxplot', 33 | data, 34 | options: { 35 | quantiles: 'fivenum', 36 | plugins: { 37 | legend: { 38 | display: false, 39 | }, 40 | }, 41 | }, 42 | }; 43 | // #endregion fivenum 44 | -------------------------------------------------------------------------------- /docs/examples/horizontal.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Horizontal BoxPlot / Violin Plot 3 | --- 4 | 5 | # Horizontal 6 | 7 | 11 | 12 | ## BoxPlot 13 | 14 | 18 | 19 | ### Code 20 | 21 | :::code-group 22 | 23 | <<< ./horizontalBoxPlot.ts#config [config] 24 | 25 | <<< ./boxplot.ts#data [data] 26 | 27 | ::: 28 | 29 | ## Violin Plot 30 | 31 | 35 | 36 | ### Code 37 | 38 | :::code-group 39 | 40 | <<< ./horizontalViolin.ts#config [config] 41 | 42 | <<< ./violin.ts#data [data] 43 | 44 | ::: 45 | -------------------------------------------------------------------------------- /docs/examples/horizontalBoxPlot.ts: -------------------------------------------------------------------------------- 1 | import type { ChartConfiguration } from 'chart.js'; 2 | import type {} from '../../src'; 3 | import { data } from './boxplot'; 4 | 5 | // #region config 6 | export const config: ChartConfiguration<'boxplot'> = { 7 | type: 'boxplot', 8 | data, 9 | options: { 10 | indexAxis: 'y', 11 | }, 12 | }; 13 | // #endregion config 14 | -------------------------------------------------------------------------------- /docs/examples/horizontalViolin.ts: -------------------------------------------------------------------------------- 1 | import type { ChartConfiguration } from 'chart.js'; 2 | import type {} from '../../src'; 3 | import { data } from './violin'; 4 | 5 | // #region config 6 | export const config: ChartConfiguration<'violin'> = { 7 | type: 'violin', 8 | data, 9 | options: { 10 | indexAxis: 'y', 11 | }, 12 | }; 13 | // #endregion config 14 | -------------------------------------------------------------------------------- /docs/examples/hybrid.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Hybrid Chart 3 | --- 4 | 5 | # Hybrid Chart 6 | 7 | 10 | 11 | 15 | 16 | ### Code 17 | 18 | :::code-group 19 | 20 | <<< ./hybrid.ts#config [config] 21 | 22 | <<< ./hybrid.ts#data [data] 23 | 24 | ::: 25 | -------------------------------------------------------------------------------- /docs/examples/hybrid.ts: -------------------------------------------------------------------------------- 1 | import type { ChartConfiguration } from 'chart.js'; 2 | import type {} from '../../src'; 3 | 4 | // #region data 5 | function randomValues(count: number, min: number, max: number, extra: number[] = []): number[] { 6 | const delta = max - min; 7 | return [...Array.from({ length: count }).map(() => Math.random() * delta + min), ...extra]; 8 | } 9 | 10 | const data: ChartConfiguration<'boxplot'>['data'] = { 11 | labels: ['A', 'B', 'C'], 12 | datasets: [ 13 | { 14 | label: 'Box', 15 | type: 'boxplot', 16 | data: [randomValues(100, 0, 100), randomValues(100, 0, 20, [110]), randomValues(100, 20, 70)], 17 | }, 18 | { 19 | label: 'Bar', 20 | type: 'bar', 21 | data: randomValues(3, 0, 150), 22 | }, 23 | { 24 | label: 'Line', 25 | type: 'line', 26 | data: randomValues(3, 0, 150), 27 | }, 28 | ], 29 | }; 30 | // #endregion data 31 | 32 | // #region config 33 | export const config: ChartConfiguration<'boxplot'> = { 34 | type: 'boxplot', 35 | data, 36 | }; 37 | // #endregion config 38 | -------------------------------------------------------------------------------- /docs/examples/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Examples 3 | --- 4 | 5 | # Examples 6 | 7 | 11 | 12 | ## BoxPlot 13 | 14 | 18 | 19 | ### Code 20 | 21 | :::code-group 22 | 23 | <<< ./boxplot.ts#config [config] 24 | 25 | <<< ./boxplot.ts#data [data] 26 | 27 | ::: 28 | 29 | ## Violin Plot 30 | 31 | 35 | 36 | ### Code 37 | 38 | :::code-group 39 | 40 | <<< ./violin.ts#config [config] 41 | 42 | <<< ./violin.ts#data [data] 43 | 44 | ::: 45 | -------------------------------------------------------------------------------- /docs/examples/items.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Data Points 3 | --- 4 | 5 | # Data Points 6 | 7 | the individual data points can be displayed by setting the `itemRadius` to a value larger than 0. They are jittered randomly to support larger quantities. 8 | 9 | 12 | 13 | 17 | 18 | ### Code 19 | 20 | :::code-group 21 | 22 | <<< ./items.ts#config [config] 23 | 24 | <<< ./boxplot.ts#data [data] 25 | 26 | ::: 27 | -------------------------------------------------------------------------------- /docs/examples/items.ts: -------------------------------------------------------------------------------- 1 | import type { ChartConfiguration } from 'chart.js'; 2 | import type {} from '../../src'; 3 | import { data } from './boxplot'; 4 | 5 | // #region config 6 | export const config: ChartConfiguration<'boxplot'> = { 7 | type: 'boxplot', 8 | data, 9 | options: { 10 | elements: { 11 | boxandwhiskers: { 12 | itemRadius: 2, 13 | itemHitRadius: 4, 14 | }, 15 | }, 16 | }, 17 | }; 18 | // #endregion config 19 | -------------------------------------------------------------------------------- /docs/examples/large_number.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Large Numbers 3 | --- 4 | 5 | # Large Numbers 6 | 7 | Float64 are used to handle large numbers when computing the box plot data. 8 | 9 | 12 | 13 | 17 | 18 | ### Code 19 | 20 | :::code-group 21 | 22 | <<< ./large_number.ts#config [config] 23 | 24 | <<< ./large_number.ts#data [data] 25 | 26 | ::: 27 | -------------------------------------------------------------------------------- /docs/examples/large_number.ts: -------------------------------------------------------------------------------- 1 | import type { ChartConfiguration } from 'chart.js'; 2 | import type {} from '../../src'; 3 | 4 | // #region data 5 | export const data: ChartConfiguration<'boxplot'>['data'] = { 6 | labels: ['A'], 7 | datasets: [ 8 | { 9 | itemRadius: 2, 10 | data: [[57297214, 57297216, 117540924, 117540928]], 11 | }, 12 | ], 13 | }; 14 | // #endregion data 15 | // #region config 16 | export const config: ChartConfiguration<'boxplot'> = { 17 | type: 'boxplot', 18 | data, 19 | options: { 20 | plugins: { 21 | legend: { 22 | display: false, 23 | }, 24 | }, 25 | }, 26 | }; 27 | // #endregion config 28 | -------------------------------------------------------------------------------- /docs/examples/logarithm.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Logarithmic Scale 3 | --- 4 | 5 | # Logarithmic Scale 6 | 7 | 10 | 11 | 15 | 16 | ### Code 17 | 18 | :::code-group 19 | 20 | <<< ./logarithm.ts#config [config] 21 | 22 | <<< ./boxplot.ts#data [data] 23 | 24 | ::: 25 | -------------------------------------------------------------------------------- /docs/examples/logarithm.ts: -------------------------------------------------------------------------------- 1 | import type { ChartConfiguration } from 'chart.js'; 2 | import type {} from '../../src'; 3 | import { data } from './boxplot'; 4 | 5 | // #region config 6 | export const config: ChartConfiguration<'boxplot'> = { 7 | type: 'boxplot', 8 | data, 9 | options: { 10 | scales: { 11 | y: { 12 | type: 'logarithmic', 13 | }, 14 | }, 15 | }, 16 | }; 17 | // #endregion config 18 | -------------------------------------------------------------------------------- /docs/examples/styling.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Styling 3 | --- 4 | 5 | # Styling 6 | 7 | BoxPlot and Violin plots support various styling options. see ['IBoxAndWhiskerOptions'](/api/interfaces/IBoxAndWhiskersOptions) 8 | 9 | 12 | 13 | 17 | 18 | ### Code 19 | 20 | :::code-group 21 | 22 | <<< ./styling.ts#config [config] 23 | 24 | <<< ./styling.ts#data [data] 25 | 26 | ::: 27 | -------------------------------------------------------------------------------- /docs/examples/styling.ts: -------------------------------------------------------------------------------- 1 | import type { ChartConfiguration } from 'chart.js'; 2 | import type {} from '../../src'; 3 | 4 | // #region data 5 | function randomValues(count: number, min: number, max: number, extra: number[] = []): number[] { 6 | const delta = max - min; 7 | return [...Array.from({ length: count }).map(() => Math.random() * delta + min), ...extra]; 8 | } 9 | 10 | export const data: ChartConfiguration<'boxplot'>['data'] = { 11 | labels: ['A'], 12 | datasets: [ 13 | { 14 | borderColor: 'green', 15 | medianColor: 'blue', 16 | borderWidth: 1, 17 | outlierRadius: 3, 18 | itemRadius: 3, 19 | lowerBackgroundColor: 'lightblue', 20 | outlierBackgroundColor: '#999999', 21 | data: [randomValues(100, 1, 9, [14, 16, 0]), randomValues(100, 0, 10)], 22 | }, 23 | ], 24 | }; 25 | // #endregion data 26 | // #region config 27 | export const config: ChartConfiguration<'boxplot'> = { 28 | type: 'boxplot', 29 | data, 30 | options: { 31 | plugins: { 32 | legend: { 33 | display: false, 34 | }, 35 | }, 36 | }, 37 | }; 38 | // #endregion config 39 | -------------------------------------------------------------------------------- /docs/examples/violin.ts: -------------------------------------------------------------------------------- 1 | import type { ChartConfiguration } from 'chart.js'; 2 | import type {} from '../../src'; 3 | 4 | // #region data 5 | export function randomValues(count: number, min: number, max: number, extra: number[] = []): number[] { 6 | const delta = max - min; 7 | return [...Array.from({ length: count }).map(() => Math.random() * delta + min), ...extra]; 8 | } 9 | 10 | export const data: ChartConfiguration<'violin'>['data'] = { 11 | labels: ['A', 'B', 'C', 'D'], 12 | datasets: [ 13 | { 14 | label: 'Dataset 1', 15 | data: [ 16 | randomValues(100, 0, 100), 17 | randomValues(100, 0, 20, [80]), 18 | randomValues(100, 20, 70), 19 | // empty data 20 | [null as unknown as number], 21 | ], 22 | }, 23 | { 24 | label: 'Dataset 2', 25 | data: [ 26 | randomValues(100, 60, 100, [5, 10]), 27 | randomValues(100, 0, 100), 28 | randomValues(100, 0, 20), 29 | randomValues(100, 20, 40), 30 | ], 31 | }, 32 | ], 33 | }; 34 | // #endregion data 35 | // #region config 36 | export const config: ChartConfiguration<'violin'> = { 37 | type: 'violin', 38 | data, 39 | }; 40 | // #endregion config 41 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Getting Started 3 | --- 4 | 5 | Chart.js module for charting box and violin plots. This is a maintained fork of [@datavisyn/chartjs-chart-box-and-violin-plot](https://github.com/datavisyn/chartjs-chart-box-and-violin-plot), which I originally developed during my time at datavisyn. 6 | 7 | ![Box Plot](https://user-images.githubusercontent.com/4129778/42724341-9a6ec554-8770-11e8-99b5-626e34dafdb3.png) 8 | ![Violin Plot](https://user-images.githubusercontent.com/4129778/42724342-9a8dbb58-8770-11e8-9a30-3e69d07d3b79.png) 9 | 10 | ## Install 11 | 12 | ```sh 13 | npm install chart.js @sgratzl/chartjs-chart-boxplot 14 | ``` 15 | 16 | ## Usage 17 | 18 | see [Examples](./examples/) 19 | 20 | and [CodePen](https://codepen.io/sgratzl/pen/QxoLoY) 21 | 22 | ## Configuration 23 | 24 | ### Data Structure 25 | 26 | see [DataStructures](./examples/datastructures) 27 | 28 | ### Styling 29 | 30 | see [IBoxAndWhiskersOptions](/api/interfaces/IBoxAndWhiskersOptions.md), [IViolinElementOptions](/api/type-aliases/IViolinElementOptions.md), and [Styling](./examples/styling) 31 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: 'chartjs-chart-boxplot' 7 | text: 'chart.js plugin' 8 | tagline: Chart.js module for charting boxplots and violin charts 9 | actions: 10 | - theme: brand 11 | text: Getting Started 12 | link: /getting-started 13 | - theme: alt 14 | text: Examples 15 | link: /examples/ 16 | - theme: alt 17 | text: API 18 | link: /api/ 19 | # features: 20 | # - title: Feature A 21 | # details: Lorem ipsum dolor sit amet, consectetur adipiscing elit 22 | --- 23 | -------------------------------------------------------------------------------- /docs/related.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Related Plugins 3 | --- 4 | 5 | There are several related chart.js plugins providing additional functionality and chart types: 6 | 7 | - [chartjs-chart-boxplot](https://github.com/sgratzl/chartjs-chart-boxplot) for rendering boxplots and violin charts 8 | - [chartjs-chart-error-bars](https://github.com/sgratzl/chartjs-chart-error-bars) for rendering errors bars to bars and line charts 9 | - [chartjs-chart-funnel](https://github.com/sgratzl/chartjs-chart-funnel) for rendering funnel charts 10 | - [chartjs-chart-geo](https://github.com/sgratzl/chartjs-chart-geo) for rendering map, bubble maps, and choropleth charts 11 | - [chartjs-chart-graph](https://github.com/sgratzl/chartjs-chart-graph) for rendering graphs, trees, and networks 12 | - [chartjs-chart-pcp](https://github.com/sgratzl/chartjs-chart-pcp) for rendering parallel coordinate plots 13 | - [chartjs-chart-venn](https://github.com/sgratzl/chartjs-chart-venn) for rendering venn and euler diagrams 14 | - [chartjs-chart-wordcloud](https://github.com/sgratzl/chartjs-chart-wordcloud) for rendering word clouds 15 | - [chartjs-plugin-hierarchical](https://github.com/sgratzl/chartjs-plugin-hierarchical) for rendering hierarchical categorical axes which can be expanded and collapsed 16 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from '@eslint/js'; 4 | import tseslint from 'typescript-eslint'; 5 | import prettier from 'eslint-plugin-prettier'; 6 | 7 | export default tseslint.config(eslint.configs.recommended, ...tseslint.configs.recommended, { 8 | plugins: { prettier }, 9 | rules: { 10 | '@typescript-eslint/no-explicit-any': 'off', 11 | 'max-classes-per-file': 'off', 12 | 'no-underscore-dangle': 'off', 13 | 'import/extensions': 'off', 14 | }, 15 | }); 16 | 17 | // import path from "node:path"; 18 | // import { fileURLToPath } from "node:url"; 19 | // import js from "@eslint/js"; 20 | // import { FlatCompat } from "@eslint/eslintrc"; 21 | 22 | // const __filename = fileURLToPath(import.meta.url); 23 | // const __dirname = path.dirname(__filename); 24 | // const compat = new FlatCompat({ 25 | // baseDirectory: __dirname, 26 | // recommendedConfig: js.configs.recommended, 27 | // allConfig: js.configs.all 28 | // }); 29 | 30 | // export default [...fixupConfigRules(compat.extends( 31 | // "airbnb-typescript", 32 | // "react-app", 33 | // "plugin:prettier/recommended", 34 | // "prettier", 35 | // )), { 36 | // plugins: { 37 | // prettier: fixupPluginRules(prettier), 38 | // }, 39 | 40 | // languageOptions: { 41 | // ecmaVersion: 5, 42 | // sourceType: "script", 43 | 44 | // parserOptions: { 45 | // project: "./tsconfig.eslint.json", 46 | // }, 47 | // }, 48 | 49 | // settings: { 50 | // react: { 51 | // version: "99.99.99", 52 | // }, 53 | // }, 54 | 55 | // rules: { 56 | // "@typescript-eslint/no-explicit-any": "off", 57 | // "max-classes-per-file": "off", 58 | // "no-underscore-dangle": "off", 59 | // "import/extensions": "off", 60 | // }, 61 | // }]; 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sgratzl/chartjs-chart-boxplot", 3 | "description": "Chart.js module for charting boxplots and violin charts", 4 | "version": "4.4.4", 5 | "publishConfig": { 6 | "access": "public" 7 | }, 8 | "author": { 9 | "name": "Samuel Gratzl", 10 | "email": "sam@sgratzl.com", 11 | "url": "https://www.sgratzl.com" 12 | }, 13 | "contributors": [ 14 | { 15 | "name": "datavisyn", 16 | "email": "contact@datavisyn.io", 17 | "url": "https://www.datavisyn.io" 18 | }, 19 | { 20 | "name": "Stefan Luger", 21 | "email": "stefan.luger@datavisyn.io", 22 | "url": "https://github.com/sluger" 23 | } 24 | ], 25 | "license": "MIT", 26 | "homepage": "https://github.com/sgratzl/chartjs-chart-boxplot", 27 | "bugs": { 28 | "url": "https://github.com/sgratzl/chartjs-chart-boxplot/issues" 29 | }, 30 | "keywords": [ 31 | "chart.js", 32 | "boxplot", 33 | "violin" 34 | ], 35 | "repository": { 36 | "type": "git", 37 | "url": "https://github.com/sgratzl/chartjs-chart-boxplot.git" 38 | }, 39 | "global": "ChartBoxPlot", 40 | "type": "module", 41 | "main": "build/index.js", 42 | "module": "build/index.js", 43 | "require": "build/index.cjs", 44 | "umd": "build/index.umd.js", 45 | "unpkg": "build/index.umd.min.js", 46 | "jsdelivr": "build/index.umd.min.js", 47 | "types": "build/index.d.ts", 48 | "exports": { 49 | ".": { 50 | "import": "./build/index.js", 51 | "require": "./build/index.cjs", 52 | "scripts": "./build/index.umd.min.js", 53 | "types": "./build/index.d.ts" 54 | } 55 | }, 56 | "sideEffects": false, 57 | "files": [ 58 | "build", 59 | "src/**/*.ts", 60 | "src/**/*.tsx" 61 | ], 62 | "peerDependencies": { 63 | "chart.js": "^4.1.1" 64 | }, 65 | "browserslist": [ 66 | "Firefox ESR", 67 | "last 2 Chrome versions", 68 | "last 2 Firefox versions" 69 | ], 70 | "dependencies": { 71 | "@sgratzl/boxplots": "^2.0.0" 72 | }, 73 | "devDependencies": { 74 | "@chiogen/rollup-plugin-terser": "^7.1.3", 75 | "@eslint/js": "~9.14.0", 76 | "@rollup/plugin-commonjs": "^28.0.1", 77 | "@rollup/plugin-node-resolve": "^15.3.0", 78 | "@rollup/plugin-replace": "^6.0.1", 79 | "@rollup/plugin-typescript": "^12.1.1", 80 | "@types/jest-image-snapshot": "^6.4.0", 81 | "@types/node": "^22.9.0", 82 | "@yarnpkg/sdks": "^3.2.0", 83 | "canvas": "^2.11.2", 84 | "canvas-5-polyfill": "^0.1.5", 85 | "chart.js": "^4.4.6", 86 | "eslint": "~9.14.0", 87 | "eslint-plugin-prettier": "^5.2.1", 88 | "jest-image-snapshot": "^6.4.0", 89 | "jsdom": "^25.0.1", 90 | "prettier": "^3.3.3", 91 | "rimraf": "^6.0.1", 92 | "rollup": "^4.27.2", 93 | "rollup-plugin-cleanup": "^3.2.1", 94 | "rollup-plugin-dts": "^6.1.1", 95 | "ts-jest": "^29.2.5", 96 | "tslib": "^2.8.1", 97 | "typedoc": "^0.26.11", 98 | "typedoc-plugin-markdown": "^4.2.10", 99 | "typedoc-vitepress-theme": "^1.0.2", 100 | "typescript": "^5.6.3", 101 | "typescript-eslint": "^8.14.0", 102 | "vite": "^5.4.11", 103 | "vitepress": "^1.5.0", 104 | "vitest": "^2.1.5", 105 | "vue": "^3.5.13", 106 | "vue-chartjs": "^5.3.2" 107 | }, 108 | "scripts": { 109 | "clean": "rimraf --glob build node_modules \"*.tgz\" \"*.tsbuildinfo\"", 110 | "compile": "tsc -b tsconfig.c.json", 111 | "start": "yarn run watch", 112 | "watch": "rollup -c -w", 113 | "build": "rollup -c", 114 | "test": "vitest --passWithNoTests", 115 | "test:watch": "yarn run test --watch", 116 | "test:coverage": "yarn run test --coverage", 117 | "lint": "yarn run eslint && yarn run prettier", 118 | "fix": "yarn run eslint:fix && yarn run prettier:write", 119 | "prettier:write": "prettier \"*\" \"*/**\" --write", 120 | "prettier": "prettier \"*\" \"*/**\" --check", 121 | "eslint": "eslint src --cache", 122 | "eslint:fix": "yarn run eslint --fix", 123 | "prepare": "yarn run build", 124 | "docs:api": "typedoc --options typedoc.json", 125 | "docs:dev": "vitepress dev docs", 126 | "docs:build": "yarn run docs:api && vitepress build docs", 127 | "docs:preview": "vitepress preview docs" 128 | }, 129 | "packageManager": "yarn@4.5.1" 130 | } 131 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import commonjs from '@rollup/plugin-commonjs'; 2 | import resolve from '@rollup/plugin-node-resolve'; 3 | import cleanup from 'rollup-plugin-cleanup'; 4 | import dts from 'rollup-plugin-dts'; 5 | import typescript from '@rollup/plugin-typescript'; 6 | import { terser } from '@chiogen/rollup-plugin-terser'; 7 | import replace from '@rollup/plugin-replace'; 8 | 9 | import fs from 'fs'; 10 | 11 | const pkg = JSON.parse(fs.readFileSync('./package.json')); 12 | 13 | function resolveYear() { 14 | // Extract copyrights from the LICENSE. 15 | const license = fs.readFileSync('./LICENSE', 'utf-8').toString(); 16 | const matches = Array.from(license.matchAll(/\(c\) (\d+-\d+)/gm)); 17 | if (!matches || matches.length === 0) { 18 | return 2021; 19 | } 20 | return matches[matches.length - 1][1]; 21 | } 22 | const year = resolveYear(); 23 | 24 | const banner = `/** 25 | * ${pkg.name} 26 | * ${pkg.homepage} 27 | * 28 | * Copyright (c) ${year} ${pkg.author.name} <${pkg.author.email}> 29 | */ 30 | `; 31 | 32 | /** 33 | * defines which formats (umd, esm, cjs, types) should be built when watching 34 | */ 35 | const watchOnly = ['umd']; 36 | 37 | const isDependency = (v) => Object.keys(pkg.dependencies || {}).some((e) => e === v || v.startsWith(`${e}/`)); 38 | const isPeerDependency = (v) => Object.keys(pkg.peerDependencies || {}).some((e) => e === v || v.startsWith(`${e}/`)); 39 | 40 | export default function Config(options) { 41 | const buildFormat = (format) => { 42 | return !options.watch || watchOnly.includes(format); 43 | }; 44 | const commonOutput = { 45 | sourcemap: true, 46 | banner, 47 | globals: { 48 | 'chart.js': 'Chart', 49 | 'chart.js/helpers': 'Chart.helpers', 50 | }, 51 | }; 52 | 53 | const base = { 54 | input: './src/index.ts', 55 | external: (v) => isDependency(v) || isPeerDependency(v), 56 | plugins: [ 57 | typescript(), 58 | resolve({ 59 | mainFields: ['module', 'main'], 60 | extensions: ['.mjs', '.cjs', '.js', '.jsx', '.json', '.node'], 61 | modulesOnly: true, 62 | }), 63 | commonjs(), 64 | replace({ 65 | preventAssignment: true, 66 | values: { 67 | // eslint-disable-next-line no-undef 68 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV) || 'production', 69 | __VERSION__: JSON.stringify(pkg.version), 70 | }, 71 | }), 72 | cleanup({ 73 | comments: ['some', 'ts', 'ts3s'], 74 | extensions: ['ts', 'tsx', 'js', 'jsx'], 75 | include: './src/**/*', 76 | }), 77 | ], 78 | }; 79 | return [ 80 | buildFormat('esm') && { 81 | ...base, 82 | output: { 83 | ...commonOutput, 84 | file: pkg.module, 85 | format: 'esm', 86 | }, 87 | }, 88 | buildFormat('cjs') && { 89 | ...base, 90 | output: { 91 | ...commonOutput, 92 | file: pkg.require, 93 | format: 'cjs', 94 | }, 95 | external: (v) => (isDependency(v) || isPeerDependency(v)) && ['d3-'].every((di) => !v.includes(di)), 96 | }, 97 | (buildFormat('umd') || buildFormat('umd-min')) && { 98 | ...base, 99 | input: './src/index.umd.ts', 100 | output: [ 101 | buildFormat('umd') && { 102 | ...commonOutput, 103 | file: pkg.umd, 104 | format: 'umd', 105 | name: pkg.global, 106 | }, 107 | buildFormat('umd-min') && { 108 | ...commonOutput, 109 | file: pkg.unpkg, 110 | format: 'umd', 111 | name: pkg.global, 112 | plugins: [terser()], 113 | }, 114 | ].filter(Boolean), 115 | external: (v) => isPeerDependency(v), 116 | }, 117 | buildFormat('types') && { 118 | ...base, 119 | output: { 120 | ...commonOutput, 121 | file: pkg.types, 122 | format: 'es', 123 | }, 124 | plugins: [ 125 | dts({ 126 | respectExternal: true, 127 | compilerOptions: { 128 | skipLibCheck: true, 129 | skipDefaultLibCheck: true, 130 | }, 131 | }), 132 | ], 133 | }, 134 | ].filter(Boolean); 135 | } 136 | -------------------------------------------------------------------------------- /src/__tests__/createChart.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { expect } from 'vitest'; 4 | import { Chart, ChartConfiguration, defaults, ChartType, DefaultDataPoint } from 'chart.js'; 5 | import { toMatchImageSnapshot, MatchImageSnapshotOptions } from 'jest-image-snapshot'; 6 | import 'canvas-5-polyfill'; 7 | 8 | expect.extend({ toMatchImageSnapshot }); 9 | 10 | function toBuffer(canvas: HTMLCanvasElement) { 11 | return new Promise((resolve) => { 12 | canvas.toBlob((b) => { 13 | const file = new FileReader(); 14 | file.onload = () => resolve(Buffer.from(file.result as ArrayBuffer)); 15 | 16 | file.readAsArrayBuffer(b!); 17 | }); 18 | }); 19 | } 20 | 21 | export async function expectMatchSnapshot(canvas: HTMLCanvasElement): Promise { 22 | const image = await toBuffer(canvas); 23 | expect(image).toMatchImageSnapshot(); 24 | } 25 | 26 | export interface ChartHelper, LABEL = string> { 27 | chart: Chart; 28 | canvas: HTMLCanvasElement; 29 | ctx: CanvasRenderingContext2D; 30 | toMatchImageSnapshot(options?: MatchImageSnapshotOptions): Promise; 31 | } 32 | 33 | export default function createChart< 34 | TYPE extends ChartType, 35 | DATA extends unknown[] = DefaultDataPoint, 36 | LABEL = string, 37 | >(config: ChartConfiguration, width = 800, height = 600): ChartHelper { 38 | const canvas = document.createElement('canvas'); 39 | canvas.width = width; 40 | canvas.height = height; 41 | Object.assign(defaults.font, { family: 'Courier New' }); 42 | // defaults.color = 'transparent'; 43 | 44 | config.options = { 45 | responsive: false, 46 | animation: { 47 | duration: 1, 48 | }, 49 | plugins: { 50 | legend: { 51 | display: false, 52 | }, 53 | title: { 54 | display: false, 55 | }, 56 | }, 57 | ...(config.options || {}), 58 | } as any; 59 | 60 | const ctx = canvas.getContext('2d')!; 61 | 62 | const t = new Chart(ctx, config); 63 | 64 | return { 65 | chart: t, 66 | canvas, 67 | ctx, 68 | async toMatchImageSnapshot(options?: MatchImageSnapshotOptions) { 69 | await new Promise((resolve) => setTimeout(resolve, 100)); 70 | 71 | const image = await toBuffer(canvas); 72 | expect(image).toMatchImageSnapshot(options); 73 | }, 74 | }; 75 | } 76 | -------------------------------------------------------------------------------- /src/animation.ts: -------------------------------------------------------------------------------- 1 | import type { IKDEPoint } from './data'; 2 | 3 | const interpolators = { 4 | number(from: number | undefined | null, to: number | undefined | null, factor: number) { 5 | if (from === to) { 6 | return to; 7 | } 8 | if (from == null) { 9 | return to; 10 | } 11 | if (to == null) { 12 | return from; 13 | } 14 | return from + (to - from) * factor; 15 | }, 16 | }; 17 | 18 | export function interpolateNumberArray( 19 | from: number | number[], 20 | to: number | number[], 21 | factor: number 22 | ): number | null | undefined | (number | null | undefined)[] { 23 | if (typeof from === 'number' && typeof to === 'number') { 24 | return interpolators.number(from, to, factor); 25 | } 26 | if (Array.isArray(from) && Array.isArray(to)) { 27 | return to.map((t, i) => interpolators.number(from[i], t, factor)); 28 | } 29 | return to; 30 | } 31 | 32 | export function interpolateKdeCoords( 33 | from: IKDEPoint[], 34 | to: IKDEPoint[], 35 | factor: number 36 | ): { v: number | null | undefined; estimate: number | null | undefined }[] { 37 | if (Array.isArray(from) && Array.isArray(to)) { 38 | return to.map((t, i) => ({ 39 | v: interpolators.number(from[i] ? from[i].v : null, t.v, factor), 40 | estimate: interpolators.number(from[i] ? from[i].estimate : null, t.estimate, factor), 41 | })); 42 | } 43 | return to; 44 | } 45 | -------------------------------------------------------------------------------- /src/controllers/BoxPlotController.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | registry, 3 | BarController, 4 | LineController, 5 | PointElement, 6 | BarElement, 7 | LineElement, 8 | CategoryScale, 9 | LinearScale, 10 | LogarithmicScale, 11 | } from 'chart.js'; 12 | import { describe, beforeAll, test } from 'vitest'; 13 | import createChart from '../__tests__/createChart'; 14 | import { BoxPlotController, BoxPlotDataPoint } from './BoxPlotController'; 15 | import { Samples } from './__tests__/utils'; 16 | import { BoxAndWiskers } from '../elements'; 17 | 18 | const options = { 19 | options: { 20 | scales: { 21 | x: { 22 | display: false, 23 | }, 24 | y: { 25 | display: false, 26 | }, 27 | }, 28 | }, 29 | }; 30 | 31 | describe('boxplot', () => { 32 | beforeAll(() => { 33 | registry.addControllers(BoxPlotController, BarController, LineController); 34 | registry.addElements(BoxAndWiskers, PointElement, BarElement, LineElement); 35 | registry.addScales(CategoryScale, LinearScale, LogarithmicScale); 36 | }); 37 | 38 | test('default', () => { 39 | const samples = new Samples(10); 40 | 41 | const chart = createChart<'boxplot', BoxPlotDataPoint[]>({ 42 | type: BoxPlotController.id, 43 | data: { 44 | labels: samples.months({ count: 7 }), 45 | datasets: [ 46 | { 47 | label: 'Dataset 1', 48 | backgroundColor: 'red', 49 | borderWidth: 1, 50 | data: samples.boxplots({ count: 7 }), 51 | outlierBackgroundColor: '#999999', 52 | }, 53 | { 54 | label: 'Dataset 2', 55 | backgroundColor: 'blue', 56 | borderWidth: 1, 57 | data: samples.boxplotsArray({ count: 7 }), 58 | outlierBackgroundColor: '#999999', 59 | lowerBackgroundColor: '#461e7d', 60 | }, 61 | ], 62 | }, 63 | ...options, 64 | }); 65 | 66 | return chart.toMatchImageSnapshot(); 67 | }); 68 | 69 | test('minmax', () => { 70 | const chart = createChart({ 71 | type: BoxPlotController.id, 72 | data: { 73 | labels: ['A', 'B'], 74 | datasets: [ 75 | { 76 | label: 'Min = Max', 77 | data: [ 78 | [10, 20, 30, 40, 60, 80, 100], 79 | [20, 20, 20], 80 | ], 81 | }, 82 | ], 83 | }, 84 | ...options, 85 | }); 86 | 87 | return chart.toMatchImageSnapshot(); 88 | }); 89 | 90 | test('mediancolor', () => { 91 | const samples = new Samples(10); 92 | const chart = createChart({ 93 | type: BoxPlotController.id, 94 | data: { 95 | labels: ['A', 'B'], 96 | datasets: [ 97 | { 98 | label: 'Dataset 1', 99 | borderColor: 'green', 100 | medianColor: 'blue', 101 | borderWidth: 1, 102 | outlierRadius: 3, 103 | itemRadius: 3, 104 | outlierBackgroundColor: '#999999', 105 | data: [ 106 | samples.numbers({ count: 100, min: 1, max: 9 }).concat([14, 16, 0]), 107 | samples.numbers({ count: 100, min: 0, max: 10 }), 108 | ], 109 | }, 110 | ], 111 | }, 112 | ...options, 113 | }); 114 | 115 | return chart.toMatchImageSnapshot(); 116 | }); 117 | 118 | test('logarithmic', () => { 119 | const samples = new Samples(10); 120 | const chart = createChart({ 121 | type: BoxPlotController.id, 122 | data: { 123 | labels: samples.months({ count: 7 }), 124 | datasets: [ 125 | { 126 | label: 'Dataset 1', 127 | backgroundColor: 'red', 128 | borderWidth: 1, 129 | data: samples.boxplots({ count: 7 }), 130 | }, 131 | { 132 | label: 'Dataset 2', 133 | backgroundColor: 'blue', 134 | borderWidth: 1, 135 | data: samples.boxplots({ count: 7 }), 136 | }, 137 | ], 138 | }, 139 | options: { 140 | scales: { 141 | x: { 142 | display: false, 143 | }, 144 | y: { 145 | type: 'logarithmic', 146 | display: false, 147 | }, 148 | }, 149 | }, 150 | }); 151 | 152 | return chart.toMatchImageSnapshot(); 153 | }); 154 | 155 | test('items', () => { 156 | const samples = new Samples(10); 157 | 158 | const chart = createChart<'boxplot', BoxPlotDataPoint[]>({ 159 | type: BoxPlotController.id, 160 | data: { 161 | labels: samples.months({ count: 7 }), 162 | datasets: [ 163 | { 164 | label: 'Dataset 1', 165 | backgroundColor: 'red', 166 | borderWidth: 1, 167 | itemRadius: 2, 168 | data: samples.boxplots({ count: 7 }), 169 | outlierBackgroundColor: '#999999', 170 | }, 171 | { 172 | label: 'Dataset 2', 173 | backgroundColor: 'blue', 174 | borderWidth: 1, 175 | itemRadius: 2, 176 | data: samples.boxplotsArray({ count: 7 }), 177 | outlierBackgroundColor: '#999999', 178 | lowerBackgroundColor: '#461e7d', 179 | }, 180 | ], 181 | }, 182 | ...options, 183 | }); 184 | 185 | return chart.toMatchImageSnapshot(); 186 | }); 187 | 188 | test('hybrid', () => { 189 | const samples = new Samples(10); 190 | 191 | const chart = createChart({ 192 | type: BoxPlotController.id, 193 | data: { 194 | labels: samples.months({ count: 7 }), 195 | datasets: [ 196 | { 197 | label: 'Box', 198 | backgroundColor: 'steelblue', 199 | data: samples.boxplots({ count: 7 }), 200 | }, 201 | { 202 | label: 'Bar', 203 | type: 'bar', 204 | backgroundColor: 'red', 205 | data: samples.numbers({ count: 7, max: 150 }) as any, 206 | }, 207 | { 208 | label: 'Line', 209 | type: 'line', 210 | data: samples.numbers({ count: 7, max: 150 }) as any, 211 | }, 212 | ], 213 | }, 214 | ...options, 215 | }); 216 | 217 | return chart.toMatchImageSnapshot(); 218 | }); 219 | 220 | test('quantiles types 7', () => { 221 | const chart = createChart({ 222 | type: BoxPlotController.id, 223 | data: { 224 | labels: ['A'], 225 | datasets: [ 226 | { 227 | quantiles: 'quantiles', 228 | borderColor: 'black', 229 | data: [[18882.492, 7712.077, 5830.748, 7206.05]], 230 | }, 231 | ], 232 | }, 233 | ...options, 234 | }); 235 | 236 | return chart.toMatchImageSnapshot(); 237 | }); 238 | 239 | test('quantiles fivenum', () => { 240 | const chart = createChart({ 241 | type: BoxPlotController.id, 242 | data: { 243 | labels: ['A'], 244 | datasets: [{ quantiles: 'fivenum', borderColor: 'black', data: [[18882.492, 7712.077, 5830.748, 7206.05]] }], 245 | }, 246 | ...options, 247 | }); 248 | 249 | return chart.toMatchImageSnapshot(); 250 | }); 251 | 252 | test('datalimits', () => { 253 | const samples = new Samples(10); 254 | 255 | const chart = createChart<'boxplot', BoxPlotDataPoint[]>({ 256 | type: BoxPlotController.id, 257 | data: { 258 | labels: samples.months({ count: 7 }), 259 | datasets: [ 260 | { 261 | label: 'Dataset 1', 262 | minStats: 'min', 263 | maxStats: 'max', 264 | backgroundColor: 'red', 265 | borderWidth: 1, 266 | data: samples.boxplots({ count: 7 }), 267 | outlierBackgroundColor: '#999999', 268 | }, 269 | { 270 | label: 'Dataset 2', 271 | minStats: 'min', 272 | maxStats: 'max', 273 | backgroundColor: 'blue', 274 | borderWidth: 1, 275 | data: samples.boxplotsArray({ count: 7 }), 276 | outlierBackgroundColor: '#999999', 277 | }, 278 | ], 279 | }, 280 | ...options, 281 | }); 282 | 283 | return chart.toMatchImageSnapshot(); 284 | }); 285 | 286 | test('data structures', () => { 287 | const chart = createChart({ 288 | type: BoxPlotController.id, 289 | data: { 290 | labels: ['array', '{boxplot values}', 'with items', 'as outliers'], 291 | datasets: [ 292 | { 293 | label: 'Dataset 1', 294 | minStats: 'min', 295 | maxStats: 'max', 296 | backgroundColor: 'rgba(255,0,0,0.2)', 297 | borderColor: 'red', 298 | borderWidth: 1, 299 | itemRadius: 2, 300 | itemStyle: 'circle', 301 | itemBackgroundColor: '#000', 302 | outlierBackgroundColor: '#000', 303 | data: [ 304 | [1, 2, 3, 4, 5], 305 | { 306 | min: 1, 307 | q1: 2, 308 | median: 3, 309 | q3: 4, 310 | max: 5, 311 | }, 312 | { 313 | min: 1, 314 | q1: 2, 315 | median: 3, 316 | q3: 4, 317 | max: 5, 318 | items: [1, 2, 3, 4, 5], 319 | }, 320 | { 321 | min: 1, 322 | q1: 2, 323 | median: 3, 324 | q3: 4, 325 | max: 5, 326 | outliers: [1, 2, 3, 4, 5], 327 | }, 328 | ], 329 | }, 330 | ], 331 | }, 332 | ...options, 333 | }); 334 | 335 | return chart.toMatchImageSnapshot(); 336 | }); 337 | 338 | test('empty', () => { 339 | const samples = new Samples(10); 340 | const chart = createChart({ 341 | type: BoxPlotController.id, 342 | data: { 343 | labels: ['A', 'B'], 344 | datasets: [ 345 | { 346 | label: 'Dataset 1', 347 | minStats: 'min', 348 | maxStats: 'max', 349 | borderColor: 'red', 350 | borderWidth: 1, 351 | outlierRadius: 3, 352 | itemRadius: 3, 353 | outlierBackgroundColor: '#999999', 354 | data: [[], samples.numbers({ count: 100, min: 0, max: 10 })], 355 | }, 356 | ], 357 | }, 358 | ...options, 359 | }); 360 | 361 | return chart.toMatchImageSnapshot(); 362 | }); 363 | }); 364 | -------------------------------------------------------------------------------- /src/controllers/BoxPlotController.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Chart, 3 | BarController, 4 | ControllerDatasetOptions, 5 | ScriptableAndArrayOptions, 6 | CommonHoverOptions, 7 | ChartItem, 8 | ChartConfiguration, 9 | LinearScale, 10 | CategoryScale, 11 | AnimationOptions, 12 | ScriptableContext, 13 | CartesianScaleTypeRegistry, 14 | BarControllerDatasetOptions, 15 | } from 'chart.js'; 16 | import { merge } from 'chart.js/helpers'; 17 | import { asBoxPlotStats, IBoxPlot, IBoxplotOptions } from '../data'; 18 | import { baseDefaults, StatsBase, defaultOverrides } from './StatsBase'; 19 | import { BoxAndWiskers, IBoxAndWhiskersOptions } from '../elements'; 20 | import patchController from './patchController'; 21 | import { boxOptionsKeys } from '../elements/BoxAndWiskers'; 22 | 23 | export class BoxPlotController extends StatsBase> { 24 | /** 25 | * @hidden 26 | */ 27 | 28 | protected _parseStats(value: unknown, config: IBoxplotOptions): IBoxPlot | undefined { 29 | return asBoxPlotStats(value, config); 30 | } 31 | 32 | /** 33 | * @hidden 34 | */ 35 | 36 | protected _transformStats(target: any, source: IBoxPlot, mapper: (v: number) => T): void { 37 | super._transformStats(target, source, mapper); 38 | for (const key of ['whiskerMin', 'whiskerMax']) { 39 | target[key] = mapper(source[key as 'whiskerMin' | 'whiskerMax']); 40 | } 41 | } 42 | 43 | static readonly id = 'boxplot'; 44 | 45 | /** 46 | * @hidden 47 | */ 48 | static readonly defaults: any = /* #__PURE__ */ merge({}, [ 49 | BarController.defaults, 50 | baseDefaults(boxOptionsKeys), 51 | { 52 | animations: { 53 | numbers: { 54 | type: 'number', 55 | properties: (BarController.defaults as any).animations.numbers.properties.concat( 56 | ['q1', 'q3', 'min', 'max', 'median', 'whiskerMin', 'whiskerMax', 'mean'], 57 | boxOptionsKeys.filter((c) => !c.endsWith('Color')) 58 | ), 59 | }, 60 | }, 61 | dataElementType: BoxAndWiskers.id, 62 | }, 63 | ]); 64 | 65 | /** 66 | * @hidden 67 | */ 68 | static readonly overrides: any = /* #__PURE__ */ merge({}, [(BarController as any).overrides, defaultOverrides()]); 69 | } 70 | 71 | export interface BoxPlotControllerDatasetOptions 72 | extends ControllerDatasetOptions, 73 | Pick< 74 | BarControllerDatasetOptions, 75 | 'barPercentage' | 'barThickness' | 'categoryPercentage' | 'maxBarThickness' | 'minBarLength' 76 | >, 77 | IBoxplotOptions, 78 | ScriptableAndArrayOptions>, 79 | ScriptableAndArrayOptions>, 80 | AnimationOptions<'boxplot'> {} 81 | 82 | export type BoxPlotDataPoint = number[] | (Partial & Pick); 83 | 84 | export type IBoxPlotChartOptions = IBoxplotOptions; 85 | 86 | declare module 'chart.js' { 87 | export interface ChartTypeRegistry { 88 | boxplot: { 89 | chartOptions: IBoxPlotChartOptions; 90 | datasetOptions: BoxPlotControllerDatasetOptions; 91 | defaultDataPoint: BoxPlotDataPoint; 92 | scales: keyof CartesianScaleTypeRegistry; 93 | metaExtensions: object; 94 | parsedDataType: IBoxPlot & ChartTypeRegistry['bar']['parsedDataType']; 95 | }; 96 | } 97 | } 98 | 99 | export class BoxPlotChart extends Chart< 100 | 'boxplot', 101 | DATA, 102 | LABEL 103 | > { 104 | static id = BoxPlotController.id; 105 | 106 | constructor(item: ChartItem, config: Omit, 'type'>) { 107 | super(item, patchController('boxplot', config, BoxPlotController, BoxAndWiskers, [LinearScale, CategoryScale])); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/controllers/StatsBase.ts: -------------------------------------------------------------------------------- 1 | import { BarController, Element, ChartMeta, LinearScale, Scale, UpdateMode } from 'chart.js'; 2 | import { formatNumber } from 'chart.js/helpers'; 3 | import { interpolateNumberArray } from '../animation'; 4 | import { outlierPositioner, patchInHoveredOutlier } from '../tooltip'; 5 | import { defaultStatsOptions, IBaseOptions, IBaseStats } from '../data'; 6 | /** 7 | * @hidden 8 | */ 9 | export /* #__PURE__ */ function baseDefaults(keys: string[]): Record { 10 | const colorKeys = ['borderColor', 'backgroundColor'].concat(keys.filter((c) => c.endsWith('Color'))); 11 | return { 12 | animations: { 13 | numberArray: { 14 | fn: interpolateNumberArray, 15 | properties: ['outliers', 'items'], 16 | }, 17 | colors: { 18 | type: 'color', 19 | properties: colorKeys, 20 | }, 21 | }, 22 | transitions: { 23 | show: { 24 | animations: { 25 | colors: { 26 | type: 'color', 27 | properties: colorKeys, 28 | from: 'transparent', 29 | }, 30 | }, 31 | }, 32 | hide: { 33 | animations: { 34 | colors: { 35 | type: 'color', 36 | properties: colorKeys, 37 | to: 'transparent', 38 | }, 39 | }, 40 | }, 41 | }, 42 | minStats: 'min', 43 | maxStats: 'max', 44 | ...defaultStatsOptions, 45 | }; 46 | } 47 | 48 | export function defaultOverrides(): Record { 49 | return { 50 | plugins: { 51 | tooltip: { 52 | position: outlierPositioner.register().id, 53 | callbacks: { 54 | beforeLabel: patchInHoveredOutlier, 55 | }, 56 | }, 57 | }, 58 | }; 59 | } 60 | 61 | export abstract class StatsBase> extends BarController { 62 | /** 63 | * @hidden 64 | */ 65 | declare options: C; 66 | 67 | /** 68 | * @hidden 69 | */ 70 | 71 | protected _transformStats(target: any, source: S, mapper: (v: number) => T): void { 72 | for (const key of ['min', 'max', 'median', 'q3', 'q1', 'mean']) { 73 | const v = source[key as keyof IBaseStats]; 74 | if (typeof v === 'number') { 75 | target[key] = mapper(v); 76 | } 77 | } 78 | for (const key of ['outliers', 'items']) { 79 | if (Array.isArray(source[key as keyof IBaseStats])) { 80 | target[key] = source[key as 'outliers' | 'items'].map(mapper); 81 | } 82 | } 83 | } 84 | 85 | /** 86 | * @hidden 87 | */ 88 | getMinMax(scale: Scale, canStack?: boolean | undefined): { min: number; max: number } { 89 | const bak = scale.axis; 90 | const config = this.options; 91 | 92 | scale.axis = config.minStats; 93 | const { min } = super.getMinMax(scale, canStack); 94 | 95 | scale.axis = config.maxStats; 96 | const { max } = super.getMinMax(scale, canStack); 97 | 98 | scale.axis = bak; 99 | return { min, max }; 100 | } 101 | 102 | /** 103 | * @hidden 104 | */ 105 | parsePrimitiveData(meta: ChartMeta, data: any[], start: number, count: number): Record[] { 106 | const vScale = meta.vScale!; 107 | 108 | const iScale = meta.iScale!; 109 | const labels = iScale.getLabels(); 110 | const r = []; 111 | for (let i = 0; i < count; i += 1) { 112 | const index = i + start; 113 | const parsed: any = {}; 114 | parsed[iScale.axis] = iScale.parse(labels[index], index); 115 | const stats = this._parseStats(data == null ? null : data[index], this.options); 116 | if (stats) { 117 | Object.assign(parsed, stats); 118 | parsed[vScale.axis] = stats.median; 119 | } 120 | r.push(parsed); 121 | } 122 | return r; 123 | } 124 | 125 | /** 126 | * @hidden 127 | */ 128 | parseArrayData(meta: ChartMeta, data: any[], start: number, count: number): Record[] { 129 | return this.parsePrimitiveData(meta, data, start, count); 130 | } 131 | 132 | /** 133 | * @hidden 134 | */ 135 | parseObjectData(meta: ChartMeta, data: any[], start: number, count: number): Record[] { 136 | return this.parsePrimitiveData(meta, data, start, count); 137 | } 138 | 139 | /** 140 | * @hidden 141 | */ 142 | 143 | protected abstract _parseStats(value: any, options: C): S | undefined; 144 | /** 145 | * @hidden 146 | */ 147 | getLabelAndValue(index: number): { 148 | label: string; 149 | value: string & { raw: S; hoveredOutlierIndex: number; hoveredItemIndex: number } & S; 150 | } { 151 | const r = super.getLabelAndValue(index) as any; 152 | const { vScale } = this._cachedMeta; 153 | const parsed = this.getParsed(index) as unknown as S; 154 | if (!vScale || !parsed || r.value === 'NaN') { 155 | return r; 156 | } 157 | r.value = { 158 | raw: parsed, 159 | hoveredOutlierIndex: -1, 160 | hoveredItemIndex: -1, 161 | }; 162 | this._transformStats(r.value, parsed, (v) => vScale.getLabelForValue(v)); 163 | const s = this._toStringStats(r.value.raw); 164 | r.value.toString = function toString() { 165 | // custom to string function for the 'value' 166 | if (this.hoveredOutlierIndex >= 0) { 167 | // TODO formatter 168 | return `(outlier: ${this.outliers[this.hoveredOutlierIndex]})`; 169 | } 170 | if (this.hoveredItemIndex >= 0) { 171 | // TODO formatter 172 | return `(item: ${this.items[this.hoveredItemIndex]})`; 173 | } 174 | return s; 175 | }; 176 | return r; 177 | } 178 | 179 | /** 180 | * @hidden 181 | */ 182 | 183 | protected _toStringStats(b: S): string { 184 | const f = (v: number) => (v == null ? 'NaN' : formatNumber(v, this.chart.options.locale!, {})); 185 | return `(min: ${f(b.min)}, 25% quantile: ${f(b.q1)}, median: ${f(b.median)}, mean: ${f(b.mean)}, 75% quantile: ${f( 186 | b.q3 187 | )}, max: ${f(b.max)})`; 188 | } 189 | 190 | /** 191 | * @hidden 192 | */ 193 | 194 | updateElement(rectangle: Element, index: number, properties: any, mode: UpdateMode): void { 195 | const reset = mode === 'reset'; 196 | const scale = this._cachedMeta.vScale as LinearScale; 197 | const parsed = this.getParsed(index) as unknown as S; 198 | const base = scale.getBasePixel(); 199 | 200 | properties._datasetIndex = this.index; 201 | 202 | properties._index = index; 203 | this._transformStats(properties, parsed, (v) => (reset ? base : scale.getPixelForValue(v, index))); 204 | super.updateElement(rectangle, index, properties, mode); 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/controllers/ViolinController.spec.ts: -------------------------------------------------------------------------------- 1 | import { CategoryScale, LinearScale, registry } from 'chart.js'; 2 | import createChart from '../__tests__/createChart'; 3 | import { ViolinController } from './ViolinController'; 4 | import { Samples } from './__tests__/utils'; 5 | import { Violin } from '../elements'; 6 | import { describe, beforeAll, test } from 'vitest'; 7 | 8 | describe('violin', () => { 9 | beforeAll(() => { 10 | registry.addControllers(ViolinController); 11 | registry.addElements(Violin); 12 | registry.addScales(CategoryScale, LinearScale); 13 | }); 14 | test('default', () => { 15 | const samples = new Samples(10); 16 | const chart = createChart({ 17 | type: ViolinController.id, 18 | data: { 19 | labels: samples.months({ count: 7 }), 20 | datasets: [ 21 | { 22 | label: 'Dataset 1', 23 | backgroundColor: 'red', 24 | borderWidth: 1, 25 | data: samples.boxplotsArray({ count: 7 }), 26 | outlierBackgroundColor: '#999999', 27 | }, 28 | { 29 | label: 'Dataset 2', 30 | backgroundColor: 'blue', 31 | borderWidth: 1, 32 | data: samples.boxplotsArray({ count: 7 }), 33 | outlierBackgroundColor: '#999999', 34 | }, 35 | ], 36 | }, 37 | options: { 38 | scales: { 39 | x: { 40 | display: false, 41 | }, 42 | y: { 43 | display: false, 44 | }, 45 | }, 46 | }, 47 | }); 48 | return chart.toMatchImageSnapshot(); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/controllers/ViolinController.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Chart, 3 | BarController, 4 | ChartItem, 5 | ControllerDatasetOptions, 6 | ScriptableAndArrayOptions, 7 | CommonHoverOptions, 8 | ChartConfiguration, 9 | LinearScale, 10 | CategoryScale, 11 | AnimationOptions, 12 | ScriptableContext, 13 | CartesianScaleTypeRegistry, 14 | BarControllerDatasetOptions, 15 | } from 'chart.js'; 16 | import { merge } from 'chart.js/helpers'; 17 | import { asViolinStats, IViolin, IViolinOptions } from '../data'; 18 | import { StatsBase, baseDefaults, defaultOverrides } from './StatsBase'; 19 | import { baseOptionKeys } from '../elements/base'; 20 | import { IViolinElementOptions, Violin } from '../elements'; 21 | import { interpolateKdeCoords } from '../animation'; 22 | import patchController from './patchController'; 23 | 24 | export class ViolinController extends StatsBase> { 25 | /** 26 | * @hidden 27 | */ 28 | 29 | protected _parseStats(value: any, config: IViolinOptions): IViolin | undefined { 30 | return asViolinStats(value, config); 31 | } 32 | 33 | /** 34 | * @hidden 35 | */ 36 | 37 | protected _transformStats(target: any, source: IViolin, mapper: (v: number) => T): void { 38 | super._transformStats(target, source, mapper); 39 | 40 | target.maxEstimate = source.maxEstimate; 41 | if (Array.isArray(source.coords)) { 42 | target.coords = source.coords.map((c) => ({ ...c, v: mapper(c.v) })); 43 | } 44 | } 45 | 46 | static readonly id = 'violin'; 47 | 48 | /** 49 | * @hidden 50 | */ 51 | static readonly defaults: any = /* #__PURE__ */ merge({}, [ 52 | BarController.defaults, 53 | baseDefaults(baseOptionKeys), 54 | { 55 | points: 100, 56 | animations: { 57 | numbers: { 58 | type: 'number', 59 | properties: (BarController.defaults as any).animations.numbers.properties.concat( 60 | ['q1', 'q3', 'min', 'max', 'median', 'maxEstimate'], 61 | baseOptionKeys.filter((c) => !c.endsWith('Color')) 62 | ), 63 | }, 64 | kdeCoords: { 65 | fn: interpolateKdeCoords, 66 | properties: ['coords'], 67 | }, 68 | }, 69 | dataElementType: Violin.id, 70 | }, 71 | ]); 72 | 73 | /** 74 | * @hidden 75 | */ 76 | static readonly overrides: any = /* #__PURE__ */ merge({}, [(BarController as any).overrides, defaultOverrides()]); 77 | } 78 | export type ViolinDataPoint = number[] | (Partial & Pick); 79 | 80 | export interface ViolinControllerDatasetOptions 81 | extends ControllerDatasetOptions, 82 | Pick< 83 | BarControllerDatasetOptions, 84 | 'barPercentage' | 'barThickness' | 'categoryPercentage' | 'maxBarThickness' | 'minBarLength' 85 | >, 86 | IViolinOptions, 87 | ScriptableAndArrayOptions>, 88 | ScriptableAndArrayOptions>, 89 | AnimationOptions<'violin'> {} 90 | 91 | export type IViolinChartOptions = IViolinOptions; 92 | 93 | declare module 'chart.js' { 94 | export interface ChartTypeRegistry { 95 | violin: { 96 | chartOptions: IViolinChartOptions; 97 | datasetOptions: ViolinControllerDatasetOptions; 98 | defaultDataPoint: ViolinDataPoint; 99 | scales: keyof CartesianScaleTypeRegistry; 100 | metaExtensions: object; 101 | parsedDataType: IViolin & ChartTypeRegistry['bar']['parsedDataType']; 102 | }; 103 | } 104 | } 105 | 106 | export class ViolinChart extends Chart< 107 | 'violin', 108 | DATA, 109 | LABEL 110 | > { 111 | static id = ViolinController.id; 112 | 113 | constructor(item: ChartItem, config: Omit, 'type'>) { 114 | super(item, patchController('violin', config, ViolinController, Violin, [LinearScale, CategoryScale])); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/controllers/__image_snapshots__/box-plot-controller-spec-ts-boxplot-data-structures-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgratzl/chartjs-chart-boxplot/f9f20dad666f1ceac89bccffe38908fdfb9a9146/src/controllers/__image_snapshots__/box-plot-controller-spec-ts-boxplot-data-structures-1-snap.png -------------------------------------------------------------------------------- /src/controllers/__image_snapshots__/box-plot-controller-spec-ts-boxplot-datalimits-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgratzl/chartjs-chart-boxplot/f9f20dad666f1ceac89bccffe38908fdfb9a9146/src/controllers/__image_snapshots__/box-plot-controller-spec-ts-boxplot-datalimits-1-snap.png -------------------------------------------------------------------------------- /src/controllers/__image_snapshots__/box-plot-controller-spec-ts-boxplot-datastructures-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgratzl/chartjs-chart-boxplot/f9f20dad666f1ceac89bccffe38908fdfb9a9146/src/controllers/__image_snapshots__/box-plot-controller-spec-ts-boxplot-datastructures-1-snap.png -------------------------------------------------------------------------------- /src/controllers/__image_snapshots__/box-plot-controller-spec-ts-boxplot-default-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgratzl/chartjs-chart-boxplot/f9f20dad666f1ceac89bccffe38908fdfb9a9146/src/controllers/__image_snapshots__/box-plot-controller-spec-ts-boxplot-default-1-snap.png -------------------------------------------------------------------------------- /src/controllers/__image_snapshots__/box-plot-controller-spec-ts-boxplot-empty-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgratzl/chartjs-chart-boxplot/f9f20dad666f1ceac89bccffe38908fdfb9a9146/src/controllers/__image_snapshots__/box-plot-controller-spec-ts-boxplot-empty-1-snap.png -------------------------------------------------------------------------------- /src/controllers/__image_snapshots__/box-plot-controller-spec-ts-boxplot-hybrid-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgratzl/chartjs-chart-boxplot/f9f20dad666f1ceac89bccffe38908fdfb9a9146/src/controllers/__image_snapshots__/box-plot-controller-spec-ts-boxplot-hybrid-1-snap.png -------------------------------------------------------------------------------- /src/controllers/__image_snapshots__/box-plot-controller-spec-ts-boxplot-items-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgratzl/chartjs-chart-boxplot/f9f20dad666f1ceac89bccffe38908fdfb9a9146/src/controllers/__image_snapshots__/box-plot-controller-spec-ts-boxplot-items-1-snap.png -------------------------------------------------------------------------------- /src/controllers/__image_snapshots__/box-plot-controller-spec-ts-boxplot-logarithmic-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgratzl/chartjs-chart-boxplot/f9f20dad666f1ceac89bccffe38908fdfb9a9146/src/controllers/__image_snapshots__/box-plot-controller-spec-ts-boxplot-logarithmic-1-snap.png -------------------------------------------------------------------------------- /src/controllers/__image_snapshots__/box-plot-controller-spec-ts-boxplot-mediancolor-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgratzl/chartjs-chart-boxplot/f9f20dad666f1ceac89bccffe38908fdfb9a9146/src/controllers/__image_snapshots__/box-plot-controller-spec-ts-boxplot-mediancolor-1-snap.png -------------------------------------------------------------------------------- /src/controllers/__image_snapshots__/box-plot-controller-spec-ts-boxplot-minmax-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgratzl/chartjs-chart-boxplot/f9f20dad666f1ceac89bccffe38908fdfb9a9146/src/controllers/__image_snapshots__/box-plot-controller-spec-ts-boxplot-minmax-1-snap.png -------------------------------------------------------------------------------- /src/controllers/__image_snapshots__/box-plot-controller-spec-ts-boxplot-quantiles-fivenum-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgratzl/chartjs-chart-boxplot/f9f20dad666f1ceac89bccffe38908fdfb9a9146/src/controllers/__image_snapshots__/box-plot-controller-spec-ts-boxplot-quantiles-fivenum-1-snap.png -------------------------------------------------------------------------------- /src/controllers/__image_snapshots__/box-plot-controller-spec-ts-boxplot-quantiles-types-7-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgratzl/chartjs-chart-boxplot/f9f20dad666f1ceac89bccffe38908fdfb9a9146/src/controllers/__image_snapshots__/box-plot-controller-spec-ts-boxplot-quantiles-types-7-1-snap.png -------------------------------------------------------------------------------- /src/controllers/__image_snapshots__/violin-controller-spec-ts-violin-default-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgratzl/chartjs-chart-boxplot/f9f20dad666f1ceac89bccffe38908fdfb9a9146/src/controllers/__image_snapshots__/violin-controller-spec-ts-violin-default-1-snap.png -------------------------------------------------------------------------------- /src/controllers/__tests__/utils.ts: -------------------------------------------------------------------------------- 1 | import type { BoxPlotDataPoint } from '../BoxPlotController'; 2 | 3 | const Months = [ 4 | 'January', 5 | 'February', 6 | 'March', 7 | 'April', 8 | 'May', 9 | 'June', 10 | 'July', 11 | 'August', 12 | 'September', 13 | 'October', 14 | 'November', 15 | 'December', 16 | ]; 17 | 18 | export interface INumberOptions { 19 | decimals?: number; 20 | min?: number; 21 | max?: number; 22 | continuity?: number; 23 | from?: number[]; 24 | count?: number; 25 | random?(min: number, max: number): () => number; 26 | random01?(): () => number; 27 | } 28 | 29 | export class Samples { 30 | _seed; 31 | 32 | // Adapted from http://indiegamr.com/generate-repeatable-random-numbers-in-js/ 33 | constructor(seed = 0) { 34 | this._seed = seed; 35 | } 36 | 37 | randF(min = 0, max = 1): () => number { 38 | return () => { 39 | this._seed = (this._seed * 9301 + 49297) % 233280; 40 | return min + (this._seed / 233280) * (max - min); 41 | }; 42 | } 43 | 44 | rand(min?: number, max?: number): number { 45 | return this.randF(min, max)(); 46 | } 47 | 48 | months({ count = 12, section }: { count?: number; section?: number }): string[] { 49 | const values: string[] = []; 50 | 51 | for (let i = 0; i < count; i += 1) { 52 | const value = Months[Math.ceil(i) % 12]; 53 | values.push(value.substring(0, section)); 54 | } 55 | 56 | return values; 57 | } 58 | 59 | numbers({ 60 | count = 8, 61 | min = 0, 62 | max = 100, 63 | decimals = 8, 64 | from = [], 65 | continuity = 1, 66 | random, 67 | random01, 68 | }: INumberOptions = {}): number[] { 69 | const dfactor = Math.pow(10, decimals) || 0; 70 | const data: number[] = []; 71 | const rand = random ? random(min, max) : this.randF(min, max); 72 | const rand01 = random01 ? random01() : this.randF(); 73 | for (let i = 0; i < count; i += 1) { 74 | const value = (from[i] || 0) + rand(); 75 | if (rand01() <= continuity) { 76 | data.push(Math.round(dfactor * value) / dfactor); 77 | } else { 78 | data.push(Number.NaN); 79 | } 80 | } 81 | 82 | return data; 83 | } 84 | 85 | randomBoxPlot(config: INumberOptions = {}): BoxPlotDataPoint { 86 | const base = this.numbers({ ...config, count: 10 }) as number[]; 87 | base.sort((a, b) => { 88 | if (a === b) { 89 | return 0; 90 | } 91 | return a! < b! ? -1 : 1; 92 | }); 93 | const shift = 3; 94 | return { 95 | min: base[shift + 0]!, 96 | q1: base[shift + 1]!, 97 | median: base[shift + 2]!, 98 | q3: base[shift + 3]!, 99 | max: base[shift + 4]!, 100 | items: base, 101 | mean: base.reduce((acc, v) => acc + v, 0) / base.length, 102 | outliers: base.slice(0, 3).concat(base.slice(shift + 5)), 103 | }; 104 | } 105 | 106 | boxplots(config: INumberOptions = {}): BoxPlotDataPoint[] { 107 | const count = config.count || 8; 108 | return Array(count) 109 | .fill(0) 110 | .map(() => this.randomBoxPlot(config)); 111 | } 112 | 113 | boxplotsArray(config: INumberOptions = {}): number[][] { 114 | const count = config.count || 8; 115 | return Array(count) 116 | .fill(0) 117 | .map(() => this.numbers({ ...config, count: 50 }) as number[]); 118 | } 119 | 120 | labels({ 121 | min = 0, 122 | max = 100, 123 | count = 8, 124 | decimals = 8, 125 | prefix = '', 126 | }: { min?: number; max?: number; count?: number; decimals?: number; prefix?: string } = {}): string[] { 127 | const step = (max - min) / count; 128 | const dfactor = Math.pow(10, decimals) || 0; 129 | const values: string[] = []; 130 | for (let i = min; i < max; i += step) { 131 | values.push(prefix + Math.round(dfactor * i) / dfactor); 132 | } 133 | return values; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './BoxPlotController'; 2 | export * from './ViolinController'; 3 | -------------------------------------------------------------------------------- /src/controllers/patchController.ts: -------------------------------------------------------------------------------- 1 | import { registry, DatasetControllerChartComponent, ChartComponent } from 'chart.js'; 2 | 3 | export default function patchController( 4 | type: TYPE, 5 | config: T, 6 | controller: DatasetControllerChartComponent, 7 | elements: ChartComponent | ChartComponent[] = [], 8 | scales: ChartComponent | ChartComponent[] = [] 9 | ): T & { type: TYPE } { 10 | registry.addControllers(controller); 11 | if (Array.isArray(elements)) { 12 | registry.addElements(...elements); 13 | } else { 14 | registry.addElements(elements); 15 | } 16 | if (Array.isArray(scales)) { 17 | registry.addScales(...scales); 18 | } else { 19 | registry.addScales(scales); 20 | } 21 | const c = config as any; 22 | c.type = type; 23 | return c; 24 | } 25 | -------------------------------------------------------------------------------- /src/data.ts: -------------------------------------------------------------------------------- 1 | import { 2 | boxplot as boxplots, 3 | quantilesFivenum, 4 | quantilesHigher, 5 | quantilesHinges, 6 | quantilesLinear, 7 | quantilesLower, 8 | quantilesMidpoint, 9 | quantilesNearest, 10 | quantilesType7, 11 | } from '@sgratzl/boxplots'; 12 | 13 | export { 14 | quantilesFivenum, 15 | quantilesHigher, 16 | quantilesHinges, 17 | quantilesLinear, 18 | quantilesLower, 19 | quantilesMidpoint, 20 | quantilesNearest, 21 | quantilesType7, 22 | } from '@sgratzl/boxplots'; 23 | 24 | export interface IBaseStats { 25 | min: number; 26 | max: number; 27 | q1: number; 28 | q3: number; 29 | median: number; 30 | mean: number; 31 | items: readonly number[]; 32 | outliers: readonly number[]; 33 | } 34 | 35 | export interface IBoxPlot extends IBaseStats { 36 | whiskerMax: number; 37 | whiskerMin: number; 38 | } 39 | 40 | export interface IKDEPoint { 41 | v: number; 42 | estimate: number; 43 | } 44 | 45 | export interface IViolin extends IBaseStats { 46 | maxEstimate: number; 47 | coords: IKDEPoint[]; 48 | } 49 | 50 | /** 51 | * compute the whiskers 52 | * @param boxplot 53 | * @param {number[]} arr sorted array 54 | * @param {number} coef 55 | */ 56 | export function whiskers( 57 | boxplot: IBoxPlot, 58 | arr: number[] | null, 59 | coef = 1.5 60 | ): { whiskerMin: number; whiskerMax: number } { 61 | const iqr = boxplot.q3 - boxplot.q1; 62 | // since top left is max 63 | const coefValid = typeof coef === 'number' && coef > 0; 64 | let whiskerMin = coefValid ? Math.max(boxplot.min, boxplot.q1 - coef * iqr) : boxplot.min; 65 | let whiskerMax = coefValid ? Math.min(boxplot.max, boxplot.q3 + coef * iqr) : boxplot.max; 66 | 67 | if (Array.isArray(arr)) { 68 | // compute the closest real element 69 | for (let i = 0; i < arr.length; i += 1) { 70 | const v = arr[i]; 71 | if (v >= whiskerMin) { 72 | whiskerMin = v; 73 | break; 74 | } 75 | } 76 | for (let i = arr.length - 1; i >= 0; i -= 1) { 77 | const v = arr[i]; 78 | if (v <= whiskerMax) { 79 | whiskerMax = v; 80 | break; 81 | } 82 | } 83 | } 84 | 85 | return { 86 | whiskerMin, 87 | whiskerMax, 88 | }; 89 | } 90 | 91 | export type QuantileMethod = 92 | | 7 93 | | 'quantiles' 94 | | 'hinges' 95 | | 'fivenum' 96 | | 'linear' 97 | | 'lower' 98 | | 'higher' 99 | | 'nearest' 100 | | 'midpoint' 101 | | ((arr: ArrayLike, length?: number | undefined) => { q1: number; median: number; q3: number }); 102 | 103 | export interface IBaseOptions { 104 | /** 105 | * statistic measure that should be used for computing the minimal data limit 106 | * @default 'min' 107 | */ 108 | minStats?: 'min' | 'q1' | 'whiskerMin'; 109 | 110 | /** 111 | * statistic measure that should be used for computing the maximal data limit 112 | * @default 'max' 113 | */ 114 | maxStats?: 'max' | 'q3' | 'whiskerMax'; 115 | 116 | /** 117 | * from the R doc: this determines how far the plot ‘whiskers’ extend out from 118 | * the box. If coef is positive, the whiskers extend to the most extreme data 119 | * point which is no more than coef times the length of the box away from the 120 | * box. A value of zero causes the whiskers to extend to the data extremes 121 | * @default 1.5 122 | */ 123 | coef?: number; 124 | 125 | /** 126 | * the method to compute the quantiles. 127 | * 128 | * 7, 'quantiles': the type-7 method as used by R 'quantiles' method. 129 | * 'hinges' and 'fivenum': the method used by R 'boxplot.stats' method. 130 | * 'linear': the interpolation method 'linear' as used by 'numpy.percentile' function 131 | * 'lower': the interpolation method 'lower' as used by 'numpy.percentile' function 132 | * 'higher': the interpolation method 'higher' as used by 'numpy.percentile' function 133 | * 'nearest': the interpolation method 'nearest' as used by 'numpy.percentile' function 134 | * 'midpoint': the interpolation method 'midpoint' as used by 'numpy.percentile' function 135 | * @default 7 136 | */ 137 | quantiles?: QuantileMethod; 138 | 139 | /** 140 | * the method to compute the whiskers. 141 | * 142 | * 'nearest': with this mode computed whisker values will be replaced with nearest real data points 143 | * 'exact': with this mode exact computed whisker values will be displayed on chart 144 | * @default 'nearest' 145 | */ 146 | whiskersMode?: 'nearest' | 'exact'; 147 | } 148 | 149 | export type IBoxplotOptions = IBaseOptions; 150 | 151 | export interface IViolinOptions extends IBaseOptions { 152 | /** 153 | * number of points that should be samples of the KDE 154 | * @default 100 155 | */ 156 | points: number; 157 | } 158 | 159 | /** 160 | * @hidden 161 | */ 162 | export const defaultStatsOptions: Required> = { 163 | coef: 1.5, 164 | quantiles: 7, 165 | whiskersMode: 'nearest', 166 | }; 167 | 168 | function determineQuantiles(q: QuantileMethod) { 169 | if (typeof q === 'function') { 170 | return q; 171 | } 172 | const lookup = { 173 | hinges: quantilesHinges, 174 | fivenum: quantilesFivenum, 175 | 7: quantilesType7, 176 | quantiles: quantilesType7, 177 | linear: quantilesLinear, 178 | lower: quantilesLower, 179 | higher: quantilesHigher, 180 | nearest: quantilesNearest, 181 | midpoint: quantilesMidpoint, 182 | }; 183 | return lookup[q] || quantilesType7; 184 | } 185 | 186 | function determineStatsOptions(options?: IBaseOptions) { 187 | const coef = options == null || typeof options.coef !== 'number' ? defaultStatsOptions.coef : options.coef; 188 | const q = options == null || options.quantiles == null ? quantilesType7 : options.quantiles; 189 | const quantiles = determineQuantiles(q); 190 | const whiskersMode = 191 | options == null || typeof options.whiskersMode !== 'string' 192 | ? defaultStatsOptions.whiskersMode 193 | : options.whiskersMode; 194 | return { 195 | coef, 196 | quantiles, 197 | whiskersMode, 198 | }; 199 | } 200 | 201 | /** 202 | * @hidden 203 | */ 204 | export function boxplotStats(arr: readonly number[] | Float32Array | Float64Array, options: IBaseOptions): IBoxPlot { 205 | const vs = 206 | typeof Float64Array !== 'undefined' && !(arr instanceof Float32Array || arr instanceof Float64Array) 207 | ? Float64Array.from(arr) 208 | : arr; 209 | const r = boxplots(vs, determineStatsOptions(options)); 210 | return { 211 | items: Array.from(r.items), 212 | outliers: r.outlier, 213 | whiskerMax: r.whiskerHigh, 214 | whiskerMin: r.whiskerLow, 215 | max: r.max, 216 | median: r.median, 217 | mean: r.mean, 218 | min: r.min, 219 | q1: r.q1, 220 | q3: r.q3, 221 | }; 222 | } 223 | 224 | function computeSamples(min: number, max: number, points: number) { 225 | // generate coordinates 226 | const range = max - min; 227 | const samples: number[] = []; 228 | const inc = range / points; 229 | for (let v = min; v <= max && inc > 0; v += inc) { 230 | samples.push(v); 231 | } 232 | if (samples[samples.length - 1] !== max) { 233 | samples.push(max); 234 | } 235 | return samples; 236 | } 237 | 238 | /** 239 | * @hidden 240 | */ 241 | export function violinStats(arr: readonly number[], options: IViolinOptions): IViolin | undefined { 242 | // console.assert(Array.isArray(arr)); 243 | if (arr.length === 0) { 244 | return undefined; 245 | } 246 | const vs = 247 | typeof Float64Array !== 'undefined' && !(arr instanceof Float32Array || arr instanceof Float64Array) 248 | ? Float64Array.from(arr) 249 | : arr; 250 | const stats = boxplots(vs, determineStatsOptions(options)); 251 | 252 | // generate coordinates 253 | const samples = computeSamples(stats.min, stats.max, options.points); 254 | const coords = samples.map((v) => ({ v, estimate: stats.kde(v) })); 255 | const maxEstimate = coords.reduce((a, d) => Math.max(a, d.estimate), Number.NEGATIVE_INFINITY); 256 | 257 | return { 258 | max: stats.max, 259 | min: stats.min, 260 | mean: stats.mean, 261 | median: stats.median, 262 | q1: stats.q1, 263 | q3: stats.q3, 264 | items: Array.from(stats.items), 265 | coords, 266 | outliers: [], // items.filter((d) => d < stats.q1 || d > stats.q3), 267 | maxEstimate, 268 | }; 269 | } 270 | 271 | /** 272 | * @hidden 273 | */ 274 | export function asBoxPlotStats(value: any, options: IBoxplotOptions): IBoxPlot | undefined { 275 | if (!value) { 276 | return undefined; 277 | } 278 | if (typeof value.median === 'number' && typeof value.q1 === 'number' && typeof value.q3 === 'number') { 279 | // sounds good, check for helper 280 | if (typeof value.whiskerMin === 'undefined') { 281 | const { coef } = determineStatsOptions(options); 282 | const { whiskerMin, whiskerMax } = whiskers( 283 | value, 284 | Array.isArray(value.items) ? (value.items as number[]).slice().sort((a, b) => a - b) : null, 285 | coef 286 | ); 287 | value.whiskerMin = whiskerMin; 288 | value.whiskerMax = whiskerMax; 289 | } 290 | return value; 291 | } 292 | if (!Array.isArray(value)) { 293 | return undefined; 294 | } 295 | return boxplotStats(value, options); 296 | } 297 | 298 | /** 299 | * @hidden 300 | */ 301 | 302 | export function asViolinStats(value: any, options: IViolinOptions): IViolin | undefined { 303 | if (!value) { 304 | return undefined; 305 | } 306 | if (typeof value.median === 'number' && Array.isArray(value.coords)) { 307 | return value; 308 | } 309 | if (!Array.isArray(value)) { 310 | return undefined; 311 | } 312 | return violinStats(value, options); 313 | } 314 | 315 | /** 316 | * @hidden 317 | */ 318 | export function rnd(seed = Date.now()): () => number { 319 | // Adapted from http://indiegamr.com/generate-repeatable-random-numbers-in-js/ 320 | let s = seed; 321 | return () => { 322 | s = (s * 9301 + 49297) % 233280; 323 | return s / 233280; 324 | }; 325 | } 326 | -------------------------------------------------------------------------------- /src/elements/BoxAndWiskers.ts: -------------------------------------------------------------------------------- 1 | import { BarElement, ChartType, CommonHoverOptions, ScriptableAndArrayOptions, ScriptableContext } from 'chart.js'; 2 | import { 3 | StatsBase, 4 | baseDefaults, 5 | baseOptionKeys, 6 | baseRoutes, 7 | type IStatsBaseOptions, 8 | type IStatsBaseProps, 9 | } from './base'; 10 | /** 11 | * @hidden 12 | */ 13 | export const boxOptionsKeys = baseOptionKeys.concat(['medianColor', 'lowerBackgroundColor']); 14 | 15 | export interface IBoxAndWhiskersOptions extends IStatsBaseOptions { 16 | /** 17 | * separate color for the median line 18 | * @default 'transparent' takes the current borderColor 19 | * scriptable 20 | * indexable 21 | */ 22 | medianColor: string; 23 | 24 | /** 25 | * color the lower half (median-q3) of the box in a different color 26 | * @default 'transparent' takes the current borderColor 27 | * scriptable 28 | * indexable 29 | */ 30 | lowerBackgroundColor: string; 31 | } 32 | 33 | export interface IBoxAndWhiskerProps extends IStatsBaseProps { 34 | q1: number; 35 | q3: number; 36 | median: number; 37 | whiskerMin: number; 38 | whiskerMax: number; 39 | mean: number; 40 | } 41 | 42 | export class BoxAndWiskers extends StatsBase { 43 | /** 44 | * @hidden 45 | */ 46 | draw(ctx: CanvasRenderingContext2D): void { 47 | ctx.save(); 48 | 49 | ctx.fillStyle = this.options.backgroundColor; 50 | ctx.strokeStyle = this.options.borderColor; 51 | ctx.lineWidth = this.options.borderWidth; 52 | 53 | this._drawBoxPlot(ctx); 54 | this._drawOutliers(ctx); 55 | this._drawMeanDot(ctx); 56 | 57 | ctx.restore(); 58 | 59 | this._drawItems(ctx); 60 | } 61 | 62 | /** 63 | * @hidden 64 | */ 65 | protected _drawBoxPlot(ctx: CanvasRenderingContext2D): void { 66 | if (this.isVertical()) { 67 | this._drawBoxPlotVertical(ctx); 68 | } else { 69 | this._drawBoxPlotHorizontal(ctx); 70 | } 71 | } 72 | 73 | /** 74 | * @hidden 75 | */ 76 | protected _drawBoxPlotVertical(ctx: CanvasRenderingContext2D): void { 77 | const { options } = this; 78 | const props = this.getProps(['x', 'width', 'q1', 'q3', 'median', 'whiskerMin', 'whiskerMax']); 79 | 80 | const { x } = props; 81 | const { width } = props; 82 | const x0 = x - width / 2; 83 | // Draw the q1>q3 box 84 | if (props.q3 > props.q1) { 85 | ctx.fillRect(x0, props.q1, width, props.q3 - props.q1); 86 | } else { 87 | ctx.fillRect(x0, props.q3, width, props.q1 - props.q3); 88 | } 89 | 90 | // Draw the median line 91 | ctx.save(); 92 | if (options.medianColor && options.medianColor !== 'transparent' && options.medianColor !== '#0000') { 93 | ctx.strokeStyle = options.medianColor; 94 | } 95 | ctx.beginPath(); 96 | ctx.moveTo(x0, props.median); 97 | ctx.lineTo(x0 + width, props.median); 98 | ctx.closePath(); 99 | ctx.stroke(); 100 | ctx.restore(); 101 | 102 | ctx.save(); 103 | // fill the part below the median with lowerColor 104 | if ( 105 | options.lowerBackgroundColor && 106 | options.lowerBackgroundColor !== 'transparent' && 107 | options.lowerBackgroundColor !== '#0000' 108 | ) { 109 | ctx.fillStyle = options.lowerBackgroundColor; 110 | if (props.q3 > props.q1) { 111 | ctx.fillRect(x0, props.median, width, props.q3 - props.median); 112 | } else { 113 | ctx.fillRect(x0, props.median, width, props.q1 - props.median); 114 | } 115 | } 116 | ctx.restore(); 117 | 118 | // Draw the border around the main q1>q3 box 119 | if (props.q3 > props.q1) { 120 | ctx.strokeRect(x0, props.q1, width, props.q3 - props.q1); 121 | } else { 122 | ctx.strokeRect(x0, props.q3, width, props.q1 - props.q3); 123 | } 124 | 125 | // Draw the whiskers 126 | ctx.beginPath(); 127 | ctx.moveTo(x0, props.whiskerMin); 128 | ctx.lineTo(x0 + width, props.whiskerMin); 129 | ctx.moveTo(x, props.whiskerMin); 130 | ctx.lineTo(x, props.q1); 131 | ctx.moveTo(x0, props.whiskerMax); 132 | ctx.lineTo(x0 + width, props.whiskerMax); 133 | ctx.moveTo(x, props.whiskerMax); 134 | ctx.lineTo(x, props.q3); 135 | ctx.closePath(); 136 | ctx.stroke(); 137 | } 138 | 139 | /** 140 | * @hidden 141 | */ 142 | protected _drawBoxPlotHorizontal(ctx: CanvasRenderingContext2D): void { 143 | const { options } = this; 144 | const props = this.getProps(['y', 'height', 'q1', 'q3', 'median', 'whiskerMin', 'whiskerMax']); 145 | 146 | const { y } = props; 147 | const { height } = props; 148 | const y0 = y - height / 2; 149 | 150 | // Draw the q1>q3 box 151 | if (props.q3 > props.q1) { 152 | ctx.fillRect(props.q1, y0, props.q3 - props.q1, height); 153 | } else { 154 | ctx.fillRect(props.q3, y0, props.q1 - props.q3, height); 155 | } 156 | 157 | // Draw the median line 158 | ctx.save(); 159 | if (options.medianColor && options.medianColor !== 'transparent') { 160 | ctx.strokeStyle = options.medianColor; 161 | } 162 | ctx.beginPath(); 163 | ctx.moveTo(props.median, y0); 164 | ctx.lineTo(props.median, y0 + height); 165 | ctx.closePath(); 166 | ctx.stroke(); 167 | ctx.restore(); 168 | 169 | ctx.save(); 170 | // fill the part below the median with lowerColor 171 | if (options.lowerBackgroundColor && options.lowerBackgroundColor !== 'transparent') { 172 | ctx.fillStyle = options.lowerBackgroundColor; 173 | if (props.q3 > props.q1) { 174 | ctx.fillRect(props.median, y0, props.q3 - props.median, height); 175 | } else { 176 | ctx.fillRect(props.median, y0, props.q1 - props.median, height); 177 | } 178 | } 179 | ctx.restore(); 180 | 181 | // Draw the border around the main q1>q3 box 182 | if (props.q3 > props.q1) { 183 | ctx.strokeRect(props.q1, y0, props.q3 - props.q1, height); 184 | } else { 185 | ctx.strokeRect(props.q3, y0, props.q1 - props.q3, height); 186 | } 187 | 188 | // Draw the whiskers 189 | ctx.beginPath(); 190 | ctx.moveTo(props.whiskerMin, y0); 191 | ctx.lineTo(props.whiskerMin, y0 + height); 192 | ctx.moveTo(props.whiskerMin, y); 193 | ctx.lineTo(props.q1, y); 194 | ctx.moveTo(props.whiskerMax, y0); 195 | ctx.lineTo(props.whiskerMax, y0 + height); 196 | ctx.moveTo(props.whiskerMax, y); 197 | ctx.lineTo(props.q3, y); 198 | ctx.closePath(); 199 | ctx.stroke(); 200 | } 201 | 202 | /** 203 | * @hidden 204 | */ 205 | _getBounds(useFinalPosition?: boolean): { left: number; top: number; right: number; bottom: number } { 206 | const vert = this.isVertical(); 207 | if (this.x == null) { 208 | return { 209 | left: 0, 210 | top: 0, 211 | right: 0, 212 | bottom: 0, 213 | }; 214 | } 215 | 216 | if (vert) { 217 | const { x, width, whiskerMax, whiskerMin } = this.getProps( 218 | ['x', 'width', 'whiskerMin', 'whiskerMax'], 219 | useFinalPosition 220 | ); 221 | const x0 = x - width / 2; 222 | return { 223 | left: x0, 224 | top: whiskerMax, 225 | right: x0 + width, 226 | bottom: whiskerMin, 227 | }; 228 | } 229 | const { y, height, whiskerMax, whiskerMin } = this.getProps( 230 | ['y', 'height', 'whiskerMin', 'whiskerMax'], 231 | useFinalPosition 232 | ); 233 | const y0 = y - height / 2; 234 | return { 235 | left: whiskerMin, 236 | top: y0, 237 | right: whiskerMax, 238 | bottom: y0 + height, 239 | }; 240 | } 241 | 242 | static id = 'boxandwhiskers'; 243 | 244 | /** 245 | * @hidden 246 | */ 247 | static defaults = /* #__PURE__ */ { 248 | ...BarElement.defaults, 249 | ...baseDefaults, 250 | medianColor: 'transparent', 251 | lowerBackgroundColor: 'transparent', 252 | }; 253 | 254 | /** 255 | * @hidden 256 | */ 257 | static defaultRoutes = /* #__PURE__ */ { ...BarElement.defaultRoutes, ...baseRoutes }; 258 | } 259 | 260 | declare module 'chart.js' { 261 | export interface ElementOptionsByType { 262 | boxandwhiskers: ScriptableAndArrayOptions>; 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /src/elements/Violin.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BarElement, 3 | type ChartType, 4 | type CommonHoverOptions, 5 | type ScriptableAndArrayOptions, 6 | type ScriptableContext, 7 | } from 'chart.js'; 8 | import { drawPoint } from 'chart.js/helpers'; 9 | import type { IKDEPoint } from '../data'; 10 | import { StatsBase, baseDefaults, baseRoutes, type IStatsBaseOptions, type IStatsBaseProps } from './base'; 11 | 12 | export type IViolinElementOptions = IStatsBaseOptions; 13 | 14 | export interface IViolinElementProps extends IStatsBaseProps { 15 | min: number; 16 | max: number; 17 | median: number; 18 | coords: IKDEPoint[]; 19 | maxEstimate?: number; 20 | } 21 | 22 | export class Violin extends StatsBase { 23 | /** 24 | * @hidden 25 | */ 26 | draw(ctx: CanvasRenderingContext2D): void { 27 | ctx.save(); 28 | 29 | ctx.fillStyle = this.options.backgroundColor; 30 | ctx.strokeStyle = this.options.borderColor; 31 | ctx.lineWidth = this.options.borderWidth; 32 | 33 | const props = this.getProps(['x', 'y', 'median', 'width', 'height', 'min', 'max', 'coords', 'maxEstimate']); 34 | 35 | if (props.median != null) { 36 | // draw median dot 37 | drawPoint( 38 | ctx, 39 | { 40 | pointStyle: 'rectRot', 41 | radius: 5, 42 | borderWidth: this.options.borderWidth, 43 | }, 44 | props.x, 45 | props.y 46 | ); 47 | } 48 | 49 | if (props.coords && props.coords.length > 0) { 50 | this._drawCoords(ctx, props); 51 | } 52 | this._drawOutliers(ctx); 53 | this._drawMeanDot(ctx); 54 | 55 | ctx.restore(); 56 | 57 | this._drawItems(ctx); 58 | } 59 | 60 | /** 61 | * @hidden 62 | */ 63 | protected _drawCoords( 64 | ctx: CanvasRenderingContext2D, 65 | props: Pick 66 | ): void { 67 | let maxEstimate: number; 68 | if (props.maxEstimate == null) { 69 | maxEstimate = props.coords.reduce((a, d) => Math.max(a, d.estimate), Number.NEGATIVE_INFINITY); 70 | } else { 71 | maxEstimate = props.maxEstimate; 72 | } 73 | 74 | ctx.beginPath(); 75 | if (this.isVertical()) { 76 | const { x, width } = props; 77 | const factor = width / 2 / maxEstimate; 78 | 79 | props.coords.forEach((c) => { 80 | ctx.lineTo(x - c.estimate * factor, c.v); 81 | }); 82 | 83 | props.coords 84 | .slice() 85 | .reverse() 86 | .forEach((c) => { 87 | ctx.lineTo(x + c.estimate * factor, c.v); 88 | }); 89 | } else { 90 | const { y, height } = props; 91 | const factor = height / 2 / maxEstimate; 92 | 93 | props.coords.forEach((c) => { 94 | ctx.lineTo(c.v, y - c.estimate * factor); 95 | }); 96 | 97 | props.coords 98 | .slice() 99 | .reverse() 100 | .forEach((c) => { 101 | ctx.lineTo(c.v, y + c.estimate * factor); 102 | }); 103 | } 104 | ctx.closePath(); 105 | ctx.stroke(); 106 | ctx.fill(); 107 | } 108 | 109 | /** 110 | * @hidden 111 | */ 112 | _getBounds(useFinalPosition?: boolean): { left: number; top: number; right: number; bottom: number } { 113 | if (this.isVertical()) { 114 | const { x, width, min, max } = this.getProps(['x', 'width', 'min', 'max'], useFinalPosition); 115 | const x0 = x - width / 2; 116 | return { 117 | left: x0, 118 | top: max, 119 | right: x0 + width, 120 | bottom: min, 121 | }; 122 | } 123 | const { y, height, min, max } = this.getProps(['y', 'height', 'min', 'max'], useFinalPosition); 124 | const y0 = y - height / 2; 125 | return { 126 | left: min, 127 | top: y0, 128 | right: max, 129 | bottom: y0 + height, 130 | }; 131 | } 132 | 133 | static id = 'violin'; 134 | 135 | /** 136 | * @hidden 137 | */ 138 | static defaults = /* #__PURE__ */ { ...BarElement.defaults, ...baseDefaults }; 139 | 140 | /** 141 | * @hidden 142 | */ 143 | static defaultRoutes = /* #__PURE__ */ { ...BarElement.defaultRoutes, ...baseRoutes }; 144 | } 145 | 146 | declare module 'chart.js' { 147 | export interface ElementOptionsByType { 148 | violin: ScriptableAndArrayOptions>; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/elements/base.ts: -------------------------------------------------------------------------------- 1 | import { Element } from 'chart.js'; 2 | import { drawPoint } from 'chart.js/helpers'; 3 | import { rnd } from '../data'; 4 | import type { ExtendedTooltip } from '../tooltip'; 5 | 6 | export interface IStatsBaseOptions { 7 | /** 8 | * @default see rectangle 9 | * scriptable 10 | * indexable 11 | */ 12 | backgroundColor: string; 13 | 14 | /** 15 | * @default see rectangle 16 | * scriptable 17 | * indexable 18 | */ 19 | borderColor: string; 20 | 21 | /** 22 | * @default 1 23 | * scriptable 24 | * indexable 25 | */ 26 | borderWidth: number; 27 | 28 | /** 29 | * item style used to render outliers 30 | * @default circle 31 | */ 32 | outlierStyle: 33 | | 'circle' 34 | | 'triangle' 35 | | 'rect' 36 | | 'rectRounded' 37 | | 'rectRot' 38 | | 'cross' 39 | | 'crossRot' 40 | | 'star' 41 | | 'line' 42 | | 'dash'; 43 | 44 | /** 45 | * radius used to render outliers 46 | * @default 2 47 | * scriptable 48 | * indexable 49 | */ 50 | outlierRadius: number; 51 | 52 | /** 53 | * @default see rectangle.backgroundColor 54 | * scriptable 55 | * indexable 56 | */ 57 | outlierBackgroundColor: string; 58 | 59 | /** 60 | * @default see rectangle.borderColor 61 | * scriptable 62 | * indexable 63 | */ 64 | outlierBorderColor: string; 65 | /** 66 | * @default 1 67 | * scriptable 68 | * indexable 69 | */ 70 | outlierBorderWidth: number; 71 | 72 | /** 73 | * item style used to render items 74 | * @default circle 75 | */ 76 | itemStyle: 77 | | 'circle' 78 | | 'triangle' 79 | | 'rect' 80 | | 'rectRounded' 81 | | 'rectRot' 82 | | 'cross' 83 | | 'crossRot' 84 | | 'star' 85 | | 'line' 86 | | 'dash'; 87 | 88 | /** 89 | * radius used to render items 90 | * @default 0 so disabled 91 | * scriptable 92 | * indexable 93 | */ 94 | itemRadius: number; 95 | 96 | /** 97 | * background color for items 98 | * @default see rectangle.backgroundColor 99 | * scriptable 100 | * indexable 101 | */ 102 | itemBackgroundColor: string; 103 | 104 | /** 105 | * border color for items 106 | * @default see rectangle.borderColor 107 | * scriptable 108 | * indexable 109 | */ 110 | itemBorderColor: string; 111 | 112 | /** 113 | * border width for items 114 | * @default 0 115 | * scriptable 116 | * indexable 117 | */ 118 | itemBorderWidth: number; 119 | /** 120 | * hit radius for hit test of items 121 | * @default 0 122 | * scriptable 123 | * indexable 124 | */ 125 | itemHitRadius: number; 126 | 127 | /** 128 | * padding that is added around the bounding box when computing a mouse hit 129 | * @default 2 130 | * scriptable 131 | * indexable 132 | */ 133 | hitPadding: number; 134 | 135 | /** 136 | * hit radius for hit test of outliers 137 | * @default 4 138 | * scriptable 139 | * indexable 140 | */ 141 | outlierHitRadius: number; 142 | 143 | /** 144 | * item style used to render mean dot 145 | * @default circle 146 | */ 147 | meanStyle: 148 | | 'circle' 149 | | 'triangle' 150 | | 'rect' 151 | | 'rectRounded' 152 | | 'rectRot' 153 | | 'cross' 154 | | 'crossRot' 155 | | 'star' 156 | | 'line' 157 | | 'dash'; 158 | 159 | /** 160 | * radius used to mean dots 161 | * @default 3 162 | * scriptable 163 | * indexable 164 | */ 165 | meanRadius: number; 166 | 167 | /** 168 | * background color for mean dot 169 | * @default see rectangle.backgroundColor 170 | * scriptable 171 | * indexable 172 | */ 173 | meanBackgroundColor: string; 174 | 175 | /** 176 | * border color for mean dot 177 | * @default see rectangle.borderColor 178 | * scriptable 179 | * indexable 180 | */ 181 | meanBorderColor: string; 182 | 183 | /** 184 | * border width for mean dot 185 | * @default 0 186 | * scriptable 187 | * indexable 188 | */ 189 | meanBorderWidth: number; 190 | } 191 | 192 | /** 193 | * @hidden 194 | */ 195 | export const baseDefaults = { 196 | borderWidth: 1, 197 | 198 | outlierStyle: 'circle', 199 | outlierRadius: 2, 200 | outlierBorderWidth: 1, 201 | 202 | itemStyle: 'circle', 203 | itemRadius: 0, 204 | itemBorderWidth: 0, 205 | itemHitRadius: 0, 206 | 207 | meanStyle: 'circle', 208 | meanRadius: 3, 209 | meanBorderWidth: 1, 210 | 211 | hitPadding: 2, 212 | outlierHitRadius: 4, 213 | }; 214 | 215 | /** 216 | * @hidden 217 | */ 218 | export const baseRoutes = { 219 | outlierBackgroundColor: 'backgroundColor', 220 | outlierBorderColor: 'borderColor', 221 | itemBackgroundColor: 'backgroundColor', 222 | itemBorderColor: 'borderColor', 223 | meanBackgroundColor: 'backgroundColor', 224 | meanBorderColor: 'borderColor', 225 | }; 226 | 227 | /** 228 | * @hidden 229 | */ 230 | export const baseOptionKeys = /* #__PURE__ */ (() => Object.keys(baseDefaults).concat(Object.keys(baseRoutes)))(); 231 | 232 | export interface IStatsBaseProps { 233 | x: number; 234 | y: number; 235 | width: number; 236 | height: number; 237 | items: number[]; 238 | outliers: number[]; 239 | } 240 | 241 | export class StatsBase extends Element< 242 | T, 243 | O 244 | > { 245 | /** 246 | * @hidden 247 | */ 248 | declare _datasetIndex: number; 249 | 250 | /** 251 | * @hidden 252 | */ 253 | declare horizontal: boolean; 254 | 255 | /** 256 | * @hidden 257 | */ 258 | declare _index: number; 259 | 260 | /** 261 | * @hidden 262 | */ 263 | isVertical(): boolean { 264 | return !this.horizontal; 265 | } 266 | 267 | /** 268 | * @hidden 269 | */ 270 | protected _drawItems(ctx: CanvasRenderingContext2D): void { 271 | const vert = this.isVertical(); 272 | const props = this.getProps(['x', 'y', 'items', 'width', 'height', 'outliers']); 273 | const { options } = this; 274 | 275 | if (options.itemRadius <= 0 || !props.items || props.items.length <= 0) { 276 | return; 277 | } 278 | ctx.save(); 279 | ctx.strokeStyle = options.itemBorderColor; 280 | ctx.fillStyle = options.itemBackgroundColor; 281 | ctx.lineWidth = options.itemBorderWidth; 282 | // jitter based on random data 283 | // use the dataset index and index to initialize the random number generator 284 | const random = rnd(this._datasetIndex * 1000 + this._index); 285 | 286 | const pointOptions = { 287 | pointStyle: options.itemStyle, 288 | radius: options.itemRadius, 289 | borderWidth: options.itemBorderWidth, 290 | }; 291 | const outliers = new Set(props.outliers || []); 292 | 293 | if (vert) { 294 | props.items.forEach((v) => { 295 | if (!outliers.has(v)) { 296 | drawPoint(ctx, pointOptions, props.x - props.width / 2 + random() * props.width, v); 297 | } 298 | }); 299 | } else { 300 | props.items.forEach((v) => { 301 | if (!outliers.has(v)) { 302 | drawPoint(ctx, pointOptions, v, props.y - props.height / 2 + random() * props.height); 303 | } 304 | }); 305 | } 306 | ctx.restore(); 307 | } 308 | 309 | /** 310 | * @hidden 311 | */ 312 | protected _drawOutliers(ctx: CanvasRenderingContext2D): void { 313 | const vert = this.isVertical(); 314 | const props = this.getProps(['x', 'y', 'outliers']); 315 | const { options } = this; 316 | if (options.outlierRadius <= 0 || !props.outliers || props.outliers.length === 0) { 317 | return; 318 | } 319 | ctx.save(); 320 | ctx.fillStyle = options.outlierBackgroundColor; 321 | ctx.strokeStyle = options.outlierBorderColor; 322 | ctx.lineWidth = options.outlierBorderWidth; 323 | 324 | const pointOptions = { 325 | pointStyle: options.outlierStyle, 326 | radius: options.outlierRadius, 327 | borderWidth: options.outlierBorderWidth, 328 | }; 329 | 330 | if (vert) { 331 | props.outliers.forEach((v) => { 332 | drawPoint(ctx, pointOptions, props.x, v); 333 | }); 334 | } else { 335 | props.outliers.forEach((v) => { 336 | drawPoint(ctx, pointOptions, v, props.y); 337 | }); 338 | } 339 | 340 | ctx.restore(); 341 | } 342 | 343 | /** 344 | * @hidden 345 | */ 346 | protected _drawMeanDot(ctx: CanvasRenderingContext2D): void { 347 | const vert = this.isVertical(); 348 | const props = this.getProps(['x', 'y', 'mean']); 349 | const { options } = this; 350 | if (options.meanRadius <= 0 || props.mean == null || Number.isNaN(props.mean)) { 351 | return; 352 | } 353 | ctx.save(); 354 | ctx.fillStyle = options.meanBackgroundColor; 355 | ctx.strokeStyle = options.meanBorderColor; 356 | ctx.lineWidth = options.meanBorderWidth; 357 | 358 | const pointOptions = { 359 | pointStyle: options.meanStyle, 360 | radius: options.meanRadius, 361 | borderWidth: options.meanBorderWidth, 362 | }; 363 | 364 | if (vert) { 365 | drawPoint(ctx, pointOptions, props.x, props.mean); 366 | } else { 367 | drawPoint(ctx, pointOptions, props.mean, props.y); 368 | } 369 | 370 | ctx.restore(); 371 | } 372 | 373 | /** 374 | * @hidden 375 | */ 376 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 377 | _getBounds(_useFinalPosition?: boolean): { left: number; top: number; right: number; bottom: number } { 378 | // abstract 379 | return { 380 | left: 0, 381 | top: 0, 382 | right: 0, 383 | bottom: 0, 384 | }; 385 | } 386 | 387 | /** 388 | * @hidden 389 | */ 390 | _getHitBounds(useFinalPosition?: boolean): { left: number; top: number; right: number; bottom: number } { 391 | const padding = this.options.hitPadding; 392 | const b = this._getBounds(useFinalPosition); 393 | return { 394 | left: b.left - padding, 395 | top: b.top - padding, 396 | right: b.right + padding, 397 | bottom: b.bottom + padding, 398 | }; 399 | } 400 | 401 | /** 402 | * @hidden 403 | */ 404 | inRange(mouseX: number, mouseY: number, useFinalPosition?: boolean): boolean { 405 | if (Number.isNaN(this.x) && Number.isNaN(this.y)) { 406 | return false; 407 | } 408 | return ( 409 | this._boxInRange(mouseX, mouseY, useFinalPosition) || 410 | this._outlierIndexInRange(mouseX, mouseY, useFinalPosition) != null || 411 | this._itemIndexInRange(mouseX, mouseY, useFinalPosition) != null 412 | ); 413 | } 414 | 415 | /** 416 | * @hidden 417 | */ 418 | inXRange(mouseX: number, useFinalPosition?: boolean): boolean { 419 | const bounds = this._getHitBounds(useFinalPosition); 420 | return mouseX >= bounds.left && mouseX <= bounds.right; 421 | } 422 | 423 | /** 424 | * @hidden 425 | */ 426 | inYRange(mouseY: number, useFinalPosition?: boolean): boolean { 427 | const bounds = this._getHitBounds(useFinalPosition); 428 | return mouseY >= bounds.top && mouseY <= bounds.bottom; 429 | } 430 | 431 | /** 432 | * @hidden 433 | */ 434 | protected _outlierIndexInRange( 435 | mouseX: number, 436 | mouseY: number, 437 | useFinalPosition?: boolean 438 | ): { index: number; x: number; y: number } | null { 439 | const props = this.getProps(['x', 'y'], useFinalPosition); 440 | const hitRadius = this.options.outlierHitRadius; 441 | const outliers = this._getOutliers(useFinalPosition); 442 | const vertical = this.isVertical(); 443 | 444 | // check if along the outlier line 445 | if ((vertical && Math.abs(mouseX - props.x) > hitRadius) || (!vertical && Math.abs(mouseY - props.y) > hitRadius)) { 446 | return null; 447 | } 448 | const toCompare = vertical ? mouseY : mouseX; 449 | for (let i = 0; i < outliers.length; i += 1) { 450 | if (Math.abs(outliers[i] - toCompare) <= hitRadius) { 451 | return vertical ? { index: i, x: props.x, y: outliers[i] } : { index: i, x: outliers[i], y: props.y }; 452 | } 453 | } 454 | return null; 455 | } 456 | 457 | /** 458 | * @hidden 459 | */ 460 | protected _itemIndexInRange( 461 | mouseX: number, 462 | mouseY: number, 463 | useFinalPosition?: boolean 464 | ): { index: number; x: number; y: number } | null { 465 | const hitRadius = this.options.itemHitRadius; 466 | if (hitRadius <= 0) { 467 | return null; 468 | } 469 | const props = this.getProps(['x', 'y', 'items', 'width', 'height', 'outliers'], useFinalPosition); 470 | const vert = this.isVertical(); 471 | const { options } = this; 472 | 473 | if (options.itemRadius <= 0 || !props.items || props.items.length <= 0) { 474 | return null; 475 | } 476 | // jitter based on random data 477 | // use the dataset index and index to initialize the random number generator 478 | const random = rnd(this._datasetIndex * 1000 + this._index); 479 | const outliers = new Set(props.outliers || []); 480 | 481 | if (vert) { 482 | for (let i = 0; i < props.items.length; i++) { 483 | const y = props.items[i]; 484 | if (!outliers.has(y)) { 485 | const x = props.x - props.width / 2 + random() * props.width; 486 | if (Math.abs(x - mouseX) <= hitRadius && Math.abs(y - mouseY) <= hitRadius) { 487 | return { index: i, x, y }; 488 | } 489 | } 490 | } 491 | } else { 492 | for (let i = 0; i < props.items.length; i++) { 493 | const x = props.items[i]; 494 | if (!outliers.has(x)) { 495 | const y = props.y - props.height / 2 + random() * props.height; 496 | if (Math.abs(x - mouseX) <= hitRadius && Math.abs(y - mouseY) <= hitRadius) { 497 | return { index: i, x, y }; 498 | } 499 | } 500 | } 501 | } 502 | return null; 503 | } 504 | 505 | /** 506 | * @hidden 507 | */ 508 | protected _boxInRange(mouseX: number, mouseY: number, useFinalPosition?: boolean): boolean { 509 | const bounds = this._getHitBounds(useFinalPosition); 510 | return mouseX >= bounds.left && mouseX <= bounds.right && mouseY >= bounds.top && mouseY <= bounds.bottom; 511 | } 512 | 513 | /** 514 | * @hidden 515 | */ 516 | getCenterPoint(useFinalPosition?: boolean): { x: number; y: number } { 517 | const props = this.getProps(['x', 'y'], useFinalPosition); 518 | return { 519 | x: props.x, 520 | y: props.y, 521 | }; 522 | } 523 | 524 | /** 525 | * @hidden 526 | */ 527 | protected _getOutliers(useFinalPosition?: boolean): number[] { 528 | const props = this.getProps(['outliers'], useFinalPosition); 529 | return props.outliers || []; 530 | } 531 | 532 | /** 533 | * @hidden 534 | */ 535 | tooltipPosition( 536 | eventPosition?: { x: number; y: number } | boolean, 537 | tooltip?: ExtendedTooltip 538 | ): { x: number; y: number } { 539 | if (!eventPosition || typeof eventPosition === 'boolean') { 540 | // fallback 541 | return this.getCenterPoint(); 542 | } 543 | if (tooltip) { 544 | delete tooltip._tooltipOutlier; 545 | 546 | delete tooltip._tooltipItem; 547 | } 548 | 549 | //outlier 550 | const info = this._outlierIndexInRange(eventPosition.x, eventPosition.y); 551 | if (info != null && tooltip) { 552 | // hack in the data of the hovered outlier 553 | 554 | tooltip._tooltipOutlier = { 555 | index: info.index, 556 | datasetIndex: this._datasetIndex, 557 | }; 558 | return { 559 | x: info.x, 560 | y: info.y, 561 | }; 562 | } 563 | // items 564 | const itemInfo = this._itemIndexInRange(eventPosition.x, eventPosition.y); 565 | if (itemInfo != null && tooltip) { 566 | // hack in the data of the hovered outlier 567 | 568 | tooltip._tooltipItem = { 569 | index: itemInfo.index, 570 | datasetIndex: this._datasetIndex, 571 | }; 572 | return { 573 | x: itemInfo.x, 574 | y: itemInfo.y, 575 | }; 576 | } 577 | 578 | // fallback 579 | return this.getCenterPoint(); 580 | } 581 | } 582 | -------------------------------------------------------------------------------- /src/elements/index.ts: -------------------------------------------------------------------------------- 1 | export { type IStatsBaseOptions, type IStatsBaseProps, StatsBase } from './base'; 2 | export { BoxAndWiskers, type IBoxAndWhiskerProps, type IBoxAndWhiskersOptions } from './BoxAndWiskers'; 3 | export { type IViolinElementOptions, type IViolinElementProps, Violin } from './Violin'; 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './elements'; 2 | export * from './controllers'; 3 | 4 | export type { 5 | IBaseOptions, 6 | IBaseStats, 7 | IBoxPlot, 8 | IBoxplotOptions, 9 | IKDEPoint, 10 | IViolin, 11 | IViolinOptions, 12 | QuantileMethod, 13 | } from './data'; 14 | 15 | export type { ExtendedTooltip } from './tooltip'; 16 | -------------------------------------------------------------------------------- /src/index.umd.ts: -------------------------------------------------------------------------------- 1 | import { registry } from 'chart.js'; 2 | import { BoxPlotController, ViolinController } from './controllers'; 3 | import { BoxAndWiskers, Violin } from './elements'; 4 | 5 | export * from '.'; 6 | 7 | registry.addControllers(BoxPlotController, ViolinController); 8 | registry.addElements(BoxAndWiskers, Violin); 9 | -------------------------------------------------------------------------------- /src/tooltip.ts: -------------------------------------------------------------------------------- 1 | import { InteractionItem, TooltipItem, Tooltip, TooltipModel } from 'chart.js'; 2 | 3 | export interface ExtendedTooltip extends TooltipModel<'boxplot' | 'violin'> { 4 | _tooltipOutlier?: { 5 | index: number; 6 | datasetIndex: number; 7 | }; 8 | _tooltipItem?: { 9 | index: number; 10 | datasetIndex: number; 11 | }; 12 | } 13 | 14 | /** 15 | * @hidden 16 | */ 17 | export function patchInHoveredOutlier( 18 | this: TooltipModel<'boxplot' | 'violin'>, 19 | item: TooltipItem<'boxplot' | 'violin'> 20 | ): void { 21 | const value = item.formattedValue as any; 22 | const that = this as ExtendedTooltip; 23 | if (value && that._tooltipOutlier != null && item.datasetIndex === that._tooltipOutlier.datasetIndex) { 24 | value.hoveredOutlierIndex = that._tooltipOutlier.index; 25 | } 26 | if (value && that._tooltipItem != null && item.datasetIndex === that._tooltipItem.datasetIndex) { 27 | value.hoveredItemIndex = that._tooltipItem.index; 28 | } 29 | } 30 | 31 | /** 32 | * based on average positioner but allow access to the tooltip instance 33 | * @hidden 34 | */ 35 | export function outlierPositioner( 36 | this: TooltipModel<'boxplot' | 'violin'>, 37 | items: readonly InteractionItem[], 38 | eventPosition: { x: number; y: number } 39 | ): false | { x: number; y: number } { 40 | if (!items.length) { 41 | return false; 42 | } 43 | let x = 0; 44 | let y = 0; 45 | let count = 0; 46 | for (let i = 0; i < items.length; i += 1) { 47 | const el = items[i].element; 48 | if (el && el.hasValue()) { 49 | const pos = (el as any).tooltipPosition(eventPosition, this); 50 | x += pos.x; 51 | y += pos.y; 52 | count += 1; 53 | } 54 | } 55 | return { 56 | x: x / count, 57 | y: y / count, 58 | }; 59 | } 60 | 61 | outlierPositioner.id = 'average'; 62 | outlierPositioner.register = () => { 63 | Tooltip.positioners.average = outlierPositioner as any; 64 | return outlierPositioner; 65 | }; 66 | -------------------------------------------------------------------------------- /tsconfig.c.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "build", 5 | "declaration": true, 6 | "declarationMap": true, 7 | "noEmit": true, 8 | "composite": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "ESNext", 5 | "lib": ["DOM", "ES2020"], 6 | "importHelpers": false, 7 | "declaration": false, 8 | "sourceMap": true, 9 | "strict": true, 10 | "removeComments": true, 11 | "verbatimModuleSyntax": false, 12 | "experimentalDecorators": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "strictBindCallApply": true, 15 | "stripInternal": true, 16 | "resolveJsonModule": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noImplicitReturns": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "moduleResolution": "Bundler", 22 | "jsx": "react", 23 | "esModuleInterop": true, 24 | "rootDir": "./src", 25 | "baseUrl": "./", 26 | "noEmit": true, 27 | "paths": { 28 | "@": ["./src"], 29 | "*": ["*", "node_modules/*"], 30 | // workaround for: https://github.com/vitest-dev/vitest/issues/4567 31 | "rollup/parseAst": ["./node_modules/rollup/dist/parseAst"] 32 | } 33 | }, 34 | "include": ["src/**/*.ts", "src/**/*.tsx", "docs/**/*.tsx"] 35 | } 36 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://typedoc.org/schema.json", 3 | "entryPoints": ["./src"], 4 | "plugin": ["typedoc-plugin-markdown", "typedoc-vitepress-theme"], 5 | "name": "chartjs-chart-boxplot", 6 | "out": "./docs/api", 7 | "docsRoot": "./docs/", 8 | "readme": "none", 9 | "sidebar": { 10 | "pretty": true 11 | }, 12 | "theme": "default", 13 | "excludeExternals": true, 14 | "excludeInternal": true, 15 | "excludePrivate": true, 16 | "includeVersion": true, 17 | "categorizeByGroup": true, 18 | "cleanOutputDir": true, 19 | "hideGenerator": true 20 | } 21 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from 'vite'; 3 | 4 | export default defineConfig({ 5 | test: { 6 | environment: 'jsdom', 7 | root: './src', 8 | }, 9 | }); 10 | --------------------------------------------------------------------------------