├── .circleci
└── config.yml
├── .editorconfig
├── .eslintrc
├── .github
└── CODEOWNERS
├── .gitignore
├── .release-it.json
├── .travis.yml
├── LICENSE
├── README.md
├── babel.config.js
├── jest.config.js
├── package-lock.json
├── package.json
├── rollup.config.js
├── samples
├── animation.html
├── chartjs291.html
├── datalimits.html
├── datastructures.html
├── empty.html
├── fivenum.html
├── horizontalBoxplot.html
├── horizontalViolin.html
├── hybrid.html
├── ie11.html
├── logarithm.html
├── mediancolor.html
├── minmax.html
├── utils.js
├── vertical.html
├── vertical_segment.html
├── violin.html
└── violinSingle.html
└── src
├── controllers
├── base.js
├── boxplot.js
└── violin.js
├── data.js
├── data.spec.js
├── elements
├── base.js
├── boxandwhiskers.js
└── violin.js
├── index.js
├── scale
├── arrayLinear.js
└── arrayLogarithmic.js
└── tooltip.js
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | jobs:
3 | build:
4 | working_directory: ~/src
5 | docker:
6 | - image: circleci/node:10-browsers
7 | steps:
8 | - checkout
9 | - restore_cache:
10 | key: deps2-{{ .Branch }}-{{ checksum "package.json" }}
11 | - run:
12 | name: install-npm-wee
13 | command: npm install
14 | - run: #remove all resolved github dependencies
15 | name: delete-vcs-dependencies
16 | command: |
17 | (grep -l '._resolved.: .\(git[^:]*\|bitbucket\):' ./node_modules/*/package.json || true) | xargs -r dirname | xargs -r rm -rf
18 | - save_cache:
19 | key: deps2-{{ .Branch }}-{{ checksum "package.json" }}
20 | paths:
21 | - ./node_modules
22 | - run: #install all dependencies
23 | name: install-npm-wee2
24 | command: npm install
25 | - run:
26 | name: build
27 | command: npm run build
28 | - store_artifacts:
29 | path: build
30 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 |
12 | [*.yml]
13 | indent_style = space
14 | indent_size = 2
15 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | env:
2 | amd: true
3 | browser: true
4 | es6: true
5 | jquery: true
6 | node: true
7 | jest: true
8 |
9 | parserOptions:
10 | ecmaVersion: 2018
11 | sourceType: module
12 |
13 | # http://eslint.org/docs/rules/
14 | rules:
15 | # Possible Errors
16 | no-cond-assign: 2
17 | no-console: [2, {allow: [warn, error]}]
18 | no-constant-condition: 2
19 | no-control-regex: 2
20 | no-debugger: 2
21 | no-dupe-args: 2
22 | no-dupe-keys: 2
23 | no-duplicate-case: 2
24 | no-empty: 2
25 | no-empty-character-class: 2
26 | no-ex-assign: 2
27 | no-extra-boolean-cast: 2
28 | no-extra-parens: [2, functions]
29 | no-extra-semi: 2
30 | no-func-assign: 2
31 | no-inner-declarations: [2, functions]
32 | no-invalid-regexp: 2
33 | no-irregular-whitespace: 2
34 | no-negated-in-lhs: 2
35 | no-obj-calls: 2
36 | no-regex-spaces: 2
37 | no-sparse-arrays: 2
38 | no-unexpected-multiline: 2
39 | no-unreachable: 2
40 | use-isnan: 2
41 | valid-jsdoc: 0
42 | valid-typeof: 2
43 |
44 | # Best Practices
45 | accessor-pairs: 2
46 | array-callback-return: 0
47 | block-scoped-var: 0
48 | complexity: [2, 15]
49 | consistent-return: 0
50 | curly: [2, all]
51 | default-case: 2
52 | dot-location: 0
53 | dot-notation: 2
54 | eqeqeq: ["error", "always", {"null": "ignore"}]
55 | guard-for-in: 2
56 | no-alert: 2
57 | no-caller: 2
58 | no-case-declarations: 2
59 | no-div-regex: 2
60 | no-else-return: 2
61 | no-empty-pattern: 2
62 | no-eq-null: 0
63 | no-eval: 2
64 | no-extend-native: 2
65 | no-extra-bind: 2
66 | no-fallthrough: 2
67 | no-floating-decimal: 2
68 | no-implicit-coercion: 0
69 | no-implied-eval: 2
70 | no-invalid-this: 0
71 | no-iterator: 2
72 | no-labels: 2
73 | no-lone-blocks: 2
74 | no-loop-func: 2
75 | no-magic-number: 0
76 | no-multi-spaces: 2
77 | no-multi-str: 2
78 | no-native-reassign: 2
79 | no-new-func: 2
80 | no-new-wrappers: 2
81 | no-new: 2
82 | no-octal-escape: 2
83 | no-octal: 2
84 | no-proto: 2
85 | no-redeclare: 2
86 | no-return-assign: 2
87 | no-script-url: 2
88 | no-self-compare: 2
89 | no-sequences: 2
90 | no-throw-literal: 0
91 | no-unused-expressions: 2
92 | no-useless-call: 2
93 | no-useless-concat: 2
94 | no-void: 2
95 | no-warning-comments: 0
96 | no-with: 2
97 | radix: 2
98 | vars-on-top: 0
99 | wrap-iife: 2
100 | yoda: [1, never]
101 |
102 | # Strict
103 | strict: 0
104 |
105 | # Variables
106 | init-declarations: 0
107 | no-catch-shadow: 2
108 | no-delete-var: 2
109 | no-label-var: 2
110 | no-shadow-restricted-names: 2
111 | no-shadow: 2
112 | no-undef-init: 2
113 | no-undef: 2
114 | no-undefined: 0
115 | no-unused-vars: 2
116 | no-use-before-define: 2
117 |
118 | # Node.js and CommonJS
119 | callback-return: 2
120 | global-require: 2
121 | handle-callback-err: 2
122 | no-mixed-requires: 0
123 | no-new-require: 0
124 | no-path-concat: 2
125 | no-process-exit: 2
126 | no-restricted-modules: 0
127 | no-sync: 0
128 |
129 | # Stylistic Issues
130 | array-bracket-spacing: [2, never]
131 | block-spacing: 0
132 | brace-style: [2, 1tbs]
133 | camelcase: 2
134 | comma-dangle: [2, only-multiline]
135 | comma-spacing: 2
136 | comma-style: [2, last]
137 | computed-property-spacing: [2, never]
138 | consistent-this: [2, me]
139 | eol-last: 2
140 | func-call-spacing: 0
141 | func-names: [2, never]
142 | func-style: 0
143 | id-length: 0
144 | id-match: 0
145 | indent: [2, 2]
146 | jsx-quotes: 0
147 | key-spacing: 2
148 | keyword-spacing: 2
149 | linebreak-style: 0
150 | lines-around-comment: 0
151 | max-depth: 0
152 | max-len: 0
153 | max-lines: 0
154 | max-nested-callbacks: 0
155 | max-params: 0
156 | max-statements-per-line: 0
157 | max-statements: [2, 43]
158 | multiline-ternary: 0
159 | new-cap: 0
160 | new-parens: 2
161 | newline-after-var: 0
162 | newline-before-return: 0
163 | newline-per-chained-call: 0
164 | no-array-constructor: 0
165 | no-bitwise: 0
166 | no-continue: 0
167 | no-inline-comments: 0
168 | no-lonely-if: 2
169 | no-mixed-operators: 0
170 | no-mixed-spaces-and-tabs: 2
171 | no-multiple-empty-lines: [2, {max: 2}]
172 | no-negated-condition: 0
173 | no-nested-ternary: 0
174 | no-new-object: 0
175 | no-plusplus: 0
176 | no-restricted-syntax: 0
177 | no-spaced-func: 0
178 | no-ternary: 0
179 | no-trailing-spaces: 2
180 | no-underscore-dangle: 0
181 | no-unneeded-ternary: 0
182 | no-whitespace-before-property: 2
183 | object-curly-newline: 0
184 | object-curly-spacing: [2, never]
185 | object-property-newline: 0
186 | one-var-declaration-per-line: 2
187 | one-var: [2, {initialized: never}]
188 | operator-assignment: 0
189 | operator-linebreak: 0
190 | padded-blocks: 0
191 | quote-props: [2, as-needed]
192 | quotes: [2, single, {avoidEscape: true}]
193 | require-jsdoc: 0
194 | semi-spacing: 2
195 | semi: [2, always]
196 | sort-keys: 0
197 | sort-vars: 0
198 | space-before-blocks: [2, always]
199 | space-before-function-paren: [2, never]
200 | space-in-parens: [2, never]
201 | space-infix-ops: 2
202 | space-unary-ops: [2, {words: true, nonwords: false}]
203 | spaced-comment: [2, always]
204 | unicode-bom: 0
205 | wrap-regex: 2
206 |
207 | # ECMAScript 6
208 | arrow-body-style: 0
209 | arrow-parens: 0
210 | arrow-spacing: 0
211 | constructor-super: 0
212 | generator-star-spacing: 0
213 | no-arrow-condition: 0
214 | no-class-assign: 0
215 | no-const-assign: 0
216 | no-dupe-class-members: 0
217 | no-this-before-super: 0
218 | no-var: 0
219 | object-shorthand: 0
220 | prefer-arrow-callback: 0
221 | prefer-const: 0
222 | prefer-reflect: 0
223 | prefer-spread: 0
224 | prefer-template: 0
225 | require-yield: 0
226 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @dg-datavisyn
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 |
6 | # Runtime data
7 | pids
8 | *.pid
9 | *.seed
10 |
11 | # Directory for instrumented libs generated by jscoverage/JSCover
12 | lib-cov
13 |
14 | # Coverage directory used by tools like istanbul
15 | coverage
16 |
17 | # nyc test coverage
18 | .nyc_output
19 |
20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
21 | .grunt
22 |
23 | # node-waf configuration
24 | .lock-wscript
25 |
26 | # Compiled binary addons (http://nodejs.org/api/addons.html)
27 | build/Release
28 |
29 | # Dependency directories
30 | node_modules
31 | jspm_packages
32 |
33 | # Optional npm cache directory
34 | .npm
35 |
36 | # Optional REPL history
37 | .node_repl_history
38 |
39 | # Visual Studio
40 | .vs
41 |
42 | # Idea
43 | .idea
44 |
45 | # Build files
46 | /build
47 | *.tgz
48 |
--------------------------------------------------------------------------------
/.release-it.json:
--------------------------------------------------------------------------------
1 | {
2 | "hooks": {
3 | "before:init": "npm test",
4 | "before:release": "npm run build & npm pack",
5 | "after:release": "echo Successfully released ${name} v${version} to ${repo.repository}."
6 | },
7 | "git": {
8 | "tagName": "v%s"
9 | },
10 | "npm": {
11 | "publish": true
12 | },
13 | "github": {
14 | "release": true,
15 | "assets": ["build/*.js", "*.tgz"]
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - 6
4 | before_install:
5 | - export DISPLAY=:99.0
6 | - sh -e /etc/init.d/xvfb start
7 | script: npm run prepublish
8 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2016, davavisyn GmbH
4 | All rights reserved.
5 |
6 | Redistribution and use in source and binary forms, with or without
7 | modification, are permitted provided that the following conditions are met:
8 |
9 | * Redistributions of source code must retain the above copyright notice, this
10 | list of conditions and the following disclaimer.
11 |
12 | * Redistributions in binary form must reproduce the above copyright notice,
13 | this list of conditions and the following disclaimer in the documentation
14 | and/or other materials provided with the distribution.
15 |
16 | * Neither the name of the copyright holder nor the names of its
17 | contributors may be used to endorse or promote products derived from
18 | this software without specific prior written permission.
19 |
20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # DEPRECATED: Chart.js Box and Violin Plot
2 | [![Target Discovery Platform][tdp-image]][tdp-url] [![NPM Package][npm-image]][npm-url] [![CircleCI][circleci-image]][circleci-url]
3 |
4 | Chart.js module for charting box and violin plots. **Works only with Chart.js >= 2.8.0**
5 |
6 | 
7 | 
8 |
9 | ### DEPRECATION Information
10 |
11 | Please note that this project has been archived and is no longer being maintained. There is an active fork https://github.com/sgratzl/chartjs-chart-boxplot and we will also contribute our future changes to it.
12 |
13 |
14 | ## Install
15 |
16 | ```bash
17 | npm install --save chart.js chartjs-chart-box-and-violin-plot
18 | ```
19 |
20 | ## Usage
21 | see [Samples](https://github.com/datavisyn/chartjs-chart-box-and-violin-plot/tree/master/samples) on Github
22 |
23 | and [CodePen](https://codepen.io/sgratzl/pen/QxoLoY)
24 |
25 | ## Chart
26 |
27 | four new types: `boxplot`, `horizontalBoxplot`, `violin`, and `horizontalViolin`.
28 |
29 | ## Config
30 |
31 | ```typescript
32 | /**
33 | * Limit decimal digits by an optional config option
34 | **/
35 | tooltipDecimals?: number;
36 | ```
37 |
38 | ## Styling
39 | The boxplot element is called `boxandwhiskers`. The basic options are from the `rectangle` element. The violin element is called `violin` also based on the `rectangle` element.
40 |
41 | ```typescript
42 | interface IBaseStyling {
43 | /**
44 | * @default see rectangle
45 | * @scriptable
46 | * @indexable
47 | */
48 | backgroundColor: string;
49 |
50 | /**
51 | * @default see rectangle
52 | * @scriptable
53 | * @indexable
54 | */
55 | borderColor: string;
56 |
57 | /**
58 | * @default null takes the current borderColor
59 | * @scriptable
60 | * @indexable
61 | */
62 | medianColor: string;
63 |
64 | /**
65 | * @default 1
66 | * @scriptable
67 | * @indexable
68 | */
69 | borderWidth: number;
70 |
71 | /**
72 | * radius used to render outliers
73 | * @default 2
74 | * @scriptable
75 | * @indexable
76 | */
77 | outlierRadius: number;
78 |
79 | /**
80 | * @default see rectangle.backgroundColor
81 | * @scriptable
82 | * @indexable
83 | */
84 | outlierColor: string;
85 |
86 | /**
87 | * to fill color below the median line of the box
88 | * @default see rectangle.lowerColor
89 | * @scriptable
90 | * @indexable
91 | */
92 | lowerColor: string;
93 |
94 | /**
95 | * radius used to render items
96 | * @default 0 so disabled
97 | * @scriptable
98 | * @indexable
99 | */
100 | itemRadius: number;
101 |
102 | /**
103 | * item style used to render items
104 | * @default circle
105 | */
106 | itemStyle: 'circle'|'triangle'|'rect'|'rectRounded'|'rectRot'|'cross'|'crossRot'|'star'|'line'|'dash';
107 |
108 | /**
109 | * background color for items
110 | * @default see rectangle backgroundColor
111 | * @scriptable
112 | * @indexable
113 | */
114 | itemBackgroundColor: string;
115 |
116 | /**
117 | * border color for items
118 | * @default see rectangle backgroundColor
119 | * @scriptable
120 | * @indexable
121 | */
122 | itemBorderColor: string;
123 |
124 | /**
125 | * padding that is added around the bounding box when computing a mouse hit
126 | * @default 1
127 | * @scriptable
128 | * @indexable
129 | */
130 | hitPadding: number;
131 |
132 | /**
133 | * hit radius for hit test of outliers
134 | * @default 4
135 | * @scriptable
136 | * @indexable
137 | */
138 | outlierHitRadius: number;
139 | }
140 |
141 | interface IBoxPlotStyling extends IBaseStyling {
142 | // no extra styling options
143 | }
144 |
145 | interface IViolinStyling extends IBaseStyling {
146 | /**
147 | * number of sample points of the underlying KDE for creating the violin plot
148 | * @default 100
149 | */
150 | points: number;
151 | }
152 | ```
153 |
154 | In addition, two new scales were created `arrayLinear` and `arrayLogarithmic`. They were needed to adapt to the required data structure.
155 |
156 | ## Scale Options
157 |
158 | Both `arrayLinear` and `arrayLogarithmic` support the two additional options to their regular counterparts:
159 |
160 | ```typescript
161 | interface IArrayLinearScale {
162 | ticks: {
163 | /**
164 | * statistic measure that should be used for computing the minimal data limit
165 | * @default 'min'
166 | */
167 | minStats: 'min'|'q1'|'whiskerMin';
168 |
169 | /**
170 | * statistic measure that should be used for computing the maximal data limit
171 | * @default 'max'
172 | */
173 | maxStats: 'max'|'q3'|'whiskerMax';
174 |
175 | /**
176 | * from the R doc: this determines how far the plot ‘whiskers’ extend out from
177 | * the box. If coef is positive, the whiskers extend to the most extreme data
178 | * point which is no more than coef times the length of the box away from the
179 | * box. A value of zero causes the whiskers to extend to the data extremes
180 | * @default 1.5
181 | */
182 | coef: number;
183 |
184 | /**
185 | * the method to compute the quantiles. 7 and 'quantiles' refers to the type-7 method as used by R 'quantiles' method. 'hinges' and 'fivenum' refers to the method used by R 'boxplot.stats' method.
186 | * @default 7
187 | */
188 | quantiles: 7 | 'quantiles' | 'hinges' | 'fivenum' | ((sortedArr: number[]) => {min: number, q1: number, median: number, q3: number, max: number});
189 | };
190 | }
191 | ```
192 |
193 | ## Data structure
194 |
195 | 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:
196 |
197 |
198 | ```typescript
199 | interface IBaseItem {
200 | min: number;
201 | median: number;
202 | max: number;
203 | /**
204 | * values of the raw items used for rendering jittered background points
205 | */
206 | items?: number[];
207 | }
208 |
209 | interface IBoxPlotItem extends IBaseItem {
210 | q1: number;
211 | q3: number;
212 | whiskerMin?: number;
213 | whiskerMax?: number;
214 | /**
215 | * list of box plot outlier values
216 | */
217 | outliers?: number[];
218 | }
219 |
220 | interface IKDESamplePoint {
221 | /**
222 | * sample value
223 | */
224 | v: number;
225 | /**
226 | * sample estimation
227 | */
228 | estimate: number;
229 | }
230 |
231 | interface IViolinItem extends IBaseItem {
232 | /**
233 | * samples of the underlying KDE
234 | */
235 | coords: IKDESamplePoint[];
236 | }
237 | ```
238 |
239 | **Note**: The statistics will be cached within the array. Thus, if you manipulate the array content without creating a new instance the changes won't be reflected in the stats. See also [CodePen](https://codepen.io/sgratzl/pen/JxQVaZ?editors=0010) for a comparison.
240 |
241 | ## Tooltips
242 |
243 | In order to simplify the customization of the tooltips, two additional tooltip callback methods are available. Internally the `label` callback will call the correspondig callback depending on the type.
244 |
245 | ```js
246 | arr = {
247 | options: {
248 | tooltips: {
249 | callbacks: {
250 | /**
251 | * custom callback for boxplot tooltips
252 | * @param item see label callback
253 | * @param data see label callback
254 | * @param stats {IBoxPlotItem} the stats of the hovered element
255 | * @param hoveredOutlierIndex {number} the hovered outlier index or -1 if none
256 | * @return {string} see label callback
257 | */
258 | boxplotLabel: function(item, data, stats, hoveredOutlierIndex) {
259 | return 'Custom tooltip';
260 | },
261 | /**
262 | * custom callback for violin tooltips
263 | * @param item see label callback
264 | * @param data see label callback
265 | * @param stats {IViolinItem} the stats of the hovered element
266 | * @return {string} see label callback
267 | */
268 | violinLabel: function(item, data, stats) {
269 | return 'Custom tooltip';
270 | },
271 | }
272 | }
273 | }
274 | }
275 | ```
276 |
277 | ## Building
278 |
279 | ```sh
280 | npm install
281 | npm run build
282 | ```
283 |
284 | ## Angular CLI Usage
285 | Here is an example project based on Angular CLI with Angular 7 dependencies: https://github.com/sluger/ng-chartjs-boxplot
286 |
287 | The incomaptibility with Webpack 4, mjs and Angular CLI can be solved by importing the chartjs boxplot library via the `.js` build artifact:
288 | ```javascript
289 | import "chartjs-chart-box-and-violin-plot/build/Chart.BoxPlot.js";
290 | ```
291 |
292 | ***
293 |
294 |
295 | This repository is part of the **Target Discovery Platform** (TDP). For tutorials, API docs, and more information about the build and deployment process, see the [documentation page](https://wiki.datavisyn.io).
296 |
297 |
298 |
299 | [tdp-image]: https://img.shields.io/badge/Target%20Discovery%20Platform-Library-violet.svg
300 | [tdp-url]: http://datavisyn.io
301 | [npm-image]: https://badge.fury.io/js/chartjs-chart-box-and-violin-plot.svg
302 | [npm-url]: https://npmjs.org/package/chartjs-chart-box-and-violin-plot
303 | [circleci-image]: https://circleci.com/gh/datavisyn/chartjs-chart-box-and-violin-plot.svg?style=shield
304 | [circleci-url]: https://circleci.com/gh/datavisyn/chartjs-chart-box-and-violin-plot
305 |
306 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "presets": [
3 | [
4 | "@babel/preset-env",
5 | {
6 | "modules": false
7 | }
8 | ]
9 | ],
10 | "env": {
11 | "test": {
12 | "presets": [["@babel/preset-env"]]
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | transformIgnorePatterns: []
3 | };
4 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "chartjs-chart-box-and-violin-plot",
3 | "description": "Chart.js module for charting boxplots",
4 | "homepage": "https://datavisyn.io",
5 | "version": "4.0.0",
6 | "author": {
7 | "name": "datavisyn GmbH",
8 | "email": "contact@datavisyn.io",
9 | "url": "https://datavisyn.io"
10 | },
11 | "contributors": [
12 | {
13 | "name": "Samuel Gratzl",
14 | "email": "sam@sgratzl.com",
15 | "url": "https://www.sgratzl.com"
16 | },
17 | {
18 | "name": "Stefan Luger",
19 | "email": "stefan.luger@datavisyn.io",
20 | "url": "https://github.com/sluger"
21 | }
22 | ],
23 | "license": "BSD-3-Clause",
24 | "repository": {
25 | "type": "git",
26 | "url": "https://github.com/datavisyn/chartjs-chart-box-and-violin-plot.git"
27 | },
28 | "main": "build/Chart.BoxPlot.js",
29 | "unpkg": "build/Chart.BoxPlot.min.js",
30 | "module": "build/Chart.BoxPlot.esm.js",
31 | "files": [
32 | "build",
33 | "src/**/*.js"
34 | ],
35 | "dependencies": {
36 | "chart.js": "^2.8.0",
37 | "@sgratzl/science": "^2.0.0"
38 | },
39 | "devDependencies": {
40 | "@babel/core": "^7.6.2",
41 | "@babel/preset-env": "^7.6.2",
42 | "babel-core": "^7.0.0-bridge.0",
43 | "babel-jest": "^24.9.0",
44 | "eslint": "^6.5.0",
45 | "jest": "^24.9.0",
46 | "release-it": "^12.4.1",
47 | "rimraf": "^3.0.0",
48 | "rollup": "^1.22.0",
49 | "rollup-plugin-babel": "^4.3.3",
50 | "rollup-plugin-commonjs": "^10.1.0",
51 | "rollup-plugin-node-resolve": "^5.2.0",
52 | "rollup-watch": "^4.3.1",
53 | "uglify-es": "^3.3.9"
54 | },
55 | "scripts": {
56 | "clean": "rimraf build *.tgz",
57 | "watch": "rollup -c -w -i src/index.js",
58 | "lint": "eslint src",
59 | "test": "jest --passWithNoTests",
60 | "test:watch": "jest --watch",
61 | "posttest": "npm run lint",
62 | "build:dev": "rollup -c -i src/index.js",
63 | "build:prod": "npm run build:dev && uglifyjs build/Chart.BoxPlot.js -c -m -o build/Chart.BoxPlot.min.js",
64 | "prebuild": "npm run clean && npm test",
65 | "build": "npm run build:prod",
66 | "preversion": "npm run test",
67 | "prepare": "npm run build:dev",
68 | "prepublishOnly": "npm run build:prod",
69 | "release:major": "release-it major",
70 | "release:minor": "release-it minor",
71 | "release:patch": "release-it patch",
72 | "release:pre": "release-it --preRelease=alpha --npm.tag=next"
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | // rollup.config.js
2 | import resolve from 'rollup-plugin-node-resolve';
3 | import commonjs from 'rollup-plugin-commonjs';
4 | import babel from 'rollup-plugin-babel';
5 |
6 | export default [{
7 | output: {
8 | file: 'build/Chart.BoxPlot.js',
9 | name: 'ChartBoxPlot',
10 | format: 'umd',
11 | globals: {
12 | 'chart.js': 'Chart'
13 | }
14 | },
15 | external: ['chart.js'],
16 | plugins: [
17 | resolve(),
18 | commonjs(),
19 | babel()
20 | ]
21 | }, {
22 | output: {
23 | file: 'build/Chart.BoxPlot.esm.js',
24 | name: 'ChartBoxPlot',
25 | format: 'esm',
26 | globals: {
27 | 'chart.js': 'Chart'
28 | }
29 | },
30 | external: ['chart.js'],
31 | plugins: [
32 | resolve(),
33 | commonjs(),
34 | babel()
35 | ]
36 | }];
37 |
--------------------------------------------------------------------------------
/samples/animation.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Box Plot Chart
6 |
7 |
8 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
73 |
74 |
75 |
76 |
--------------------------------------------------------------------------------
/samples/chartjs291.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Box Plot Chart
6 |
7 |
8 |
9 |
10 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
132 |
133 |
134 |
135 |
--------------------------------------------------------------------------------
/samples/datalimits.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Box Plot Chart
6 |
7 |
8 |
9 |
10 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
95 |
96 |
97 |
98 |
--------------------------------------------------------------------------------
/samples/datastructures.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Box Plot Chart
6 |
7 |
8 |
9 |
10 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
82 |
83 |
84 |
85 |
--------------------------------------------------------------------------------
/samples/empty.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Box Plot Chart
6 |
7 |
8 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
67 |
68 |
69 |
70 |
--------------------------------------------------------------------------------
/samples/fivenum.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Box Plot Chart
6 |
7 |
8 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
91 |
92 |
93 |
94 |
--------------------------------------------------------------------------------
/samples/horizontalBoxplot.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Horizontal Bar Chart
6 |
7 |
8 |
9 |
10 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
122 |
123 |
124 |
125 |
--------------------------------------------------------------------------------
/samples/horizontalViolin.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Violin Chart
6 |
7 |
8 |
9 |
10 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
123 |
124 |
125 |
126 |
--------------------------------------------------------------------------------
/samples/hybrid.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Box Plot Chart
6 |
7 |
8 |
9 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
66 |
67 |
68 |
69 |
--------------------------------------------------------------------------------
/samples/ie11.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Box Plot Chart
6 |
7 |
8 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
71 |
72 |
73 |
74 |
--------------------------------------------------------------------------------
/samples/logarithm.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Box Plot Chart
6 |
7 |
8 |
9 |
10 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
126 |
127 |
128 |
129 |
--------------------------------------------------------------------------------
/samples/mediancolor.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Box Plot Chart
6 |
7 |
8 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/samples/minmax.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Box Plot Chart
6 |
7 |
8 |
9 |
10 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
61 |
62 |
63 |
64 |
--------------------------------------------------------------------------------
/samples/utils.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | window.chartColors = {
4 | red: 'rgb(255, 99, 132)',
5 | orange: 'rgb(255, 159, 64)',
6 | yellow: 'rgb(255, 205, 86)',
7 | green: 'rgb(75, 192, 192)',
8 | blue: 'rgb(54, 162, 235)',
9 | purple: 'rgb(153, 102, 255)',
10 | grey: 'rgb(201, 203, 207)'
11 | };
12 |
13 | (function(global) {
14 | var Months = [
15 | 'January',
16 | 'February',
17 | 'March',
18 | 'April',
19 | 'May',
20 | 'June',
21 | 'July',
22 | 'August',
23 | 'September',
24 | 'October',
25 | 'November',
26 | 'December'
27 | ];
28 |
29 | var COLORS = [
30 | '#4dc9f6',
31 | '#f67019',
32 | '#f53794',
33 | '#537bc4',
34 | '#acc236',
35 | '#166a8f',
36 | '#00a950',
37 | '#58595b',
38 | '#8549ba'
39 | ];
40 |
41 | var Samples = global.Samples || (global.Samples = {});
42 | var Color = global.Color;
43 |
44 | Samples.utils = {
45 | // Adapted from http://indiegamr.com/generate-repeatable-random-numbers-in-js/
46 | srand: function(seed) {
47 | this._seed = seed;
48 | },
49 |
50 | randF: function(min, max) {
51 | min = min === undefined ? 0 : min;
52 | max = max === undefined ? 1 : max;
53 | return () => {
54 | this._seed = (this._seed * 9301 + 49297) % 233280;
55 | return min + (this._seed / 233280) * (max - min);
56 | };
57 | },
58 |
59 | rand: function(min, max) {
60 | return this.randF(min, max)();
61 | },
62 |
63 | numbers: function(config) {
64 | var cfg = config || {};
65 | var min = cfg.min || 0;
66 | var max = cfg.max || 100;
67 | var from = cfg.from || [];
68 | var count = cfg.count || 8;
69 | var decimals = cfg.decimals || 8;
70 | var continuity = cfg.continuity || 1;
71 | var dfactor = Math.pow(10, decimals) || 0;
72 | var data = [];
73 | var i, value;
74 | var rand = cfg.random ? cfg.random(min, max) : this.randF(min, max);
75 | var rand01 = cfg.random01 ? cfg.random01() : this.randF();
76 |
77 | for (i = 0; i < count; ++i) {
78 | value = (from[i] || 0) + rand();
79 | if (rand01() <= continuity) {
80 | data.push(Math.round(dfactor * value) / dfactor);
81 | } else {
82 | data.push(null);
83 | }
84 | }
85 |
86 | return data;
87 | },
88 |
89 | randomBoxPlot: function(config) {
90 | const base = this.numbers({...config, count: 10});
91 | base.sort(function(a,b) { return a - b; });
92 | const shift = 3;
93 | return {
94 | min: base[shift + 0],
95 | q1: base[shift + 1],
96 | median: base[shift + 2],
97 | q3: base[shift + 3],
98 | max: base[shift + 4],
99 | outliers: base.slice(0, 3).concat(base.slice(shift + 5))
100 | };
101 | },
102 |
103 | boxplots: function(config) {
104 | const count = (config || {}).count || 8;
105 | const data = [];
106 | for(let i = 0; i < count; ++i) {
107 | data.push(this.randomBoxPlot(config));
108 | }
109 | return data;
110 | },
111 |
112 | boxplotsArray: function(config) {
113 | const count = (config || {}).count || 8;
114 | const data = [];
115 | for(let i = 0; i < count; ++i) {
116 | data.push(this.numbers({...config, count: 50}));
117 | }
118 | return data;
119 | },
120 |
121 | labels: function(config) {
122 | var cfg = config || {};
123 | var min = cfg.min || 0;
124 | var max = cfg.max || 100;
125 | var count = cfg.count || 8;
126 | var step = (max - min) / count;
127 | var decimals = cfg.decimals || 8;
128 | var dfactor = Math.pow(10, decimals) || 0;
129 | var prefix = cfg.prefix || '';
130 | var values = [];
131 | var i;
132 |
133 | for (i = min; i < max; i += step) {
134 | values.push(prefix + Math.round(dfactor * i) / dfactor);
135 | }
136 |
137 | return values;
138 | },
139 |
140 | months: function(config) {
141 | var cfg = config || {};
142 | var count = cfg.count || 12;
143 | var section = cfg.section;
144 | var values = [];
145 | var i, value;
146 |
147 | for (i = 0; i < count; ++i) {
148 | value = Months[Math.ceil(i) % 12];
149 | values.push(value.substring(0, section));
150 | }
151 |
152 | return values;
153 | },
154 |
155 | nextMonth: function(count) {
156 | return Months[Math.ceil(count + 1) % 12];
157 | },
158 |
159 | color: function(index) {
160 | return COLORS[index % COLORS.length];
161 | },
162 |
163 | transparentize: function(color, opacity) {
164 | var alpha = opacity === undefined ? 0.5 : 1 - opacity;
165 | return Color(color).alpha(alpha).rgbString();
166 | }
167 | };
168 |
169 | // DEPRECATED
170 | window.randomScalingFactor = function() {
171 | return Math.round(Samples.utils.rand(-100, 100));
172 | };
173 |
174 | // INITIALIZATION
175 |
176 | Samples.utils.srand(Date.now());
177 |
178 | }(this));
179 |
--------------------------------------------------------------------------------
/samples/vertical.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Box Plot Chart
6 |
7 |
8 |
9 |
10 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
134 |
135 |
136 |
137 |
--------------------------------------------------------------------------------
/samples/vertical_segment.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Box Plot Chart
6 |
7 |
8 |
9 |
10 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
130 |
131 |
132 |
133 |
--------------------------------------------------------------------------------
/samples/violin.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Violin Chart
6 |
7 |
8 |
9 |
10 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
124 |
125 |
126 |
127 |
--------------------------------------------------------------------------------
/samples/violinSingle.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Violin Chart
6 |
7 |
8 |
9 |
10 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
123 |
124 |
125 |
126 |
--------------------------------------------------------------------------------
/src/controllers/base.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import * as Chart from 'chart.js';
4 |
5 | export const verticalDefaults = {
6 | scales: {
7 | yAxes: [{
8 | type: 'arrayLinear'
9 | }]
10 | }
11 | };
12 | export const horizontalDefaults = {
13 | scales: {
14 | xAxes: [{
15 | type: 'arrayLinear'
16 | }],
17 | }
18 | };
19 |
20 | export function toFixed(value) {
21 | const decimals = this._chart.config.options.tooltipDecimals; // inject number of decimals from config
22 | if (decimals == null || typeof decimals !== 'number' || decimals < 0) {
23 | return value;
24 | }
25 | return Number.parseFloat(value).toFixed(decimals);
26 | }
27 |
28 | const configKeys = ['outlierRadius', 'itemRadius', 'itemStyle', 'itemBackgroundColor', 'itemBorderColor', 'outlierColor', 'medianColor', 'segmentColor', 'hitPadding', 'outlierHitRadius', 'lowerColor'];
29 | const configKeyIsColor = [false, false, false, true, true, true, true, true, false, false, true];
30 |
31 | const array = {
32 | _elementOptions() {
33 | return {};
34 | },
35 | updateElement(elem, index, reset) {
36 | const dataset = this.getDataset();
37 | const custom = elem.custom || {};
38 | const options = this._elementOptions();
39 |
40 | Chart.controllers.bar.prototype.updateElement.call(this, elem, index, reset);
41 | const resolve = Chart.helpers.options.resolve;
42 |
43 | // Scriptable options
44 | const context = {
45 | chart: this.chart,
46 | dataIndex: index,
47 | dataset,
48 | datasetIndex: this.index
49 | };
50 |
51 | configKeys.forEach((item) => {
52 | elem._model[item] = resolve([custom[item], dataset[item], options[item]], context, index);
53 | });
54 | },
55 | _calculateCommonModel(r, data, container, scale) {
56 | if (container.outliers) {
57 | r.outliers = container.outliers.map((d) => scale.getPixelForValue(Number(d)));
58 | }
59 |
60 | if (Array.isArray(data)) {
61 | r.items = data.map((d) => scale.getPixelForValue(Number(d)));
62 | } else if (container.items) {
63 | r.items = container.items.map((d) => scale.getPixelForValue(Number(d)));
64 | }
65 | },
66 | setHoverStyle(element) {
67 | Chart.controllers.bar.prototype.setHoverStyle.call(this, element);
68 |
69 | const dataset = this.chart.data.datasets[element._datasetIndex];
70 | const index = element._index;
71 | const custom = element.custom || {};
72 | const model = element._model;
73 | const getHoverColor = Chart.helpers.getHoverColor;
74 | const resolve = Chart.helpers.options.resolve;
75 |
76 |
77 | configKeys.forEach((item, i) => {
78 | element.$previousStyle[item] = model[item];
79 | const hoverKey = `hover${item.charAt(0).toUpperCase()}${item.slice(1)}`;
80 | const modelValue = configKeyIsColor[i] && model[item] != null ? getHoverColor(model[item]) : model[item];
81 | element._model[item] = resolve([custom[hoverKey], dataset[hoverKey], modelValue], undefined, index);
82 | });
83 | }
84 | };
85 |
86 | export default array;
87 |
--------------------------------------------------------------------------------
/src/controllers/boxplot.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import {asBoxPlotStats} from '../data';
4 | import * as Chart from 'chart.js';
5 | import base, {verticalDefaults, horizontalDefaults, toFixed} from './base';
6 |
7 |
8 | function boxplotTooltip(item, data, ...args) {
9 | const value = data.datasets[item.datasetIndex].data[item.index];
10 | const options = this._chart.getDatasetMeta(item.datasetIndex).controller._getValueScale().options.ticks;
11 | const b = asBoxPlotStats(value, options);
12 |
13 | const hoveredOutlierIndex = this._tooltipOutlier == null ? -1 : this._tooltipOutlier;
14 |
15 | const label = this._options.callbacks.boxplotLabel;
16 | return label.apply(this, [item, data, b, hoveredOutlierIndex, ...args]);
17 | }
18 |
19 | const defaults = {
20 | tooltips: {
21 | position: 'boxplot',
22 | callbacks: {
23 | label: boxplotTooltip,
24 | boxplotLabel(item, data, b, hoveredOutlierIndex) {
25 | const datasetLabel = data.datasets[item.datasetIndex].label || '';
26 | let label = `${datasetLabel} ${typeof item.xLabel === 'string' ? item.xLabel : item.yLabel}`;
27 | if (!b) {
28 | return `${label} (NaN)`;
29 | }
30 | if (hoveredOutlierIndex >= 0) {
31 | const outlier = b.outliers[hoveredOutlierIndex];
32 | return `${label} (outlier: ${toFixed.call(this, outlier)})`;
33 | }
34 | return `${label} (min: ${toFixed.call(this, b.min)}, q1: ${toFixed.call(this, b.q1)}, median: ${toFixed.call(this, b.median)}, q3: ${toFixed.call(this, b.q3)}, max: ${toFixed.call(this, b.max)})`;
35 | }
36 | }
37 | }
38 | };
39 |
40 | Chart.defaults.boxplot = Chart.helpers.merge({}, [Chart.defaults.bar, verticalDefaults, defaults]);
41 | Chart.defaults.horizontalBoxplot = Chart.helpers.merge({}, [Chart.defaults.horizontalBar, horizontalDefaults, defaults]);
42 |
43 | if (Chart.defaults.global.datasets && Chart.defaults.global.datasets.bar) {
44 | Chart.defaults.global.datasets.boxplot = {...Chart.defaults.global.datasets.bar};
45 | }
46 | if (Chart.defaults.global.datasets && Chart.defaults.global.datasets.horizontalBar) {
47 | Chart.defaults.global.datasets.horizontalBoxplot = {...Chart.defaults.global.datasets.horizontalBar};
48 | }
49 |
50 | const boxplot = {
51 | ...base,
52 | dataElementType: Chart.elements.BoxAndWhiskers,
53 |
54 | _elementOptions() {
55 | return this.chart.options.elements.boxandwhiskers;
56 | },
57 | /**
58 | * @private
59 | */
60 | _updateElementGeometry(elem, index, reset, ...args) {
61 | Chart.controllers.bar.prototype._updateElementGeometry.call(this, elem, index, reset, ...args);
62 | elem._model.boxplot = this._calculateBoxPlotValuesPixels(this.index, index);
63 | },
64 |
65 | /**
66 | * @private
67 | */
68 |
69 | _calculateBoxPlotValuesPixels(datasetIndex, index) {
70 | const scale = this._getValueScale();
71 | const data = this.chart.data.datasets[datasetIndex].data[index];
72 | if (!data) {
73 | return null;
74 | }
75 | const v = asBoxPlotStats(data, scale.options.ticks);
76 |
77 | const r = {};
78 | Object.keys(v).forEach((key) => {
79 | if (key !== 'outliers' && key !== 'items') {
80 | r[key] = scale.getPixelForValue(Number(v[key]));
81 | }
82 | });
83 | this._calculateCommonModel(r, data, v, scale);
84 | return r;
85 | }
86 | };
87 | /**
88 | * This class is based off controller.bar.js from the upstream Chart.js library
89 | */
90 | export const BoxPlot = Chart.controllers.boxplot = Chart.controllers.bar.extend(boxplot);
91 | export const HorizontalBoxPlot = Chart.controllers.horizontalBoxplot = Chart.controllers.horizontalBar.extend(boxplot);
92 |
--------------------------------------------------------------------------------
/src/controllers/violin.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import {asViolinStats} from '../data';
4 | import * as Chart from 'chart.js';
5 | import base, {verticalDefaults, horizontalDefaults, toFixed} from './base';
6 |
7 |
8 | function violinTooltip(item, data, ...args) {
9 | const value = data.datasets[item.datasetIndex].data[item.index];
10 | const options = this._chart.getDatasetMeta(item.datasetIndex).controller._getValueScale().options.ticks;
11 | const v = asViolinStats(value, options);
12 |
13 | const label = this._options.callbacks.violinLabel;
14 | return label.apply(this, [item, data, v, ...args]);
15 | }
16 |
17 | const defaults = {
18 | tooltips: {
19 | callbacks: {
20 | label: violinTooltip,
21 | violinLabel(item, data) {
22 | const datasetLabel = data.datasets[item.datasetIndex].label || '';
23 | const value = item.value;
24 | const label = `${datasetLabel} ${typeof item.xLabel === 'string' ? item.xLabel : item.yLabel}`;
25 | return `${label} (${toFixed.call(this, value)})`;
26 | }
27 | }
28 | }
29 | };
30 |
31 | Chart.defaults.violin = Chart.helpers.merge({}, [Chart.defaults.bar, verticalDefaults, defaults]);
32 | Chart.defaults.horizontalViolin = Chart.helpers.merge({}, [Chart.defaults.horizontalBar, horizontalDefaults, defaults]);
33 |
34 | if (Chart.defaults.global.datasets && Chart.defaults.global.datasets.bar) {
35 | Chart.defaults.global.datasets.violin = {...Chart.defaults.global.datasets.bar};
36 | }
37 | if (Chart.defaults.global.datasets && Chart.defaults.global.datasets.horizontalBar) {
38 | Chart.defaults.global.datasets.horizontalViolin = {...Chart.defaults.global.datasets.horizontalBar};
39 | }
40 |
41 | const controller = {
42 | ...base,
43 | dataElementType: Chart.elements.Violin,
44 |
45 | _elementOptions() {
46 | return this.chart.options.elements.violin;
47 | },
48 | /**
49 | * @private
50 | */
51 | _updateElementGeometry(elem, index, reset, ...args) {
52 | Chart.controllers.bar.prototype._updateElementGeometry.call(this, elem, index, reset, ...args);
53 | const custom = elem.custom || {};
54 | const options = this._elementOptions();
55 | elem._model.violin = this._calculateViolinValuesPixels(this.index, index, custom.points !== undefined ? custom.points : options.points);
56 | },
57 |
58 | /**
59 | * @private
60 | */
61 |
62 | _calculateViolinValuesPixels(datasetIndex, index, points) {
63 | const scale = this._getValueScale();
64 | const data = this.chart.data.datasets[datasetIndex].data[index];
65 | const violin = asViolinStats(data, scale.options.ticks);
66 |
67 | if ((!Array.isArray(data) && typeof data === 'number' && !Number.isNaN) || violin == null) {
68 | return {
69 | min: data,
70 | max: data,
71 | median: data,
72 | coords: [{v: data, estimate: Number.NEGATIVE_INFINITY}],
73 | maxEstimate: Number.NEGATIVE_INFINITY
74 | };
75 | }
76 |
77 | const range = violin.max - violin.min;
78 | const samples = [];
79 | const inc = range / points;
80 | for (let v = violin.min; v <= violin.max && inc > 0; v += inc) {
81 | samples.push(v);
82 | }
83 | if (samples[samples.length - 1] !== violin.max) {
84 | samples.push(violin.max);
85 | }
86 | const coords = violin.coords || violin.kde(samples).map((v) => ({v: v[0], estimate: v[1]}));
87 | const r = {
88 | min: scale.getPixelForValue(violin.min),
89 | max: scale.getPixelForValue(violin.max),
90 | median: scale.getPixelForValue(violin.median),
91 | coords: coords.map(({v, estimate}) => ({v: scale.getPixelForValue(v), estimate})),
92 | maxEstimate: coords.reduce((a, d) => Math.max(a, d.estimate), Number.NEGATIVE_INFINITY)
93 | };
94 | this._calculateCommonModel(r, data, violin, scale);
95 | return r;
96 | }
97 | };
98 | /**
99 | * This class is based off controller.bar.js from the upstream Chart.js library
100 | */
101 | export const Violin = Chart.controllers.violin = Chart.controllers.bar.extend(controller);
102 | export const HorizontalViolin = Chart.controllers.horizontalViolin = Chart.controllers.horizontalBar.extend(controller);
103 |
--------------------------------------------------------------------------------
/src/data.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import kde from '@sgratzl/science/src/stats/kde';
4 |
5 | // Uses R's quantile algorithm type=7.
6 | // https://en.wikipedia.org/wiki/Quantile#Quantiles_of_a_population
7 | export function quantilesType7(arr) {
8 | const n1 = arr.length - 1;
9 | const compute = (q) => {
10 | const index = 1 + q * n1;
11 | const lo = Math.floor(index);
12 | const h = index - lo;
13 | const a = arr[lo - 1];
14 |
15 | return h === 0 ? a : a + h * (arr[lo] - a);
16 | };
17 |
18 | return {
19 | min: arr[0],
20 | q1: compute(0.25),
21 | median: compute(0.5),
22 | q3: compute(0.75),
23 | max: arr[n1]
24 | };
25 | }
26 |
27 | /**
28 | * The hinges equal the quartiles for odd n (where n <- length(x))
29 | * and differ for even n. Whereas the quartiles only equal observations
30 | * for n %% 4 == 1 (n = 1 mod 4), the hinges do so additionally
31 | * for n %% 4 == 2 (n = 2 mod 4), and are in the middle of
32 | * two observations otherwise.
33 | * @param {number[]} arr sorted array
34 | */
35 | export function fivenum(arr) {
36 | // based on R fivenum
37 | const n = arr.length;
38 |
39 | // assuming R 1 index system, so arr[1] is the first element
40 | const n4 = Math.floor((n + 3) / 2) / 2;
41 | const compute = (d) => 0.5 * (arr[Math.floor(d) - 1] + arr[Math.ceil(d) - 1]);
42 |
43 | return {
44 | min: arr[0],
45 | q1: compute(n4),
46 | median: compute((n + 1) / 2),
47 | q3: compute(n + 1 - n4),
48 | max: arr[n - 1]
49 | };
50 | }
51 |
52 |
53 | /**
54 | * compute the whiskers
55 | * @param boxplot
56 | * @param {number[]} arr sorted array
57 | * @param {number} coef
58 | */
59 | export function whiskers(boxplot, arr, coef = 1.5) {
60 | const iqr = boxplot.q3 - boxplot.q1;
61 | // since top left is max
62 | const coefValid = typeof coef === 'number' && coef > 0;
63 | let whiskerMin = coefValid ? Math.max(boxplot.min, boxplot.q1 - coef * iqr) : boxplot.min;
64 | let whiskerMax = coefValid ? Math.min(boxplot.max, boxplot.q3 + coef * iqr) : boxplot.max;
65 |
66 | if (Array.isArray(arr)) {
67 | // compute the closest real element
68 | for (let i = 0; i < arr.length; i++) {
69 | const v = arr[i];
70 | if (v >= whiskerMin) {
71 | whiskerMin = v;
72 | break;
73 | }
74 | }
75 | for (let i = arr.length - 1; i >= 0; i--) {
76 | const v = arr[i];
77 | if (v <= whiskerMax) {
78 | whiskerMax = v;
79 | break;
80 | }
81 | }
82 | }
83 |
84 | return {
85 | whiskerMin,
86 | whiskerMax
87 | };
88 | }
89 |
90 | const defaultStatsOptions = {
91 | coef: 1.5,
92 | quantiles: 7
93 | };
94 |
95 | function determineStatsOptions(options) {
96 | const coef = options == null || typeof options.coef !== 'number' ? defaultStatsOptions.coef : options.coef;
97 | const q = options == null ? null : options.quantiles;
98 | const quantiles = typeof q === 'function' ? q : (q === 'hinges' || q === 'fivenum' ? fivenum : quantilesType7);
99 | return {
100 | coef,
101 | quantiles
102 | };
103 | }
104 |
105 | export function boxplotStats(arr, options) {
106 | // console.assert(Array.isArray(arr));
107 | if (arr.length === 0) {
108 | return {
109 | min: NaN,
110 | max: NaN,
111 | median: NaN,
112 | q1: NaN,
113 | q3: NaN,
114 | whiskerMin: NaN,
115 | whiskerMax: NaN,
116 | outliers: []
117 | };
118 | }
119 |
120 | arr = arr.filter((v) => typeof v === 'number' && !isNaN(v));
121 | arr.sort((a, b) => a - b);
122 |
123 | const {quantiles, coef} = determineStatsOptions(options);
124 |
125 | const stats = quantiles(arr);
126 | const {whiskerMin, whiskerMax} = whiskers(stats, arr, coef);
127 | stats.outliers = arr.filter((v) => v < whiskerMin || v > whiskerMax);
128 | stats.whiskerMin = whiskerMin;
129 | stats.whiskerMax = whiskerMax;
130 | return stats;
131 | }
132 |
133 | export function violinStats(arr, options) {
134 | // console.assert(Array.isArray(arr));
135 | if (arr.length === 0) {
136 | return {};
137 | }
138 | arr = arr.filter((v) => typeof v === 'number' && !isNaN(v));
139 | arr.sort((a, b) => a - b);
140 |
141 | const {quantiles} = determineStatsOptions(options);
142 |
143 | const stats = quantiles(arr);
144 | stats.kde = kde().sample(arr);
145 | return stats;
146 | }
147 |
148 | export function asBoxPlotStats(value, options) {
149 | if (!value) {
150 | return null;
151 | }
152 | if (typeof value.median === 'number' && typeof value.q1 === 'number' && typeof value.q3 === 'number') {
153 | // sounds good, check for helper
154 | if (typeof value.whiskerMin === 'undefined') {
155 | const {coef} = determineStatsOptions(options);
156 | const {whiskerMin, whiskerMax} = whiskers(value, Array.isArray(value.items) ? value.items.slice().sort((a, b) => a - b) : null, coef);
157 | value.whiskerMin = whiskerMin;
158 | value.whiskerMax = whiskerMax;
159 | }
160 | return value;
161 | }
162 | if (!Array.isArray(value)) {
163 | return undefined;
164 | }
165 | if (value.__stats === undefined) {
166 | value.__stats = boxplotStats(value, options);
167 | }
168 | return value.__stats;
169 | }
170 |
171 | export function asViolinStats(value, options) {
172 | if (!value) {
173 | return null;
174 | }
175 | if (typeof value.median === 'number' && (typeof value.kde === 'function' || Array.isArray(value.coords))) {
176 | return value;
177 | }
178 | if (!Array.isArray(value)) {
179 | return undefined;
180 | }
181 | if (value.__kde === undefined) {
182 | value.__kde = violinStats(value, options);
183 | }
184 | return value.__kde;
185 | }
186 |
187 | export function asValueStats(value, minStats, maxStats, options) {
188 | if (typeof value[minStats] === 'number' && typeof value[maxStats] === 'number') {
189 | return value;
190 | }
191 | if (!Array.isArray(value) || value.length === 0) {
192 | return undefined;
193 | }
194 | return asBoxPlotStats(value, options);
195 | }
196 |
197 | export function getRightValue(rawValue, options) {
198 | if (!rawValue) {
199 | return rawValue;
200 | }
201 | if (typeof rawValue === 'number' || typeof rawValue === 'string') {
202 | return Number(rawValue);
203 | }
204 | const b = asBoxPlotStats(rawValue, options);
205 | return b ? b.median : rawValue;
206 | }
207 |
208 | export const commonScaleOptions = {
209 | ticks: {
210 | minStats: 'min',
211 | maxStats: 'max',
212 | ...defaultStatsOptions
213 | }
214 | };
215 |
216 | export function commonDataLimits(extraCallback) {
217 | const chart = this.chart;
218 | const isHorizontal = this.isHorizontal();
219 | const {minStats, maxStats} = this.options.ticks;
220 |
221 | const matchID = (meta) => isHorizontal ? meta.xAxisID === this.id : meta.yAxisID === this.id;
222 |
223 | // First Calculate the range
224 | this.min = null;
225 | this.max = null;
226 |
227 | // Regular charts use x, y values
228 | // For the boxplot chart we have rawValue.min and rawValue.max for each point
229 | chart.data.datasets.forEach((d, i) => {
230 | const meta = chart.getDatasetMeta(i);
231 | if (!chart.isDatasetVisible(i) || !matchID(meta)) {
232 | return;
233 | }
234 | d.data.forEach((value, j) => {
235 | if (value == null || meta.data[j].hidden) {
236 | return;
237 | }
238 |
239 | const stats = asValueStats(value, minStats, maxStats, this.options.ticks);
240 | let minValue;
241 | let maxValue;
242 |
243 | if (stats) {
244 | minValue = stats[minStats];
245 | maxValue = stats[maxStats];
246 | } else {
247 | // if stats are not available use the plain value
248 | const parsed = +this.getRightValue(value);
249 | if (isNaN(parsed)) {
250 | return;
251 | }
252 | minValue = maxValue = parsed;
253 | }
254 |
255 | if (this.min === null || minValue < this.min) {
256 | this.min = minValue;
257 | }
258 |
259 | if (this.max === null || maxValue > this.max) {
260 | this.max = maxValue;
261 | }
262 |
263 | if (extraCallback) {
264 | extraCallback(stats);
265 | }
266 | });
267 | });
268 | }
269 |
270 | export function rnd(seed) {
271 | // Adapted from http://indiegamr.com/generate-repeatable-random-numbers-in-js/
272 | if (seed === undefined) {
273 | seed = Date.now();
274 | }
275 | return () => {
276 | seed = (seed * 9301 + 49297) % 233280;
277 | return seed / 233280;
278 | };
279 | }
280 |
--------------------------------------------------------------------------------
/src/data.spec.js:
--------------------------------------------------------------------------------
1 | import {quantilesType7, fivenum} from './data';
2 |
3 | function asc(a, b) {
4 | return a - b;
5 | }
6 |
7 | const closeTo = (expected, precision = 2) => ({
8 | asymmetricMatch: (actual) => Math.abs(expected - actual) < Math.pow(10, -precision) / 2
9 | });
10 |
11 | function asB(min, q1, median, q3, max) {
12 | return {
13 | min: closeTo(min),
14 | q1: closeTo(q1),
15 | median: closeTo(median),
16 | q3: closeTo(q3),
17 | max: closeTo(max)
18 | };
19 | }
20 |
21 | describe('quantiles', () => {
22 | it('is a function', () => {
23 | expect(typeof quantilesType7).toBe('function');
24 | });
25 | });
26 |
27 | describe('fivenum', () => {
28 | it('is a function', () => {
29 | expect(typeof fivenum).toBe('function');
30 | });
31 | });
32 |
33 | describe('quantiles and fivenum', () => {
34 | describe('11', () => {
35 | const arr = [-0.4022530, -1.4521869, 0.1352280, -1.8620118, -0.5687531,
36 | 0.4218371, -1.1165662, 0.5960255, -0.5008038, -0.3941780, 1.3709885].sort(asc);
37 | it('type7', () => {
38 | expect(quantilesType7(arr)).toEqual(asB(-1.8620118, -0.84265965, -0.4022530, 0.27853255, 1.3709885));
39 | });
40 | it('fivenum', () => {
41 | expect(fivenum(arr)).toEqual(asB(-1.8620118, -0.84265965, -0.4022530, 0.27853255, 1.3709885));
42 | });
43 | });
44 | describe('12', () => {
45 | const arr = [1.086657167, 0.294672807, 1.462293013, 0.485641706, 1.577482640,
46 | 0.827809286, -0.397192557, -1.222111542, 1.071236583, -1.182959319, -0.003749222, -0.360759239].sort(asc);
47 | it('type7', () => {
48 | expect(quantilesType7(arr)).toEqual(asB(-1.222111542, -0.3698675685, 0.3901572565, 1.075091729, 1.577482640));
49 | });
50 | it('fivenum', () => {
51 | expect(fivenum(arr)).toEqual(asB(-1.222111542, -0.378975898, 0.3901572565, 1.078946875, 1.577482640));
52 | });
53 | });
54 |
55 | describe('5', () => {
56 | const arr = [0, 25, 51, 75, 99].sort(asc);
57 | it('type7', () => {
58 | expect(quantilesType7(arr)).toEqual(asB(0, 25, 51, 75, 99));
59 | });
60 | it('fivenum', () => {
61 | expect(fivenum(arr)).toEqual(asB(0, 25, 51, 75, 99));
62 | });
63 | });
64 |
65 |
66 | describe('strange', () => {
67 | const arr = [18882.492, 7712.077, 5830.748, 7206.05].sort(asc);
68 | it('type7', () => {
69 | expect(quantilesType7(arr)).toEqual(asB(5830.748, 6862.2245, 7459.0635, 10504.68075, 18882.492));
70 | });
71 | it('fivenum', () => {
72 | expect(fivenum(arr)).toEqual(asB(5830.748, 6518.398999999999, 7459.0635, 13297.2845, 18882.492));
73 | });
74 | });
75 |
76 |
77 | });
78 |
--------------------------------------------------------------------------------
/src/elements/base.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import * as Chart from 'chart.js';
4 | import {rnd} from '../data';
5 |
6 | export const defaults = {
7 | ...Chart.defaults.global.elements.rectangle,
8 | borderWidth: 1,
9 | outlierRadius: 2,
10 | outlierColor: Chart.defaults.global.elements.rectangle.backgroundColor,
11 | lowerColor: Chart.defaults.global.elements.rectangle.lowerColor,
12 | medianColor: null,
13 | itemRadius: 0,
14 | itemStyle: 'circle',
15 | itemBackgroundColor: Chart.defaults.global.elements.rectangle.backgroundColor,
16 | itemBorderColor: Chart.defaults.global.elements.rectangle.borderColor,
17 | hitPadding: 2,
18 | outlierHitRadius: 4,
19 | tooltipDecimals: 2
20 | };
21 |
22 | const ArrayElementBase = Chart.Element.extend({
23 | isVertical() {
24 | return this._view.width !== undefined;
25 | },
26 | draw() {
27 | // abstract
28 | },
29 | _drawItems(vm, container, ctx, vert) {
30 | if (vm.itemRadius <= 0 || !container.items || container.items.length <= 0) {
31 | return;
32 | }
33 | ctx.save();
34 | ctx.strokeStyle = vm.itemBorderColor;
35 | ctx.fillStyle = vm.itemBackgroundColor;
36 | // jitter based on random data
37 | // use the datesetindex and index to initialize the random number generator
38 | const random = rnd(this._datasetIndex * 1000 + this._index);
39 |
40 | if (vert) {
41 | container.items.forEach((v) => {
42 | Chart.canvasHelpers.drawPoint(ctx, vm.itemStyle, vm.itemRadius, vm.x - vm.width / 2 + random() * vm.width, v);
43 | });
44 | } else {
45 | container.items.forEach((v) => {
46 | Chart.canvasHelpers.drawPoint(ctx, vm.itemStyle, vm.itemRadius, v, vm.y - vm.height / 2 + random() * vm.height);
47 | });
48 | }
49 | ctx.restore();
50 | },
51 | _drawOutliers(vm, container, ctx, vert) {
52 | if (vm.outlierRadius <= 0 || !container.outliers || container.outliers.length === 0) {
53 | return;
54 | }
55 | ctx.fillStyle = vm.outlierColor;
56 | ctx.beginPath();
57 | if (vert) {
58 | container.outliers.forEach((v) => {
59 | ctx.arc(vm.x, v, vm.outlierRadius, 0, Math.PI * 2);
60 | });
61 | } else {
62 | container.outliers.forEach((v) => {
63 | ctx.arc(v, vm.y, vm.outlierRadius, 0, Math.PI * 2);
64 | });
65 | }
66 | ctx.fill();
67 | ctx.closePath();
68 | },
69 |
70 | _getBounds() {
71 | // abstract
72 | return {
73 | left: 0,
74 | top: 0,
75 | right: 0,
76 | bottom: 0
77 | };
78 | },
79 | _getHitBounds() {
80 | const padding = this._view.hitPadding;
81 | const b = this._getBounds();
82 | return {
83 | left: b.left - padding,
84 | top: b.top - padding,
85 | right: b.right + padding,
86 | bottom: b.bottom + padding
87 | };
88 | },
89 | height() {
90 | return 0; // abstract
91 | },
92 | inRange(mouseX, mouseY) {
93 | if (!this._view) {
94 | return false;
95 | }
96 | return this._boxInRange(mouseX, mouseY) || this._outlierIndexInRange(mouseX, mouseY) >= 0;
97 | },
98 | inLabelRange(mouseX, mouseY) {
99 | if (!this._view) {
100 | return false;
101 | }
102 | const bounds = this._getHitBounds();
103 | if (this.isVertical()) {
104 | return mouseX >= bounds.left && mouseX <= bounds.right;
105 | }
106 | return mouseY >= bounds.top && mouseY <= bounds.bottom;
107 | },
108 | inXRange(mouseX) {
109 | const bounds = this._getHitBounds();
110 | return mouseX >= bounds.left && mouseX <= bounds.right;
111 | },
112 | inYRange(mouseY) {
113 | const bounds = this._getHitBounds();
114 | return mouseY >= bounds.top && mouseY <= bounds.bottom;
115 | },
116 | _outlierIndexInRange(mouseX, mouseY) {
117 | const vm = this._view;
118 | const hitRadius = vm.outlierHitRadius;
119 | const outliers = this._getOutliers();
120 | const vertical = this.isVertical();
121 |
122 | // check if along the outlier line
123 | if ((vertical && Math.abs(mouseX - vm.x) > hitRadius) || (!vertical && Math.abs(mouseY - vm.y) > hitRadius)) {
124 | return -1;
125 | }
126 | const toCompare = vertical ? mouseY : mouseX;
127 | for (let i = 0; i < outliers.length; i++) {
128 | if (Math.abs(outliers[i] - toCompare) <= hitRadius) {
129 | return i;
130 | }
131 | }
132 | return -1;
133 | },
134 | _boxInRange(mouseX, mouseY) {
135 | const bounds = this._getHitBounds();
136 | return mouseX >= bounds.left && mouseX <= bounds.right && mouseY >= bounds.top && mouseY <= bounds.bottom;
137 | },
138 | getCenterPoint() {
139 | const {x, y} = this._view;
140 | return {x, y};
141 | },
142 | getArea() {
143 | return 0; // abstract
144 | },
145 | _getOutliers() {
146 | return []; // abstract
147 | },
148 | tooltipPosition(eventPosition, tooltip) {
149 | if (!eventPosition) {
150 | // fallback
151 | return this.getCenterPoint();
152 | }
153 | delete tooltip._tooltipOutlier;
154 |
155 | const vm = this._view;
156 | const index = this._outlierIndexInRange(eventPosition.x, eventPosition.y);
157 | if (index < 0) {
158 | return this.getCenterPoint();
159 | }
160 | tooltip._tooltipOutlier = index;
161 | if (this.isVertical()) {
162 | return {
163 | x: vm.x,
164 | y: this._getOutliers()[index]
165 | };
166 | }
167 | return {
168 | x: this._getOutliers()[index],
169 | y: vm.y,
170 | };
171 | }
172 | });
173 |
174 | export default ArrayElementBase;
175 |
--------------------------------------------------------------------------------
/src/elements/boxandwhiskers.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import * as Chart from 'chart.js';
4 | import ArrayElementBase, {
5 | defaults
6 | } from './base';
7 |
8 |
9 | Chart.defaults.global.elements.boxandwhiskers = {
10 | ...defaults
11 | };
12 |
13 | function transitionBoxPlot(start, view, model, ease) {
14 | const keys = Object.keys(model);
15 | for (const key of keys) {
16 | const target = model[key];
17 | const origin = start[key];
18 | if (origin === target) {
19 | continue;
20 | }
21 | if (typeof target === 'number') {
22 | view[key] = origin + (target - origin) * ease;
23 | continue;
24 | }
25 | if (Array.isArray(target)) {
26 | const v = view[key];
27 | const common = Math.min(target.length, origin.length);
28 | for (let i = 0; i < common; ++i) {
29 | v[i] = origin[i] + (target[i] - origin[i]) * ease;
30 | }
31 | }
32 | }
33 | }
34 |
35 | const BoxAndWiskers = Chart.elements.BoxAndWhiskers = ArrayElementBase.extend({
36 | transition(ease) {
37 | const r = Chart.Element.prototype.transition.call(this, ease);
38 | const model = this._model;
39 | const start = this._start;
40 | const view = this._view;
41 |
42 | // No animation -> No Transition
43 | if (!model || ease === 1) {
44 | return r;
45 | }
46 | if (start.boxplot == null) {
47 | return r; // model === view -> not copied
48 | }
49 |
50 | // create deep copy to avoid alternation
51 | if (model.boxplot === view.boxplot) {
52 | view.boxplot = Chart.helpers.clone(view.boxplot);
53 | }
54 | transitionBoxPlot(start.boxplot, view.boxplot, model.boxplot, ease);
55 |
56 | return r;
57 | },
58 | draw() {
59 | const ctx = this._chart.ctx;
60 | const vm = this._view;
61 |
62 | const boxplot = vm.boxplot;
63 | const vert = this.isVertical();
64 |
65 | ctx.save();
66 |
67 | ctx.fillStyle = vm.backgroundColor;
68 | ctx.strokeStyle = vm.borderColor;
69 | ctx.lineWidth = vm.borderWidth;
70 |
71 | this._drawBoxPlot(vm, boxplot, ctx, vert);
72 | this._drawOutliers(vm, boxplot, ctx, vert);
73 |
74 | ctx.restore();
75 |
76 | this._drawItems(vm, boxplot, ctx, vert);
77 |
78 | },
79 | _drawBoxPlot(vm, boxplot, ctx, vert) {
80 | if (vert) {
81 | this._drawBoxPlotVert(vm, boxplot, ctx);
82 | } else {
83 | this._drawBoxPlotHoriz(vm, boxplot, ctx);
84 | }
85 |
86 | },
87 | _drawBoxPlotVert(vm, boxplot, ctx) {
88 | const x = vm.x;
89 | const width = vm.width;
90 | const x0 = x - width / 2;
91 |
92 | // Draw the q1>q3 box
93 | if (boxplot.q3 > boxplot.q1) {
94 | ctx.fillRect(x0, boxplot.q1, width, boxplot.q3 - boxplot.q1);
95 | } else {
96 | ctx.fillRect(x0, boxplot.q3, width, boxplot.q1 - boxplot.q3);
97 | }
98 |
99 | // Draw the median line
100 | ctx.save();
101 | if (vm.medianColor) {
102 | ctx.strokeStyle = vm.medianColor;
103 | }
104 | ctx.beginPath();
105 | ctx.moveTo(x0, boxplot.median);
106 | ctx.lineTo(x0 + width, boxplot.median);
107 | ctx.closePath();
108 | ctx.stroke();
109 |
110 | // Draw the segment line
111 | if (boxplot.segment != null) {
112 | if (vm.segmentColor) {
113 | ctx.strokeStyle = vm.segmentColor;
114 | }
115 | ctx.beginPath();
116 | ctx.moveTo(x0, boxplot.segment);
117 | ctx.lineTo(x0 + width, boxplot.segment);
118 | ctx.closePath();
119 | }
120 | // fill the part below the median with lowerColor
121 | if (vm.lowerColor) {
122 | ctx.fillStyle = vm.lowerColor;
123 | if (boxplot.q3 > boxplot.q1) {
124 | ctx.fillRect(x0, boxplot.median, width, boxplot.q3 - boxplot.median);
125 | } else {
126 | ctx.fillRect(x0, boxplot.median, width, boxplot.q1 - boxplot.median);
127 | }
128 | }
129 |
130 | ctx.closePath();
131 | ctx.stroke();
132 | ctx.restore();
133 |
134 | // Draw the border around the main q1>q3 box
135 | if (boxplot.q3 > boxplot.q1) {
136 | ctx.strokeRect(x0, boxplot.q1, width, boxplot.q3 - boxplot.q1);
137 | } else {
138 | ctx.strokeRect(x0, boxplot.q3, width, boxplot.q1 - boxplot.q3);
139 | }
140 |
141 | // Draw the whiskers
142 | ctx.beginPath();
143 | ctx.moveTo(x0, boxplot.whiskerMin);
144 | ctx.lineTo(x0 + width, boxplot.whiskerMin);
145 | ctx.moveTo(x, boxplot.whiskerMin);
146 | ctx.lineTo(x, boxplot.q1);
147 | ctx.moveTo(x0, boxplot.whiskerMax);
148 | ctx.lineTo(x0 + width, boxplot.whiskerMax);
149 | ctx.moveTo(x, boxplot.whiskerMax);
150 | ctx.lineTo(x, boxplot.q3);
151 | ctx.closePath();
152 | ctx.stroke();
153 | },
154 | _drawBoxPlotHoriz(vm, boxplot, ctx) {
155 | const y = vm.y;
156 | const height = vm.height;
157 | const y0 = y - height / 2;
158 |
159 | // Draw the q1>q3 box
160 | if (boxplot.q3 > boxplot.q1) {
161 | ctx.fillRect(boxplot.q1, y0, boxplot.q3 - boxplot.q1, height);
162 | } else {
163 | ctx.fillRect(boxplot.q3, y0, boxplot.q1 - boxplot.q3, height);
164 | }
165 |
166 | // Draw the median line
167 | ctx.save();
168 | if (vm.medianColor) {
169 | ctx.strokeStyle = vm.medianColor;
170 | }
171 | ctx.beginPath();
172 | ctx.moveTo(boxplot.median, y0);
173 | ctx.lineTo(boxplot.median, y0 + height);
174 | ctx.closePath();
175 | ctx.stroke();
176 | ctx.restore();
177 |
178 | // Draw the border around the main q1>q3 box
179 | if (boxplot.q3 > boxplot.q1) {
180 | ctx.strokeRect(boxplot.q1, y0, boxplot.q3 - boxplot.q1, height);
181 | } else {
182 | ctx.strokeRect(boxplot.q3, y0, boxplot.q1 - boxplot.q3, height);
183 | }
184 |
185 | // Draw the whiskers
186 | ctx.beginPath();
187 | ctx.moveTo(boxplot.whiskerMin, y0);
188 | ctx.lineTo(boxplot.whiskerMin, y0 + height);
189 | ctx.moveTo(boxplot.whiskerMin, y);
190 | ctx.lineTo(boxplot.q1, y);
191 | ctx.moveTo(boxplot.whiskerMax, y0);
192 | ctx.lineTo(boxplot.whiskerMax, y0 + height);
193 | ctx.moveTo(boxplot.whiskerMax, y);
194 | ctx.lineTo(boxplot.q3, y);
195 | ctx.closePath();
196 | ctx.stroke();
197 | },
198 | _getBounds() {
199 | const vm = this._view;
200 |
201 | const vert = this.isVertical();
202 | const boxplot = vm.boxplot;
203 |
204 | if (!boxplot) {
205 | return {
206 | left: 0,
207 | top: 0,
208 | right: 0,
209 | bottom: 0
210 | };
211 | }
212 |
213 | if (vert) {
214 | const {
215 | x,
216 | width
217 | } = vm;
218 | const x0 = x - width / 2;
219 | return {
220 | left: x0,
221 | top: boxplot.whiskerMax,
222 | right: x0 + width,
223 | bottom: boxplot.whiskerMin
224 | };
225 | }
226 | const {
227 | y,
228 | height
229 | } = vm;
230 | const y0 = y - height / 2;
231 | return {
232 | left: boxplot.whiskerMin,
233 | top: y0,
234 | right: boxplot.whiskerMax,
235 | bottom: y0 + height
236 | };
237 | },
238 | height() {
239 | const vm = this._view;
240 | return vm.base - Math.min(vm.boxplot.q1, vm.boxplot.q3);
241 | },
242 | getArea() {
243 | const vm = this._view;
244 | const iqr = Math.abs(vm.boxplot.q3 - vm.boxplot.q1);
245 | if (this.isVertical()) {
246 | return iqr * vm.width;
247 | }
248 | return iqr * vm.height;
249 | },
250 | _getOutliers() {
251 | return this._view.boxplot ? this._view.boxplot.outliers || [] : [];
252 | },
253 | });
254 |
255 | export default BoxAndWiskers;
256 |
--------------------------------------------------------------------------------
/src/elements/violin.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import * as Chart from 'chart.js';
4 | import ArrayElementBase, {
5 | defaults
6 | } from './base';
7 |
8 |
9 | Chart.defaults.global.elements.violin = {
10 | points: 100,
11 | ...defaults
12 | };
13 |
14 | function transitionViolin(start, view, model, ease) {
15 | const keys = Object.keys(model);
16 | for (const key of keys) {
17 | const target = model[key];
18 | const origin = start[key];
19 | if (origin === target) {
20 | continue;
21 | }
22 | if (typeof target === 'number') {
23 | view[key] = origin + (target - origin) * ease;
24 | continue;
25 | }
26 | if (key === 'coords') {
27 | const v = view[key];
28 | const common = Math.min(target.length, origin.length);
29 | for (let i = 0; i < common; ++i) {
30 | v[i].v = origin[i].v + (target[i].v - origin[i].v) * ease;
31 | v[i].estimate = origin[i].estimate + (target[i].estimate - origin[i].estimate) * ease;
32 | }
33 | }
34 | }
35 | }
36 |
37 | const Violin = Chart.elements.Violin = ArrayElementBase.extend({
38 | transition(ease) {
39 | const r = Chart.Element.prototype.transition.call(this, ease);
40 | const model = this._model;
41 | const start = this._start;
42 | const view = this._view;
43 |
44 | // No animation -> No Transition
45 | if (!model || ease === 1) {
46 | return r;
47 | }
48 | if (start.violin == null) {
49 | return r; // model === view -> not copied
50 | }
51 |
52 | // create deep copy to avoid alternation
53 | if (model.violin === view.violin) {
54 | view.violin = Chart.helpers.clone(view.violin);
55 | }
56 | transitionViolin(start.violin, view.violin, model.violin, ease);
57 |
58 | return r;
59 | },
60 | draw() {
61 | const ctx = this._chart.ctx;
62 | const vm = this._view;
63 |
64 | const violin = vm.violin;
65 | const vert = this.isVertical();
66 |
67 | ctx.save();
68 |
69 | ctx.fillStyle = vm.backgroundColor;
70 | ctx.strokeStyle = vm.borderColor;
71 | ctx.lineWidth = vm.borderWidth;
72 |
73 | const coords = violin.coords;
74 |
75 | Chart.canvasHelpers.drawPoint(ctx, 'rectRot', 5, vm.x, vm.y);
76 | ctx.stroke();
77 |
78 | ctx.beginPath();
79 | if (vert) {
80 | const x = vm.x;
81 | const width = vm.width;
82 | const factor = (width / 2) / violin.maxEstimate;
83 | ctx.moveTo(x, violin.min);
84 | coords.forEach(({
85 | v,
86 | estimate
87 | }) => {
88 | ctx.lineTo(x - estimate * factor, v);
89 | });
90 | ctx.lineTo(x, violin.max);
91 | ctx.moveTo(x, violin.min);
92 | coords.forEach(({
93 | v,
94 | estimate
95 | }) => {
96 | ctx.lineTo(x + estimate * factor, v);
97 | });
98 | ctx.lineTo(x, violin.max);
99 | } else {
100 | const y = vm.y;
101 | const height = vm.height;
102 | const factor = (height / 2) / violin.maxEstimate;
103 | ctx.moveTo(violin.min, y);
104 | coords.forEach(({
105 | v,
106 | estimate
107 | }) => {
108 | ctx.lineTo(v, y - estimate * factor);
109 | });
110 | ctx.lineTo(violin.max, y);
111 | ctx.moveTo(violin.min, y);
112 | coords.forEach(({
113 | v,
114 | estimate
115 | }) => {
116 | ctx.lineTo(v, y + estimate * factor);
117 | });
118 | ctx.lineTo(violin.max, y);
119 | }
120 | ctx.stroke();
121 | ctx.fill();
122 | ctx.closePath();
123 |
124 | this._drawOutliers(vm, violin, ctx, vert);
125 |
126 | ctx.restore();
127 |
128 | this._drawItems(vm, violin, ctx, vert);
129 |
130 | },
131 | _getBounds() {
132 | const vm = this._view;
133 |
134 | const vert = this.isVertical();
135 | const violin = vm.violin;
136 |
137 | if (vert) {
138 | const {
139 | x,
140 | width
141 | } = vm;
142 | const x0 = x - width / 2;
143 | return {
144 | left: x0,
145 | top: violin.max,
146 | right: x0 + width,
147 | bottom: violin.min
148 | };
149 | }
150 | const {
151 | y,
152 | height
153 | } = vm;
154 | const y0 = y - height / 2;
155 | return {
156 | left: violin.min,
157 | top: y0,
158 | right: violin.max,
159 | bottom: y0 + height
160 | };
161 | },
162 | height() {
163 | const vm = this._view;
164 | return vm.base - Math.min(vm.violin.min, vm.violin.max);
165 | },
166 | getArea() {
167 | const vm = this._view;
168 | const iqr = Math.abs(vm.violin.max - vm.violin.min);
169 | if (this.isVertical()) {
170 | return iqr * vm.width;
171 | }
172 | return iqr * vm.height;
173 | },
174 | _getOutliers() {
175 | return this._view.violin.outliers || [];
176 | },
177 | });
178 |
179 | export default Violin;
180 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | export {default as BoxAndWhiskers} from './elements/boxandwhiskers';
4 | export {default as Violin} from './elements/violin';
5 | export * from './controllers/boxplot';
6 | export * from './controllers/violin';
7 | export {default as ArrayLinearScale} from './scale/arrayLinear';
8 | export {default as ArrayLogarithmicScale} from './scale/arrayLogarithmic';
9 | import './tooltip';
10 |
--------------------------------------------------------------------------------
/src/scale/arrayLinear.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import * as Chart from 'chart.js';
4 | import {getRightValue, commonDataLimits, commonScaleOptions} from '../data';
5 |
6 | const helpers = Chart.helpers;
7 |
8 | const ArrayLinearScaleOptions = helpers.merge({}, [commonScaleOptions, Chart.scaleService.getScaleDefaults('linear')]);
9 |
10 | const ArrayLinearScale = Chart.scaleService.getScaleConstructor('linear').extend({
11 | getRightValue(rawValue) {
12 | return Chart.LinearScaleBase.prototype.getRightValue.call(this, getRightValue(rawValue, this.options.ticks));
13 | },
14 | _parseValue(rawValue) {
15 | return Chart.LinearScaleBase.prototype._parseValue.call(this, getRightValue(rawValue, this.options.ticks));
16 | },
17 | determineDataLimits() {
18 | commonDataLimits.call(this);
19 | // Common base implementation to handle ticks.min, ticks.max, ticks.beginAtZero
20 | this.handleTickRangeOptions();
21 | }
22 | });
23 | Chart.scaleService.registerScaleType('arrayLinear', ArrayLinearScale, ArrayLinearScaleOptions);
24 |
25 | export default ArrayLinearScale;
26 |
--------------------------------------------------------------------------------
/src/scale/arrayLogarithmic.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import * as Chart from 'chart.js';
4 | import {getRightValue, commonDataLimits, commonScaleOptions} from '../data';
5 |
6 | const helpers = Chart.helpers;
7 |
8 | const ArrayLogarithmicScaleOptions = helpers.merge({}, [commonScaleOptions, Chart.scaleService.getScaleDefaults('logarithmic')]);
9 |
10 |
11 | const ArrayLogarithmicScale = Chart.scaleService.getScaleConstructor('logarithmic').extend({
12 | getRightValue(rawValue) {
13 | return Chart.LinearScaleBase.prototype.getRightValue.call(this, getRightValue(rawValue, this.options.ticks));
14 | },
15 | _parseValue(rawValue) {
16 | return Chart.LinearScaleBase.prototype._parseValue.call(this, getRightValue(rawValue, this.options.ticks));
17 | },
18 | determineDataLimits() {
19 | // Add whitespace around bars. Axis shouldn't go exactly from min to max
20 | const tickOpts = this.options.ticks;
21 | this.minNotZero = null;
22 | commonDataLimits.call(this, (boxPlot) => {
23 | const value = boxPlot[tickOpts.minStats];
24 | if (value !== 0 && (this.minNotZero === null || value < this.minNotZero)) {
25 | this.minNotZero = value;
26 | }
27 | });
28 |
29 | this.min = helpers.valueOrDefault(tickOpts.min, this.min - this.min * 0.05);
30 | this.max = helpers.valueOrDefault(tickOpts.max, this.max + this.max * 0.05);
31 |
32 | if (this.min === this.max) {
33 | if (this.min !== 0 && this.min !== null) {
34 | this.min = Math.pow(10, Math.floor(helpers.log10(this.min)) - 1);
35 | this.max = Math.pow(10, Math.floor(helpers.log10(this.max)) + 1);
36 | } else {
37 | this.min = 1;
38 | this.max = 10;
39 | }
40 | }
41 | }
42 | });
43 | Chart.scaleService.registerScaleType('arrayLogarithmic', ArrayLogarithmicScale, ArrayLogarithmicScaleOptions);
44 |
45 | export default ArrayLogarithmicScale;
46 |
--------------------------------------------------------------------------------
/src/tooltip.js:
--------------------------------------------------------------------------------
1 | import * as Chart from 'chart.js';
2 |
3 | export function boxplotPositioner(elements, eventPosition) {
4 | if (!elements.length) {
5 | return false;
6 | }
7 |
8 | const [x, y, count] = elements.reduce(([xi, ci, counti], el) => {
9 | if (el && el.hasValue()) {
10 | const pos = el.tooltipPosition(eventPosition, this);
11 | return [
12 | xi + pos.x,
13 | ci + pos.y,
14 | counti + 1
15 | ];
16 | }
17 | return [xi, ci, counti];
18 | }, [0, 0, 0]);
19 |
20 | return {
21 | x: x / count,
22 | y: y / count
23 | };
24 | }
25 |
26 | Chart.Tooltip.positioners.boxplot = boxplotPositioner;
27 |
--------------------------------------------------------------------------------