├── .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 | 
8 | 
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 | 
8 | 
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 |
--------------------------------------------------------------------------------