├── .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 | ![Box Plot](https://user-images.githubusercontent.com/4129778/42724341-9a6ec554-8770-11e8-99b5-626e34dafdb3.png) 7 | ![Violin Plot](https://user-images.githubusercontent.com/4129778/42724342-9a8dbb58-8770-11e8-9a30-3e69d07d3b79.png) 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 | --------------------------------------------------------------------------------