├── .babelrc ├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md ├── example.gif ├── logo.png └── workflows │ └── npm-publish.yml ├── .gitignore ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── docs ├── _config.yml ├── assets │ ├── css │ │ ├── bootstrap.min.css │ │ ├── hljs.css │ │ ├── index.css │ │ └── reset.css │ ├── img │ │ ├── callisto.jpg │ │ ├── europa.jpg │ │ ├── frappe-bird.png │ │ ├── ganymede.jpg │ │ └── io.jpg │ └── js │ │ ├── data.js │ │ ├── demoConfig.js │ │ ├── highlight.pack.js │ │ ├── index.js │ │ ├── index.min.js │ │ └── index.min.js.map ├── docs.html └── index.html ├── package-lock.json ├── package.json ├── rollup.config.js └── src ├── css ├── charts.scss └── chartsCss.js └── js ├── chart.js ├── charts ├── AggregationChart.js ├── AxisChart.js ├── BaseChart.js ├── DonutChart.js ├── Heatmap.js ├── PercentageChart.js └── PieChart.js ├── index.js ├── objects ├── ChartComponents.js └── SvgTip.js └── utils ├── animate.js ├── animation.js ├── axis-chart-utils.js ├── colors.js ├── constants.js ├── date-utils.js ├── dom.js ├── draw-utils.js ├── draw.js ├── export.js ├── helpers.js ├── intervals.js └── test ├── colors.test.js └── helpers.test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "browsers": ["last 2 versions", "safari >= 7"] 8 | }, 9 | "modules": false 10 | } 11 | ] 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "parserOptions": { 8 | "sourceType": "module" 9 | }, 10 | "rules": { 11 | "indent": ["error", "tab"], 12 | "linebreak-style": ["error", "unix"], 13 | "semi": ["error", "always"], 14 | "no-console": [ 15 | "error", 16 | { 17 | "allow": ["warn", "error"] 18 | } 19 | ] 20 | }, 21 | "globals": { 22 | "ENV": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | #### Expected Behaviour 2 | 3 | #### Actual Behaviour 4 | 5 | #### Steps to Reproduce: 6 | * 7 | 8 | 9 | NOTE: Add a GIF/Screenshot if required. 10 | 11 | Frappé Charts version: 12 | Codepen / Codesandbox: -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ###### Explanation About What Code Achieves: 5 | 6 | - Explanation 7 | 8 | ###### Screenshots/GIFs: 9 | 10 | - Screenshot 11 | 12 | ###### Steps To Test: 13 | 14 | - Steps 15 | 16 | ###### TODOs: 17 | 18 | - None 19 | -------------------------------------------------------------------------------- /.github/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/charts/7b15424c3af80b1c8e178d8e612c290d2d630904/.github/example.gif -------------------------------------------------------------------------------- /.github/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/charts/7b15424c3af80b1c8e178d8e612c290d2d630904/.github/logo.png -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v1 16 | with: 17 | node-version: 12 18 | - run: npm ci 19 | - run: npm build 20 | 21 | publish-npm: 22 | needs: build 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v2 26 | - uses: actions/setup-node@v1 27 | with: 28 | node-version: 12 29 | registry-url: https://registry.npmjs.org/ 30 | - run: npm ci 31 | - run: npm publish 32 | env: 33 | NODE_AUTH_TOKEN: ${{secrets.NPM_PUBLISH_TOKEN}} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # npm build output 64 | dist 65 | docs 66 | docs/assets/ 67 | 68 | .DS_Store 69 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "6" 5 | - "8" 6 | 7 | before_install: 8 | - make install 9 | 10 | script: 11 | - make test 12 | 13 | after_success: 14 | - make coveralls 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Prateeksha Singh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | -include .env 2 | 3 | BASEDIR = $(realpath .) 4 | 5 | SRCDIR = $(BASEDIR)/src 6 | DISTDIR = $(BASEDIR)/dist 7 | DOCSDIR = $(BASEDIR)/docs 8 | 9 | PROJECT = frappe-charts 10 | 11 | NODEMOD = $(BASEDIR)/node_modules 12 | NODEBIN = $(NODEMOD)/.bin 13 | 14 | build: clean install 15 | $(NODEBIN)/rollup \ 16 | --config $(BASEDIR)/rollup.config.js \ 17 | --watch=$(watch) 18 | 19 | clean: 20 | rm -rf \ 21 | $(BASEDIR)/.nyc_output \ 22 | $(BASEDIR)/.yarn-error.log 23 | 24 | clear 25 | 26 | install.dep: 27 | ifeq ($(shell command -v yarn),) 28 | @echo "Installing yarn..." 29 | npm install -g yarn 30 | endif 31 | 32 | install: install.dep 33 | yarn --cwd $(BASEDIR) 34 | 35 | test: clean 36 | $(NODEBIN)/cross-env \ 37 | NODE_ENV=test \ 38 | $(NODEBIN)/nyc \ 39 | $(NODEBIN)/mocha \ 40 | --require $(NODEMOD)/babel-register \ 41 | --recursive \ 42 | $(SRCDIR)/js/**/test/*.test.js 43 | 44 | coveralls: 45 | $(NODEBIN)/nyc report --reporter text-lcov | $(NODEBIN)/coveralls 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | charts-logo 4 | 5 | # Frappe Charts 6 | **GitHub-inspired modern, intuitive and responsive charts with zero dependencies** 7 | 8 |

9 | 10 | 11 | 12 |

13 | 14 | 15 | 16 |
17 | 18 | [Explore Demos](https://frappe.io/charts) - [Edit at CodeSandbox](https://codesandbox.io/s/frappe-charts-demo-viqud) - [Documentation](https://frappe.io/charts/docs) 19 | 20 |
21 | 22 |
23 | 24 | ## Frappe Charts 25 | Frappe Charts is a simple charting library with a focus on a simple API. The design is inspired by various charts you see on GitHub. 26 | 27 | ### Motivation 28 | 29 | ERPNext needed a simple sales history graph for its user company master to help users track sales. While using c3.js for reports, the library didn’t align well with our product’s classic design. Existing JS libraries were either too complex or rigid in their structure and behavior. To address this, I decided to create a library for translating value pairs into relative shapes or positions, focusing on simplicity. 30 | 31 | ### Key Features 32 | 33 | - **Variety of chart types**: Frappe Charts supports various chart types, including Axis Charts, Area and Trends, Bar, Line, Pie, Percentage, Mixed Axis, and Heatmap. 34 | - **Annotations and tooltips**: Charts can be annotated with x and y markers, regions, and tooltips for enhanced data context and clarity. 35 | - **Dynamic data handling**: Add, remove, or update individual data points in place, or refresh the entire dataset to reflect changes. 36 | - **Customizable configurations**: Flexible options like colors, animations, and custom titles allow for a highly personalized chart experience. 37 | 38 | ## Usage 39 | 40 | ```sh 41 | npm install frappe-charts 42 | ``` 43 | 44 | Import in your project: 45 | ```js 46 | import { Chart } from 'frappe-charts' 47 | // or esm import 48 | import { Chart } from 'frappe-charts/dist/frappe-charts.esm.js' 49 | // import css 50 | import 'frappe-charts/dist/frappe-charts.min.css' 51 | ``` 52 | 53 | Or directly include script in your HTML 54 | 55 | ```html 56 | 57 | ``` 58 | 59 | 60 | ```js 61 | const data = { 62 | labels: ["12am-3am", "3am-6pm", "6am-9am", "9am-12am", 63 | "12pm-3pm", "3pm-6pm", "6pm-9pm", "9am-12am" 64 | ], 65 | datasets: [ 66 | { 67 | name: "Some Data", chartType: "bar", 68 | values: [25, 40, 30, 35, 8, 52, 17, -4] 69 | }, 70 | { 71 | name: "Another Set", chartType: "line", 72 | values: [25, 50, -10, 15, 18, 32, 27, 14] 73 | } 74 | ] 75 | } 76 | 77 | const chart = new frappe.Chart("#chart", { // or a DOM element, 78 | // new Chart() in case of ES6 module with above usage 79 | title: "My Awesome Chart", 80 | data: data, 81 | type: 'axis-mixed', // or 'bar', 'line', 'scatter', 'pie', 'percentage' 82 | height: 250, 83 | colors: ['#7cd6fd', '#743ee2'] 84 | }) 85 | ``` 86 | 87 | ## Contributing 88 | 89 | 1. Clone this repo. 90 | 2. `cd` into project directory 91 | 3. `npm install` 92 | 4. `npm i npm-run-all -D` (*optional --> might be required for some developers*) 93 | 5. `npm run dev` 94 | 95 | ## Links 96 | 97 | - [Read the blog](https://medium.com/@pratu16x7/so-we-decided-to-create-our-own-charts-a95cb5032c97) 98 | 99 | 100 |
101 |
102 |
103 | 104 | 105 | 106 | Frappe Technologies 107 | 108 | 109 |
110 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | - jekyll-redirect-from 3 | -------------------------------------------------------------------------------- /docs/assets/css/hljs.css: -------------------------------------------------------------------------------- 1 | /* 2 | github.com style (c) Vasily Polovnyov 3 | */ 4 | 5 | .hljs { 6 | display: block; 7 | color: #36414c; 8 | overflow-x: auto; 9 | padding: 0.5em; 10 | background: #F8F8F9; 11 | border-radius: 3px; 12 | } 13 | 14 | .hljs-comment, 15 | .hljs-quote { 16 | color: #998; 17 | font-style: italic; 18 | } 19 | 20 | .hljs-keyword, 21 | .hljs-selector-tag, 22 | .hljs-subst { 23 | color: #333; 24 | font-weight: bold; 25 | } 26 | 27 | .hljs-number, 28 | .hljs-literal, 29 | .hljs-variable, 30 | .hljs-template-variable, 31 | .hljs-tag .hljs-attr { 32 | color: #008080; 33 | } 34 | 35 | .hljs-string, 36 | .hljs-doctag { 37 | color: #d14; 38 | } 39 | 40 | .hljs-title, 41 | .hljs-section, 42 | .hljs-selector-id { 43 | color: #900; 44 | font-weight: bold; 45 | } 46 | 47 | .hljs-subst { 48 | font-weight: normal; 49 | } 50 | 51 | .hljs-type, 52 | .hljs-class .hljs-title { 53 | color: #458; 54 | font-weight: bold; 55 | } 56 | 57 | .hljs-tag, 58 | .hljs-name, 59 | .hljs-attribute { 60 | color: #000080; 61 | font-weight: normal; 62 | } 63 | 64 | .hljs-regexp, 65 | .hljs-link { 66 | color: #009926; 67 | } 68 | 69 | .hljs-symbol, 70 | 71 | .hljs-bullet { 72 | color: #990073; 73 | } 74 | 75 | .hljs-built_in, 76 | .hljs-builtin-name { 77 | color: #0086b3; 78 | } 79 | 80 | .hljs-meta { 81 | color: #999; 82 | font-weight: bold; 83 | } 84 | 85 | .hljs-deletion { 86 | background: #fdd; 87 | } 88 | 89 | .hljs-addition { 90 | background: #dfd; 91 | } 92 | 93 | .hljs-emphasis { 94 | font-style: italic; 95 | } 96 | 97 | .hljs-strong { 98 | font-weight: bold; 99 | } 100 | -------------------------------------------------------------------------------- /docs/assets/css/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | /* container styles */ 3 | max-width: 720px; 4 | margin: auto; 5 | 6 | font-family: "proxima-nova", sans-serif; 7 | font-size: 15px; 8 | color: #6c7680; 9 | text-rendering: optimizeLegibility !important; 10 | line-height: 1.5em; 11 | -moz-osx-font-smoothing: grayscale; 12 | -webkit-font-smoothing: antialiased; 13 | } 14 | 15 | h1, 16 | h2, 17 | h3, 18 | h4, 19 | h5, 20 | h6, 21 | .lead, 22 | .page-sidebar, 23 | .breadcrumb, 24 | .label, 25 | .h6, 26 | .sans, 27 | blockquote { 28 | font-family: "proxima-nova", sans-serif; 29 | color: #36414C; 30 | } 31 | 32 | header { 33 | margin: 4rem 0; /* SAME 1 */ 34 | font-size: 1.6em; 35 | font-weight: 300; 36 | text-align: center; 37 | } 38 | header .lead-text { 39 | line-height: 3rem; 40 | margin: 2rem 0; 41 | } 42 | .demo-tip { 43 | margin-top: 1rem; /* SAME 2 */ 44 | font-size: 1rem; 45 | } 46 | section { 47 | margin: 4em 0; /* SAME 1 */ 48 | } 49 | h1 { 50 | font-size: 3.5rem; 51 | margin-bottom: 1.5rem; 52 | } 53 | h1, h6 { 54 | font-weight: 700; 55 | } 56 | .btn { 57 | outline: none !important; 58 | } 59 | .blue.button { 60 | color: #fff; 61 | background: #7575ff; 62 | border: 0px; 63 | border-bottom: 3px solid rgba(0, 0, 0, 0.2); 64 | } 65 | .blue.button:hover { 66 | background: #5b5be5; 67 | } 68 | .large.button { 69 | font-size: 1.33em; 70 | padding: 12px 24px 10px; 71 | border-bottom: 3px solid rgba(0, 0, 0, 0.2); 72 | } 73 | a { 74 | color: #5E64FF; 75 | } 76 | a, a:focus, a:hover { 77 | transition: color 0.3s, border 0.3s, background-color 0.3s; 78 | } 79 | 80 | 81 | /* BaseCSS */ 82 | .margin-top { 83 | margin-top: 1rem; /* SAME 2 */ 84 | } 85 | .mv1 { 86 | margin: 2em 0 1em 0; 87 | } 88 | .border { 89 | border: 1px solid #ddd; 90 | border-radius: 3px; 91 | } 92 | 93 | 94 | /* Moon images */ 95 | .image-container { 96 | padding: 3px; 97 | } 98 | .image-container img{ 99 | display: block; 100 | width: 100%; 101 | } 102 | .content-data p { 103 | margin-bottom: 5px; 104 | font-size: 12px; 105 | } 106 | 107 | 108 | .text-center { 109 | text-align: center; 110 | } 111 | -------------------------------------------------------------------------------- /docs/assets/css/reset.css: -------------------------------------------------------------------------------- 1 | /*! 2 | *this reset is a copy of bootstrap's reboot.css which is inturn a fork of normalise* 3 | * Bootstrap Reboot v4.0.0-beta.3 (https://getbootstrap.com) 4 | * Copyright 2011-2017 The Bootstrap Authors 5 | * Copyright 2011-2017 Twitter, Inc. 6 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 7 | * Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md) 8 | */ 9 | 10 | *, 11 | *::before, 12 | *::after { 13 | box-sizing: border-box; 14 | } 15 | 16 | html { 17 | font-family: sans-serif; 18 | -webkit-text-size-adjust: 100%; 19 | -ms-text-size-adjust: 100%; 20 | -ms-overflow-style: scrollbar; 21 | -webkit-tap-highlight-color: transparent; 22 | --line-height: 3; 23 | line-height: calc(((var(--line-height) * var(--capital-height)) - var(--valign)) * 1px); 24 | } 25 | 26 | @-ms-viewport { 27 | width: device-width; 28 | } 29 | 30 | article, aside, dialog, figcaption, figure, footer, header, hgroup, main, nav, section { 31 | display: block; 32 | } 33 | 34 | body { 35 | margin: 0; 36 | font-size: 1em; 37 | font-weight: 400; 38 | /* line-height: 1.5; */ 39 | text-align: left; 40 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, Noto, Oxygen-Sans, "Noto Sans", Ubuntu,Cantarell, sans-serif, "Apple Color Emoji", "Noto Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 41 | color: #36414c; 42 | font-weight:normal; 43 | -webkit-text-size-adjust: 100%; 44 | -webkit-font-feature-settings: "kern" 1; 45 | -moz-font-feature-settings: "kern" 1; 46 | -o-font-feature-settings: "kern" 1; 47 | font-feature-settings: "kern" 1; 48 | font-kerning: normal; 49 | text-rendering: optimizeLegibility; 50 | } 51 | 52 | [tabindex="-1"]:focus { 53 | outline: 0 !important; 54 | } 55 | 56 | hr { 57 | box-sizing: content-box; 58 | height: 0; 59 | overflow: visible; 60 | } 61 | 62 | h1, h2, h3, h4, h5, h6 { 63 | margin-top: 0; 64 | margin-bottom: 1.6rem; 65 | } 66 | 67 | p { 68 | margin-top: 0; 69 | margin-bottom: 1rem; 70 | } 71 | 72 | abbr[title], 73 | abbr[data-original-title] { 74 | text-decoration: underline; 75 | -webkit-text-decoration: underline dotted; 76 | text-decoration: underline dotted; 77 | cursor: help; 78 | border-bottom: 0; 79 | } 80 | 81 | address { 82 | margin-bottom: 1rem; 83 | font-style: normal; 84 | line-height: inherit; 85 | } 86 | 87 | ol, 88 | ul, 89 | dl { 90 | margin-top: 0; 91 | margin-bottom: 1rem; 92 | } 93 | 94 | ol ol, 95 | ul ul, 96 | ol ul, 97 | ul ol { 98 | margin-bottom: 0; 99 | } 100 | 101 | dt { 102 | font-weight: 700; 103 | } 104 | 105 | dd { 106 | margin-bottom: .5rem; 107 | margin-left: 0; 108 | } 109 | 110 | blockquote { 111 | margin: 0 0 1rem; 112 | } 113 | 114 | dfn { 115 | font-style: italic; 116 | } 117 | 118 | b, 119 | strong { 120 | font-weight: bolder; 121 | } 122 | 123 | small { 124 | font-size: 80%; 125 | } 126 | 127 | sub, 128 | sup { 129 | position: relative; 130 | font-size: 75%; 131 | line-height: 0; 132 | vertical-align: baseline; 133 | } 134 | 135 | sub { 136 | bottom: -.25em; 137 | } 138 | 139 | sup { 140 | top: -.5em; 141 | } 142 | 143 | a { 144 | color: #007bff; 145 | text-decoration: none; 146 | background-color: transparent; 147 | -webkit-text-decoration-skip: objects; 148 | } 149 | 150 | a:hover { 151 | color: #0056b3; 152 | text-decoration: underline; 153 | } 154 | 155 | a:not([href]):not([tabindex]) { 156 | color: inherit; 157 | text-decoration: none; 158 | } 159 | 160 | a:not([href]):not([tabindex]):focus, a:not([href]):not([tabindex]):hover { 161 | color: inherit; 162 | text-decoration: none; 163 | } 164 | 165 | a:not([href]):not([tabindex]):focus { 166 | outline: 0; 167 | } 168 | 169 | pre, 170 | code, 171 | kbd, 172 | samp { 173 | font-family: monospace, monospace; 174 | font-size: 1em; 175 | } 176 | 177 | pre { 178 | margin-top: 0; 179 | margin-bottom: 1rem; 180 | overflow: auto; 181 | -ms-overflow-style: scrollbar; 182 | } 183 | 184 | figure { 185 | margin: 0 0 1rem; 186 | } 187 | 188 | img { 189 | vertical-align: middle; 190 | border-style: none; 191 | } 192 | 193 | svg:not(:root) { 194 | overflow: hidden; 195 | } 196 | 197 | a, 198 | area, 199 | button, 200 | [role="button"], 201 | input:not([type="range"]), 202 | label, 203 | select, 204 | summary, 205 | textarea { 206 | -ms-touch-action: manipulation; 207 | touch-action: manipulation; 208 | } 209 | 210 | table { 211 | border-collapse: collapse; 212 | } 213 | 214 | caption { 215 | padding-top: 0.75rem; 216 | padding-bottom: 0.75rem; 217 | color: #6c757d; 218 | text-align: left; 219 | caption-side: bottom; 220 | } 221 | 222 | th { 223 | text-align: inherit; 224 | } 225 | 226 | label { 227 | display: inline-block; 228 | margin-bottom: .5rem; 229 | } 230 | 231 | button { 232 | border-radius: 0; 233 | } 234 | 235 | button:focus { 236 | outline: 1px dotted; 237 | outline: 5px auto -webkit-focus-ring-color; 238 | } 239 | 240 | input, 241 | button, 242 | select, 243 | optgroup, 244 | textarea { 245 | margin: 0; 246 | font-family: inherit; 247 | font-size: inherit; 248 | line-height: inherit; 249 | } 250 | 251 | button, 252 | input { 253 | overflow: visible; 254 | } 255 | 256 | button, 257 | select { 258 | text-transform: none; 259 | } 260 | 261 | button, 262 | html [type="button"], 263 | [type="reset"], 264 | [type="submit"] { 265 | -webkit-appearance: button; 266 | } 267 | 268 | button::-moz-focus-inner, 269 | [type="button"]::-moz-focus-inner, 270 | [type="reset"]::-moz-focus-inner, 271 | [type="submit"]::-moz-focus-inner { 272 | padding: 0; 273 | border-style: none; 274 | } 275 | 276 | input[type="radio"], 277 | input[type="checkbox"] { 278 | box-sizing: border-box; 279 | padding: 0; 280 | } 281 | 282 | input[type="date"], 283 | input[type="time"], 284 | input[type="datetime-local"], 285 | input[type="month"] { 286 | -webkit-appearance: listbox; 287 | } 288 | 289 | textarea { 290 | overflow: auto; 291 | resize: vertical; 292 | } 293 | 294 | fieldset { 295 | min-width: 0; 296 | padding: 0; 297 | margin: 0; 298 | border: 0; 299 | } 300 | 301 | legend { 302 | display: block; 303 | width: 100%; 304 | max-width: 100%; 305 | padding: 0; 306 | margin-bottom: .5rem; 307 | font-size: 1.5rem; 308 | line-height: inherit; 309 | color: inherit; 310 | white-space: normal; 311 | } 312 | 313 | progress { 314 | vertical-align: baseline; 315 | } 316 | 317 | [type="number"]::-webkit-inner-spin-button, 318 | [type="number"]::-webkit-outer-spin-button { 319 | height: auto; 320 | } 321 | 322 | [type="search"] { 323 | outline-offset: -2px; 324 | -webkit-appearance: none; 325 | } 326 | 327 | [type="search"]::-webkit-search-cancel-button, 328 | [type="search"]::-webkit-search-decoration { 329 | -webkit-appearance: none; 330 | } 331 | 332 | ::-webkit-file-upload-button { 333 | font: inherit; 334 | -webkit-appearance: button; 335 | } 336 | 337 | output { 338 | display: inline-block; 339 | } 340 | 341 | summary { 342 | display: list-item; 343 | cursor: pointer; 344 | } 345 | 346 | template { 347 | display: none; 348 | } 349 | 350 | [hidden] { 351 | display: none !important; 352 | } 353 | /*# sourceMappingURL=bootstrap-reboot.css.map */ 354 | -------------------------------------------------------------------------------- /docs/assets/img/callisto.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/charts/7b15424c3af80b1c8e178d8e612c290d2d630904/docs/assets/img/callisto.jpg -------------------------------------------------------------------------------- /docs/assets/img/europa.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/charts/7b15424c3af80b1c8e178d8e612c290d2d630904/docs/assets/img/europa.jpg -------------------------------------------------------------------------------- /docs/assets/img/frappe-bird.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/charts/7b15424c3af80b1c8e178d8e612c290d2d630904/docs/assets/img/frappe-bird.png -------------------------------------------------------------------------------- /docs/assets/img/ganymede.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/charts/7b15424c3af80b1c8e178d8e612c290d2d630904/docs/assets/img/ganymede.jpg -------------------------------------------------------------------------------- /docs/assets/img/io.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/charts/7b15424c3af80b1c8e178d8e612c290d2d630904/docs/assets/img/io.jpg -------------------------------------------------------------------------------- /docs/assets/js/data.js: -------------------------------------------------------------------------------- 1 | import { MONTH_NAMES_SHORT } from "../../../src/js/utils/date-utils"; 2 | 3 | // Composite Chart 4 | // ================================================================================ 5 | const reportCountList = [ 6 | 152, 7 | 222, 8 | 199, 9 | 287, 10 | 534, 11 | 709, 12 | 1179, 13 | 1256, 14 | 1632, 15 | 1856, 16 | 1850, 17 | ]; 18 | 19 | export const lineCompositeData = { 20 | labels: [ 21 | "2007", 22 | "2008", 23 | "2009", 24 | "2010", 25 | "2011", 26 | "2012", 27 | "2013", 28 | "2014", 29 | "2015", 30 | "2016", 31 | "2017", 32 | ], 33 | 34 | yMarkers: [ 35 | { 36 | label: "Average 100 reports/month", 37 | value: 1200, 38 | options: { labelPos: "left" }, 39 | }, 40 | ], 41 | 42 | datasets: [ 43 | { 44 | name: "Events", 45 | values: reportCountList, 46 | }, 47 | ], 48 | }; 49 | 50 | export const fireball_5_25 = [ 51 | [4, 0, 3, 1, 1, 2, 1, 1, 1, 0, 1, 1], 52 | [2, 3, 3, 2, 1, 3, 0, 1, 2, 7, 10, 4], 53 | [5, 6, 2, 4, 0, 1, 4, 3, 0, 2, 0, 1], 54 | [0, 2, 6, 2, 1, 1, 2, 3, 6, 3, 7, 8], 55 | [6, 8, 7, 7, 4, 5, 6, 5, 22, 12, 10, 11], 56 | [7, 10, 11, 7, 3, 2, 7, 7, 11, 15, 22, 20], 57 | [13, 16, 21, 18, 19, 17, 12, 17, 31, 28, 25, 29], 58 | [24, 14, 21, 14, 11, 15, 19, 21, 41, 22, 32, 18], 59 | [31, 20, 30, 22, 14, 17, 21, 35, 27, 50, 117, 24], 60 | [32, 24, 21, 27, 11, 27, 43, 37, 44, 40, 48, 32], 61 | [31, 38, 36, 26, 23, 23, 25, 29, 26, 47, 61, 50], 62 | ]; 63 | export const fireball_2_5 = [ 64 | [22, 6, 6, 9, 7, 8, 6, 14, 19, 10, 8, 20], 65 | [11, 13, 12, 8, 9, 11, 9, 13, 10, 22, 40, 24], 66 | [20, 13, 13, 19, 13, 10, 14, 13, 20, 18, 5, 9], 67 | [7, 13, 16, 19, 12, 11, 21, 27, 27, 24, 33, 33], 68 | [38, 25, 28, 22, 31, 21, 35, 42, 37, 32, 46, 53], 69 | [50, 33, 36, 34, 35, 28, 27, 52, 58, 59, 75, 69], 70 | [54, 67, 67, 45, 66, 51, 38, 64, 90, 113, 116, 87], 71 | [84, 52, 56, 51, 55, 46, 50, 87, 114, 83, 152, 93], 72 | [73, 58, 59, 63, 56, 51, 83, 140, 103, 115, 265, 89], 73 | [106, 95, 94, 71, 77, 75, 99, 136, 129, 154, 168, 156], 74 | [81, 102, 95, 72, 58, 91, 89, 122, 124, 135, 183, 171], 75 | ]; 76 | export const fireballOver25 = [ 77 | // [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 78 | [0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0], 79 | [0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0], 80 | [1, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0], 81 | [0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 2], 82 | [3, 2, 1, 3, 2, 0, 2, 2, 2, 3, 0, 1], 83 | [2, 3, 5, 2, 1, 3, 0, 2, 3, 5, 1, 4], 84 | [7, 4, 6, 1, 9, 2, 2, 2, 20, 9, 4, 9], 85 | [5, 6, 1, 2, 5, 4, 5, 5, 16, 9, 14, 9], 86 | [5, 4, 7, 5, 1, 5, 3, 3, 5, 7, 22, 2], 87 | [5, 13, 11, 6, 1, 7, 9, 8, 14, 17, 16, 3], 88 | [8, 9, 8, 6, 4, 8, 5, 6, 14, 11, 21, 12], 89 | ]; 90 | 91 | export const barCompositeData = { 92 | labels: MONTH_NAMES_SHORT, 93 | datasets: [ 94 | { 95 | name: "Over 25 reports", 96 | values: fireballOver25[9], 97 | }, 98 | { 99 | name: "5 to 25 reports", 100 | values: fireball_5_25[9], 101 | }, 102 | { 103 | name: "2 to 5 reports", 104 | values: fireball_2_5[9], 105 | }, 106 | ], 107 | }; 108 | 109 | // Demo Chart multitype Chart 110 | // ================================================================================ 111 | export const typeData = { 112 | labels: [ 113 | "12am-3am", 114 | "3am-6am", 115 | "6am-9am", 116 | "9am-12pm", 117 | "12pm-3pm", 118 | "3pm-6pm", 119 | "6pm-9pm", 120 | "9pm-12am", 121 | ], 122 | 123 | yMarkers: [ 124 | { 125 | label: "Marker", 126 | value: 43, 127 | options: { labelPos: "left" }, 128 | // type: 'dashed' 129 | }, 130 | ], 131 | 132 | yRegions: [ 133 | { 134 | label: "Region", 135 | start: -10, 136 | end: 50, 137 | options: { labelPos: "right" }, 138 | }, 139 | ], 140 | 141 | datasets: [ 142 | { 143 | name: "Some Data", 144 | values: [18, 40, 30, 35, 8, 52, 17, -4], 145 | axisPosition: "right", 146 | chartType: "bar", 147 | }, 148 | { 149 | name: "Another Set", 150 | values: [30, 50, -10, 15, 18, 32, 27, 14], 151 | axisPosition: "right", 152 | chartType: "bar", 153 | }, 154 | { 155 | name: "Yet Another", 156 | values: [15, 20, -3, -15, 58, 12, -17, 37], 157 | chartType: "line", 158 | }, 159 | ], 160 | }; 161 | 162 | export const trendsData = { 163 | labels: [ 164 | 1967, 165 | 1968, 166 | 1969, 167 | 1970, 168 | 1971, 169 | 1972, 170 | 1973, 171 | 1974, 172 | 1975, 173 | 1976, 174 | 1977, 175 | 1978, 176 | 1979, 177 | 1980, 178 | 1981, 179 | 1982, 180 | 1983, 181 | 1984, 182 | 1985, 183 | 1986, 184 | 1987, 185 | 1988, 186 | 1989, 187 | 1990, 188 | 1991, 189 | 1992, 190 | 1993, 191 | 1994, 192 | 1995, 193 | 1996, 194 | 1997, 195 | 1998, 196 | 1999, 197 | 2000, 198 | 2001, 199 | 2002, 200 | 2003, 201 | 2004, 202 | 2005, 203 | 2006, 204 | 2007, 205 | 2008, 206 | 2009, 207 | 2010, 208 | 2011, 209 | 2012, 210 | 2013, 211 | 2014, 212 | 2015, 213 | 2016, 214 | ], 215 | datasets: [ 216 | { 217 | values: [ 218 | 132.9, 219 | 150.0, 220 | 149.4, 221 | 148.0, 222 | 94.4, 223 | 97.6, 224 | 54.1, 225 | 49.2, 226 | 22.5, 227 | 18.4, 228 | 39.3, 229 | 131.0, 230 | 220.1, 231 | 218.9, 232 | 198.9, 233 | 162.4, 234 | 91.0, 235 | 60.5, 236 | 20.6, 237 | 14.8, 238 | 33.9, 239 | 123.0, 240 | 211.1, 241 | 191.8, 242 | 203.3, 243 | 133.0, 244 | 76.1, 245 | 44.9, 246 | 25.1, 247 | 11.6, 248 | 28.9, 249 | 88.3, 250 | 136.3, 251 | 173.9, 252 | 170.4, 253 | 163.6, 254 | 99.3, 255 | 65.3, 256 | 45.8, 257 | 24.7, 258 | 12.6, 259 | 4.2, 260 | 4.8, 261 | 24.9, 262 | 80.8, 263 | 84.5, 264 | 94.0, 265 | 113.3, 266 | 69.8, 267 | 39.8, 268 | ], 269 | }, 270 | ], 271 | }; 272 | 273 | export const moonData = { 274 | names: ["Ganymede", "Callisto", "Io", "Europa"], 275 | masses: [14819000, 10759000, 8931900, 4800000], 276 | distances: [1070.412, 1882.709, 421.7, 671.034], 277 | diameters: [5262.4, 4820.6, 3637.4, 3121.6], 278 | }; 279 | -------------------------------------------------------------------------------- /docs/assets/js/demoConfig.js: -------------------------------------------------------------------------------- 1 | import { lineCompositeData, barCompositeData } from './data'; 2 | 3 | export default { 4 | lineComposite: { 5 | elementID: "#chart-composite-1", 6 | options: { 7 | title: "Fireball/Bolide Events - Yearly (reported)", 8 | data: lineCompositeData, 9 | type: "line", 10 | height: 190, 11 | colors: ["green"], 12 | isNavigable: 1, 13 | valuesOverPoints: 1, 14 | 15 | lineOptions: { 16 | dotSize: 8 17 | } 18 | } 19 | }, 20 | 21 | barComposite: { 22 | elementID: "#chart-composite-2", 23 | options: { 24 | data: barCompositeData, 25 | type: "bar", 26 | height: 210, 27 | colors: ["violet", "light-blue", "#46a9f9"], 28 | valuesOverPoints: 1, 29 | axisOptions: { 30 | xAxisMode: "tick", 31 | shortenYAxisNumbers: true 32 | }, 33 | barOptions: { 34 | stacked: 1 35 | } 36 | } 37 | }, 38 | 39 | demoMain: { 40 | elementID: "", 41 | options: { 42 | title: "My Awesome Chart", 43 | data: "typeData", 44 | type: "axis-mixed", 45 | height: 300, 46 | colors: ["purple", "magenta", "light-blue"], 47 | maxSlices: 10, 48 | 49 | tooltipOptions: { 50 | formatTooltipX: d => (d + '').toUpperCase(), 51 | formatTooltipY: d => d + ' pts', 52 | } 53 | } 54 | } 55 | }; -------------------------------------------------------------------------------- /docs/assets/js/highlight.pack.js: -------------------------------------------------------------------------------- 1 | /*! highlight.js v9.9.0 | BSD3 License | git.io/hljslicense */ 2 | !function(e){var n="object"==typeof window&&window||"object"==typeof self&&self;"undefined"!=typeof exports?e(exports):n&&(n.hljs=e({}),"function"==typeof define&&define.amd&&define([],function(){return n.hljs}))}(function(e){function n(e){return e.replace(/[&<>]/gm,function(e){return I[e]})}function t(e){return e.nodeName.toLowerCase()}function r(e,n){var t=e&&e.exec(n);return t&&0===t.index}function i(e){return k.test(e)}function a(e){var n,t,r,a,o=e.className+" ";if(o+=e.parentNode?e.parentNode.className:"",t=B.exec(o))return R(t[1])?t[1]:"no-highlight";for(o=o.split(/\s+/),n=0,r=o.length;r>n;n++)if(a=o[n],i(a)||R(a))return a}function o(e,n){var t,r={};for(t in e)r[t]=e[t];if(n)for(t in n)r[t]=n[t];return r}function u(e){var n=[];return function r(e,i){for(var a=e.firstChild;a;a=a.nextSibling)3===a.nodeType?i+=a.nodeValue.length:1===a.nodeType&&(n.push({event:"start",offset:i,node:a}),i=r(a,i),t(a).match(/br|hr|img|input/)||n.push({event:"stop",offset:i,node:a}));return i}(e,0),n}function c(e,r,i){function a(){return e.length&&r.length?e[0].offset!==r[0].offset?e[0].offset"}function u(e){l+=""}function c(e){("start"===e.event?o:u)(e.node)}for(var s=0,l="",f=[];e.length||r.length;){var g=a();if(l+=n(i.substring(s,g[0].offset)),s=g[0].offset,g===e){f.reverse().forEach(u);do c(g.splice(0,1)[0]),g=a();while(g===e&&g.length&&g[0].offset===s);f.reverse().forEach(o)}else"start"===g[0].event?f.push(g[0].node):f.pop(),c(g.splice(0,1)[0])}return l+n(i.substr(s))}function s(e){function n(e){return e&&e.source||e}function t(t,r){return new RegExp(n(t),"m"+(e.cI?"i":"")+(r?"g":""))}function r(i,a){if(!i.compiled){if(i.compiled=!0,i.k=i.k||i.bK,i.k){var u={},c=function(n,t){e.cI&&(t=t.toLowerCase()),t.split(" ").forEach(function(e){var t=e.split("|");u[t[0]]=[n,t[1]?Number(t[1]):1]})};"string"==typeof i.k?c("keyword",i.k):E(i.k).forEach(function(e){c(e,i.k[e])}),i.k=u}i.lR=t(i.l||/\w+/,!0),a&&(i.bK&&(i.b="\\b("+i.bK.split(" ").join("|")+")\\b"),i.b||(i.b=/\B|\b/),i.bR=t(i.b),i.e||i.eW||(i.e=/\B|\b/),i.e&&(i.eR=t(i.e)),i.tE=n(i.e)||"",i.eW&&a.tE&&(i.tE+=(i.e?"|":"")+a.tE)),i.i&&(i.iR=t(i.i)),null==i.r&&(i.r=1),i.c||(i.c=[]);var s=[];i.c.forEach(function(e){e.v?e.v.forEach(function(n){s.push(o(e,n))}):s.push("self"===e?i:e)}),i.c=s,i.c.forEach(function(e){r(e,i)}),i.starts&&r(i.starts,a);var l=i.c.map(function(e){return e.bK?"\\.?("+e.b+")\\.?":e.b}).concat([i.tE,i.i]).map(n).filter(Boolean);i.t=l.length?t(l.join("|"),!0):{exec:function(){return null}}}}r(e)}function l(e,t,i,a){function o(e,n){var t,i;for(t=0,i=n.c.length;i>t;t++)if(r(n.c[t].bR,e))return n.c[t]}function u(e,n){if(r(e.eR,n)){for(;e.endsParent&&e.parent;)e=e.parent;return e}return e.eW?u(e.parent,n):void 0}function c(e,n){return!i&&r(n.iR,e)}function g(e,n){var t=N.cI?n[0].toLowerCase():n[0];return e.k.hasOwnProperty(t)&&e.k[t]}function h(e,n,t,r){var i=r?"":y.classPrefix,a='',a+n+o}function p(){var e,t,r,i;if(!E.k)return n(B);for(i="",t=0,E.lR.lastIndex=0,r=E.lR.exec(B);r;)i+=n(B.substring(t,r.index)),e=g(E,r),e?(M+=e[1],i+=h(e[0],n(r[0]))):i+=n(r[0]),t=E.lR.lastIndex,r=E.lR.exec(B);return i+n(B.substr(t))}function d(){var e="string"==typeof E.sL;if(e&&!x[E.sL])return n(B);var t=e?l(E.sL,B,!0,L[E.sL]):f(B,E.sL.length?E.sL:void 0);return E.r>0&&(M+=t.r),e&&(L[E.sL]=t.top),h(t.language,t.value,!1,!0)}function b(){k+=null!=E.sL?d():p(),B=""}function v(e){k+=e.cN?h(e.cN,"",!0):"",E=Object.create(e,{parent:{value:E}})}function m(e,n){if(B+=e,null==n)return b(),0;var t=o(n,E);if(t)return t.skip?B+=n:(t.eB&&(B+=n),b(),t.rB||t.eB||(B=n)),v(t,n),t.rB?0:n.length;var r=u(E,n);if(r){var i=E;i.skip?B+=n:(i.rE||i.eE||(B+=n),b(),i.eE&&(B=n));do E.cN&&(k+=C),E.skip||(M+=E.r),E=E.parent;while(E!==r.parent);return r.starts&&v(r.starts,""),i.rE?0:n.length}if(c(n,E))throw new Error('Illegal lexeme "'+n+'" for mode "'+(E.cN||"")+'"');return B+=n,n.length||1}var N=R(e);if(!N)throw new Error('Unknown language: "'+e+'"');s(N);var w,E=a||N,L={},k="";for(w=E;w!==N;w=w.parent)w.cN&&(k=h(w.cN,"",!0)+k);var B="",M=0;try{for(var I,j,O=0;;){if(E.t.lastIndex=O,I=E.t.exec(t),!I)break;j=m(t.substring(O,I.index),I[0]),O=I.index+j}for(m(t.substr(O)),w=E;w.parent;w=w.parent)w.cN&&(k+=C);return{r:M,value:k,language:e,top:E}}catch(T){if(T.message&&-1!==T.message.indexOf("Illegal"))return{r:0,value:n(t)};throw T}}function f(e,t){t=t||y.languages||E(x);var r={r:0,value:n(e)},i=r;return t.filter(R).forEach(function(n){var t=l(n,e,!1);t.language=n,t.r>i.r&&(i=t),t.r>r.r&&(i=r,r=t)}),i.language&&(r.second_best=i),r}function g(e){return y.tabReplace||y.useBR?e.replace(M,function(e,n){return y.useBR&&"\n"===e?"
":y.tabReplace?n.replace(/\t/g,y.tabReplace):void 0}):e}function h(e,n,t){var r=n?L[n]:t,i=[e.trim()];return e.match(/\bhljs\b/)||i.push("hljs"),-1===e.indexOf(r)&&i.push(r),i.join(" ").trim()}function p(e){var n,t,r,o,s,p=a(e);i(p)||(y.useBR?(n=document.createElementNS("http://www.w3.org/1999/xhtml","div"),n.innerHTML=e.innerHTML.replace(/\n/g,"").replace(//g,"\n")):n=e,s=n.textContent,r=p?l(p,s,!0):f(s),t=u(n),t.length&&(o=document.createElementNS("http://www.w3.org/1999/xhtml","div"),o.innerHTML=r.value,r.value=c(t,u(o),s)),r.value=g(r.value),e.innerHTML=r.value,e.className=h(e.className,p,r.language),e.result={language:r.language,re:r.r},r.second_best&&(e.second_best={language:r.second_best.language,re:r.second_best.r}))}function d(e){y=o(y,e)}function b(){if(!b.called){b.called=!0;var e=document.querySelectorAll("pre code");w.forEach.call(e,p)}}function v(){addEventListener("DOMContentLoaded",b,!1),addEventListener("load",b,!1)}function m(n,t){var r=x[n]=t(e);r.aliases&&r.aliases.forEach(function(e){L[e]=n})}function N(){return E(x)}function R(e){return e=(e||"").toLowerCase(),x[e]||x[L[e]]}var w=[],E=Object.keys,x={},L={},k=/^(no-?highlight|plain|text)$/i,B=/\blang(?:uage)?-([\w-]+)\b/i,M=/((^(<[^>]+>|\t|)+|(?:\n)))/gm,C="
",y={classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:void 0},I={"&":"&","<":"<",">":">"};return e.highlight=l,e.highlightAuto=f,e.fixMarkup=g,e.highlightBlock=p,e.configure=d,e.initHighlighting=b,e.initHighlightingOnLoad=v,e.registerLanguage=m,e.listLanguages=N,e.getLanguage=R,e.inherit=o,e.IR="[a-zA-Z]\\w*",e.UIR="[a-zA-Z_]\\w*",e.NR="\\b\\d+(\\.\\d+)?",e.CNR="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",e.BNR="\\b(0b[01]+)",e.RSR="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",e.BE={b:"\\\\[\\s\\S]",r:0},e.ASM={cN:"string",b:"'",e:"'",i:"\\n",c:[e.BE]},e.QSM={cN:"string",b:'"',e:'"',i:"\\n",c:[e.BE]},e.PWM={b:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|like)\b/},e.C=function(n,t,r){var i=e.inherit({cN:"comment",b:n,e:t,c:[]},r||{});return i.c.push(e.PWM),i.c.push({cN:"doctag",b:"(?:TODO|FIXME|NOTE|BUG|XXX):",r:0}),i},e.CLCM=e.C("//","$"),e.CBCM=e.C("/\\*","\\*/"),e.HCM=e.C("#","$"),e.NM={cN:"number",b:e.NR,r:0},e.CNM={cN:"number",b:e.CNR,r:0},e.BNM={cN:"number",b:e.BNR,r:0},e.CSSNM={cN:"number",b:e.NR+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",r:0},e.RM={cN:"regexp",b:/\//,e:/\/[gimuy]*/,i:/\n/,c:[e.BE,{b:/\[/,e:/\]/,r:0,c:[e.BE]}]},e.TM={cN:"title",b:e.IR,r:0},e.UTM={cN:"title",b:e.UIR,r:0},e.METHOD_GUARD={b:"\\.\\s*"+e.UIR,r:0},e});hljs.registerLanguage("xml",function(s){var e="[A-Za-z0-9\\._:-]+",t={eW:!0,i:/`]+/}]}]}]};return{aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist"],cI:!0,c:[{cN:"meta",b:"",r:10,c:[{b:"\\[",e:"\\]"}]},s.C("",{r:10}),{b:"<\\!\\[CDATA\\[",e:"\\]\\]>",r:10},{b:/<\?(php)?/,e:/\?>/,sL:"php",c:[{b:"/\\*",e:"\\*/",skip:!0}]},{cN:"tag",b:"|$)",e:">",k:{name:"style"},c:[t],starts:{e:"",rE:!0,sL:["css","xml"]}},{cN:"tag",b:"|$)",e:">",k:{name:"script"},c:[t],starts:{e:"",rE:!0,sL:["actionscript","javascript","handlebars","xml"]}},{cN:"meta",v:[{b:/<\?xml/,e:/\?>/,r:10},{b:/<\?\w+/,e:/\?>/}]},{cN:"tag",b:"",c:[{cN:"name",b:/[^\/><\s]+/,r:0},t]}]}});hljs.registerLanguage("javascript",function(e){var r="[A-Za-z$_][0-9A-Za-z$_]*",t={keyword:"in of if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const export super debugger as async await static import from as",literal:"true false null undefined NaN Infinity",built_in:"eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document Symbol Set Map WeakSet WeakMap Proxy Reflect Promise"},a={cN:"number",v:[{b:"\\b(0[bB][01]+)"},{b:"\\b(0[oO][0-7]+)"},{b:e.CNR}],r:0},n={cN:"subst",b:"\\$\\{",e:"\\}",k:t,c:[]},c={cN:"string",b:"`",e:"`",c:[e.BE,n]};n.c=[e.ASM,e.QSM,c,a,e.RM];var s=n.c.concat([e.CBCM,e.CLCM]);return{aliases:["js","jsx"],k:t,c:[{cN:"meta",r:10,b:/^\s*['"]use (strict|asm)['"]/},{cN:"meta",b:/^#!/,e:/$/},e.ASM,e.QSM,c,e.CLCM,e.CBCM,a,{b:/[{,]\s*/,r:0,c:[{b:r+"\\s*:",rB:!0,r:0,c:[{cN:"attr",b:r,r:0}]}]},{b:"("+e.RSR+"|\\b(case|return|throw)\\b)\\s*",k:"return throw case",c:[e.CLCM,e.CBCM,e.RM,{cN:"function",b:"(\\(.*?\\)|"+r+")\\s*=>",rB:!0,e:"\\s*=>",c:[{cN:"params",v:[{b:r},{b:/\(\s*\)/},{b:/\(/,e:/\)/,eB:!0,eE:!0,k:t,c:s}]}]},{b://,sL:"xml",c:[{b:/<\w+\s*\/>/,skip:!0},{b:/<\w+/,e:/(\/\w+|\w+\/)>/,skip:!0,c:[{b:/<\w+\s*\/>/,skip:!0},"self"]}]}],r:0},{cN:"function",bK:"function",e:/\{/,eE:!0,c:[e.inherit(e.TM,{b:r}),{cN:"params",b:/\(/,e:/\)/,eB:!0,eE:!0,c:s}],i:/\[|%/},{b:/\$[(.]/},e.METHOD_GUARD,{cN:"class",bK:"class",e:/[{;=]/,eE:!0,i:/[:"\[\]]/,c:[{bK:"extends"},e.UTM]},{bK:"constructor",e:/\{/,eE:!0}],i:/#(?!!)/}}); -------------------------------------------------------------------------------- /docs/assets/js/index.js: -------------------------------------------------------------------------------- 1 | import { shuffle, getRandomBias } from '../../../src/js/utils/helpers'; 2 | import { HEATMAP_COLORS_YELLOW, HEATMAP_COLORS_BLUE } from '../../../src/js/utils/constants'; 3 | import { SEC_IN_DAY, clone, timestampToMidnight, timestampSec, addDays } from '../../../src/js/utils/date-utils'; 4 | /* eslint-disable no-unused-vars */ 5 | import { fireballOver25, fireball_2_5, fireball_5_25, lineCompositeData, 6 | barCompositeData, typeData, trendsData, moonData } from './data'; 7 | /* eslint-enable no-unused-vars */ 8 | import demoConfig from './demoConfig'; 9 | // import { lineComposite, barComposite } from './demoConfig'; 10 | // ================================================================================ 11 | 12 | let Chart = frappe.Chart; // eslint-disable-line no-undef 13 | 14 | let lc = demoConfig.lineComposite; 15 | let lineCompositeChart = new Chart (lc.elementID, lc.options); 16 | 17 | let bc = demoConfig.barComposite; 18 | let barCompositeChart = new Chart (bc.elementID, bc.options); 19 | 20 | lineCompositeChart.parent.addEventListener('data-select', (e) => { 21 | let i = e.index; 22 | barCompositeChart.updateDatasets([ 23 | fireballOver25[i], fireball_5_25[i], fireball_2_5[i] 24 | ]); 25 | }); 26 | 27 | // ================================================================================ 28 | 29 | let customColors = ['purple', 'magenta', 'light-blue']; 30 | let typeChartArgs = { 31 | title: "My Awesome Chart", 32 | data: typeData, 33 | type: 'axis-mixed', 34 | height: 300, 35 | colors: customColors, 36 | 37 | // maxLegendPoints: 6, 38 | maxSlices: 10, 39 | 40 | tooltipOptions: { 41 | formatTooltipX: d => (d + '').toUpperCase(), 42 | formatTooltipY: d => d + ' pts', 43 | } 44 | }; 45 | 46 | let aggrChart = new Chart("#chart-aggr", typeChartArgs); 47 | 48 | Array.prototype.slice.call( 49 | document.querySelectorAll('.aggr-type-buttons button') 50 | ).map(el => { 51 | el.addEventListener('click', (e) => { 52 | let btn = e.target; 53 | let type = btn.getAttribute('data-type'); 54 | typeChartArgs.type = type; 55 | if(type !== 'axis-mixed') { 56 | typeChartArgs.colors = undefined; 57 | } else { 58 | typeChartArgs.colors = customColors; 59 | } 60 | 61 | if(type !== 'percentage') { 62 | typeChartArgs.height = 300; 63 | } else { 64 | typeChartArgs.height = undefined; 65 | } 66 | 67 | let newChart = new Chart("#chart-aggr", typeChartArgs); 68 | if(newChart){ 69 | aggrChart = newChart; 70 | } 71 | Array.prototype.slice.call( 72 | btn.parentNode.querySelectorAll('button')).map(el => { 73 | el.classList.remove('active'); 74 | }); 75 | btn.classList.add('active'); 76 | }); 77 | }); 78 | 79 | document.querySelector('.export-aggr').addEventListener('click', () => { 80 | aggrChart.export(); 81 | }); 82 | 83 | // Update values chart 84 | // ================================================================================ 85 | let updateDataAllLabels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", "Mon", "Tue", 86 | "Wed", "Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", 87 | "Sat", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", "Mon"]; 88 | 89 | let getRandom = () => Math.floor(getRandomBias(-40, 60, 0.8, 1)); 90 | let updateDataAllValues = Array.from({length: 30}, getRandom); 91 | 92 | // We're gonna be shuffling this 93 | let updateDataAllIndices = updateDataAllLabels.map((d,i) => i); 94 | 95 | let getUpdateData = (source_array, length=10) => { 96 | let indices = updateDataAllIndices.slice(0, length); 97 | return indices.map((index) => source_array[index]); 98 | }; 99 | 100 | let updateData = { 101 | labels: getUpdateData(updateDataAllLabels), 102 | datasets: [{ 103 | "values": getUpdateData(updateDataAllValues) 104 | }], 105 | yMarkers: [ 106 | { 107 | label: "Altitude", 108 | value: 25, 109 | type: 'dashed' 110 | } 111 | ], 112 | yRegions: [ 113 | { 114 | label: "Range", 115 | start: 10, 116 | end: 45 117 | }, 118 | ], 119 | }; 120 | 121 | let updateChart = new Chart("#chart-update", { 122 | data: updateData, 123 | type: 'line', 124 | height: 300, 125 | colors: ['#ff6c03'], 126 | lineOptions: { 127 | // hideLine: 1, 128 | regionFill: 1 129 | }, 130 | }); 131 | 132 | let chartUpdateButtons = document.querySelector('.chart-update-buttons'); 133 | 134 | chartUpdateButtons.querySelector('[data-update="random"]').addEventListener("click", () => { 135 | shuffle(updateDataAllIndices); 136 | let value = getRandom(); 137 | let start = getRandom(); 138 | let end = getRandom(); 139 | let data = { 140 | labels: updateDataAllLabels.slice(0, 10), 141 | datasets: [{values: getUpdateData(updateDataAllValues)}], 142 | yMarkers: [ 143 | { 144 | label: "Altitude", 145 | value: value, 146 | type: 'dashed' 147 | } 148 | ], 149 | yRegions: [ 150 | { 151 | label: "Range", 152 | start: start, 153 | end: end 154 | }, 155 | ], 156 | }; 157 | updateChart.update(data); 158 | }); 159 | 160 | chartUpdateButtons.querySelector('[data-update="add"]').addEventListener("click", () => { 161 | let index = updateChart.state.datasetLength; // last index to add 162 | if(index >= updateDataAllIndices.length) return; 163 | updateChart.addDataPoint( 164 | updateDataAllLabels[index], [updateDataAllValues[index]] 165 | ); 166 | }); 167 | 168 | chartUpdateButtons.querySelector('[data-update="remove"]').addEventListener("click", () => { 169 | updateChart.removeDataPoint(); 170 | }); 171 | 172 | document.querySelector('.export-update').addEventListener('click', () => { 173 | updateChart.export(); 174 | }); 175 | 176 | // Trends Chart 177 | // ================================================================================ 178 | 179 | let plotChartArgs = { 180 | title: "Mean Total Sunspot Count - Yearly", 181 | data: trendsData, 182 | type: 'line', 183 | height: 300, 184 | colors: ['#238e38'], 185 | lineOptions: { 186 | hideDots: 1, 187 | heatline: 1, 188 | }, 189 | axisOptions: { 190 | xAxisMode: 'tick', 191 | yAxisMode: 'span', 192 | xIsSeries: 1 193 | } 194 | }; 195 | 196 | let trendsChart = new Chart("#chart-trends", plotChartArgs); 197 | 198 | Array.prototype.slice.call( 199 | document.querySelectorAll('.chart-plot-buttons button') 200 | ).map(el => { 201 | el.addEventListener('click', (e) => { 202 | let btn = e.target; 203 | let type = btn.getAttribute('data-type'); 204 | let config = {}; 205 | config[type] = 1; 206 | 207 | if(['regionFill', 'heatline'].includes(type)) { 208 | config.hideDots = 1; 209 | } 210 | 211 | // plotChartArgs.init = false; 212 | plotChartArgs.lineOptions = config; 213 | 214 | new Chart("#chart-trends", plotChartArgs); 215 | 216 | Array.prototype.slice.call( 217 | btn.parentNode.querySelectorAll('button')).map(el => { 218 | el.classList.remove('active'); 219 | }); 220 | btn.classList.add('active'); 221 | }); 222 | }); 223 | 224 | document.querySelector('.export-trends').addEventListener('click', () => { 225 | trendsChart.export(); 226 | }); 227 | 228 | 229 | // Event chart 230 | // ================================================================================ 231 | 232 | 233 | 234 | let eventsData = { 235 | labels: ["Ganymede", "Callisto", "Io", "Europa"], 236 | datasets: [ 237 | { 238 | "values": moonData.distances, 239 | "formatted": moonData.distances.map(d => d*1000 + " km") 240 | } 241 | ] 242 | }; 243 | 244 | let eventsChart = new Chart("#chart-events", { 245 | title: "Jupiter's Moons: Semi-major Axis (1000 km)", 246 | data: eventsData, 247 | type: 'bar', 248 | height: 330, 249 | colors: ['grey'], 250 | isNavigable: 1, 251 | }); 252 | 253 | let dataDiv = document.querySelector('.chart-events-data'); 254 | 255 | eventsChart.parent.addEventListener('data-select', (e) => { 256 | let name = moonData.names[e.index]; 257 | dataDiv.querySelector('.moon-name').innerHTML = name; 258 | dataDiv.querySelector('.semi-major-axis').innerHTML = moonData.distances[e.index] * 1000; 259 | dataDiv.querySelector('.mass').innerHTML = moonData.masses[e.index]; 260 | dataDiv.querySelector('.diameter').innerHTML = moonData.diameters[e.index]; 261 | dataDiv.querySelector('img').src = "./assets/img/" + name.toLowerCase() + ".jpg"; 262 | }); 263 | 264 | // Heatmap 265 | // ================================================================================ 266 | 267 | let today = new Date(); 268 | let start = clone(today); 269 | addDays(start, 4); 270 | let end = clone(start); 271 | start.setFullYear( start.getFullYear() - 2 ); 272 | end.setFullYear( end.getFullYear() - 1 ); 273 | 274 | let dataPoints = {}; 275 | 276 | let startTs = timestampSec(start); 277 | let endTs = timestampSec(end); 278 | 279 | startTs = timestampToMidnight(startTs); 280 | endTs = timestampToMidnight(endTs, true); 281 | 282 | while (startTs < endTs) { 283 | dataPoints[parseInt(startTs)] = Math.floor(getRandomBias(0, 5, 0.2, 1)); 284 | startTs += SEC_IN_DAY; 285 | } 286 | 287 | const heatmapData = { 288 | dataPoints: dataPoints, 289 | start: start, 290 | end: end 291 | }; 292 | 293 | let heatmapArgs = { 294 | title: "Monthly Distribution", 295 | data: heatmapData, 296 | type: 'heatmap', 297 | discreteDomains: 1, 298 | countLabel: 'Level', 299 | colors: HEATMAP_COLORS_BLUE, 300 | legendScale: [0, 1, 2, 4, 5] 301 | }; 302 | let heatmapChart = new Chart("#chart-heatmap", heatmapArgs); 303 | 304 | Array.prototype.slice.call( 305 | document.querySelectorAll('.heatmap-mode-buttons button') 306 | ).map(el => { 307 | el.addEventListener('click', (e) => { 308 | let btn = e.target; 309 | let mode = btn.getAttribute('data-mode'); 310 | let discreteDomains = 0; 311 | 312 | if(mode === 'discrete') { 313 | discreteDomains = 1; 314 | } 315 | 316 | let colors = []; 317 | let colors_mode = document 318 | .querySelector('.heatmap-color-buttons .active') 319 | .getAttribute('data-color'); 320 | if(colors_mode === 'halloween') { 321 | colors = HEATMAP_COLORS_YELLOW; 322 | } else if (colors_mode === 'blue') { 323 | colors = HEATMAP_COLORS_BLUE; 324 | } 325 | 326 | heatmapArgs.discreteDomains = discreteDomains; 327 | heatmapArgs.colors = colors; 328 | new Chart("#chart-heatmap", heatmapArgs); 329 | 330 | Array.prototype.slice.call( 331 | btn.parentNode.querySelectorAll('button')).map(el => { 332 | el.classList.remove('active'); 333 | }); 334 | btn.classList.add('active'); 335 | }); 336 | }); 337 | 338 | Array.prototype.slice.call( 339 | document.querySelectorAll('.heatmap-color-buttons button') 340 | ).map(el => { 341 | el.addEventListener('click', (e) => { 342 | let btn = e.target; 343 | let colors_mode = btn.getAttribute('data-color'); 344 | let colors = []; 345 | 346 | if(colors_mode === 'halloween') { 347 | colors = HEATMAP_COLORS_YELLOW; 348 | } else if (colors_mode === 'blue') { 349 | colors = HEATMAP_COLORS_BLUE; 350 | } 351 | 352 | let discreteDomains = 1; 353 | 354 | let view_mode = document 355 | .querySelector('.heatmap-mode-buttons .active') 356 | .getAttribute('data-mode'); 357 | if(view_mode === 'continuous') { 358 | discreteDomains = 0; 359 | } 360 | 361 | heatmapArgs.discreteDomains = discreteDomains; 362 | heatmapArgs.colors = colors; 363 | new Chart("#chart-heatmap", heatmapArgs); 364 | 365 | Array.prototype.slice.call( 366 | btn.parentNode.querySelectorAll('button')).map(el => { 367 | el.classList.remove('active'); 368 | }); 369 | btn.classList.add('active'); 370 | }); 371 | }); 372 | 373 | document.querySelector('.export-heatmap').addEventListener('click', () => { 374 | heatmapChart.export(); 375 | }); 376 | -------------------------------------------------------------------------------- /docs/assets/js/index.min.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | 'use strict'; 3 | 4 | var HEATMAP_COLORS_BLUE = ['#ebedf0', '#c0ddf9', '#73b3f3', '#3886e1', '#17459e']; 5 | var HEATMAP_COLORS_YELLOW = ['#ebedf0', '#fdf436', '#ffc700', '#ff9100', '#06001c']; 6 | 7 | // Universal constants 8 | var ANGLE_RATIO = Math.PI / 180; 9 | 10 | /** 11 | * Shuffles array in place. ES6 version 12 | * @param {Array} array An array containing the items. 13 | */ 14 | function shuffle(array) { 15 | // Awesomeness: https://bost.ocks.org/mike/shuffle/ 16 | // https://stackoverflow.com/a/2450976/6495043 17 | // https://stackoverflow.com/questions/6274339/how-can-i-shuffle-an-array?noredirect=1&lq=1 18 | 19 | for (var i = array.length - 1; i > 0; i--) { 20 | var j = Math.floor(Math.random() * (i + 1)); 21 | var _ref = [array[j], array[i]]; 22 | array[i] = _ref[0]; 23 | array[j] = _ref[1]; 24 | } 25 | 26 | return array; 27 | } 28 | 29 | // https://stackoverflow.com/a/29325222 30 | function getRandomBias(min, max, bias, influence) { 31 | var range = max - min; 32 | var biasValue = range * bias + min; 33 | var rnd = Math.random() * range + min, 34 | // random in range 35 | mix = Math.random() * influence; // random mixer 36 | return rnd * (1 - mix) + biasValue * mix; // mix full range and bias 37 | } 38 | 39 | // Playing around with dates 40 | var NO_OF_MILLIS = 1000; 41 | var SEC_IN_DAY = 86400; 42 | var MONTH_NAMES_SHORT = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; 43 | 44 | function clone(date) { 45 | return new Date(date.getTime()); 46 | } 47 | 48 | function timestampSec(date) { 49 | return date.getTime() / NO_OF_MILLIS; 50 | } 51 | 52 | function timestampToMidnight(timestamp) { 53 | var roundAhead = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; 54 | 55 | var midnightTs = Math.floor(timestamp - timestamp % SEC_IN_DAY); 56 | if (roundAhead) { 57 | return midnightTs + SEC_IN_DAY; 58 | } 59 | return midnightTs; 60 | } 61 | 62 | // mutates 63 | function addDays(date, numberOfDays) { 64 | date.setDate(date.getDate() + numberOfDays); 65 | } 66 | 67 | // Composite Chart 68 | // ================================================================================ 69 | var reportCountList = [152, 222, 199, 287, 534, 709, 1179, 1256, 1632, 1856, 1850]; 70 | 71 | var lineCompositeData = { 72 | labels: ["2007", "2008", "2009", "2010", "2011", "2012", "2013", "2014", "2015", "2016", "2017"], 73 | 74 | yMarkers: [{ 75 | label: "Average 100 reports/month", 76 | value: 1200, 77 | options: { labelPos: "left" } 78 | }], 79 | 80 | datasets: [{ 81 | name: "Events", 82 | values: reportCountList 83 | }] 84 | }; 85 | 86 | var fireball_5_25 = [[4, 0, 3, 1, 1, 2, 1, 1, 1, 0, 1, 1], [2, 3, 3, 2, 1, 3, 0, 1, 2, 7, 10, 4], [5, 6, 2, 4, 0, 1, 4, 3, 0, 2, 0, 1], [0, 2, 6, 2, 1, 1, 2, 3, 6, 3, 7, 8], [6, 8, 7, 7, 4, 5, 6, 5, 22, 12, 10, 11], [7, 10, 11, 7, 3, 2, 7, 7, 11, 15, 22, 20], [13, 16, 21, 18, 19, 17, 12, 17, 31, 28, 25, 29], [24, 14, 21, 14, 11, 15, 19, 21, 41, 22, 32, 18], [31, 20, 30, 22, 14, 17, 21, 35, 27, 50, 117, 24], [32, 24, 21, 27, 11, 27, 43, 37, 44, 40, 48, 32], [31, 38, 36, 26, 23, 23, 25, 29, 26, 47, 61, 50]]; 87 | var fireball_2_5 = [[22, 6, 6, 9, 7, 8, 6, 14, 19, 10, 8, 20], [11, 13, 12, 8, 9, 11, 9, 13, 10, 22, 40, 24], [20, 13, 13, 19, 13, 10, 14, 13, 20, 18, 5, 9], [7, 13, 16, 19, 12, 11, 21, 27, 27, 24, 33, 33], [38, 25, 28, 22, 31, 21, 35, 42, 37, 32, 46, 53], [50, 33, 36, 34, 35, 28, 27, 52, 58, 59, 75, 69], [54, 67, 67, 45, 66, 51, 38, 64, 90, 113, 116, 87], [84, 52, 56, 51, 55, 46, 50, 87, 114, 83, 152, 93], [73, 58, 59, 63, 56, 51, 83, 140, 103, 115, 265, 89], [106, 95, 94, 71, 77, 75, 99, 136, 129, 154, 168, 156], [81, 102, 95, 72, 58, 91, 89, 122, 124, 135, 183, 171]]; 88 | var fireballOver25 = [ 89 | // [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 90 | [0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0], [0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0], [1, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0], [0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 2], [3, 2, 1, 3, 2, 0, 2, 2, 2, 3, 0, 1], [2, 3, 5, 2, 1, 3, 0, 2, 3, 5, 1, 4], [7, 4, 6, 1, 9, 2, 2, 2, 20, 9, 4, 9], [5, 6, 1, 2, 5, 4, 5, 5, 16, 9, 14, 9], [5, 4, 7, 5, 1, 5, 3, 3, 5, 7, 22, 2], [5, 13, 11, 6, 1, 7, 9, 8, 14, 17, 16, 3], [8, 9, 8, 6, 4, 8, 5, 6, 14, 11, 21, 12]]; 91 | 92 | var barCompositeData = { 93 | labels: MONTH_NAMES_SHORT, 94 | datasets: [{ 95 | name: "Over 25 reports", 96 | values: fireballOver25[9] 97 | }, { 98 | name: "5 to 25 reports", 99 | values: fireball_5_25[9] 100 | }, { 101 | name: "2 to 5 reports", 102 | values: fireball_2_5[9] 103 | }] 104 | }; 105 | 106 | // Demo Chart multitype Chart 107 | // ================================================================================ 108 | var typeData = { 109 | labels: ["12am-3am", "3am-6am", "6am-9am", "9am-12pm", "12pm-3pm", "3pm-6pm", "6pm-9pm", "9pm-12am"], 110 | 111 | yMarkers: [{ 112 | label: "Marker", 113 | value: 43, 114 | options: { labelPos: "left" } 115 | // type: 'dashed' 116 | }], 117 | 118 | yRegions: [{ 119 | label: "Region", 120 | start: -10, 121 | end: 50, 122 | options: { labelPos: "right" } 123 | }], 124 | 125 | datasets: [{ 126 | name: "Some Data", 127 | values: [18, 40, 30, 35, 8, 52, 17, -4], 128 | axisPosition: "right", 129 | chartType: "bar" 130 | }, { 131 | name: "Another Set", 132 | values: [30, 50, -10, 15, 18, 32, 27, 14], 133 | axisPosition: "right", 134 | chartType: "bar" 135 | }, { 136 | name: "Yet Another", 137 | values: [15, 20, -3, -15, 58, 12, -17, 37], 138 | chartType: "line" 139 | }] 140 | }; 141 | 142 | var trendsData = { 143 | labels: [1967, 1968, 1969, 1970, 1971, 1972, 1973, 1974, 1975, 1976, 1977, 1978, 1979, 1980, 1981, 1982, 1983, 1984, 1985, 1986, 1987, 1988, 1989, 1990, 1991, 1992, 1993, 1994, 1995, 1996, 1997, 1998, 1999, 2000, 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016], 144 | datasets: [{ 145 | values: [132.9, 150.0, 149.4, 148.0, 94.4, 97.6, 54.1, 49.2, 22.5, 18.4, 39.3, 131.0, 220.1, 218.9, 198.9, 162.4, 91.0, 60.5, 20.6, 14.8, 33.9, 123.0, 211.1, 191.8, 203.3, 133.0, 76.1, 44.9, 25.1, 11.6, 28.9, 88.3, 136.3, 173.9, 170.4, 163.6, 99.3, 65.3, 45.8, 24.7, 12.6, 4.2, 4.8, 24.9, 80.8, 84.5, 94.0, 113.3, 69.8, 39.8] 146 | }] 147 | }; 148 | 149 | var moonData = { 150 | names: ["Ganymede", "Callisto", "Io", "Europa"], 151 | masses: [14819000, 10759000, 8931900, 4800000], 152 | distances: [1070.412, 1882.709, 421.7, 671.034], 153 | diameters: [5262.4, 4820.6, 3637.4, 3121.6] 154 | }; 155 | 156 | var demoConfig = { 157 | lineComposite: { 158 | elementID: "#chart-composite-1", 159 | options: { 160 | title: "Fireball/Bolide Events - Yearly (reported)", 161 | data: lineCompositeData, 162 | type: "line", 163 | height: 190, 164 | colors: ["green"], 165 | isNavigable: 1, 166 | valuesOverPoints: 1, 167 | 168 | lineOptions: { 169 | dotSize: 8 170 | } 171 | } 172 | }, 173 | 174 | barComposite: { 175 | elementID: "#chart-composite-2", 176 | options: { 177 | data: barCompositeData, 178 | type: "bar", 179 | height: 210, 180 | colors: ["violet", "light-blue", "#46a9f9"], 181 | valuesOverPoints: 1, 182 | axisOptions: { 183 | xAxisMode: "tick", 184 | shortenYAxisNumbers: true 185 | }, 186 | barOptions: { 187 | stacked: 1 188 | } 189 | } 190 | }, 191 | 192 | demoMain: { 193 | elementID: "", 194 | options: { 195 | title: "My Awesome Chart", 196 | data: "typeData", 197 | type: "axis-mixed", 198 | height: 300, 199 | colors: ["purple", "magenta", "light-blue"], 200 | maxSlices: 10, 201 | 202 | tooltipOptions: { 203 | formatTooltipX: function formatTooltipX(d) { 204 | return (d + '').toUpperCase(); 205 | }, 206 | formatTooltipY: function formatTooltipY(d) { 207 | return d + ' pts'; 208 | } 209 | } 210 | } 211 | } 212 | }; 213 | 214 | // import { lineComposite, barComposite } from './demoConfig'; 215 | // ================================================================================ 216 | 217 | var Chart = frappe.Chart; // eslint-disable-line no-undef 218 | 219 | var lc = demoConfig.lineComposite; 220 | var lineCompositeChart = new Chart(lc.elementID, lc.options); 221 | 222 | var bc = demoConfig.barComposite; 223 | var barCompositeChart = new Chart(bc.elementID, bc.options); 224 | 225 | lineCompositeChart.parent.addEventListener('data-select', function (e) { 226 | var i = e.index; 227 | barCompositeChart.updateDatasets([fireballOver25[i], fireball_5_25[i], fireball_2_5[i]]); 228 | }); 229 | 230 | // ================================================================================ 231 | 232 | var customColors = ['purple', 'magenta', 'light-blue']; 233 | var typeChartArgs = { 234 | title: "My Awesome Chart", 235 | data: typeData, 236 | type: 'axis-mixed', 237 | height: 300, 238 | colors: customColors, 239 | 240 | // maxLegendPoints: 6, 241 | maxSlices: 10, 242 | 243 | tooltipOptions: { 244 | formatTooltipX: function formatTooltipX(d) { 245 | return (d + '').toUpperCase(); 246 | }, 247 | formatTooltipY: function formatTooltipY(d) { 248 | return d + ' pts'; 249 | } 250 | } 251 | }; 252 | 253 | var aggrChart = new Chart("#chart-aggr", typeChartArgs); 254 | 255 | Array.prototype.slice.call(document.querySelectorAll('.aggr-type-buttons button')).map(function (el) { 256 | el.addEventListener('click', function (e) { 257 | var btn = e.target; 258 | var type = btn.getAttribute('data-type'); 259 | typeChartArgs.type = type; 260 | if (type !== 'axis-mixed') { 261 | typeChartArgs.colors = undefined; 262 | } else { 263 | typeChartArgs.colors = customColors; 264 | } 265 | 266 | if (type !== 'percentage') { 267 | typeChartArgs.height = 300; 268 | } else { 269 | typeChartArgs.height = undefined; 270 | } 271 | 272 | var newChart = new Chart("#chart-aggr", typeChartArgs); 273 | if (newChart) { 274 | aggrChart = newChart; 275 | } 276 | Array.prototype.slice.call(btn.parentNode.querySelectorAll('button')).map(function (el) { 277 | el.classList.remove('active'); 278 | }); 279 | btn.classList.add('active'); 280 | }); 281 | }); 282 | 283 | document.querySelector('.export-aggr').addEventListener('click', function () { 284 | aggrChart.export(); 285 | }); 286 | 287 | // Update values chart 288 | // ================================================================================ 289 | var updateDataAllLabels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", "Mon"]; 290 | 291 | var getRandom = function getRandom() { 292 | return Math.floor(getRandomBias(-40, 60, 0.8, 1)); 293 | }; 294 | var updateDataAllValues = Array.from({ length: 30 }, getRandom); 295 | 296 | // We're gonna be shuffling this 297 | var updateDataAllIndices = updateDataAllLabels.map(function (d, i) { 298 | return i; 299 | }); 300 | 301 | var getUpdateData = function getUpdateData(source_array) { 302 | var length = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 10; 303 | 304 | var indices = updateDataAllIndices.slice(0, length); 305 | return indices.map(function (index) { 306 | return source_array[index]; 307 | }); 308 | }; 309 | 310 | var updateData = { 311 | labels: getUpdateData(updateDataAllLabels), 312 | datasets: [{ 313 | "values": getUpdateData(updateDataAllValues) 314 | }], 315 | yMarkers: [{ 316 | label: "Altitude", 317 | value: 25, 318 | type: 'dashed' 319 | }], 320 | yRegions: [{ 321 | label: "Range", 322 | start: 10, 323 | end: 45 324 | }] 325 | }; 326 | 327 | var updateChart = new Chart("#chart-update", { 328 | data: updateData, 329 | type: 'line', 330 | height: 300, 331 | colors: ['#ff6c03'], 332 | lineOptions: { 333 | // hideLine: 1, 334 | regionFill: 1 335 | } 336 | }); 337 | 338 | var chartUpdateButtons = document.querySelector('.chart-update-buttons'); 339 | 340 | chartUpdateButtons.querySelector('[data-update="random"]').addEventListener("click", function () { 341 | shuffle(updateDataAllIndices); 342 | var value = getRandom(); 343 | var start = getRandom(); 344 | var end = getRandom(); 345 | var data = { 346 | labels: updateDataAllLabels.slice(0, 10), 347 | datasets: [{ values: getUpdateData(updateDataAllValues) }], 348 | yMarkers: [{ 349 | label: "Altitude", 350 | value: value, 351 | type: 'dashed' 352 | }], 353 | yRegions: [{ 354 | label: "Range", 355 | start: start, 356 | end: end 357 | }] 358 | }; 359 | updateChart.update(data); 360 | }); 361 | 362 | chartUpdateButtons.querySelector('[data-update="add"]').addEventListener("click", function () { 363 | var index = updateChart.state.datasetLength; // last index to add 364 | if (index >= updateDataAllIndices.length) return; 365 | updateChart.addDataPoint(updateDataAllLabels[index], [updateDataAllValues[index]]); 366 | }); 367 | 368 | chartUpdateButtons.querySelector('[data-update="remove"]').addEventListener("click", function () { 369 | updateChart.removeDataPoint(); 370 | }); 371 | 372 | document.querySelector('.export-update').addEventListener('click', function () { 373 | updateChart.export(); 374 | }); 375 | 376 | // Trends Chart 377 | // ================================================================================ 378 | 379 | var plotChartArgs = { 380 | title: "Mean Total Sunspot Count - Yearly", 381 | data: trendsData, 382 | type: 'line', 383 | height: 300, 384 | colors: ['#238e38'], 385 | lineOptions: { 386 | hideDots: 1, 387 | heatline: 1 388 | }, 389 | axisOptions: { 390 | xAxisMode: 'tick', 391 | yAxisMode: 'span', 392 | xIsSeries: 1 393 | } 394 | }; 395 | 396 | var trendsChart = new Chart("#chart-trends", plotChartArgs); 397 | 398 | Array.prototype.slice.call(document.querySelectorAll('.chart-plot-buttons button')).map(function (el) { 399 | el.addEventListener('click', function (e) { 400 | var btn = e.target; 401 | var type = btn.getAttribute('data-type'); 402 | var config = {}; 403 | config[type] = 1; 404 | 405 | if (['regionFill', 'heatline'].includes(type)) { 406 | config.hideDots = 1; 407 | } 408 | 409 | // plotChartArgs.init = false; 410 | plotChartArgs.lineOptions = config; 411 | 412 | new Chart("#chart-trends", plotChartArgs); 413 | 414 | Array.prototype.slice.call(btn.parentNode.querySelectorAll('button')).map(function (el) { 415 | el.classList.remove('active'); 416 | }); 417 | btn.classList.add('active'); 418 | }); 419 | }); 420 | 421 | document.querySelector('.export-trends').addEventListener('click', function () { 422 | trendsChart.export(); 423 | }); 424 | 425 | // Event chart 426 | // ================================================================================ 427 | 428 | 429 | var eventsData = { 430 | labels: ["Ganymede", "Callisto", "Io", "Europa"], 431 | datasets: [{ 432 | "values": moonData.distances, 433 | "formatted": moonData.distances.map(function (d) { 434 | return d * 1000 + " km"; 435 | }) 436 | }] 437 | }; 438 | 439 | var eventsChart = new Chart("#chart-events", { 440 | title: "Jupiter's Moons: Semi-major Axis (1000 km)", 441 | data: eventsData, 442 | type: 'bar', 443 | height: 330, 444 | colors: ['grey'], 445 | isNavigable: 1 446 | }); 447 | 448 | var dataDiv = document.querySelector('.chart-events-data'); 449 | 450 | eventsChart.parent.addEventListener('data-select', function (e) { 451 | var name = moonData.names[e.index]; 452 | dataDiv.querySelector('.moon-name').innerHTML = name; 453 | dataDiv.querySelector('.semi-major-axis').innerHTML = moonData.distances[e.index] * 1000; 454 | dataDiv.querySelector('.mass').innerHTML = moonData.masses[e.index]; 455 | dataDiv.querySelector('.diameter').innerHTML = moonData.diameters[e.index]; 456 | dataDiv.querySelector('img').src = "./assets/img/" + name.toLowerCase() + ".jpg"; 457 | }); 458 | 459 | // Heatmap 460 | // ================================================================================ 461 | 462 | var today = new Date(); 463 | var start = clone(today); 464 | addDays(start, 4); 465 | var end = clone(start); 466 | start.setFullYear(start.getFullYear() - 2); 467 | end.setFullYear(end.getFullYear() - 1); 468 | 469 | var dataPoints = {}; 470 | 471 | var startTs = timestampSec(start); 472 | var endTs = timestampSec(end); 473 | 474 | startTs = timestampToMidnight(startTs); 475 | endTs = timestampToMidnight(endTs, true); 476 | 477 | while (startTs < endTs) { 478 | dataPoints[parseInt(startTs)] = Math.floor(getRandomBias(0, 5, 0.2, 1)); 479 | startTs += SEC_IN_DAY; 480 | } 481 | 482 | var heatmapData = { 483 | dataPoints: dataPoints, 484 | start: start, 485 | end: end 486 | }; 487 | 488 | var heatmapArgs = { 489 | title: "Monthly Distribution", 490 | data: heatmapData, 491 | type: 'heatmap', 492 | discreteDomains: 1, 493 | countLabel: 'Level', 494 | colors: HEATMAP_COLORS_BLUE, 495 | legendScale: [0, 1, 2, 4, 5] 496 | }; 497 | var heatmapChart = new Chart("#chart-heatmap", heatmapArgs); 498 | 499 | Array.prototype.slice.call(document.querySelectorAll('.heatmap-mode-buttons button')).map(function (el) { 500 | el.addEventListener('click', function (e) { 501 | var btn = e.target; 502 | var mode = btn.getAttribute('data-mode'); 503 | var discreteDomains = 0; 504 | 505 | if (mode === 'discrete') { 506 | discreteDomains = 1; 507 | } 508 | 509 | var colors = []; 510 | var colors_mode = document.querySelector('.heatmap-color-buttons .active').getAttribute('data-color'); 511 | if (colors_mode === 'halloween') { 512 | colors = HEATMAP_COLORS_YELLOW; 513 | } else if (colors_mode === 'blue') { 514 | colors = HEATMAP_COLORS_BLUE; 515 | } 516 | 517 | heatmapArgs.discreteDomains = discreteDomains; 518 | heatmapArgs.colors = colors; 519 | new Chart("#chart-heatmap", heatmapArgs); 520 | 521 | Array.prototype.slice.call(btn.parentNode.querySelectorAll('button')).map(function (el) { 522 | el.classList.remove('active'); 523 | }); 524 | btn.classList.add('active'); 525 | }); 526 | }); 527 | 528 | Array.prototype.slice.call(document.querySelectorAll('.heatmap-color-buttons button')).map(function (el) { 529 | el.addEventListener('click', function (e) { 530 | var btn = e.target; 531 | var colors_mode = btn.getAttribute('data-color'); 532 | var colors = []; 533 | 534 | if (colors_mode === 'halloween') { 535 | colors = HEATMAP_COLORS_YELLOW; 536 | } else if (colors_mode === 'blue') { 537 | colors = HEATMAP_COLORS_BLUE; 538 | } 539 | 540 | var discreteDomains = 1; 541 | 542 | var view_mode = document.querySelector('.heatmap-mode-buttons .active').getAttribute('data-mode'); 543 | if (view_mode === 'continuous') { 544 | discreteDomains = 0; 545 | } 546 | 547 | heatmapArgs.discreteDomains = discreteDomains; 548 | heatmapArgs.colors = colors; 549 | new Chart("#chart-heatmap", heatmapArgs); 550 | 551 | Array.prototype.slice.call(btn.parentNode.querySelectorAll('button')).map(function (el) { 552 | el.classList.remove('active'); 553 | }); 554 | btn.classList.add('active'); 555 | }); 556 | }); 557 | 558 | document.querySelector('.export-heatmap').addEventListener('click', function () { 559 | heatmapChart.export(); 560 | }); 561 | 562 | }()); 563 | //# sourceMappingURL=index.min.js.map 564 | -------------------------------------------------------------------------------- /docs/docs.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/charts/7b15424c3af80b1c8e178d8e612c290d2d630904/docs/docs.html -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | --- 2 | redirect_to: "https://frappe.io/charts" 3 | --- 4 | 5 | 6 | 7 | 8 | Frappe Charts 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 |

Frappe Charts

30 |

GitHub-inspired simple and modern SVG charts for the web
with zero dependencies.

31 |
32 |

Click or use arrow keys to navigate data points

33 |
34 |
35 | 36 |
37 |
Create a chart
38 |
  <!--HTML-->
 39 |   <div id="chart"></div>
40 |
  // Javascript
 41 |   let chart = new frappe.Chart( "#chart", { // or DOM element
 42 |     data: {
 43 |       labels: ["12am-3am", "3am-6am", "6am-9am", "9am-12pm",
 44 |       "12pm-3pm", "3pm-6pm", "6pm-9pm", "9pm-12am"],
 45 | 
 46 |       datasets: [
 47 |         {
 48 |           name: "Some Data", chartType: 'bar',
 49 |           values: [25, 40, 30, 35, 8, 52, 17, -4]
 50 |         },
 51 |         {
 52 |           name: "Another Set", chartType: 'bar',
 53 |           values: [25, 50, -10, 15, 18, 32, 27, 14]
 54 |         },
 55 |         {
 56 |           name: "Yet Another", chartType: 'line',
 57 |           values: [15, 20, -3, -15, 58, 12, -17, 37]
 58 |         }
 59 |       ],
 60 | 
 61 |       yMarkers: [{ label: "Marker", value: 70,
 62 |         options: { labelPos: 'left' }}],
 63 |       yRegions: [{ label: "Region", start: -10, end: 50,
 64 |         options: { labelPos: 'right' }}]
 65 |     },
 66 | 
 67 |     title: "My Awesome Chart",
 68 |     type: 'axis-mixed', // or 'bar', 'line', 'pie', 'percentage', 'donut'
 69 |     height: 300,
 70 |     colors: ['purple', '#ffa3ef', 'light-blue'],
 71 | 
 72 |     tooltipOptions: {
 73 |       formatTooltipX: d => (d + '').toUpperCase(),
 74 |       formatTooltipY: d => d + ' pts',
 75 |     }
 76 |   });
 77 | 
 78 |   chart.export();
 79 | 
80 | 81 |
82 |
83 | 84 | 85 | 86 | 87 |
88 |
89 | 90 |
91 |
92 | 93 |
94 |
Update Values
95 |
96 | 97 |
98 | 99 | 100 | 101 | 102 |
103 |
104 | 105 |
106 |
Plot Trends
107 | 108 | 109 |
110 | 111 | 112 | 113 | 114 |
115 |
116 | 117 |
118 |
119 | 120 |
121 |
Listen to state change
122 |
123 |
124 |
125 |
126 |
127 |
128 | 129 |
130 |
131 |
Europa
132 |

Semi-major-axis: 671034 km

133 |

Mass: 4800000 x 10^16 kg

134 |

Diameter: 3121.6 km

135 |
136 |
137 |
138 |
  ...
139 |   isNavigable: 1, // Navigate across data points; default 0
140 |   ...
141 | 
142 |   chart.parent.addEventListener('data-select', (e) => {
143 |     update_moon_data(e.index); // e contains index and value of current datapoint
144 |   });
145 |
146 | 147 |
148 |
149 | And a Month-wise Heatmap 150 |
151 |
153 |
154 | 155 | 156 |
157 |
158 | 159 | 160 | 161 |
162 |
163 | 164 |
165 |
  let heatmap = new frappe.Chart("#heatmap", {
166 |     type: 'heatmap',
167 |     title: "Monthly Distribution",
168 |     data: {
169 |       dataPoints: {'1524064033': 8, /* ... */},
170 |                         // object with timestamp-value pairs
171 |       start: startDate
172 |       end: endDate      // Date objects
173 |     },
174 |     countLabel: 'Level',
175 |     discreteDomains: 0  // default: 1
176 |     colors: ['#ebedf0', '#c0ddf9', '#73b3f3', '#3886e1', '#17459e'],
177 |                 // Set of five incremental colors,
178 |                 // preferably with a low-saturation color for zero data;
179 |                 // def: ['#ebedf0', '#c6e48b', '#7bc96f', '#239a3b', '#196127']
180 |   });
181 |
182 | 183 |
184 |
Demo
185 |

187 | See the Pen Frappe Charts Demo 188 | by Prateeksha Singh (@pratu16x7) on 189 | CodePen. 190 |

191 | 192 |
193 | 194 |
195 |
Available options
196 |

197 |     ...
198 |     {
199 |       data: {
200 |         labels: [],
201 |         datasets: [],
202 |         yRegions: [],
203 |         yMarkers: []
204 |       }
205 |       title: '',
206 |       colors: [],
207 |       height: 200,
208 | 
209 |       tooltipOptions: {
210 |         formatTooltipX: d => (d + '').toUpperCase(),
211 |         formatTooltipY: d => d + ' pts',
212 |       }
213 | 
214 |       // Axis charts
215 |       isNavigable: 1,        // default: 0
216 |       valuesOverPoints: 1,   // default: 0
217 |       barOptions: {
218 |         spaceRatio: 1        // default: 0.5
219 |         stacked: 1           // default: 0
220 |       }
221 | 
222 |       lineOptions: {
223 |         dotSize: 6,          // default: 4
224 |         hideLine: 0,         // default: 0
225 |         hideDots: 1,         // default: 0
226 |         heatline: 1,         // default: 0
227 |         regionFill: 1        // default: 0
228 |       }
229 | 
230 |       axisOptions: {
231 |         yAxisMode: 'span',   // Axis lines, default
232 |         xAxisMode: 'tick',   // No axis lines, only short ticks
233 |         xIsSeries: 1         // Allow skipping x values for space
234 |                               // default: 0
235 |       },
236 | 
237 |       // Pie/Percentage/Donut charts
238 |       maxLegendPoints: 6,    // default: 20
239 |       maxSlices: 10,         // default: 20
240 | 
241 |       // Percentage chart
242 |       barOptions: {
243 |         height: 15           // default: 20
244 |         depth: 5             // default: 2
245 |       }
246 | 
247 |       // Heatmap
248 |       discreteDomains: 1,    // default: 1
249 |     }
250 |     ...
251 | 
252 |   // Updating values
253 |   chart.update(data);
254 | 
255 |   // Axis charts:
256 |   chart.addDataPoint(label, valueFromEachDataset, index)
257 |   chart.removeDataPoint(index)
258 |   chart.updateDataset(datasetValues, index)
259 | 
260 |   // Exporting
261 |   chart.export();
262 | 
263 |   // Unbind window-resize events
264 |   chart.destroy();
265 | 
266 | 
267 |
268 | 269 |
270 |
Install
271 |

Install via npm

272 |
  npm install frappe-charts
273 |

And include it in your project

274 |
  import { Chart } from "frappe-charts"
275 |

(for ES6+ import the ESModule from the dist folder)

276 |
  import { Chart } from "frappe-charts/dist/frappe-charts.esm.js"
277 |

... or include it directly in your HTML

278 |
  <script src="https://unpkg.com/frappe-charts@1.1.0"></script>
279 |

Use as:

280 |
  new Chart();          // ES6 module
281 |                         // or
282 |   new frappe.Chart();   // Browser
283 |
284 | 285 |
286 | 287 | 288 |

289 | 290 | View on GitHub 291 |

292 |

293 | Star 294 |

295 |

License: MIT

296 |
297 | 298 | 311 | 312 | 313 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frappe-charts", 3 | "version": "v1.6.3", 4 | "type": "module", 5 | "main": "dist/frappe-charts.esm.js", 6 | "module": "dist/frappe-charts.esm.js", 7 | "browser": "dist/frappe-charts.umd.js", 8 | "common": "dist/frappe-charts.cjs.js", 9 | "unnpkg": "dist/frappe-charts.umd.js", 10 | "description": "https://frappe.github.io/charts", 11 | "directories": { 12 | "doc": "docs" 13 | }, 14 | "files": [ 15 | "src", 16 | "dist" 17 | ], 18 | "scripts": { 19 | "test": "echo \"Error: no test specified\" && exit 1", 20 | "watch": "rollup -c --watch", 21 | "dev": "npm-run-all --parallel watch", 22 | "build": "rollup -c" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/frappe/charts.git" 27 | }, 28 | "keywords": [ 29 | "js", 30 | "charts" 31 | ], 32 | "author": "Prateeksha Singh", 33 | "license": "MIT", 34 | "bugs": { 35 | "url": "https://github.com/frappe/charts/issues" 36 | }, 37 | "homepage": "https://github.com/frappe/charts#readme", 38 | "devDependencies": { 39 | "@babel/core": "^7.10.5", 40 | "@babel/preset-env": "^7.10.4", 41 | "node-sass": "^8.0.0", 42 | "rollup": "^2.21.0", 43 | "rollup-plugin-babel": "^4.4.0", 44 | "rollup-plugin-bundle-size": "^1.0.3", 45 | "rollup-plugin-commonjs": "^10.1.0", 46 | "rollup-plugin-eslint": "^7.0.0", 47 | "rollup-plugin-postcss": "^3.1.3", 48 | "rollup-plugin-scss": "^2.5.0", 49 | "rollup-plugin-terser": "^6.1.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import pkg from "./package.json"; 2 | 3 | import commonjs from "rollup-plugin-commonjs"; 4 | import babel from "rollup-plugin-babel"; 5 | import postcss from "rollup-plugin-postcss"; 6 | import scss from "rollup-plugin-scss"; 7 | import bundleSize from "rollup-plugin-bundle-size"; 8 | import { terser } from "rollup-plugin-terser"; 9 | 10 | export default [ 11 | // browser-friendly UMD build 12 | { 13 | input: "src/js/index.js", 14 | output: { 15 | sourcemap: true, 16 | name: "frappe", 17 | file: pkg.browser, 18 | format: "umd", 19 | }, 20 | plugins: [ 21 | commonjs(), 22 | babel({ 23 | exclude: ["node_modules/**"], 24 | }), 25 | terser(), 26 | scss({ output: "dist/frappe-charts.min.css" }), 27 | bundleSize(), 28 | ], 29 | }, 30 | 31 | // CommonJS (for Node) and ES module (for bundlers) build. 32 | { 33 | input: "src/js/chart.js", 34 | output: [ 35 | { file: pkg.common, format: "cjs", sourcemap: true }, 36 | { file: pkg.module, format: "es", sourcemap: true }, 37 | ], 38 | plugins: [ 39 | babel({ 40 | exclude: ["node_modules/**"], 41 | }), 42 | terser(), 43 | postcss(), 44 | bundleSize(), 45 | ], 46 | }, 47 | ]; 48 | -------------------------------------------------------------------------------- /src/css/charts.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --charts-label-color: #313b44; 3 | --charts-axis-line-color: #f4f5f6; 4 | 5 | --charts-tooltip-title: var(--charts-label-color); 6 | --charts-tooltip-label: var(--charts-label-color); 7 | --charts-tooltip-value: #192734; 8 | --charts-tooltip-bg: #ffffff; 9 | 10 | --charts-stroke-width: 2px; 11 | --charts-dataset-circle-stroke: #ffffff; 12 | --charts-dataset-circle-stroke-width: var(--charts-stroke-width); 13 | 14 | --charts-legend-label: var(--charts-label-color); 15 | --charts-legend-value: var(--charts-label-color); 16 | } 17 | 18 | .chart-container { 19 | position: relative; 20 | /* for absolutely positioned tooltip */ 21 | 22 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 23 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 24 | sans-serif; 25 | 26 | .axis, 27 | .chart-label { 28 | fill: var(--charts-label-color); 29 | 30 | line { 31 | stroke: var(--charts-axis-line-color); 32 | } 33 | } 34 | 35 | .dataset-units { 36 | circle { 37 | stroke: var(--charts-dataset-circle-stroke); 38 | stroke-width: var(--charts-dataset-circle-stroke-width); 39 | } 40 | 41 | path { 42 | fill: none; 43 | stroke-opacity: 1; 44 | stroke-width: var(--charts-stroke-width); 45 | } 46 | } 47 | 48 | .dataset-path { 49 | stroke-width: var(--charts-stroke-width); 50 | } 51 | 52 | .path-group { 53 | path { 54 | fill: none; 55 | stroke-opacity: 1; 56 | stroke-width: var(--charts-stroke-width); 57 | } 58 | } 59 | 60 | line.dashed { 61 | stroke-dasharray: 5, 3; 62 | } 63 | 64 | .axis-line { 65 | .specific-value { 66 | text-anchor: start; 67 | } 68 | 69 | .y-line { 70 | text-anchor: end; 71 | } 72 | 73 | .x-line { 74 | text-anchor: middle; 75 | } 76 | } 77 | 78 | .legend-dataset-label { 79 | fill: var(--charts-legend-label); 80 | font-weight: 600; 81 | } 82 | 83 | .legend-dataset-value { 84 | fill: var(--charts-legend-value); 85 | } 86 | } 87 | 88 | .graph-svg-tip { 89 | position: absolute; 90 | z-index: 99999; 91 | padding: 10px; 92 | font-size: 12px; 93 | text-align: center; 94 | background: var(--charts-tooltip-bg); 95 | box-shadow: 0px 1px 4px rgba(17, 43, 66, 0.1), 96 | 0px 2px 6px rgba(17, 43, 66, 0.08), 97 | 0px 40px 30px -30px rgba(17, 43, 66, 0.1); 98 | border-radius: 6px; 99 | 100 | ul { 101 | padding-left: 0; 102 | display: flex; 103 | } 104 | 105 | ol { 106 | padding-left: 0; 107 | display: flex; 108 | } 109 | 110 | ul.data-point-list { 111 | li { 112 | min-width: 90px; 113 | font-weight: 600; 114 | } 115 | } 116 | 117 | .svg-pointer { 118 | position: absolute; 119 | height: 12px; 120 | width: 12px; 121 | border-radius: 2px; 122 | background: var(--charts-tooltip-bg); 123 | transform: rotate(45deg); 124 | margin-top: -7px; 125 | margin-left: -6px; 126 | } 127 | 128 | &.comparison { 129 | text-align: left; 130 | padding: 0px; 131 | pointer-events: none; 132 | 133 | .title { 134 | display: block; 135 | padding: 16px; 136 | margin: 0; 137 | color: var(--charts-tooltip-title); 138 | font-weight: 600; 139 | line-height: 1; 140 | pointer-events: none; 141 | text-transform: uppercase; 142 | 143 | strong { 144 | color: var(--charts-tooltip-value); 145 | } 146 | } 147 | 148 | ul { 149 | margin: 0; 150 | white-space: nowrap; 151 | list-style: none; 152 | 153 | &.tooltip-grid { 154 | display: grid; 155 | grid-template-columns: repeat(4, minmax(0, 1fr)); 156 | gap: 5px; 157 | } 158 | } 159 | 160 | li { 161 | display: inline-block; 162 | display: flex; 163 | flex-direction: row; 164 | font-weight: 600; 165 | line-height: 1; 166 | 167 | padding: 5px 15px 15px 15px; 168 | 169 | .tooltip-legend { 170 | height: 12px; 171 | width: 12px; 172 | margin-right: 8px; 173 | border-radius: 2px; 174 | } 175 | 176 | .tooltip-label { 177 | margin-top: 4px; 178 | font-size: 11px; 179 | max-width: 100px; 180 | 181 | color: var(--fr-tooltip-label); 182 | overflow: hidden; 183 | text-overflow: ellipsis; 184 | white-space: nowrap; 185 | } 186 | 187 | .tooltip-value { 188 | color: var(--charts-tooltip-value); 189 | } 190 | } 191 | } 192 | } -------------------------------------------------------------------------------- /src/css/chartsCss.js: -------------------------------------------------------------------------------- 1 | export const CSSTEXT = 2 | ".chart-container{position:relative;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Roboto','Oxygen','Ubuntu','Cantarell','Fira Sans','Droid Sans','Helvetica Neue',sans-serif}.chart-container .axis,.chart-container .chart-label{fill:#555b51}.chart-container .axis line,.chart-container .chart-label line{stroke:#dadada}.chart-container .dataset-units circle{stroke:#fff;stroke-width:2}.chart-container .dataset-units path{fill:none;stroke-opacity:1;stroke-width:2px}.chart-container .dataset-path{stroke-width:2px}.chart-container .path-group path{fill:none;stroke-opacity:1;stroke-width:2px}.chart-container line.dashed{stroke-dasharray:5,3}.chart-container .axis-line .specific-value{text-anchor:start}.chart-container .axis-line .y-line{text-anchor:end}.chart-container .axis-line .x-line{text-anchor:middle}.chart-container .legend-dataset-text{fill:#6c7680;font-weight:600}.graph-svg-tip{position:absolute;z-index:99999;padding:10px;font-size:12px;color:#959da5;text-align:center;background:rgba(0,0,0,.8);border-radius:3px}.graph-svg-tip ul{padding-left:0;display:flex}.graph-svg-tip ol{padding-left:0;display:flex}.graph-svg-tip ul.data-point-list li{min-width:90px;flex:1;font-weight:600}.graph-svg-tip strong{color:#dfe2e5;font-weight:600}.graph-svg-tip .svg-pointer{position:absolute;height:5px;margin:0 0 0 -5px;content:' ';border:5px solid transparent;}.graph-svg-tip.comparison{padding:0;text-align:left;pointer-events:none}.graph-svg-tip.comparison .title{display:block;padding:10px;margin:0;font-weight:600;line-height:1;pointer-events:none}.graph-svg-tip.comparison ul{margin:0;white-space:nowrap;list-style:none}.graph-svg-tip.comparison li{display:inline-block;padding:5px 10px}"; 3 | -------------------------------------------------------------------------------- /src/js/chart.js: -------------------------------------------------------------------------------- 1 | import "../css/charts.scss"; 2 | 3 | import PercentageChart from "./charts/PercentageChart"; 4 | import PieChart from "./charts/PieChart"; 5 | import Heatmap from "./charts/Heatmap"; 6 | import AxisChart from "./charts/AxisChart"; 7 | import DonutChart from "./charts/DonutChart"; 8 | 9 | const chartTypes = { 10 | bar: AxisChart, 11 | line: AxisChart, 12 | percentage: PercentageChart, 13 | heatmap: Heatmap, 14 | pie: PieChart, 15 | donut: DonutChart, 16 | }; 17 | 18 | function getChartByType(chartType = "line", parent, options) { 19 | if (chartType === "axis-mixed") { 20 | options.type = "line"; 21 | return new AxisChart(parent, options); 22 | } 23 | 24 | if (!chartTypes[chartType]) { 25 | console.error("Undefined chart type: " + chartType); 26 | return; 27 | } 28 | 29 | return new chartTypes[chartType](parent, options); 30 | } 31 | 32 | class Chart { 33 | constructor(parent, options) { 34 | return getChartByType(options.type, parent, options); 35 | } 36 | } 37 | 38 | export { Chart, PercentageChart, PieChart, Heatmap, AxisChart }; 39 | -------------------------------------------------------------------------------- /src/js/charts/AggregationChart.js: -------------------------------------------------------------------------------- 1 | import BaseChart from "./BaseChart"; 2 | import { truncateString } from "../utils/draw-utils"; 3 | import { legendDot } from "../utils/draw"; 4 | import { round } from "../utils/helpers"; 5 | import { getExtraWidth } from "../utils/constants"; 6 | 7 | export default class AggregationChart extends BaseChart { 8 | constructor(parent, args) { 9 | super(parent, args); 10 | } 11 | 12 | configure(args) { 13 | super.configure(args); 14 | 15 | this.config.formatTooltipY = (args.tooltipOptions || {}).formatTooltipY; 16 | this.config.maxSlices = args.maxSlices || 20; 17 | this.config.maxLegendPoints = args.maxLegendPoints || 20; 18 | this.config.legendRowHeight = 60; 19 | } 20 | 21 | calc() { 22 | let s = this.state; 23 | let maxSlices = this.config.maxSlices; 24 | s.sliceTotals = []; 25 | 26 | let allTotals = this.data.labels 27 | .map((label, i) => { 28 | let total = 0; 29 | this.data.datasets.map((e) => { 30 | total += e.values[i]; 31 | }); 32 | return [total, label]; 33 | }) 34 | .filter((d) => { 35 | return d[0] >= 0; 36 | }); // keep only positive results 37 | 38 | let totals = allTotals; 39 | if (allTotals.length > maxSlices) { 40 | // Prune and keep a grey area for rest as per maxSlices 41 | allTotals.sort((a, b) => { 42 | return b[0] - a[0]; 43 | }); 44 | 45 | totals = allTotals.slice(0, maxSlices - 1); 46 | let remaining = allTotals.slice(maxSlices - 1); 47 | 48 | let sumOfRemaining = 0; 49 | remaining.map((d) => { 50 | sumOfRemaining += d[0]; 51 | }); 52 | totals.push([sumOfRemaining, "Rest"]); 53 | this.colors[maxSlices - 1] = "grey"; 54 | } 55 | 56 | s.labels = []; 57 | totals.map((d) => { 58 | s.sliceTotals.push(round(d[0])); 59 | s.labels.push(d[1]); 60 | }); 61 | 62 | s.grandTotal = s.sliceTotals.reduce((a, b) => a + b, 0); 63 | 64 | this.center = { 65 | x: this.width / 2, 66 | y: this.height / 2, 67 | }; 68 | } 69 | 70 | renderLegend() { 71 | let s = this.state; 72 | this.legendArea.textContent = ""; 73 | this.legendTotals = s.sliceTotals.slice(0, this.config.maxLegendPoints); 74 | super.renderLegend(this.legendTotals); 75 | } 76 | 77 | makeLegend(data, index, x_pos, y_pos) { 78 | let formatted = this.config.formatTooltipY 79 | ? this.config.formatTooltipY(data) 80 | : data; 81 | 82 | return legendDot( 83 | x_pos, 84 | y_pos, 85 | 12, // size 86 | 3, // dot radius 87 | this.colors[index], // fill 88 | this.state.labels[index], // label 89 | formatted, // value 90 | null, // base_font_size 91 | this.config.truncateLegends // truncate_legends 92 | ); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/js/charts/BaseChart.js: -------------------------------------------------------------------------------- 1 | import SvgTip from "../objects/SvgTip"; 2 | import { 3 | $, 4 | isElementInViewport, 5 | getElementContentWidth, 6 | isHidden, 7 | } from "../utils/dom"; 8 | import { 9 | makeSVGContainer, 10 | makeSVGDefs, 11 | makeSVGGroup, 12 | makeText, 13 | } from "../utils/draw"; 14 | import { LEGEND_ITEM_WIDTH } from "../utils/constants"; 15 | import { 16 | BASE_MEASURES, 17 | getExtraHeight, 18 | getExtraWidth, 19 | getTopOffset, 20 | getLeftOffset, 21 | INIT_CHART_UPDATE_TIMEOUT, 22 | CHART_POST_ANIMATE_TIMEOUT, 23 | DEFAULT_COLORS, 24 | } from "../utils/constants"; 25 | import { getColor, isValidColor } from "../utils/colors"; 26 | import { runSMILAnimation } from "../utils/animation"; 27 | import { downloadFile, prepareForExport } from "../utils/export"; 28 | import { deepClone } from "../utils/helpers"; 29 | 30 | export default class BaseChart { 31 | constructor(parent, options) { 32 | // deepclone options to avoid making changes to orignal object 33 | options = deepClone(options); 34 | 35 | this.parent = 36 | typeof parent === "string" 37 | ? document.querySelector(parent) 38 | : parent; 39 | 40 | if (!(this.parent instanceof HTMLElement)) { 41 | throw new Error("No `parent` element to render on was provided."); 42 | } 43 | 44 | this.rawChartArgs = options; 45 | 46 | this.title = options.title || ""; 47 | this.type = options.type || ""; 48 | 49 | this.colors = this.validateColors(options.colors, this.type); 50 | 51 | this.config = { 52 | showTooltip: 1, // calculate 53 | showLegend: 54 | typeof options.showLegend !== "undefined" 55 | ? options.showLegend 56 | : 1, 57 | isNavigable: options.isNavigable || 0, 58 | animate: 0, 59 | overrideCeiling: options.overrideCeiling || false, 60 | overrideFloor: options.overrideFloor || false, 61 | truncateLegends: 62 | typeof options.truncateLegends !== "undefined" 63 | ? options.truncateLegends 64 | : 1, 65 | continuous: 66 | typeof options.continuous !== "undefined" 67 | ? options.continuous 68 | : 1, 69 | }; 70 | 71 | this.measures = JSON.parse(JSON.stringify(BASE_MEASURES)); 72 | let m = this.measures; 73 | 74 | this.realData = this.prepareData(options.data, this.config); 75 | this.data = this.prepareFirstData(this.realData); 76 | 77 | this.setMeasures(options); 78 | if (!this.title.length) { 79 | m.titleHeight = 0; 80 | } 81 | if (!this.config.showLegend) m.legendHeight = 0; 82 | this.argHeight = options.height || m.baseHeight; 83 | 84 | this.state = {}; 85 | this.options = {}; 86 | 87 | this.initTimeout = INIT_CHART_UPDATE_TIMEOUT; 88 | 89 | if (this.config.isNavigable) { 90 | this.overlays = []; 91 | } 92 | 93 | this.configure(options); 94 | } 95 | 96 | prepareData(data) { 97 | return data; 98 | } 99 | 100 | prepareFirstData(data) { 101 | return data; 102 | } 103 | 104 | validateColors(colors, type) { 105 | const validColors = []; 106 | colors = (colors || []).concat(DEFAULT_COLORS[type]); 107 | colors.forEach((string) => { 108 | const color = getColor(string); 109 | if (!isValidColor(color)) { 110 | console.warn('"' + string + '" is not a valid color.'); 111 | } else { 112 | validColors.push(color); 113 | } 114 | }); 115 | return validColors; 116 | } 117 | 118 | setMeasures() { 119 | // Override measures, including those for title and legend 120 | // set config for legend and title 121 | } 122 | 123 | configure() { 124 | let height = this.argHeight; 125 | this.baseHeight = height; 126 | this.height = height - getExtraHeight(this.measures); 127 | 128 | // Bind window events 129 | this.boundDrawFn = () => this.draw(true); 130 | // Look into improving responsiveness 131 | //if (ResizeObserver) { 132 | // this.resizeObserver = new ResizeObserver(this.boundDrawFn); 133 | // this.resizeObserver.observe(this.parent); 134 | //} 135 | window.addEventListener("resize", this.boundDrawFn); 136 | window.addEventListener("orientationchange", this.boundDrawFn); 137 | } 138 | 139 | destroy() { 140 | //if (this.resizeObserver) this.resizeObserver.disconnect(); 141 | window.removeEventListener("resize", this.boundDrawFn); 142 | window.removeEventListener("orientationchange", this.boundDrawFn); 143 | } 144 | 145 | // Has to be called manually 146 | setup() { 147 | this.makeContainer(); 148 | this.updateWidth(); 149 | this.makeTooltip(); 150 | 151 | this.draw(false, true); 152 | } 153 | 154 | makeContainer() { 155 | // Chart needs a dedicated parent element 156 | this.parent.innerHTML = ""; 157 | 158 | let args = { 159 | inside: this.parent, 160 | className: "chart-container", 161 | }; 162 | 163 | if (this.independentWidth) { 164 | args.styles = { width: this.independentWidth + "px" }; 165 | } 166 | 167 | this.container = $.create("div", args); 168 | } 169 | 170 | makeTooltip() { 171 | this.tip = new SvgTip({ 172 | parent: this.container, 173 | colors: this.colors, 174 | }); 175 | this.bindTooltip(); 176 | } 177 | 178 | bindTooltip() {} 179 | 180 | draw(onlyWidthChange = false, init = false) { 181 | if (onlyWidthChange && isHidden(this.parent)) { 182 | // Don't update anything if the chart is hidden 183 | return; 184 | } 185 | this.updateWidth(); 186 | 187 | this.calc(onlyWidthChange); 188 | this.makeChartArea(); 189 | this.setupComponents(); 190 | 191 | this.components.forEach((c) => c.setup(this.drawArea)); 192 | // this.components.forEach(c => c.make()); 193 | this.render(this.components, false); 194 | 195 | if (init) { 196 | this.data = this.realData; 197 | this.update(this.data, true); 198 | // Not needed anymore since animate defaults to 0 and might potentially be refactored or deprecated 199 | /* setTimeout(() => { 200 | this.update(this.data, true); 201 | }, this.initTimeout); */ 202 | } 203 | 204 | if (this.config.showLegend) { 205 | this.renderLegend(); 206 | } 207 | 208 | this.setupNavigation(init); 209 | } 210 | 211 | calc() {} // builds state 212 | 213 | updateWidth() { 214 | this.baseWidth = getElementContentWidth(this.parent); 215 | this.width = this.baseWidth - getExtraWidth(this.measures); 216 | } 217 | 218 | makeChartArea() { 219 | if (this.svg) { 220 | this.container.removeChild(this.svg); 221 | } 222 | let m = this.measures; 223 | 224 | this.svg = makeSVGContainer( 225 | this.container, 226 | "frappe-chart chart", 227 | this.baseWidth, 228 | this.baseHeight 229 | ); 230 | this.svgDefs = makeSVGDefs(this.svg); 231 | 232 | if (this.title.length) { 233 | this.titleEL = makeText( 234 | "title", 235 | m.margins.left, 236 | m.margins.top, 237 | this.title, 238 | { 239 | fontSize: m.titleFontSize, 240 | fill: "#666666", 241 | dy: m.titleFontSize, 242 | } 243 | ); 244 | } 245 | 246 | let top = getTopOffset(m); 247 | this.drawArea = makeSVGGroup( 248 | this.type + "-chart chart-draw-area", 249 | `translate(${getLeftOffset(m)}, ${top})` 250 | ); 251 | 252 | if (this.config.showLegend) { 253 | top += this.height + m.paddings.bottom; 254 | this.legendArea = makeSVGGroup( 255 | "chart-legend", 256 | `translate(${getLeftOffset(m)}, ${top})` 257 | ); 258 | } 259 | 260 | if (this.title.length) { 261 | this.svg.appendChild(this.titleEL); 262 | } 263 | this.svg.appendChild(this.drawArea); 264 | if (this.config.showLegend) { 265 | this.svg.appendChild(this.legendArea); 266 | } 267 | 268 | this.updateTipOffset(getLeftOffset(m), getTopOffset(m)); 269 | } 270 | 271 | updateTipOffset(x, y) { 272 | this.tip.offset = { 273 | x: x, 274 | y: y, 275 | }; 276 | } 277 | 278 | setupComponents() { 279 | this.components = new Map(); 280 | } 281 | 282 | update(data, drawing = false, config) { 283 | if (!data) console.error("No data to update."); 284 | if (!drawing) data = deepClone(data); 285 | this.data = this.prepareData(data, config); 286 | this.calc(); // builds state 287 | this.render(this.components, this.config.animate); 288 | } 289 | 290 | render(components = this.components, animate = true) { 291 | if (this.config.isNavigable) { 292 | // Remove all existing overlays 293 | this.overlays.map((o) => o.parentNode.removeChild(o)); 294 | // ref.parentNode.insertBefore(element, ref); 295 | } 296 | let elementsToAnimate = []; 297 | // Can decouple to this.refreshComponents() first to save animation timeout 298 | components.forEach((c) => { 299 | elementsToAnimate = elementsToAnimate.concat(c.update(animate)); 300 | }); 301 | if (elementsToAnimate.length > 0) { 302 | runSMILAnimation(this.container, this.svg, elementsToAnimate); 303 | setTimeout(() => { 304 | components.forEach((c) => c.make()); 305 | this.updateNav(); 306 | }, CHART_POST_ANIMATE_TIMEOUT); 307 | } else { 308 | components.forEach((c) => c.make()); 309 | this.updateNav(); 310 | } 311 | } 312 | 313 | updateNav() { 314 | if (this.config.isNavigable) { 315 | this.makeOverlay(); 316 | this.bindUnits(); 317 | } 318 | } 319 | 320 | renderLegend(dataset) { 321 | this.legendArea.textContent = ""; 322 | let count = 0; 323 | let y = 0; 324 | 325 | dataset.map((data, index) => { 326 | let divisor = Math.floor(this.width / LEGEND_ITEM_WIDTH); 327 | if (count > divisor) { 328 | count = 0; 329 | y += this.config.legendRowHeight; 330 | } 331 | let x = LEGEND_ITEM_WIDTH * count; 332 | let dot = this.makeLegend(data, index, x, y); 333 | this.legendArea.appendChild(dot); 334 | count++; 335 | }); 336 | } 337 | 338 | makeLegend() {} 339 | 340 | setupNavigation(init = false) { 341 | if (!this.config.isNavigable) return; 342 | 343 | if (init) { 344 | this.bindOverlay(); 345 | 346 | this.keyActions = { 347 | 13: this.onEnterKey.bind(this), 348 | 37: this.onLeftArrow.bind(this), 349 | 38: this.onUpArrow.bind(this), 350 | 39: this.onRightArrow.bind(this), 351 | 40: this.onDownArrow.bind(this), 352 | }; 353 | 354 | document.addEventListener("keydown", (e) => { 355 | if (isElementInViewport(this.container)) { 356 | e = e || window.event; 357 | if (this.keyActions[e.keyCode]) { 358 | this.keyActions[e.keyCode](); 359 | } 360 | } 361 | }); 362 | } 363 | } 364 | 365 | makeOverlay() {} 366 | updateOverlay() {} 367 | bindOverlay() {} 368 | bindUnits() {} 369 | 370 | onLeftArrow() {} 371 | onRightArrow() {} 372 | onUpArrow() {} 373 | onDownArrow() {} 374 | onEnterKey() {} 375 | 376 | addDataPoint() {} 377 | removeDataPoint() {} 378 | 379 | getDataPoint() {} 380 | setCurrentDataPoint() {} 381 | 382 | updateDataset() {} 383 | 384 | export() { 385 | let chartSvg = prepareForExport(this.svg); 386 | downloadFile(this.title || "Chart", [chartSvg]); 387 | } 388 | } 389 | -------------------------------------------------------------------------------- /src/js/charts/DonutChart.js: -------------------------------------------------------------------------------- 1 | import AggregationChart from "./AggregationChart"; 2 | import { getComponent } from "../objects/ChartComponents"; 3 | import { getOffset } from "../utils/dom"; 4 | import { getPositionByAngle } from "../utils/helpers"; 5 | import { makeArcStrokePathStr, makeStrokeCircleStr } from "../utils/draw"; 6 | import { lightenDarkenColor } from "../utils/colors"; 7 | import { transform } from "../utils/animation"; 8 | import { FULL_ANGLE } from "../utils/constants"; 9 | 10 | export default class DonutChart extends AggregationChart { 11 | constructor(parent, args) { 12 | super(parent, args); 13 | this.type = "donut"; 14 | this.initTimeout = 0; 15 | this.init = 1; 16 | 17 | this.setup(); 18 | } 19 | 20 | configure(args) { 21 | super.configure(args); 22 | this.mouseMove = this.mouseMove.bind(this); 23 | this.mouseLeave = this.mouseLeave.bind(this); 24 | 25 | this.hoverRadio = args.hoverRadio || 0.1; 26 | this.config.startAngle = args.startAngle || 0; 27 | 28 | this.clockWise = args.clockWise || false; 29 | this.strokeWidth = args.strokeWidth || 30; 30 | } 31 | 32 | calc() { 33 | super.calc(); 34 | let s = this.state; 35 | this.radius = 36 | this.height > this.width 37 | ? this.center.x - this.strokeWidth / 2 38 | : this.center.y - this.strokeWidth / 2; 39 | 40 | const { radius, clockWise } = this; 41 | 42 | const prevSlicesProperties = s.slicesProperties || []; 43 | s.sliceStrings = []; 44 | s.slicesProperties = []; 45 | let curAngle = 180 - this.config.startAngle; 46 | 47 | s.sliceTotals.map((total, i) => { 48 | const startAngle = curAngle; 49 | const originDiffAngle = (total / s.grandTotal) * FULL_ANGLE; 50 | const largeArc = originDiffAngle > 180 ? 1 : 0; 51 | const diffAngle = clockWise ? -originDiffAngle : originDiffAngle; 52 | const endAngle = (curAngle = curAngle + diffAngle); 53 | const startPosition = getPositionByAngle(startAngle, radius); 54 | const endPosition = getPositionByAngle(endAngle, radius); 55 | 56 | const prevProperty = this.init && prevSlicesProperties[i]; 57 | 58 | let curStart, curEnd; 59 | if (this.init) { 60 | curStart = prevProperty ? prevProperty.startPosition : startPosition; 61 | curEnd = prevProperty ? prevProperty.endPosition : startPosition; 62 | } else { 63 | curStart = startPosition; 64 | curEnd = endPosition; 65 | } 66 | const curPath = 67 | originDiffAngle === 360 68 | ? makeStrokeCircleStr( 69 | curStart, 70 | curEnd, 71 | this.center, 72 | this.radius, 73 | this.clockWise, 74 | largeArc 75 | ) 76 | : makeArcStrokePathStr( 77 | curStart, 78 | curEnd, 79 | this.center, 80 | this.radius, 81 | this.clockWise, 82 | largeArc 83 | ); 84 | 85 | s.sliceStrings.push(curPath); 86 | s.slicesProperties.push({ 87 | startPosition, 88 | endPosition, 89 | value: total, 90 | total: s.grandTotal, 91 | startAngle, 92 | endAngle, 93 | angle: diffAngle, 94 | }); 95 | }); 96 | this.init = 0; 97 | } 98 | 99 | setupComponents() { 100 | let s = this.state; 101 | 102 | let componentConfigs = [ 103 | [ 104 | "donutSlices", 105 | {}, 106 | function () { 107 | return { 108 | sliceStrings: s.sliceStrings, 109 | colors: this.colors, 110 | strokeWidth: this.strokeWidth, 111 | }; 112 | }.bind(this), 113 | ], 114 | ]; 115 | 116 | this.components = new Map( 117 | componentConfigs.map((args) => { 118 | let component = getComponent(...args); 119 | return [args[0], component]; 120 | }) 121 | ); 122 | } 123 | 124 | calTranslateByAngle(property) { 125 | const { radius, hoverRadio } = this; 126 | const position = getPositionByAngle( 127 | property.startAngle + property.angle / 2, 128 | radius 129 | ); 130 | return `translate3d(${position.x * hoverRadio}px,${ 131 | position.y * hoverRadio 132 | }px,0)`; 133 | } 134 | 135 | hoverSlice(path, i, flag, e) { 136 | if (!path) return; 137 | const color = this.colors[i]; 138 | if (flag) { 139 | transform(path, this.calTranslateByAngle(this.state.slicesProperties[i])); 140 | path.style.stroke = lightenDarkenColor(color, 50); 141 | let g_off = getOffset(this.svg); 142 | let x = e.pageX - g_off.left + 10; 143 | let y = e.pageY - g_off.top - 10; 144 | let title = 145 | (this.formatted_labels && this.formatted_labels.length > 0 146 | ? this.formatted_labels[i] 147 | : this.state.labels[i]) + ": "; 148 | let percent = ( 149 | (this.state.sliceTotals[i] * 100) / 150 | this.state.grandTotal 151 | ).toFixed(1); 152 | this.tip.setValues(x, y, { name: title, value: percent + "%" }); 153 | this.tip.showTip(); 154 | } else { 155 | transform(path, "translate3d(0,0,0)"); 156 | this.tip.hideTip(); 157 | path.style.stroke = color; 158 | } 159 | } 160 | 161 | bindTooltip() { 162 | this.container.addEventListener("mousemove", this.mouseMove); 163 | this.container.addEventListener("mouseleave", this.mouseLeave); 164 | } 165 | 166 | mouseMove(e) { 167 | const target = e.target; 168 | let slices = this.components.get("donutSlices").store; 169 | let prevIndex = this.curActiveSliceIndex; 170 | let prevAcitve = this.curActiveSlice; 171 | if (slices.includes(target)) { 172 | let i = slices.indexOf(target); 173 | this.hoverSlice(prevAcitve, prevIndex, false); 174 | this.curActiveSlice = target; 175 | this.curActiveSliceIndex = i; 176 | this.hoverSlice(target, i, true, e); 177 | } else { 178 | this.mouseLeave(); 179 | } 180 | } 181 | 182 | mouseLeave() { 183 | this.hoverSlice(this.curActiveSlice, this.curActiveSliceIndex, false); 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/js/charts/Heatmap.js: -------------------------------------------------------------------------------- 1 | import BaseChart from "./BaseChart"; 2 | import { getComponent } from "../objects/ChartComponents"; 3 | import { makeText, heatSquare } from "../utils/draw"; 4 | import { 5 | DAY_NAMES_SHORT, 6 | toMidnightUTC, 7 | addDays, 8 | areInSameMonth, 9 | getLastDateInMonth, 10 | setDayToSunday, 11 | getYyyyMmDd, 12 | getWeeksBetween, 13 | getMonthName, 14 | clone, 15 | NO_OF_MILLIS, 16 | NO_OF_YEAR_MONTHS, 17 | NO_OF_DAYS_IN_WEEK, 18 | } from "../utils/date-utils"; 19 | import { calcDistribution, getMaxCheckpoint } from "../utils/intervals"; 20 | import { 21 | getExtraHeight, 22 | getExtraWidth, 23 | HEATMAP_DISTRIBUTION_SIZE, 24 | HEATMAP_SQUARE_SIZE, 25 | HEATMAP_GUTTER_SIZE, 26 | } from "../utils/constants"; 27 | 28 | const COL_WIDTH = HEATMAP_SQUARE_SIZE + HEATMAP_GUTTER_SIZE; 29 | const ROW_HEIGHT = COL_WIDTH; 30 | // const DAY_INCR = 1; 31 | 32 | export default class Heatmap extends BaseChart { 33 | constructor(parent, options) { 34 | super(parent, options); 35 | this.type = "heatmap"; 36 | 37 | this.countLabel = options.countLabel || ""; 38 | 39 | let validStarts = ["Sunday", "Monday"]; 40 | let startSubDomain = validStarts.includes(options.startSubDomain) 41 | ? options.startSubDomain 42 | : "Sunday"; 43 | this.startSubDomainIndex = validStarts.indexOf(startSubDomain); 44 | 45 | this.setup(); 46 | } 47 | 48 | setMeasures(options) { 49 | let m = this.measures; 50 | this.discreteDomains = options.discreteDomains === 0 ? 0 : 1; 51 | 52 | m.paddings.top = ROW_HEIGHT * 3; 53 | m.paddings.bottom = 0; 54 | m.legendHeight = ROW_HEIGHT * 2; 55 | m.baseHeight = ROW_HEIGHT * NO_OF_DAYS_IN_WEEK + getExtraHeight(m); 56 | 57 | let d = this.data; 58 | let spacing = this.discreteDomains ? NO_OF_YEAR_MONTHS : 0; 59 | this.independentWidth = 60 | (getWeeksBetween(d.start, d.end) + spacing) * COL_WIDTH + 61 | getExtraWidth(m); 62 | } 63 | 64 | updateWidth() { 65 | let spacing = this.discreteDomains ? NO_OF_YEAR_MONTHS : 0; 66 | let noOfWeeks = this.state.noOfWeeks ? this.state.noOfWeeks : 52; 67 | this.baseWidth = 68 | (noOfWeeks + spacing) * COL_WIDTH + getExtraWidth(this.measures); 69 | } 70 | 71 | prepareData(data = this.data) { 72 | if (data.start && data.end && data.start > data.end) { 73 | throw new Error("Start date cannot be greater than end date."); 74 | } 75 | 76 | if (!data.start) { 77 | data.start = new Date(); 78 | data.start.setFullYear(data.start.getFullYear() - 1); 79 | } 80 | data.start = toMidnightUTC(data.start); 81 | 82 | if (!data.end) { 83 | data.end = new Date(); 84 | } 85 | data.end = toMidnightUTC(data.end); 86 | 87 | data.dataPoints = data.dataPoints || {}; 88 | 89 | if (parseInt(Object.keys(data.dataPoints)[0]) > 100000) { 90 | let points = {}; 91 | Object.keys(data.dataPoints).forEach((timestampSec) => { 92 | let date = new Date(timestampSec * NO_OF_MILLIS); 93 | points[getYyyyMmDd(date)] = data.dataPoints[timestampSec]; 94 | }); 95 | data.dataPoints = points; 96 | } 97 | 98 | return data; 99 | } 100 | 101 | calc() { 102 | let s = this.state; 103 | 104 | s.start = clone(this.data.start); 105 | s.end = clone(this.data.end); 106 | 107 | s.firstWeekStart = clone(s.start); 108 | s.noOfWeeks = getWeeksBetween(s.start, s.end); 109 | s.distribution = calcDistribution( 110 | Object.values(this.data.dataPoints), 111 | HEATMAP_DISTRIBUTION_SIZE 112 | ); 113 | 114 | s.domainConfigs = this.getDomains(); 115 | } 116 | 117 | setupComponents() { 118 | let s = this.state; 119 | let lessCol = this.discreteDomains ? 0 : 1; 120 | 121 | let componentConfigs = s.domainConfigs.map((config, i) => [ 122 | "heatDomain", 123 | { 124 | index: config.index, 125 | colWidth: COL_WIDTH, 126 | rowHeight: ROW_HEIGHT, 127 | squareSize: HEATMAP_SQUARE_SIZE, 128 | radius: this.rawChartArgs.radius || 0, 129 | xTranslate: 130 | s.domainConfigs 131 | .filter((config, j) => j < i) 132 | .map((config) => config.cols.length - lessCol) 133 | .reduce((a, b) => a + b, 0) * COL_WIDTH, 134 | }, 135 | function () { 136 | return s.domainConfigs[i]; 137 | }.bind(this), 138 | ]); 139 | 140 | this.components = new Map( 141 | componentConfigs.map((args, i) => { 142 | let component = getComponent(...args); 143 | return [args[0] + "-" + i, component]; 144 | }) 145 | ); 146 | 147 | let y = 0; 148 | DAY_NAMES_SHORT.forEach((dayName, i) => { 149 | if ([1, 3, 5].includes(i)) { 150 | let dayText = makeText("subdomain-name", -COL_WIDTH / 2, y, dayName, { 151 | fontSize: HEATMAP_SQUARE_SIZE, 152 | dy: 8, 153 | textAnchor: "end", 154 | }); 155 | this.drawArea.appendChild(dayText); 156 | } 157 | y += ROW_HEIGHT; 158 | }); 159 | } 160 | 161 | update(data) { 162 | if (!data) { 163 | console.error("No data to update."); 164 | } 165 | 166 | this.data = this.prepareData(data); 167 | this.draw(); 168 | this.bindTooltip(); 169 | } 170 | 171 | bindTooltip() { 172 | this.container.addEventListener("mousemove", (e) => { 173 | this.components.forEach((comp) => { 174 | let daySquares = comp.store; 175 | let daySquare = e.target; 176 | if (daySquares.includes(daySquare)) { 177 | let count = daySquare.getAttribute("data-value"); 178 | let dateParts = daySquare.getAttribute("data-date").split("-"); 179 | 180 | let month = getMonthName(parseInt(dateParts[1]) - 1, true); 181 | 182 | let gOff = this.container.getBoundingClientRect(), 183 | pOff = daySquare.getBoundingClientRect(); 184 | 185 | let width = parseInt(e.target.getAttribute("width")); 186 | let x = pOff.left - gOff.left + width / 2; 187 | let y = pOff.top - gOff.top; 188 | let value = count + " " + this.countLabel; 189 | let name = " on " + month + " " + dateParts[0] + ", " + dateParts[2]; 190 | 191 | this.tip.setValues( 192 | x, 193 | y, 194 | { name: name, value: value, valueFirst: 1 }, 195 | [] 196 | ); 197 | this.tip.showTip(); 198 | } 199 | }); 200 | }); 201 | } 202 | 203 | renderLegend() { 204 | this.legendArea.textContent = ""; 205 | let x = 0; 206 | let y = ROW_HEIGHT; 207 | let radius = this.rawChartArgs.radius || 0; 208 | 209 | let lessText = makeText("subdomain-name", x, y, "Less", { 210 | fontSize: HEATMAP_SQUARE_SIZE + 1, 211 | dy: 9, 212 | }); 213 | x = COL_WIDTH * 2 + COL_WIDTH / 2; 214 | this.legendArea.appendChild(lessText); 215 | 216 | this.colors.slice(0, HEATMAP_DISTRIBUTION_SIZE).map((color, i) => { 217 | const square = heatSquare( 218 | "heatmap-legend-unit", 219 | x + (COL_WIDTH + 3) * i, 220 | y, 221 | HEATMAP_SQUARE_SIZE, 222 | radius, 223 | color 224 | ); 225 | this.legendArea.appendChild(square); 226 | }); 227 | 228 | let moreTextX = 229 | x + HEATMAP_DISTRIBUTION_SIZE * (COL_WIDTH + 3) + COL_WIDTH / 4; 230 | let moreText = makeText("subdomain-name", moreTextX, y, "More", { 231 | fontSize: HEATMAP_SQUARE_SIZE + 1, 232 | dy: 9, 233 | }); 234 | this.legendArea.appendChild(moreText); 235 | } 236 | 237 | getDomains() { 238 | let s = this.state; 239 | const [startMonth, startYear] = [s.start.getMonth(), s.start.getFullYear()]; 240 | const [endMonth, endYear] = [s.end.getMonth(), s.end.getFullYear()]; 241 | 242 | const noOfMonths = endMonth - startMonth + 1 + (endYear - startYear) * 12; 243 | 244 | let domainConfigs = []; 245 | 246 | let startOfMonth = clone(s.start); 247 | for (var i = 0; i < noOfMonths; i++) { 248 | let endDate = s.end; 249 | if (!areInSameMonth(startOfMonth, s.end)) { 250 | let [month, year] = [ 251 | startOfMonth.getMonth(), 252 | startOfMonth.getFullYear(), 253 | ]; 254 | endDate = getLastDateInMonth(month, year); 255 | } 256 | domainConfigs.push(this.getDomainConfig(startOfMonth, endDate)); 257 | 258 | addDays(endDate, 1); 259 | startOfMonth = endDate; 260 | } 261 | 262 | return domainConfigs; 263 | } 264 | 265 | getDomainConfig(startDate, endDate = "") { 266 | let [month, year] = [startDate.getMonth(), startDate.getFullYear()]; 267 | let startOfWeek = setDayToSunday(startDate); // TODO: Monday as well 268 | endDate = endDate 269 | ? clone(endDate) 270 | : toMidnightUTC(getLastDateInMonth(month, year)); 271 | 272 | let domainConfig = { 273 | index: month, 274 | cols: [], 275 | }; 276 | 277 | addDays(endDate, 1); 278 | let noOfMonthWeeks = getWeeksBetween(startOfWeek, endDate); 279 | 280 | let cols = [], 281 | col; 282 | for (var i = 0; i < noOfMonthWeeks; i++) { 283 | col = this.getCol(startOfWeek, month); 284 | cols.push(col); 285 | 286 | startOfWeek = toMidnightUTC( 287 | new Date(col[NO_OF_DAYS_IN_WEEK - 1].yyyyMmDd) 288 | ); 289 | addDays(startOfWeek, 1); 290 | } 291 | 292 | if (col[NO_OF_DAYS_IN_WEEK - 1].dataValue !== undefined) { 293 | addDays(startOfWeek, 1); 294 | cols.push(this.getCol(startOfWeek, month, true)); 295 | } 296 | 297 | domainConfig.cols = cols; 298 | 299 | return domainConfig; 300 | } 301 | 302 | getCol(startDate, month, empty = false) { 303 | let s = this.state; 304 | 305 | // startDate is the start of week 306 | let currentDate = clone(startDate); 307 | let col = []; 308 | 309 | for (var i = 0; i < NO_OF_DAYS_IN_WEEK; i++, addDays(currentDate, 1)) { 310 | let config = {}; 311 | 312 | // Non-generic adjustment for entire heatmap, needs state 313 | let currentDateWithinData = 314 | currentDate >= s.start && currentDate <= s.end; 315 | 316 | if (empty || currentDate.getMonth() !== month || !currentDateWithinData) { 317 | config.yyyyMmDd = getYyyyMmDd(currentDate); 318 | } else { 319 | config = this.getSubDomainConfig(currentDate); 320 | } 321 | col.push(config); 322 | } 323 | 324 | return col; 325 | } 326 | 327 | getSubDomainConfig(date) { 328 | let yyyyMmDd = getYyyyMmDd(date); 329 | let dataValue = this.data.dataPoints[yyyyMmDd]; 330 | let config = { 331 | yyyyMmDd: yyyyMmDd, 332 | dataValue: dataValue || 0, 333 | fill: this.colors[getMaxCheckpoint(dataValue, this.state.distribution)], 334 | }; 335 | return config; 336 | } 337 | } 338 | -------------------------------------------------------------------------------- /src/js/charts/PercentageChart.js: -------------------------------------------------------------------------------- 1 | import AggregationChart from "./AggregationChart"; 2 | import { getOffset } from "../utils/dom"; 3 | import { getComponent } from "../objects/ChartComponents"; 4 | import { PERCENTAGE_BAR_DEFAULT_HEIGHT } from "../utils/constants"; 5 | 6 | export default class PercentageChart extends AggregationChart { 7 | constructor(parent, args) { 8 | super(parent, args); 9 | this.type = "percentage"; 10 | this.setup(); 11 | } 12 | 13 | setMeasures(options) { 14 | let m = this.measures; 15 | this.barOptions = options.barOptions || {}; 16 | 17 | let b = this.barOptions; 18 | b.height = b.height || PERCENTAGE_BAR_DEFAULT_HEIGHT; 19 | 20 | m.paddings.right = 30; 21 | m.legendHeight = 60; 22 | m.baseHeight = (b.height + b.depth * 0.5) * 8; 23 | } 24 | 25 | setupComponents() { 26 | let s = this.state; 27 | 28 | let componentConfigs = [ 29 | [ 30 | "percentageBars", 31 | { 32 | barHeight: this.barOptions.height, 33 | }, 34 | function () { 35 | return { 36 | xPositions: s.xPositions, 37 | widths: s.widths, 38 | colors: this.colors, 39 | }; 40 | }.bind(this), 41 | ], 42 | ]; 43 | 44 | this.components = new Map( 45 | componentConfigs.map((args) => { 46 | let component = getComponent(...args); 47 | return [args[0], component]; 48 | }) 49 | ); 50 | } 51 | 52 | calc() { 53 | super.calc(); 54 | let s = this.state; 55 | 56 | s.xPositions = []; 57 | s.widths = []; 58 | 59 | let xPos = 0; 60 | s.sliceTotals.map((value) => { 61 | let width = (this.width * value) / s.grandTotal; 62 | s.widths.push(width); 63 | s.xPositions.push(xPos); 64 | xPos += width; 65 | }); 66 | } 67 | 68 | makeDataByIndex() {} 69 | 70 | bindTooltip() { 71 | let s = this.state; 72 | this.container.addEventListener("mousemove", (e) => { 73 | let bars = this.components.get("percentageBars").store; 74 | let bar = e.target; 75 | if (bars.includes(bar)) { 76 | let i = bars.indexOf(bar); 77 | let gOff = getOffset(this.container), 78 | pOff = getOffset(bar); 79 | 80 | let x = pOff.left - gOff.left + parseInt(bar.getAttribute("width")) / 2; 81 | let y = pOff.top - gOff.top; 82 | let title = 83 | (this.formattedLabels && this.formattedLabels.length > 0 84 | ? this.formattedLabels[i] 85 | : this.state.labels[i]) + ": "; 86 | let fraction = s.sliceTotals[i] / s.grandTotal; 87 | 88 | this.tip.setValues(x, y, { 89 | name: title, 90 | value: (fraction * 100).toFixed(1) + "%", 91 | }); 92 | this.tip.showTip(); 93 | } 94 | }); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/js/charts/PieChart.js: -------------------------------------------------------------------------------- 1 | import AggregationChart from "./AggregationChart"; 2 | import { getComponent } from "../objects/ChartComponents"; 3 | import { getOffset, fire } from "../utils/dom"; 4 | import { getPositionByAngle } from "../utils/helpers"; 5 | import { makeArcPathStr, makeCircleStr } from "../utils/draw"; 6 | import { lightenDarkenColor } from "../utils/colors"; 7 | import { transform } from "../utils/animation"; 8 | import { FULL_ANGLE } from "../utils/constants"; 9 | 10 | export default class PieChart extends AggregationChart { 11 | constructor(parent, args) { 12 | super(parent, args); 13 | this.type = "pie"; 14 | this.initTimeout = 0; 15 | this.init = 1; 16 | 17 | this.setup(); 18 | } 19 | 20 | configure(args) { 21 | super.configure(args); 22 | this.mouseMove = this.mouseMove.bind(this); 23 | this.mouseLeave = this.mouseLeave.bind(this); 24 | 25 | this.hoverRadio = args.hoverRadio || 0.1; 26 | this.config.startAngle = args.startAngle || 0; 27 | 28 | this.clockWise = args.clockWise || false; 29 | } 30 | 31 | calc() { 32 | super.calc(); 33 | let s = this.state; 34 | this.radius = this.height > this.width ? this.center.x : this.center.y; 35 | 36 | const { radius, clockWise } = this; 37 | 38 | const prevSlicesProperties = s.slicesProperties || []; 39 | s.sliceStrings = []; 40 | s.slicesProperties = []; 41 | let curAngle = 180 - this.config.startAngle; 42 | 43 | s.sliceTotals.map((total, i) => { 44 | const startAngle = curAngle; 45 | const originDiffAngle = (total / s.grandTotal) * FULL_ANGLE; 46 | const largeArc = originDiffAngle > 180 ? 1 : 0; 47 | const diffAngle = clockWise ? -originDiffAngle : originDiffAngle; 48 | const endAngle = (curAngle = curAngle + diffAngle); 49 | const startPosition = getPositionByAngle(startAngle, radius); 50 | const endPosition = getPositionByAngle(endAngle, radius); 51 | 52 | const prevProperty = this.init && prevSlicesProperties[i]; 53 | 54 | let curStart, curEnd; 55 | if (this.init) { 56 | curStart = prevProperty ? prevProperty.startPosition : startPosition; 57 | curEnd = prevProperty ? prevProperty.endPosition : startPosition; 58 | } else { 59 | curStart = startPosition; 60 | curEnd = endPosition; 61 | } 62 | const curPath = 63 | originDiffAngle === 360 64 | ? makeCircleStr( 65 | curStart, 66 | curEnd, 67 | this.center, 68 | this.radius, 69 | clockWise, 70 | largeArc 71 | ) 72 | : makeArcPathStr( 73 | curStart, 74 | curEnd, 75 | this.center, 76 | this.radius, 77 | clockWise, 78 | largeArc 79 | ); 80 | 81 | s.sliceStrings.push(curPath); 82 | s.slicesProperties.push({ 83 | startPosition, 84 | endPosition, 85 | value: total, 86 | total: s.grandTotal, 87 | startAngle, 88 | endAngle, 89 | angle: diffAngle, 90 | }); 91 | }); 92 | this.init = 0; 93 | } 94 | 95 | setupComponents() { 96 | let s = this.state; 97 | 98 | let componentConfigs = [ 99 | [ 100 | "pieSlices", 101 | {}, 102 | function () { 103 | return { 104 | sliceStrings: s.sliceStrings, 105 | colors: this.colors, 106 | }; 107 | }.bind(this), 108 | ], 109 | ]; 110 | 111 | this.components = new Map( 112 | componentConfigs.map((args) => { 113 | let component = getComponent(...args); 114 | return [args[0], component]; 115 | }) 116 | ); 117 | } 118 | 119 | calTranslateByAngle(property) { 120 | const { radius, hoverRadio } = this; 121 | const position = getPositionByAngle( 122 | property.startAngle + property.angle / 2, 123 | radius 124 | ); 125 | return `translate3d(${position.x * hoverRadio}px,${ 126 | position.y * hoverRadio 127 | }px,0)`; 128 | } 129 | 130 | hoverSlice(path, i, flag, e) { 131 | if (!path) return; 132 | const color = this.colors[i]; 133 | if (flag) { 134 | transform(path, this.calTranslateByAngle(this.state.slicesProperties[i])); 135 | path.style.fill = lightenDarkenColor(color, 50); 136 | let g_off = getOffset(this.svg); 137 | let x = e.pageX - g_off.left + 10; 138 | let y = e.pageY - g_off.top - 10; 139 | let title = 140 | (this.formatted_labels && this.formatted_labels.length > 0 141 | ? this.formatted_labels[i] 142 | : this.state.labels[i]) + ": "; 143 | let percent = ( 144 | (this.state.sliceTotals[i] * 100) / 145 | this.state.grandTotal 146 | ).toFixed(1); 147 | this.tip.setValues(x, y, { name: title, value: percent + "%" }); 148 | this.tip.showTip(); 149 | } else { 150 | transform(path, "translate3d(0,0,0)"); 151 | this.tip.hideTip(); 152 | path.style.fill = color; 153 | } 154 | } 155 | 156 | bindTooltip() { 157 | this.container.addEventListener("mousemove", this.mouseMove); 158 | this.container.addEventListener("mouseleave", this.mouseLeave); 159 | } 160 | getDataPoint(index = this.state.currentIndex) { 161 | let s = this.state; 162 | let data_point = { 163 | index: index, 164 | label: s.labels[index], 165 | values: s.sliceTotals[index], 166 | }; 167 | return data_point; 168 | } 169 | setCurrentDataPoint(index) { 170 | let s = this.state; 171 | index = parseInt(index); 172 | if (index < 0) index = 0; 173 | if (index >= s.labels.length) index = s.labels.length - 1; 174 | if (index === s.currentIndex) return; 175 | s.currentIndex = index; 176 | fire(this.parent, "data-select", this.getDataPoint()); 177 | } 178 | 179 | bindUnits() { 180 | const units = this.components.get("pieSlices").store; 181 | if (!units) return; 182 | units.forEach((unit, index) => { 183 | unit.addEventListener("click", () => { 184 | this.setCurrentDataPoint(index); 185 | }); 186 | }); 187 | } 188 | mouseMove(e) { 189 | const target = e.target; 190 | let slices = this.components.get("pieSlices").store; 191 | let prevIndex = this.curActiveSliceIndex; 192 | let prevAcitve = this.curActiveSlice; 193 | if (slices.includes(target)) { 194 | let i = slices.indexOf(target); 195 | this.hoverSlice(prevAcitve, prevIndex, false); 196 | this.curActiveSlice = target; 197 | this.curActiveSliceIndex = i; 198 | this.hoverSlice(target, i, true, e); 199 | } else { 200 | this.mouseLeave(); 201 | } 202 | } 203 | 204 | mouseLeave() { 205 | this.hoverSlice(this.curActiveSlice, this.curActiveSliceIndex, false); 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/js/index.js: -------------------------------------------------------------------------------- 1 | import * as Charts from "./chart"; 2 | 3 | let frappe = {}; 4 | 5 | frappe.NAME = "Frappe Charts"; 6 | frappe.VERSION = "1.6.2"; 7 | 8 | frappe = Object.assign({}, frappe, Charts); 9 | 10 | export default frappe; 11 | -------------------------------------------------------------------------------- /src/js/objects/ChartComponents.js: -------------------------------------------------------------------------------- 1 | import { makeSVGGroup } from "../utils/draw"; 2 | import { 3 | makeText, 4 | makePath, 5 | xLine, 6 | yLine, 7 | generateAxisLabel, 8 | yMarker, 9 | yRegion, 10 | datasetBar, 11 | datasetDot, 12 | percentageBar, 13 | getPaths, 14 | heatSquare, 15 | } from "../utils/draw"; 16 | import { equilizeNoOfElements } from "../utils/draw-utils"; 17 | import { 18 | translateHoriLine, 19 | translateVertLine, 20 | animateRegion, 21 | animateBar, 22 | animateDot, 23 | animatePath, 24 | animatePathStr, 25 | } from "../utils/animate"; 26 | import { getMonthName } from "../utils/date-utils"; 27 | 28 | class ChartComponent { 29 | constructor({ 30 | layerClass = "", 31 | layerTransform = "", 32 | constants, 33 | 34 | getData, 35 | makeElements, 36 | animateElements, 37 | }) { 38 | this.layerTransform = layerTransform; 39 | this.constants = constants; 40 | 41 | this.makeElements = makeElements; 42 | this.getData = getData; 43 | 44 | this.animateElements = animateElements; 45 | 46 | this.store = []; 47 | this.labels = []; 48 | 49 | this.layerClass = layerClass; 50 | this.layerClass = 51 | typeof this.layerClass === "function" 52 | ? this.layerClass() 53 | : this.layerClass; 54 | 55 | this.refresh(); 56 | } 57 | 58 | refresh(data) { 59 | this.data = data || this.getData(); 60 | } 61 | 62 | setup(parent) { 63 | this.layer = makeSVGGroup(this.layerClass, this.layerTransform, parent); 64 | } 65 | 66 | make() { 67 | this.render(this.data); 68 | this.oldData = this.data; 69 | } 70 | 71 | render(data) { 72 | this.store = this.makeElements(data); 73 | 74 | this.layer.textContent = ""; 75 | this.store.forEach((element) => { 76 | element.length 77 | ? element.forEach((el) => { 78 | this.layer.appendChild(el); 79 | }) 80 | : this.layer.appendChild(element); 81 | }); 82 | this.labels.forEach((element) => { 83 | this.layer.appendChild(element); 84 | }); 85 | } 86 | 87 | update(animate = true) { 88 | this.refresh(); 89 | let animateElements = []; 90 | if (animate) { 91 | animateElements = this.animateElements(this.data) || []; 92 | } 93 | return animateElements; 94 | } 95 | } 96 | 97 | let componentConfigs = { 98 | donutSlices: { 99 | layerClass: "donut-slices", 100 | makeElements(data) { 101 | return data.sliceStrings.map((s, i) => { 102 | let slice = makePath( 103 | s, 104 | "donut-path", 105 | data.colors[i], 106 | "none", 107 | data.strokeWidth 108 | ); 109 | slice.style.transition = "transform .3s;"; 110 | return slice; 111 | }); 112 | }, 113 | 114 | animateElements(newData) { 115 | return this.store.map((slice, i) => 116 | animatePathStr(slice, newData.sliceStrings[i]) 117 | ); 118 | }, 119 | }, 120 | pieSlices: { 121 | layerClass: "pie-slices", 122 | makeElements(data) { 123 | return data.sliceStrings.map((s, i) => { 124 | let slice = makePath(s, "pie-path", "none", data.colors[i]); 125 | slice.style.transition = "transform .3s;"; 126 | return slice; 127 | }); 128 | }, 129 | 130 | animateElements(newData) { 131 | return this.store.map((slice, i) => 132 | animatePathStr(slice, newData.sliceStrings[i]) 133 | ); 134 | }, 135 | }, 136 | percentageBars: { 137 | layerClass: "percentage-bars", 138 | makeElements(data) { 139 | const numberOfPoints = data.xPositions.length; 140 | return data.xPositions.map((x, i) => { 141 | let y = 0; 142 | 143 | let isLast = i == numberOfPoints - 1; 144 | let isFirst = i == 0; 145 | 146 | let bar = percentageBar( 147 | x, 148 | y, 149 | data.widths[i], 150 | this.constants.barHeight, 151 | isFirst, 152 | isLast, 153 | data.colors[i] 154 | ); 155 | return bar; 156 | }); 157 | }, 158 | 159 | animateElements(newData) { 160 | if (newData) return []; 161 | }, 162 | }, 163 | yAxis: { 164 | layerClass: "y axis", 165 | makeElements(data) { 166 | let elements = []; 167 | // will loop through each yaxis dataset if it exists 168 | if (data.length) { 169 | data.forEach((item, i) => { 170 | item.positions.map((position, i) => { 171 | elements.push( 172 | yLine( 173 | position, 174 | item.labels[i], 175 | this.constants.width, 176 | { 177 | mode: this.constants.mode, 178 | pos: item.pos || this.constants.pos, 179 | shortenNumbers: 180 | this.constants.shortenNumbers, 181 | title: item.title, 182 | } 183 | ) 184 | ); 185 | }); 186 | // we need to make yAxis titles if they are defined 187 | if (item.title) { 188 | elements.push( 189 | generateAxisLabel({ 190 | title: item.title, 191 | position: item.pos, 192 | height: this.constants.height || data.zeroLine, 193 | width: this.constants.width, 194 | }) 195 | ); 196 | } 197 | }); 198 | 199 | return elements; 200 | } 201 | 202 | data.positions.forEach((position, i) => { 203 | elements.push( 204 | yLine(position, data.labels[i], this.constants.width, { 205 | mode: this.constants.mode, 206 | pos: data.pos || this.constants.pos, 207 | shortenNumbers: this.constants.shortenNumbers, 208 | }) 209 | ); 210 | }); 211 | 212 | if (data.title) { 213 | elements.push( 214 | generateAxisLabel({ 215 | title: data.title, 216 | position: data.pos, 217 | height: this.constants.height || data.zeroLine, 218 | width: this.constants.width, 219 | }) 220 | ); 221 | } 222 | 223 | return elements; 224 | }, 225 | 226 | animateElements(newData) { 227 | const animateMultipleElements = (oldData, newData) => { 228 | let newPos = newData.positions; 229 | let newLabels = newData.labels; 230 | let oldPos = oldData.positions; 231 | let oldLabels = oldData.labels; 232 | 233 | [oldPos, newPos] = equilizeNoOfElements(oldPos, newPos); 234 | [oldLabels, newLabels] = equilizeNoOfElements( 235 | oldLabels, 236 | newLabels 237 | ); 238 | 239 | this.render({ 240 | positions: oldPos, 241 | labels: newLabels, 242 | }); 243 | 244 | return this.store.map((line, i) => { 245 | return translateHoriLine(line, newPos[i], oldPos[i]); 246 | }); 247 | }; 248 | 249 | // we will need to animate both axis if we have more than one. 250 | // so check if the oldData is an array of values. 251 | if (this.oldData instanceof Array) { 252 | return this.oldData.forEach((old, i) => { 253 | animateMultipleElements(old, newData[i]); 254 | }); 255 | } 256 | 257 | let newPos = newData.positions; 258 | let newLabels = newData.labels; 259 | let oldPos = this.oldData.positions; 260 | let oldLabels = this.oldData.labels; 261 | 262 | [oldPos, newPos] = equilizeNoOfElements(oldPos, newPos); 263 | [oldLabels, newLabels] = equilizeNoOfElements(oldLabels, newLabels); 264 | 265 | this.render({ 266 | positions: oldPos, 267 | labels: newLabels, 268 | }); 269 | 270 | return this.store.map((line, i) => { 271 | return translateHoriLine(line, newPos[i], oldPos[i]); 272 | }); 273 | }, 274 | }, 275 | 276 | xAxis: { 277 | layerClass: "x axis", 278 | makeElements(data) { 279 | return data.positions.map((position, i) => 280 | xLine(position, data.calcLabels[i], this.constants.height, { 281 | mode: this.constants.mode, 282 | pos: this.constants.pos, 283 | }) 284 | ); 285 | }, 286 | 287 | animateElements(newData) { 288 | let newPos = newData.positions; 289 | let newLabels = newData.calcLabels; 290 | let oldPos = this.oldData.positions; 291 | let oldLabels = this.oldData.calcLabels; 292 | 293 | [oldPos, newPos] = equilizeNoOfElements(oldPos, newPos); 294 | [oldLabels, newLabels] = equilizeNoOfElements(oldLabels, newLabels); 295 | 296 | this.render({ 297 | positions: oldPos, 298 | calcLabels: newLabels, 299 | }); 300 | 301 | return this.store.map((line, i) => { 302 | return translateVertLine(line, newPos[i], oldPos[i]); 303 | }); 304 | }, 305 | }, 306 | 307 | yMarkers: { 308 | layerClass: "y-markers", 309 | makeElements(data) { 310 | return data.map((m) => 311 | yMarker(m.position, m.label, this.constants.width, { 312 | labelPos: m.options.labelPos, 313 | stroke: m.options.stroke, 314 | mode: "span", 315 | lineType: m.options.lineType, 316 | }) 317 | ); 318 | }, 319 | animateElements(newData) { 320 | [this.oldData, newData] = equilizeNoOfElements( 321 | this.oldData, 322 | newData 323 | ); 324 | 325 | let newPos = newData.map((d) => d.position); 326 | let newLabels = newData.map((d) => d.label); 327 | let newOptions = newData.map((d) => d.options); 328 | 329 | let oldPos = this.oldData.map((d) => d.position); 330 | 331 | this.render( 332 | oldPos.map((pos, i) => { 333 | return { 334 | position: oldPos[i], 335 | label: newLabels[i], 336 | options: newOptions[i], 337 | }; 338 | }) 339 | ); 340 | 341 | return this.store.map((line, i) => { 342 | return translateHoriLine(line, newPos[i], oldPos[i]); 343 | }); 344 | }, 345 | }, 346 | 347 | yRegions: { 348 | layerClass: "y-regions", 349 | makeElements(data) { 350 | return data.map((r) => 351 | yRegion(r.startPos, r.endPos, this.constants.width, r.label, { 352 | labelPos: r.options.labelPos, 353 | }) 354 | ); 355 | }, 356 | animateElements(newData) { 357 | [this.oldData, newData] = equilizeNoOfElements( 358 | this.oldData, 359 | newData 360 | ); 361 | 362 | let newPos = newData.map((d) => d.endPos); 363 | let newLabels = newData.map((d) => d.label); 364 | let newStarts = newData.map((d) => d.startPos); 365 | let newOptions = newData.map((d) => d.options); 366 | 367 | let oldPos = this.oldData.map((d) => d.endPos); 368 | let oldStarts = this.oldData.map((d) => d.startPos); 369 | 370 | this.render( 371 | oldPos.map((pos, i) => { 372 | return { 373 | startPos: oldStarts[i], 374 | endPos: oldPos[i], 375 | label: newLabels[i], 376 | options: newOptions[i], 377 | }; 378 | }) 379 | ); 380 | 381 | let animateElements = []; 382 | 383 | this.store.map((rectGroup, i) => { 384 | animateElements = animateElements.concat( 385 | animateRegion(rectGroup, newStarts[i], newPos[i], oldPos[i]) 386 | ); 387 | }); 388 | 389 | return animateElements; 390 | }, 391 | }, 392 | 393 | heatDomain: { 394 | layerClass: function () { 395 | return "heat-domain domain-" + this.constants.index; 396 | }, 397 | makeElements(data) { 398 | let { index, colWidth, rowHeight, squareSize, radius, xTranslate } = 399 | this.constants; 400 | let monthNameHeight = -12; 401 | let x = xTranslate, 402 | y = 0; 403 | 404 | this.serializedSubDomains = []; 405 | 406 | data.cols.map((week, weekNo) => { 407 | if (weekNo === 1) { 408 | this.labels.push( 409 | makeText( 410 | "domain-name", 411 | x, 412 | monthNameHeight, 413 | getMonthName(index, true).toUpperCase(), 414 | { 415 | fontSize: 9, 416 | } 417 | ) 418 | ); 419 | } 420 | week.map((day, i) => { 421 | if (day.fill) { 422 | let data = { 423 | "data-date": day.yyyyMmDd, 424 | "data-value": day.dataValue, 425 | "data-day": i, 426 | }; 427 | let square = heatSquare( 428 | "day", 429 | x, 430 | y, 431 | squareSize, 432 | radius, 433 | day.fill, 434 | data 435 | ); 436 | this.serializedSubDomains.push(square); 437 | } 438 | y += rowHeight; 439 | }); 440 | y = 0; 441 | x += colWidth; 442 | }); 443 | 444 | return this.serializedSubDomains; 445 | }, 446 | 447 | animateElements(newData) { 448 | if (newData) return []; 449 | }, 450 | }, 451 | 452 | barGraph: { 453 | layerClass: function () { 454 | return "dataset-units dataset-bars dataset-" + this.constants.index; 455 | }, 456 | makeElements(data) { 457 | let c = this.constants; 458 | this.unitType = "bar"; 459 | this.units = data.yPositions.map((y, j) => { 460 | return datasetBar( 461 | data.xPositions[j], 462 | y, 463 | data.barWidth, 464 | c.color, 465 | data.labels[j], 466 | j, 467 | data.offsets[j], 468 | { 469 | zeroLine: data.zeroLine, 470 | barsWidth: data.barsWidth, 471 | minHeight: c.minHeight, 472 | } 473 | ); 474 | }); 475 | return this.units; 476 | }, 477 | animateElements(newData) { 478 | let newXPos = newData.xPositions; 479 | let newYPos = newData.yPositions; 480 | let newOffsets = newData.offsets; 481 | let newLabels = newData.labels; 482 | 483 | let oldXPos = this.oldData.xPositions; 484 | let oldYPos = this.oldData.yPositions; 485 | let oldOffsets = this.oldData.offsets; 486 | let oldLabels = this.oldData.labels; 487 | 488 | [oldXPos, newXPos] = equilizeNoOfElements(oldXPos, newXPos); 489 | [oldYPos, newYPos] = equilizeNoOfElements(oldYPos, newYPos); 490 | [oldOffsets, newOffsets] = equilizeNoOfElements( 491 | oldOffsets, 492 | newOffsets 493 | ); 494 | [oldLabels, newLabels] = equilizeNoOfElements(oldLabels, newLabels); 495 | 496 | this.render({ 497 | xPositions: oldXPos, 498 | yPositions: oldYPos, 499 | offsets: oldOffsets, 500 | labels: newLabels, 501 | 502 | zeroLine: this.oldData.zeroLine, 503 | barsWidth: this.oldData.barsWidth, 504 | barWidth: this.oldData.barWidth, 505 | }); 506 | 507 | let animateElements = []; 508 | 509 | this.store.map((bar, i) => { 510 | animateElements = animateElements.concat( 511 | animateBar( 512 | bar, 513 | newXPos[i], 514 | newYPos[i], 515 | newData.barWidth, 516 | newOffsets[i], 517 | { zeroLine: newData.zeroLine } 518 | ) 519 | ); 520 | }); 521 | 522 | return animateElements; 523 | }, 524 | }, 525 | 526 | lineGraph: { 527 | layerClass: function () { 528 | return "dataset-units dataset-line dataset-" + this.constants.index; 529 | }, 530 | makeElements(data) { 531 | let c = this.constants; 532 | this.unitType = "dot"; 533 | this.paths = {}; 534 | if (!c.hideLine) { 535 | this.paths = getPaths( 536 | data.xPositions, 537 | data.yPositions, 538 | c.color, 539 | { 540 | heatline: c.heatline, 541 | regionFill: c.regionFill, 542 | spline: c.spline, 543 | }, 544 | { 545 | svgDefs: c.svgDefs, 546 | zeroLine: data.zeroLine, 547 | } 548 | ); 549 | } 550 | 551 | this.units = []; 552 | if (!c.hideDots) { 553 | this.units = data.yPositions.map((y, j) => { 554 | return datasetDot( 555 | data.xPositions[j], 556 | y, 557 | data.radius, 558 | c.color, 559 | c.valuesOverPoints ? data.values[j] : "", 560 | j 561 | ); 562 | }); 563 | } 564 | 565 | return Object.values(this.paths).concat(this.units); 566 | }, 567 | animateElements(newData) { 568 | let newXPos = newData.xPositions; 569 | let newYPos = newData.yPositions; 570 | let newValues = newData.values; 571 | 572 | let oldXPos = this.oldData.xPositions; 573 | let oldYPos = this.oldData.yPositions; 574 | let oldValues = this.oldData.values; 575 | 576 | [oldXPos, newXPos] = equilizeNoOfElements(oldXPos, newXPos); 577 | [oldYPos, newYPos] = equilizeNoOfElements(oldYPos, newYPos); 578 | [oldValues, newValues] = equilizeNoOfElements(oldValues, newValues); 579 | 580 | this.render({ 581 | xPositions: oldXPos, 582 | yPositions: oldYPos, 583 | values: newValues, 584 | 585 | zeroLine: this.oldData.zeroLine, 586 | radius: this.oldData.radius, 587 | }); 588 | 589 | let animateElements = []; 590 | 591 | if (Object.keys(this.paths).length) { 592 | animateElements = animateElements.concat( 593 | animatePath( 594 | this.paths, 595 | newXPos, 596 | newYPos, 597 | newData.zeroLine, 598 | this.constants.spline 599 | ) 600 | ); 601 | } 602 | 603 | if (this.units.length) { 604 | this.units.map((dot, i) => { 605 | animateElements = animateElements.concat( 606 | animateDot(dot, newXPos[i], newYPos[i]) 607 | ); 608 | }); 609 | } 610 | 611 | return animateElements; 612 | }, 613 | }, 614 | }; 615 | 616 | export function getComponent(name, constants, getData) { 617 | let keys = Object.keys(componentConfigs).filter((k) => name.includes(k)); 618 | let config = componentConfigs[keys[0]]; 619 | Object.assign(config, { 620 | constants: constants, 621 | getData: getData, 622 | }); 623 | return new ChartComponent(config); 624 | } 625 | -------------------------------------------------------------------------------- /src/js/objects/SvgTip.js: -------------------------------------------------------------------------------- 1 | import { $ } from "../utils/dom"; 2 | import { TOOLTIP_POINTER_TRIANGLE_HEIGHT } from "../utils/constants"; 3 | 4 | export default class SvgTip { 5 | constructor({ parent = null, colors = [] }) { 6 | this.parent = parent; 7 | this.colors = colors; 8 | this.titleName = ""; 9 | this.titleValue = ""; 10 | this.listValues = []; 11 | this.titleValueFirst = 0; 12 | 13 | this.x = 0; 14 | this.y = 0; 15 | 16 | this.top = 0; 17 | this.left = 0; 18 | 19 | this.setup(); 20 | } 21 | 22 | setup() { 23 | this.makeTooltip(); 24 | } 25 | 26 | refresh() { 27 | this.fill(); 28 | this.calcPosition(); 29 | } 30 | 31 | makeTooltip() { 32 | this.container = $.create("div", { 33 | inside: this.parent, 34 | className: "graph-svg-tip comparison", 35 | innerHTML: ` 36 |
    37 |
    `, 38 | }); 39 | this.hideTip(); 40 | 41 | this.title = this.container.querySelector(".title"); 42 | this.list = this.container.querySelector(".data-point-list"); 43 | this.dataPointList = this.container.querySelector(".data-point-list"); 44 | 45 | this.parent.addEventListener("mouseleave", () => { 46 | this.hideTip(); 47 | }); 48 | } 49 | 50 | fill() { 51 | let title; 52 | if (this.index) { 53 | this.container.setAttribute("data-point-index", this.index); 54 | } 55 | if (this.titleValueFirst) { 56 | title = `${this.titleValue}${this.titleName}`; 57 | } else { 58 | title = `${this.titleName}${this.titleValue}`; 59 | } 60 | 61 | if (this.listValues.length > 4) { 62 | this.list.classList.add("tooltip-grid"); 63 | } else { 64 | this.list.classList.remove("tooltip-grid"); 65 | } 66 | 67 | this.title.innerHTML = title; 68 | this.dataPointList.innerHTML = ""; 69 | 70 | this.listValues.map((set, i) => { 71 | const color = this.colors[i] || "black"; 72 | let value = 73 | set.formatted === 0 || set.formatted ? set.formatted : set.value; 74 | let li = $.create("li", { 75 | innerHTML: `
    76 |
    77 |
    ${value === 0 || value ? value : ""}
    78 |
    ${set.title ? set.title : ""}
    79 |
    `, 80 | }); 81 | 82 | this.dataPointList.appendChild(li); 83 | }); 84 | } 85 | 86 | calcPosition() { 87 | let width = this.container.offsetWidth; 88 | 89 | this.top = 90 | this.y - this.container.offsetHeight - TOOLTIP_POINTER_TRIANGLE_HEIGHT; 91 | this.left = this.x - width / 2; 92 | let maxLeft = this.parent.offsetWidth - width; 93 | 94 | let pointer = this.container.querySelector(".svg-pointer"); 95 | 96 | if (this.left < 0) { 97 | pointer.style.left = `calc(50% - ${-1 * this.left}px)`; 98 | this.left = 0; 99 | } else if (this.left > maxLeft) { 100 | let delta = this.left - maxLeft; 101 | let pointerOffset = `calc(50% + ${delta}px)`; 102 | pointer.style.left = pointerOffset; 103 | 104 | this.left = maxLeft; 105 | } else { 106 | pointer.style.left = `50%`; 107 | } 108 | } 109 | 110 | setValues(x, y, title = {}, listValues = [], index = -1) { 111 | this.titleName = title.name; 112 | this.titleValue = title.value; 113 | this.listValues = listValues; 114 | this.x = x; 115 | this.y = y; 116 | this.titleValueFirst = title.valueFirst || 0; 117 | this.index = index; 118 | this.refresh(); 119 | } 120 | 121 | hideTip() { 122 | this.container.style.top = "0px"; 123 | this.container.style.left = "0px"; 124 | this.container.style.opacity = "0"; 125 | } 126 | 127 | showTip() { 128 | this.container.style.top = this.top + "px"; 129 | this.container.style.left = this.left + "px"; 130 | this.container.style.opacity = "1"; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/js/utils/animate.js: -------------------------------------------------------------------------------- 1 | import { getBarHeightAndYAttr, getSplineCurvePointsStr } from "./draw-utils"; 2 | 3 | export const UNIT_ANIM_DUR = 350; 4 | export const PATH_ANIM_DUR = 350; 5 | export const MARKER_LINE_ANIM_DUR = UNIT_ANIM_DUR; 6 | export const REPLACE_ALL_NEW_DUR = 250; 7 | 8 | export const STD_EASING = "easein"; 9 | 10 | export function translate(unit, oldCoord, newCoord, duration) { 11 | let old = typeof oldCoord === "string" ? oldCoord : oldCoord.join(", "); 12 | return [ 13 | unit, 14 | { transform: newCoord.join(", ") }, 15 | duration, 16 | STD_EASING, 17 | "translate", 18 | { transform: old }, 19 | ]; 20 | } 21 | 22 | export function translateVertLine(xLine, newX, oldX) { 23 | return translate(xLine, [oldX, 0], [newX, 0], MARKER_LINE_ANIM_DUR); 24 | } 25 | 26 | export function translateHoriLine(yLine, newY, oldY) { 27 | return translate(yLine, [0, oldY], [0, newY], MARKER_LINE_ANIM_DUR); 28 | } 29 | 30 | export function animateRegion(rectGroup, newY1, newY2, oldY2) { 31 | let newHeight = newY1 - newY2; 32 | let rect = rectGroup.childNodes[0]; 33 | let width = rect.getAttribute("width"); 34 | let rectAnim = [ 35 | rect, 36 | { height: newHeight, "stroke-dasharray": `${width}, ${newHeight}` }, 37 | MARKER_LINE_ANIM_DUR, 38 | STD_EASING, 39 | ]; 40 | 41 | let groupAnim = translate( 42 | rectGroup, 43 | [0, oldY2], 44 | [0, newY2], 45 | MARKER_LINE_ANIM_DUR 46 | ); 47 | return [rectAnim, groupAnim]; 48 | } 49 | 50 | export function animateBar(bar, x, yTop, width, offset = 0, meta = {}) { 51 | let [height, y] = getBarHeightAndYAttr(yTop, meta.zeroLine); 52 | y -= offset; 53 | if (bar.nodeName !== "rect") { 54 | let rect = bar.childNodes[0]; 55 | let rectAnim = [ 56 | rect, 57 | { width: width, height: height }, 58 | UNIT_ANIM_DUR, 59 | STD_EASING, 60 | ]; 61 | 62 | let oldCoordStr = bar.getAttribute("transform").split("(")[1].slice(0, -1); 63 | let groupAnim = translate(bar, oldCoordStr, [x, y], MARKER_LINE_ANIM_DUR); 64 | return [rectAnim, groupAnim]; 65 | } else { 66 | return [ 67 | [ 68 | bar, 69 | { width: width, height: height, x: x, y: y }, 70 | UNIT_ANIM_DUR, 71 | STD_EASING, 72 | ], 73 | ]; 74 | } 75 | // bar.animate({height: args.newHeight, y: yTop}, UNIT_ANIM_DUR, mina.easein); 76 | } 77 | 78 | export function animateDot(dot, x, y) { 79 | if (dot.nodeName !== "circle") { 80 | let oldCoordStr = dot.getAttribute("transform").split("(")[1].slice(0, -1); 81 | let groupAnim = translate(dot, oldCoordStr, [x, y], MARKER_LINE_ANIM_DUR); 82 | return [groupAnim]; 83 | } else { 84 | return [[dot, { cx: x, cy: y }, UNIT_ANIM_DUR, STD_EASING]]; 85 | } 86 | // dot.animate({cy: yTop}, UNIT_ANIM_DUR, mina.easein); 87 | } 88 | 89 | export function animatePath(paths, newXList, newYList, zeroLine, spline) { 90 | let pathComponents = []; 91 | let pointsStr = newYList.map((y, i) => newXList[i] + "," + y).join("L"); 92 | 93 | if (spline) pointsStr = getSplineCurvePointsStr(newXList, newYList); 94 | 95 | const animPath = [ 96 | paths.path, 97 | { d: "M" + pointsStr }, 98 | PATH_ANIM_DUR, 99 | STD_EASING, 100 | ]; 101 | pathComponents.push(animPath); 102 | 103 | if (paths.region) { 104 | let regStartPt = `${newXList[0]},${zeroLine}L`; 105 | let regEndPt = `L${newXList.slice(-1)[0]}, ${zeroLine}`; 106 | 107 | const animRegion = [ 108 | paths.region, 109 | { d: "M" + regStartPt + pointsStr + regEndPt }, 110 | PATH_ANIM_DUR, 111 | STD_EASING, 112 | ]; 113 | pathComponents.push(animRegion); 114 | } 115 | 116 | return pathComponents; 117 | } 118 | 119 | export function animatePathStr(oldPath, pathStr) { 120 | return [oldPath, { d: pathStr }, UNIT_ANIM_DUR, STD_EASING]; 121 | } 122 | -------------------------------------------------------------------------------- /src/js/utils/animation.js: -------------------------------------------------------------------------------- 1 | // Leveraging SMIL Animations 2 | 3 | import { REPLACE_ALL_NEW_DUR } from "./animate"; 4 | 5 | const EASING = { 6 | ease: "0.25 0.1 0.25 1", 7 | linear: "0 0 1 1", 8 | // easein: "0.42 0 1 1", 9 | easein: "0.1 0.8 0.2 1", 10 | easeout: "0 0 0.58 1", 11 | easeinout: "0.42 0 0.58 1", 12 | }; 13 | 14 | function animateSVGElement( 15 | element, 16 | props, 17 | dur, 18 | easingType = "linear", 19 | type = undefined, 20 | oldValues = {} 21 | ) { 22 | let animElement = element.cloneNode(true); 23 | let newElement = element.cloneNode(true); 24 | 25 | for (var attributeName in props) { 26 | let animateElement; 27 | if (attributeName === "transform") { 28 | animateElement = document.createElementNS( 29 | "http://www.w3.org/2000/svg", 30 | "animateTransform" 31 | ); 32 | } else { 33 | animateElement = document.createElementNS( 34 | "http://www.w3.org/2000/svg", 35 | "animate" 36 | ); 37 | } 38 | let currentValue = 39 | oldValues[attributeName] || element.getAttribute(attributeName); 40 | let value = props[attributeName]; 41 | 42 | let animAttr = { 43 | attributeName: attributeName, 44 | from: currentValue, 45 | to: value, 46 | begin: "0s", 47 | dur: dur / 1000 + "s", 48 | values: currentValue + ";" + value, 49 | keySplines: EASING[easingType], 50 | keyTimes: "0;1", 51 | calcMode: "spline", 52 | fill: "freeze", 53 | }; 54 | 55 | if (type) { 56 | animAttr["type"] = type; 57 | } 58 | 59 | for (var i in animAttr) { 60 | animateElement.setAttribute(i, animAttr[i]); 61 | } 62 | 63 | animElement.appendChild(animateElement); 64 | 65 | if (type) { 66 | newElement.setAttribute(attributeName, `translate(${value})`); 67 | } else { 68 | newElement.setAttribute(attributeName, value); 69 | } 70 | } 71 | 72 | return [animElement, newElement]; 73 | } 74 | 75 | export function transform(element, style) { 76 | // eslint-disable-line no-unused-vars 77 | element.style.transform = style; 78 | element.style.webkitTransform = style; 79 | element.style.msTransform = style; 80 | element.style.mozTransform = style; 81 | element.style.oTransform = style; 82 | } 83 | 84 | function animateSVG(svgContainer, elements) { 85 | let newElements = []; 86 | let animElements = []; 87 | 88 | elements.map((element) => { 89 | let unit = element[0]; 90 | let parent = unit.parentNode; 91 | 92 | let animElement, newElement; 93 | 94 | element[0] = unit; 95 | [animElement, newElement] = animateSVGElement(...element); 96 | 97 | newElements.push(newElement); 98 | animElements.push([animElement, parent]); 99 | 100 | if (parent) { 101 | parent.replaceChild(animElement, unit); 102 | } 103 | }); 104 | 105 | let animSvg = svgContainer.cloneNode(true); 106 | 107 | animElements.map((animElement, i) => { 108 | if (animElement[1]) { 109 | animElement[1].replaceChild(newElements[i], animElement[0]); 110 | elements[i][0] = newElements[i]; 111 | } 112 | }); 113 | 114 | return animSvg; 115 | } 116 | 117 | export function runSMILAnimation(parent, svgElement, elementsToAnimate) { 118 | if (elementsToAnimate.length === 0) return; 119 | 120 | let animSvgElement = animateSVG(svgElement, elementsToAnimate); 121 | if (svgElement.parentNode == parent) { 122 | parent.removeChild(svgElement); 123 | parent.appendChild(animSvgElement); 124 | } 125 | 126 | // Replace the new svgElement (data has already been replaced) 127 | setTimeout(() => { 128 | if (animSvgElement.parentNode == parent) { 129 | parent.removeChild(animSvgElement); 130 | parent.appendChild(svgElement); 131 | } 132 | }, REPLACE_ALL_NEW_DUR); 133 | } 134 | -------------------------------------------------------------------------------- /src/js/utils/axis-chart-utils.js: -------------------------------------------------------------------------------- 1 | import { fillArray } from "../utils/helpers"; 2 | import { 3 | DEFAULT_AXIS_CHART_TYPE, 4 | AXIS_DATASET_CHART_TYPES, 5 | DEFAULT_CHAR_WIDTH, 6 | SERIES_LABEL_SPACE_RATIO, 7 | } from "../utils/constants"; 8 | 9 | export function dataPrep(data, type, config) { 10 | data.labels = data.labels || []; 11 | 12 | let datasetLength = data.labels.length; 13 | 14 | // Datasets 15 | let datasets = data.datasets; 16 | let zeroArray = new Array(datasetLength).fill(0); 17 | if (!datasets) { 18 | // default 19 | datasets = [ 20 | { 21 | values: zeroArray, 22 | }, 23 | ]; 24 | } 25 | 26 | datasets.map((d) => { 27 | // Set values 28 | if (!d.values) { 29 | d.values = zeroArray; 30 | } else { 31 | // Check for non values 32 | let vals = d.values; 33 | vals = vals.map((val) => (!isNaN(val) ? val : 0)); 34 | 35 | // Trim or extend 36 | if (vals.length > datasetLength) { 37 | vals = vals.slice(0, datasetLength); 38 | } 39 | if (config) { 40 | vals = fillArray(vals, datasetLength - vals.length, null); 41 | } else { 42 | vals = fillArray(vals, datasetLength - vals.length, 0); 43 | } 44 | d.values = vals; 45 | } 46 | 47 | // Set type 48 | if (!d.chartType) { 49 | if (!AXIS_DATASET_CHART_TYPES.includes(type)) 50 | type = DEFAULT_AXIS_CHART_TYPE; 51 | d.chartType = type; 52 | } 53 | }); 54 | 55 | // Markers 56 | 57 | // Regions 58 | // data.yRegions = data.yRegions || []; 59 | if (data.yRegions) { 60 | data.yRegions.map((d) => { 61 | if (d.end < d.start) { 62 | [d.start, d.end] = [d.end, d.start]; 63 | } 64 | }); 65 | } 66 | 67 | return data; 68 | } 69 | 70 | export function zeroDataPrep(realData) { 71 | let datasetLength = realData.labels.length; 72 | let zeroArray = new Array(datasetLength).fill(0); 73 | 74 | let zeroData = { 75 | labels: realData.labels.slice(0, -1), 76 | datasets: realData.datasets.map((d) => { 77 | const { axisID } = d; 78 | return { 79 | axisID, 80 | name: "", 81 | values: zeroArray.slice(0, -1), 82 | chartType: d.chartType, 83 | }; 84 | }), 85 | }; 86 | 87 | if (realData.yMarkers) { 88 | zeroData.yMarkers = [ 89 | { 90 | value: 0, 91 | label: "", 92 | }, 93 | ]; 94 | } 95 | 96 | if (realData.yRegions) { 97 | zeroData.yRegions = [ 98 | { 99 | start: 0, 100 | end: 0, 101 | label: "", 102 | }, 103 | ]; 104 | } 105 | 106 | return zeroData; 107 | } 108 | 109 | export function getShortenedLabels(chartWidth, labels = [], isSeries = true) { 110 | let allowedSpace = (chartWidth / labels.length) * SERIES_LABEL_SPACE_RATIO; 111 | if (allowedSpace <= 0) allowedSpace = 1; 112 | let allowedLetters = allowedSpace / DEFAULT_CHAR_WIDTH; 113 | 114 | let seriesMultiple; 115 | if (isSeries) { 116 | // Find the maximum label length for spacing calculations 117 | let maxLabelLength = Math.max(...labels.map((label) => label.length)); 118 | seriesMultiple = Math.ceil(maxLabelLength / allowedLetters); 119 | } 120 | 121 | let calcLabels = labels.map((label, i) => { 122 | label += ""; 123 | if (label.length > allowedLetters) { 124 | if (!isSeries) { 125 | if (allowedLetters - 3 > 0) { 126 | label = label.slice(0, allowedLetters - 3) + " ..."; 127 | } else { 128 | label = label.slice(0, allowedLetters) + ".."; 129 | } 130 | } else { 131 | if (i % seriesMultiple !== 0 && i !== labels.length - 1) { 132 | label = ""; 133 | } 134 | } 135 | } 136 | return label; 137 | }); 138 | 139 | return calcLabels; 140 | } 141 | -------------------------------------------------------------------------------- /src/js/utils/colors.js: -------------------------------------------------------------------------------- 1 | const PRESET_COLOR_MAP = { 2 | pink: "#F683AE", 3 | blue: "#318AD8", 4 | green: "#48BB74", 5 | grey: "#A6B1B9", 6 | red: "#F56B6B", 7 | yellow: "#FACF7A", 8 | purple: "#44427B", 9 | teal: "#5FD8C4", 10 | cyan: "#15CCEF", 11 | orange: "#F8814F", 12 | "light-pink": "#FED7E5", 13 | "light-blue": "#BFDDF7", 14 | "light-green": "#48BB74", 15 | "light-grey": "#F4F5F6", 16 | "light-red": "#F6DFDF", 17 | "light-yellow": "#FEE9BF", 18 | "light-purple": "#E8E8F7", 19 | "light-teal": "#D3FDF6", 20 | "light-cyan": "#DDF8FD", 21 | "light-orange": "#FECDB8", 22 | }; 23 | 24 | function limitColor(r) { 25 | if (r > 255) return 255; 26 | else if (r < 0) return 0; 27 | return r; 28 | } 29 | 30 | export function lightenDarkenColor(color, amt) { 31 | let col = getColor(color); 32 | let usePound = false; 33 | if (col[0] == "#") { 34 | col = col.slice(1); 35 | usePound = true; 36 | } 37 | let num = parseInt(col, 16); 38 | let r = limitColor((num >> 16) + amt); 39 | let b = limitColor(((num >> 8) & 0x00ff) + amt); 40 | let g = limitColor((num & 0x0000ff) + amt); 41 | return (usePound ? "#" : "") + (g | (b << 8) | (r << 16)).toString(16); 42 | } 43 | 44 | export function isValidColor(string) { 45 | // https://stackoverflow.com/a/32685393 46 | let HEX_RE = /(^\s*)(#)((?:[A-Fa-f0-9]{3}){1,2})$/i; 47 | let RGB_RE = 48 | /(^\s*)(rgb|hsl)(a?)[(]\s*([\d.]+\s*%?)\s*,\s*([\d.]+\s*%?)\s*,\s*([\d.]+\s*%?)\s*(?:,\s*([\d.]+)\s*)?[)]$/i; 49 | return HEX_RE.test(string) || RGB_RE.test(string); 50 | } 51 | 52 | export const getColor = (color) => { 53 | // When RGB color, convert to hexadecimal (alpha value is omitted) 54 | if (/rgb[a]{0,1}\([\d, ]+\)/gim.test(color)) { 55 | return /\D+(\d*)\D+(\d*)\D+(\d*)/gim 56 | .exec(color) 57 | .map((x, i) => (i !== 0 ? Number(x).toString(16) : "#")) 58 | .reduce((c, ch) => `${c}${ch}`); 59 | } 60 | return PRESET_COLOR_MAP[color] || color; 61 | }; 62 | -------------------------------------------------------------------------------- /src/js/utils/constants.js: -------------------------------------------------------------------------------- 1 | export const ALL_CHART_TYPES = [ 2 | "line", 3 | "scatter", 4 | "bar", 5 | "percentage", 6 | "heatmap", 7 | "pie", 8 | ]; 9 | 10 | export const COMPATIBLE_CHARTS = { 11 | bar: ["line", "scatter", "percentage", "pie"], 12 | line: ["scatter", "bar", "percentage", "pie"], 13 | pie: ["line", "scatter", "percentage", "bar"], 14 | percentage: ["bar", "line", "scatter", "pie"], 15 | heatmap: [], 16 | }; 17 | 18 | export const DATA_COLOR_DIVISIONS = { 19 | bar: "datasets", 20 | line: "datasets", 21 | pie: "labels", 22 | percentage: "labels", 23 | heatmap: HEATMAP_DISTRIBUTION_SIZE, 24 | }; 25 | 26 | export const BASE_MEASURES = { 27 | margins: { 28 | top: 10, 29 | bottom: 10, 30 | left: 20, 31 | right: 20, 32 | }, 33 | paddings: { 34 | top: 20, 35 | bottom: 40, 36 | left: 30, 37 | right: 10, 38 | }, 39 | 40 | baseHeight: 240, 41 | titleHeight: 20, 42 | legendHeight: 30, 43 | 44 | titleFontSize: 12, 45 | }; 46 | 47 | export function getTopOffset(m) { 48 | return m.titleHeight + m.margins.top + m.paddings.top; 49 | } 50 | 51 | export function getLeftOffset(m) { 52 | return m.margins.left + m.paddings.left; 53 | } 54 | 55 | export function getExtraHeight(m) { 56 | let totalExtraHeight = 57 | m.margins.top + 58 | m.margins.bottom + 59 | m.paddings.top + 60 | m.paddings.bottom + 61 | m.titleHeight + 62 | m.legendHeight; 63 | return totalExtraHeight; 64 | } 65 | 66 | export function getExtraWidth(m) { 67 | let totalExtraWidth = 68 | m.margins.left + m.margins.right + m.paddings.left + m.paddings.right; 69 | 70 | return totalExtraWidth; 71 | } 72 | 73 | export const INIT_CHART_UPDATE_TIMEOUT = 700; 74 | export const CHART_POST_ANIMATE_TIMEOUT = 400; 75 | 76 | export const DEFAULT_AXIS_CHART_TYPE = "line"; 77 | export const AXIS_DATASET_CHART_TYPES = ["line", "bar"]; 78 | 79 | export const LEGEND_ITEM_WIDTH = 150; 80 | export const SERIES_LABEL_SPACE_RATIO = 0.6; 81 | 82 | export const BAR_CHART_SPACE_RATIO = 0.5; 83 | export const MIN_BAR_PERCENT_HEIGHT = 0.0; 84 | 85 | export const LINE_CHART_DOT_SIZE = 4; 86 | export const DOT_OVERLAY_SIZE_INCR = 4; 87 | 88 | export const PERCENTAGE_BAR_DEFAULT_HEIGHT = 16; 89 | 90 | // Fixed 5-color theme, 91 | // More colors are difficult to parse visually 92 | export const HEATMAP_DISTRIBUTION_SIZE = 5; 93 | 94 | export const HEATMAP_SQUARE_SIZE = 10; 95 | export const HEATMAP_GUTTER_SIZE = 2; 96 | 97 | export const DEFAULT_CHAR_WIDTH = 7; 98 | 99 | export const TOOLTIP_POINTER_TRIANGLE_HEIGHT = 7.48; 100 | const DEFAULT_CHART_COLORS = [ 101 | "pink", 102 | "blue", 103 | "green", 104 | "grey", 105 | "red", 106 | "yellow", 107 | "purple", 108 | "teal", 109 | "cyan", 110 | "orange", 111 | ]; 112 | const HEATMAP_COLORS_GREEN = [ 113 | "#ebedf0", 114 | "#c6e48b", 115 | "#7bc96f", 116 | "#239a3b", 117 | "#196127", 118 | ]; 119 | export const HEATMAP_COLORS_BLUE = [ 120 | "#ebedf0", 121 | "#c0ddf9", 122 | "#73b3f3", 123 | "#3886e1", 124 | "#17459e", 125 | ]; 126 | export const HEATMAP_COLORS_YELLOW = [ 127 | "#ebedf0", 128 | "#fdf436", 129 | "#ffc700", 130 | "#ff9100", 131 | "#06001c", 132 | ]; 133 | 134 | export const DEFAULT_COLORS = { 135 | bar: DEFAULT_CHART_COLORS, 136 | line: DEFAULT_CHART_COLORS, 137 | pie: DEFAULT_CHART_COLORS, 138 | percentage: DEFAULT_CHART_COLORS, 139 | heatmap: HEATMAP_COLORS_GREEN, 140 | donut: DEFAULT_CHART_COLORS, 141 | }; 142 | 143 | // Universal constants 144 | export const ANGLE_RATIO = Math.PI / 180; 145 | export const FULL_ANGLE = 360; 146 | -------------------------------------------------------------------------------- /src/js/utils/date-utils.js: -------------------------------------------------------------------------------- 1 | // Playing around with dates 2 | 3 | export const NO_OF_YEAR_MONTHS = 12; 4 | export const NO_OF_DAYS_IN_WEEK = 7; 5 | export const DAYS_IN_YEAR = 375; 6 | export const NO_OF_MILLIS = 1000; 7 | export const SEC_IN_DAY = 86400; 8 | 9 | export const MONTH_NAMES = [ 10 | "January", 11 | "February", 12 | "March", 13 | "April", 14 | "May", 15 | "June", 16 | "July", 17 | "August", 18 | "September", 19 | "October", 20 | "November", 21 | "December", 22 | ]; 23 | export const MONTH_NAMES_SHORT = [ 24 | "Jan", 25 | "Feb", 26 | "Mar", 27 | "Apr", 28 | "May", 29 | "Jun", 30 | "Jul", 31 | "Aug", 32 | "Sep", 33 | "Oct", 34 | "Nov", 35 | "Dec", 36 | ]; 37 | 38 | export const DAY_NAMES_SHORT = [ 39 | "Sun", 40 | "Mon", 41 | "Tue", 42 | "Wed", 43 | "Thu", 44 | "Fri", 45 | "Sat", 46 | ]; 47 | export const DAY_NAMES = [ 48 | "Sunday", 49 | "Monday", 50 | "Tuesday", 51 | "Wednesday", 52 | "Thursday", 53 | "Friday", 54 | "Saturday", 55 | ]; 56 | 57 | // https://stackoverflow.com/a/11252167/6495043 58 | function treatAsUtc(date) { 59 | let result = new Date(date); 60 | result.setMinutes(result.getMinutes() - result.getTimezoneOffset()); 61 | return result; 62 | } 63 | 64 | export function toMidnightUTC(date) { 65 | let result = new Date(date); 66 | result.setUTCHours(0, result.getTimezoneOffset(), 0, 0); 67 | return result; 68 | } 69 | 70 | export function getYyyyMmDd(date) { 71 | let dd = date.getDate(); 72 | let mm = date.getMonth() + 1; // getMonth() is zero-based 73 | return [ 74 | date.getFullYear(), 75 | (mm > 9 ? "" : "0") + mm, 76 | (dd > 9 ? "" : "0") + dd, 77 | ].join("-"); 78 | } 79 | 80 | export function clone(date) { 81 | return new Date(date.getTime()); 82 | } 83 | 84 | export function timestampSec(date) { 85 | return date.getTime() / NO_OF_MILLIS; 86 | } 87 | 88 | export function timestampToMidnight(timestamp, roundAhead = false) { 89 | let midnightTs = Math.floor(timestamp - (timestamp % SEC_IN_DAY)); 90 | if (roundAhead) { 91 | return midnightTs + SEC_IN_DAY; 92 | } 93 | return midnightTs; 94 | } 95 | 96 | // export function getMonthsBetween(startDate, endDate) {} 97 | 98 | export function getWeeksBetween(startDate, endDate) { 99 | let weekStartDate = setDayToSunday(startDate); 100 | return Math.ceil(getDaysBetween(weekStartDate, endDate) / NO_OF_DAYS_IN_WEEK); 101 | } 102 | 103 | export function getDaysBetween(startDate, endDate) { 104 | let millisecondsPerDay = SEC_IN_DAY * NO_OF_MILLIS; 105 | return (treatAsUtc(endDate) - treatAsUtc(startDate)) / millisecondsPerDay; 106 | } 107 | 108 | export function areInSameMonth(startDate, endDate) { 109 | return ( 110 | startDate.getMonth() === endDate.getMonth() && 111 | startDate.getFullYear() === endDate.getFullYear() 112 | ); 113 | } 114 | 115 | export function getMonthName(i, short = false) { 116 | let monthName = MONTH_NAMES[i]; 117 | return short ? monthName.slice(0, 3) : monthName; 118 | } 119 | 120 | export function getLastDateInMonth(month, year) { 121 | return new Date(year, month + 1, 0); // 0: last day in previous month 122 | } 123 | 124 | // mutates 125 | export function setDayToSunday(date) { 126 | let newDate = clone(date); 127 | const day = newDate.getDay(); 128 | if (day !== 0) { 129 | addDays(newDate, -1 * day); 130 | } 131 | return newDate; 132 | } 133 | 134 | // mutates 135 | export function addDays(date, numberOfDays) { 136 | date.setDate(date.getDate() + numberOfDays); 137 | } 138 | -------------------------------------------------------------------------------- /src/js/utils/dom.js: -------------------------------------------------------------------------------- 1 | export function $(expr, con) { 2 | return typeof expr === "string" 3 | ? (con || document).querySelector(expr) 4 | : expr || null; 5 | } 6 | 7 | export function findNodeIndex(node) { 8 | var i = 0; 9 | while (node.previousSibling) { 10 | node = node.previousSibling; 11 | i++; 12 | } 13 | return i; 14 | } 15 | 16 | $.create = (tag, o) => { 17 | var element = document.createElement(tag); 18 | 19 | for (var i in o) { 20 | var val = o[i]; 21 | 22 | if (i === "inside") { 23 | $(val).appendChild(element); 24 | } else if (i === "around") { 25 | var ref = $(val); 26 | ref.parentNode.insertBefore(element, ref); 27 | element.appendChild(ref); 28 | } else if (i === "styles") { 29 | if (typeof val === "object") { 30 | Object.keys(val).map((prop) => { 31 | element.style[prop] = val[prop]; 32 | }); 33 | } 34 | } else if (i in element) { 35 | element[i] = val; 36 | } else { 37 | element.setAttribute(i, val); 38 | } 39 | } 40 | 41 | return element; 42 | }; 43 | 44 | export function getOffset(element) { 45 | let rect = element.getBoundingClientRect(); 46 | return { 47 | // https://stackoverflow.com/a/7436602/6495043 48 | // rect.top varies with scroll, so we add whatever has been 49 | // scrolled to it to get absolute distance from actual page top 50 | top: 51 | rect.top + 52 | (document.documentElement.scrollTop || document.body.scrollTop), 53 | left: 54 | rect.left + 55 | (document.documentElement.scrollLeft || document.body.scrollLeft), 56 | }; 57 | } 58 | 59 | // https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetParent 60 | // an element's offsetParent property will return null whenever it, or any of its parents, 61 | // is hidden via the display style property. 62 | export function isHidden(el) { 63 | return el.offsetParent === null; 64 | } 65 | 66 | export function isElementInViewport(el) { 67 | // Although straightforward: https://stackoverflow.com/a/7557433/6495043 68 | var rect = el.getBoundingClientRect(); 69 | 70 | return ( 71 | rect.top >= 0 && 72 | rect.left >= 0 && 73 | rect.bottom <= 74 | (window.innerHeight || 75 | document.documentElement.clientHeight) /*or $(window).height() */ && 76 | rect.right <= 77 | (window.innerWidth || 78 | document.documentElement.clientWidth) /*or $(window).width() */ 79 | ); 80 | } 81 | 82 | export function getElementContentWidth(element) { 83 | var styles = window.getComputedStyle(element); 84 | var padding = 85 | parseFloat(styles.paddingLeft) + parseFloat(styles.paddingRight); 86 | 87 | return element.clientWidth - padding; 88 | } 89 | 90 | export function bind(element, o) { 91 | if (element) { 92 | for (var event in o) { 93 | var callback = o[event]; 94 | 95 | event.split(/\s+/).forEach(function (event) { 96 | element.addEventListener(event, callback); 97 | }); 98 | } 99 | } 100 | } 101 | 102 | export function unbind(element, o) { 103 | if (element) { 104 | for (var event in o) { 105 | var callback = o[event]; 106 | 107 | event.split(/\s+/).forEach(function (event) { 108 | element.removeEventListener(event, callback); 109 | }); 110 | } 111 | } 112 | } 113 | 114 | export function fire(target, type, properties) { 115 | var evt = document.createEvent("HTMLEvents"); 116 | 117 | evt.initEvent(type, true, true); 118 | 119 | for (var j in properties) { 120 | evt[j] = properties[j]; 121 | } 122 | 123 | return target.dispatchEvent(evt); 124 | } 125 | 126 | // https://css-tricks.com/snippets/javascript/loop-queryselectorall-matches/ 127 | export function forEachNode(nodeList, callback, scope) { 128 | if (!nodeList) return; 129 | for (var i = 0; i < nodeList.length; i++) { 130 | callback.call(scope, nodeList[i], i); 131 | } 132 | } 133 | 134 | export function activate( 135 | $parent, 136 | $child, 137 | commonClass, 138 | activeClass = "active", 139 | index = -1 140 | ) { 141 | let $children = $parent.querySelectorAll(`.${commonClass}.${activeClass}`); 142 | 143 | forEachNode($children, (node, i) => { 144 | if (index >= 0 && i <= index) return; 145 | node.classList.remove(activeClass); 146 | }); 147 | 148 | $child.classList.add(activeClass); 149 | } 150 | -------------------------------------------------------------------------------- /src/js/utils/draw-utils.js: -------------------------------------------------------------------------------- 1 | import { fillArray } from "./helpers"; 2 | 3 | export function getBarHeightAndYAttr(yTop, zeroLine) { 4 | let height, y; 5 | if (yTop <= zeroLine) { 6 | height = zeroLine - yTop; 7 | y = yTop; 8 | } else { 9 | height = yTop - zeroLine; 10 | y = zeroLine; 11 | } 12 | 13 | return [height, y]; 14 | } 15 | 16 | export function equilizeNoOfElements( 17 | array1, 18 | array2, 19 | extraCount = array2.length - array1.length 20 | ) { 21 | // Doesn't work if either has zero elements. 22 | if (extraCount > 0) { 23 | array1 = fillArray(array1, extraCount); 24 | } else { 25 | array2 = fillArray(array2, extraCount); 26 | } 27 | return [array1, array2]; 28 | } 29 | 30 | export function truncateString(txt, len) { 31 | if (!txt) { 32 | return; 33 | } 34 | if (txt.length > len) { 35 | return txt.slice(0, len - 3) + "..."; 36 | } else { 37 | return txt; 38 | } 39 | } 40 | 41 | export function shortenLargeNumber(label) { 42 | let number; 43 | if (typeof label === "number") number = label; 44 | else if (typeof label === "string") { 45 | number = Number(label); 46 | if (Number.isNaN(number)) return label; 47 | } 48 | 49 | // Using absolute since log wont work for negative numbers 50 | let p = Math.floor(Math.log10(Math.abs(number))); 51 | if (p <= 2) return number; // Return as is for a 3 digit number of less 52 | let l = Math.floor(p / 3); 53 | let shortened = 54 | Math.pow(10, p - l * 3) * +(number / Math.pow(10, p)).toFixed(1); 55 | 56 | // Correct for floating point error upto 2 decimal places 57 | return Math.round(shortened * 100) / 100 + " " + ["", "K", "M", "B", "T"][l]; 58 | } 59 | 60 | // cubic bezier curve calculation (from example by François Romain) 61 | export function getSplineCurvePointsStr(xList, yList) { 62 | let points = []; 63 | for (let i = 0; i < xList.length; i++) { 64 | points.push([xList[i], yList[i]]); 65 | } 66 | 67 | let smoothing = 0.2; 68 | let line = (pointA, pointB) => { 69 | let lengthX = pointB[0] - pointA[0]; 70 | let lengthY = pointB[1] - pointA[1]; 71 | return { 72 | length: Math.sqrt(Math.pow(lengthX, 2) + Math.pow(lengthY, 2)), 73 | angle: Math.atan2(lengthY, lengthX), 74 | }; 75 | }; 76 | 77 | let controlPoint = (current, previous, next, reverse) => { 78 | let p = previous || current; 79 | let n = next || current; 80 | let o = line(p, n); 81 | let angle = o.angle + (reverse ? Math.PI : 0); 82 | let length = o.length * smoothing; 83 | let x = current[0] + Math.cos(angle) * length; 84 | let y = current[1] + Math.sin(angle) * length; 85 | return [x, y]; 86 | }; 87 | 88 | let bezierCommand = (point, i, a) => { 89 | let cps = controlPoint(a[i - 1], a[i - 2], point); 90 | let cpe = controlPoint(point, a[i - 1], a[i + 1], true); 91 | return `C ${cps[0]},${cps[1]} ${cpe[0]},${cpe[1]} ${point[0]},${point[1]}`; 92 | }; 93 | 94 | let pointStr = (points, command) => { 95 | return points.reduce( 96 | (acc, point, i, a) => 97 | i === 0 ? `${point[0]},${point[1]}` : `${acc} ${command(point, i, a)}`, 98 | "" 99 | ); 100 | }; 101 | 102 | return pointStr(points, bezierCommand); 103 | } 104 | -------------------------------------------------------------------------------- /src/js/utils/draw.js: -------------------------------------------------------------------------------- 1 | import { 2 | getBarHeightAndYAttr, 3 | truncateString, 4 | shortenLargeNumber, 5 | getSplineCurvePointsStr, 6 | } from "./draw-utils"; 7 | import { getStringWidth, isValidNumber, round } from "./helpers"; 8 | 9 | import { 10 | DOT_OVERLAY_SIZE_INCR, 11 | } from "./constants"; 12 | 13 | export const AXIS_TICK_LENGTH = 6; 14 | const LABEL_MARGIN = 4; 15 | const LABEL_WIDTH = 25; 16 | const TOTAL_PADDING = 120; 17 | const LABEL_MAX_CHARS = 18; 18 | export const FONT_SIZE = 10; 19 | const BASE_LINE_COLOR = "#E2E6E9"; 20 | 21 | function $(expr, con) { 22 | return typeof expr === "string" 23 | ? (con || document).querySelector(expr) 24 | : expr || null; 25 | } 26 | 27 | export function createSVG(tag, o) { 28 | var element = document.createElementNS("http://www.w3.org/2000/svg", tag); 29 | 30 | for (var i in o) { 31 | var val = o[i]; 32 | 33 | if (i === "inside") { 34 | $(val).appendChild(element); 35 | } else if (i === "around") { 36 | var ref = $(val); 37 | ref.parentNode.insertBefore(element, ref); 38 | element.appendChild(ref); 39 | } else if (i === "styles") { 40 | if (typeof val === "object") { 41 | Object.keys(val).map((prop) => { 42 | element.style[prop] = val[prop]; 43 | }); 44 | } 45 | } else { 46 | if (i === "className") { 47 | i = "class"; 48 | } 49 | if (i === "innerHTML") { 50 | element["textContent"] = val; 51 | } else { 52 | element.setAttribute(i, val); 53 | } 54 | } 55 | } 56 | 57 | return element; 58 | } 59 | 60 | function renderVerticalGradient(svgDefElem, gradientId) { 61 | return createSVG("linearGradient", { 62 | inside: svgDefElem, 63 | id: gradientId, 64 | x1: 0, 65 | x2: 0, 66 | y1: 0, 67 | y2: 1, 68 | }); 69 | } 70 | 71 | function setGradientStop(gradElem, offset, color, opacity) { 72 | return createSVG("stop", { 73 | inside: gradElem, 74 | style: `stop-color: ${color}`, 75 | offset: offset, 76 | "stop-opacity": opacity, 77 | }); 78 | } 79 | 80 | export function makeSVGContainer(parent, className, width, height) { 81 | return createSVG("svg", { 82 | className: className, 83 | inside: parent, 84 | width: width, 85 | height: height, 86 | }); 87 | } 88 | 89 | export function makeSVGDefs(svgContainer) { 90 | return createSVG("defs", { 91 | inside: svgContainer, 92 | }); 93 | } 94 | 95 | export function makeSVGGroup(className, transform = "", parent = undefined) { 96 | let args = { 97 | className: className, 98 | transform: transform, 99 | }; 100 | if (parent) args.inside = parent; 101 | return createSVG("g", args); 102 | } 103 | 104 | export function wrapInSVGGroup(elements, className = "") { 105 | let g = createSVG("g", { 106 | className: className, 107 | }); 108 | elements.forEach((e) => g.appendChild(e)); 109 | return g; 110 | } 111 | 112 | export function makePath( 113 | pathStr, 114 | className = "", 115 | stroke = "none", 116 | fill = "none", 117 | strokeWidth = 2 118 | ) { 119 | return createSVG("path", { 120 | className: className, 121 | d: pathStr, 122 | styles: { 123 | stroke: stroke, 124 | fill: fill, 125 | "stroke-width": strokeWidth, 126 | }, 127 | }); 128 | } 129 | 130 | export function makeArcPathStr( 131 | startPosition, 132 | endPosition, 133 | center, 134 | radius, 135 | clockWise = 1, 136 | largeArc = 0 137 | ) { 138 | let [arcStartX, arcStartY] = [ 139 | center.x + startPosition.x, 140 | center.y + startPosition.y, 141 | ]; 142 | let [arcEndX, arcEndY] = [ 143 | center.x + endPosition.x, 144 | center.y + endPosition.y, 145 | ]; 146 | return `M${center.x} ${center.y} 147 | L${arcStartX} ${arcStartY} 148 | A ${radius} ${radius} 0 ${largeArc} ${clockWise ? 1 : 0} 149 | ${arcEndX} ${arcEndY} z`; 150 | } 151 | 152 | export function makeCircleStr( 153 | startPosition, 154 | endPosition, 155 | center, 156 | radius, 157 | clockWise = 1, 158 | largeArc = 0 159 | ) { 160 | let [arcStartX, arcStartY] = [ 161 | center.x + startPosition.x, 162 | center.y + startPosition.y, 163 | ]; 164 | let [arcEndX, midArc, arcEndY] = [ 165 | center.x + endPosition.x, 166 | center.y * 2, 167 | center.y + endPosition.y, 168 | ]; 169 | return `M${center.x} ${center.y} 170 | L${arcStartX} ${arcStartY} 171 | A ${radius} ${radius} 0 ${largeArc} ${clockWise ? 1 : 0} 172 | ${arcEndX} ${midArc} z 173 | L${arcStartX} ${midArc} 174 | A ${radius} ${radius} 0 ${largeArc} ${clockWise ? 1 : 0} 175 | ${arcEndX} ${arcEndY} z`; 176 | } 177 | 178 | export function makeArcStrokePathStr( 179 | startPosition, 180 | endPosition, 181 | center, 182 | radius, 183 | clockWise = 1, 184 | largeArc = 0 185 | ) { 186 | let [arcStartX, arcStartY] = [ 187 | center.x + startPosition.x, 188 | center.y + startPosition.y, 189 | ]; 190 | let [arcEndX, arcEndY] = [ 191 | center.x + endPosition.x, 192 | center.y + endPosition.y, 193 | ]; 194 | 195 | return `M${arcStartX} ${arcStartY} 196 | A ${radius} ${radius} 0 ${largeArc} ${clockWise ? 1 : 0} 197 | ${arcEndX} ${arcEndY}`; 198 | } 199 | 200 | export function makeStrokeCircleStr( 201 | startPosition, 202 | endPosition, 203 | center, 204 | radius, 205 | clockWise = 1, 206 | largeArc = 0 207 | ) { 208 | let [arcStartX, arcStartY] = [ 209 | center.x + startPosition.x, 210 | center.y + startPosition.y, 211 | ]; 212 | let [arcEndX, midArc, arcEndY] = [ 213 | center.x + endPosition.x, 214 | radius * 2 + arcStartY, 215 | center.y + startPosition.y, 216 | ]; 217 | 218 | return `M${arcStartX} ${arcStartY} 219 | A ${radius} ${radius} 0 ${largeArc} ${clockWise ? 1 : 0} 220 | ${arcEndX} ${midArc} 221 | M${arcStartX} ${midArc} 222 | A ${radius} ${radius} 0 ${largeArc} ${clockWise ? 1 : 0} 223 | ${arcEndX} ${arcEndY}`; 224 | } 225 | 226 | export function makeGradient(svgDefElem, color, lighter = false) { 227 | let gradientId = 228 | "path-fill-gradient" + 229 | "-" + 230 | color + 231 | "-" + 232 | (lighter ? "lighter" : "default"); 233 | let gradientDef = renderVerticalGradient(svgDefElem, gradientId); 234 | let opacities = [1, 0.6, 0.2]; 235 | if (lighter) { 236 | opacities = [0.4, 0.2, 0]; 237 | } 238 | 239 | setGradientStop(gradientDef, "0%", color, opacities[0]); 240 | setGradientStop(gradientDef, "50%", color, opacities[1]); 241 | setGradientStop(gradientDef, "100%", color, opacities[2]); 242 | 243 | return gradientId; 244 | } 245 | 246 | export function rightRoundedBar(x, width, height) { 247 | // https://medium.com/@dennismphil/one-side-rounded-rectangle-using-svg-fb31cf318d90 248 | let radius = height / 2; 249 | let xOffset = width - radius; 250 | 251 | return `M${x},0 h${xOffset} q${radius},0 ${radius},${radius} q0,${radius} -${radius},${radius} h-${xOffset} v${height}z`; 252 | } 253 | 254 | export function leftRoundedBar(x, width, height) { 255 | let radius = height / 2; 256 | let xOffset = width - radius; 257 | 258 | return `M${ 259 | x + radius 260 | },0 h${xOffset} v${height} h-${xOffset} q-${radius}, 0 -${radius},-${radius} q0,-${radius} ${radius},-${radius}z`; 261 | } 262 | 263 | export function percentageBar( 264 | x, 265 | y, 266 | width, 267 | height, 268 | isFirst, 269 | isLast, 270 | fill = "none" 271 | ) { 272 | if (isLast) { 273 | let pathStr = rightRoundedBar(x, width, height); 274 | return makePath(pathStr, "percentage-bar", null, fill); 275 | } 276 | 277 | if (isFirst) { 278 | let pathStr = leftRoundedBar(x, width, height); 279 | return makePath(pathStr, "percentage-bar", null, fill); 280 | } 281 | 282 | let args = { 283 | className: "percentage-bar", 284 | x: x, 285 | y: y, 286 | width: width, 287 | height: height, 288 | fill: fill, 289 | }; 290 | 291 | return createSVG("rect", args); 292 | } 293 | 294 | export function heatSquare( 295 | className, 296 | x, 297 | y, 298 | size, 299 | radius, 300 | fill = "none", 301 | data = {} 302 | ) { 303 | let args = { 304 | className: className, 305 | x: x, 306 | y: y, 307 | width: size, 308 | height: size, 309 | rx: radius, 310 | fill: fill, 311 | }; 312 | 313 | Object.keys(data).map((key) => { 314 | args[key] = data[key]; 315 | }); 316 | 317 | return createSVG("rect", args); 318 | } 319 | 320 | export function legendDot( 321 | x, 322 | y, 323 | size, 324 | radius, 325 | fill = "none", 326 | label, 327 | value, 328 | font_size = null, 329 | truncate = false 330 | ) { 331 | label = truncate ? truncateString(label, LABEL_MAX_CHARS) : label; 332 | if (!font_size) font_size = FONT_SIZE; 333 | 334 | let args = { 335 | className: "legend-dot", 336 | x: 0, 337 | y: 4 - size, 338 | height: size, 339 | width: size, 340 | rx: radius, 341 | fill: fill, 342 | }; 343 | 344 | let textLabel = createSVG("text", { 345 | className: "legend-dataset-label", 346 | x: size, 347 | y: 0, 348 | dx: font_size + "px", 349 | dy: font_size / 3 + "px", 350 | "font-size": font_size * 1.6 + "px", 351 | "text-anchor": "start", 352 | innerHTML: label, 353 | }); 354 | 355 | let textValue = null; 356 | if (value) { 357 | textValue = createSVG("text", { 358 | className: "legend-dataset-value", 359 | x: size, 360 | y: FONT_SIZE + 10, 361 | dx: FONT_SIZE + "px", 362 | dy: FONT_SIZE / 3 + "px", 363 | "font-size": FONT_SIZE * 1.2 + "px", 364 | "text-anchor": "start", 365 | innerHTML: value, 366 | }); 367 | } 368 | 369 | let group = createSVG("g", { 370 | transform: `translate(${x}, ${y})`, 371 | }); 372 | group.appendChild(createSVG("rect", args)); 373 | group.appendChild(textLabel); 374 | 375 | if (value && textValue) { 376 | group.appendChild(textValue); 377 | } 378 | 379 | return group; 380 | } 381 | 382 | export function makeText(className, x, y, content, options = {}) { 383 | let fontSize = options.fontSize || FONT_SIZE; 384 | let dy = options.dy !== undefined ? options.dy : fontSize / 2; 385 | //let fill = options.fill || "var(--charts-label-color)"; 386 | let fill = options.fill || "var(--charts-label-color)"; 387 | let textAnchor = options.textAnchor || "start"; 388 | return createSVG("text", { 389 | className: className, 390 | x: x, 391 | y: y, 392 | dy: dy + "px", 393 | "font-size": fontSize + "px", 394 | fill: fill, 395 | "text-anchor": textAnchor, 396 | innerHTML: content, 397 | }); 398 | } 399 | 400 | function makeVertLine(x, label, y1, y2, options = {}) { 401 | if (!options.stroke) options.stroke = BASE_LINE_COLOR; 402 | let l = createSVG("line", { 403 | className: "line-vertical " + options.className, 404 | x1: 0, 405 | x2: 0, 406 | y1: y1, 407 | y2: y2, 408 | styles: { 409 | stroke: options.stroke, 410 | }, 411 | }); 412 | 413 | let text = createSVG("text", { 414 | x: 0, 415 | y: y1 > y2 ? y1 + LABEL_MARGIN : y1 - LABEL_MARGIN - FONT_SIZE, 416 | dy: FONT_SIZE + "px", 417 | "font-size": FONT_SIZE + "px", 418 | "text-anchor": "middle", 419 | innerHTML: label + "", 420 | }); 421 | 422 | let line = createSVG("g", { 423 | transform: `translate(${x}, 0)`, 424 | }); 425 | 426 | line.appendChild(l); 427 | line.appendChild(text); 428 | 429 | return line; 430 | } 431 | 432 | function makeHoriLine(y, label, x1, x2, options = {}) { 433 | if (!options.stroke) options.stroke = BASE_LINE_COLOR; 434 | if (!options.lineType) options.lineType = ""; 435 | if (!options.alignment) options.alignment = "left"; 436 | if (options.shortenNumbers) label = shortenLargeNumber(label); 437 | 438 | let className = 439 | "line-horizontal " + 440 | options.className + 441 | (options.lineType === "dashed" ? "dashed" : ""); 442 | 443 | const textXPos = 444 | options.alignment === "left" 445 | ? options.title 446 | ? x1 - LABEL_MARGIN + LABEL_WIDTH 447 | : x1 - LABEL_MARGIN 448 | : options.title 449 | ? x2 + LABEL_MARGIN * 4 - LABEL_WIDTH 450 | : x2 + LABEL_MARGIN * 4; 451 | const lineX1Post = options.title ? x1 + LABEL_WIDTH : x1; 452 | const lineX2Post = options.title ? x2 - LABEL_WIDTH : x2; 453 | 454 | let l = createSVG("line", { 455 | className: className, 456 | x1: lineX1Post, 457 | x2: lineX2Post, 458 | y1: 0, 459 | y2: 0, 460 | styles: { 461 | stroke: options.stroke, 462 | }, 463 | }); 464 | 465 | let text = createSVG("text", { 466 | x: textXPos, 467 | y: 0, 468 | dy: FONT_SIZE / 2 - 2 + "px", 469 | "font-size": FONT_SIZE + "px", 470 | "text-anchor": x1 < x2 ? "end" : "start", 471 | innerHTML: label + "", 472 | }); 473 | 474 | let line = createSVG("g", { 475 | transform: `translate(0, ${y})`, 476 | "stroke-opacity": 1, 477 | }); 478 | 479 | if (text === 0 || text === "0") { 480 | line.style.stroke = "rgba(27, 31, 35, 0.6)"; 481 | } 482 | 483 | line.appendChild(l); 484 | line.appendChild(text); 485 | 486 | return line; 487 | } 488 | 489 | export function generateAxisLabel(options) { 490 | if (!options.title) return; 491 | 492 | const y = 493 | options.position === "left" 494 | ? (options.height - TOTAL_PADDING) / 2 + 495 | getStringWidth(options.title, 5) / 2 496 | : (options.height - TOTAL_PADDING) / 2 - 497 | getStringWidth(options.title, 5) / 2; 498 | const x = options.position === "left" ? 0 : options.width; 499 | const y2 = 500 | options.position === "left" 501 | ? FONT_SIZE - LABEL_WIDTH 502 | : FONT_SIZE + LABEL_WIDTH * -1; 503 | 504 | const rotation = 505 | options.position === "right" ? `rotate(90)` : `rotate(270)`; 506 | 507 | const labelSvg = createSVG("text", { 508 | className: "chart-label", 509 | x: 0, // getStringWidth(options.title, 5) / 2, 510 | y: 0, // y, 511 | dy: `${y2}px`, 512 | "font-size": `${FONT_SIZE}px`, 513 | "text-anchor": "start", 514 | innerHTML: `${options.title} `, 515 | }); 516 | 517 | let wrapper = createSVG("g", { 518 | x: 0, 519 | y: 0, 520 | transformBox: "fill-box", 521 | transform: `translate(${x}, ${y}) ${rotation}`, 522 | className: `test-${options.position}`, 523 | }); 524 | 525 | wrapper.appendChild(labelSvg); 526 | 527 | return wrapper; 528 | } 529 | 530 | export function yLine(y, label, width, options = {}) { 531 | if (!isValidNumber(y)) y = 0; 532 | 533 | if (!options.pos) options.pos = "left"; 534 | if (!options.offset) options.offset = 0; 535 | if (!options.mode) options.mode = "span"; 536 | if (!options.stroke) options.stroke = BASE_LINE_COLOR; 537 | if (!options.className) options.className = ""; 538 | 539 | let x1 = -1 * AXIS_TICK_LENGTH; 540 | let x2 = options.mode === "span" ? width + AXIS_TICK_LENGTH : 0; 541 | 542 | if (options.mode === "tick" && options.pos === "right") { 543 | x1 = width + AXIS_TICK_LENGTH; 544 | x2 = width; 545 | } 546 | 547 | let offset = options.pos === "left" ? -1 * options.offset : options.offset; 548 | 549 | // pr_366 550 | //x1 += offset; 551 | //x2 += offset; 552 | x1 += options.offset; 553 | x2 += options.offset; 554 | 555 | if (typeof label === "number") label = round(label); 556 | 557 | return makeHoriLine(y, label, x1, x2, { 558 | stroke: options.stroke, 559 | className: options.className, 560 | lineType: options.lineType, 561 | alignment: options.pos, 562 | title: options.title, 563 | shortenNumbers: options.shortenNumbers, 564 | }); 565 | } 566 | 567 | export function xLine(x, label, height, options = {}) { 568 | if (!isValidNumber(x)) x = 0; 569 | 570 | if (!options.pos) options.pos = "bottom"; 571 | if (!options.offset) options.offset = 0; 572 | if (!options.mode) options.mode = "span"; 573 | if (!options.stroke) options.stroke = BASE_LINE_COLOR; 574 | if (!options.className) options.className = ""; 575 | 576 | // Draw X axis line in span/tick mode with optional label 577 | // y2(span) 578 | // | 579 | // | 580 | // x line | 581 | // | 582 | // | 583 | // ---------------------+-- y2(tick) 584 | // | 585 | // y1 586 | 587 | let y1 = height + AXIS_TICK_LENGTH; 588 | let y2 = options.mode === "span" ? -1 * AXIS_TICK_LENGTH : height; 589 | 590 | if (options.mode === "tick" && options.pos === "top") { 591 | // top axis ticks 592 | y1 = -1 * AXIS_TICK_LENGTH; 593 | y2 = 0; 594 | } 595 | 596 | return makeVertLine(x, label, y1, y2, { 597 | stroke: options.stroke, 598 | className: options.className, 599 | lineType: options.lineType, 600 | }); 601 | } 602 | 603 | export function yMarker(y, label, width, options = {}) { 604 | if (!isValidNumber(y)) y = 0; 605 | 606 | if (!options.labelPos) options.labelPos = "right"; 607 | if (!options.lineType) options.lineType = "dashed"; 608 | let x = 609 | options.labelPos === "left" 610 | ? LABEL_MARGIN 611 | : width - getStringWidth(label, 5) - LABEL_MARGIN; 612 | 613 | let labelSvg = createSVG("text", { 614 | className: "chart-label", 615 | x: x, 616 | y: 0, 617 | dy: FONT_SIZE / -2 + "px", 618 | "font-size": FONT_SIZE + "px", 619 | "text-anchor": "start", 620 | innerHTML: label + "", 621 | }); 622 | 623 | let line = makeHoriLine(y, "", 0, width, { 624 | stroke: options.stroke || BASE_LINE_COLOR, 625 | className: options.className || "", 626 | lineType: options.lineType, 627 | }); 628 | 629 | line.appendChild(labelSvg); 630 | 631 | return line; 632 | } 633 | 634 | export function yRegion(y1, y2, width, label, options = {}) { 635 | // return a group 636 | let height = y1 - y2; 637 | 638 | let rect = createSVG("rect", { 639 | className: `bar mini`, // remove class 640 | styles: { 641 | fill: options.fill || `rgba(228, 234, 239, 0.49)`, 642 | stroke: options.stroke || BASE_LINE_COLOR, 643 | "stroke-dasharray": `${width}, ${height}`, 644 | }, 645 | // 'data-point-index': index, 646 | x: 0, 647 | y: 0, 648 | width: width, 649 | height: height, 650 | }); 651 | 652 | if (!options.labelPos) options.labelPos = "right"; 653 | let x = 654 | options.labelPos === "left" 655 | ? LABEL_MARGIN 656 | : width - getStringWidth(label + "", 4.5) - LABEL_MARGIN; 657 | 658 | let labelSvg = createSVG("text", { 659 | className: "chart-label", 660 | x: x, 661 | y: 0, 662 | dy: FONT_SIZE / -2 + "px", 663 | "font-size": FONT_SIZE + "px", 664 | "text-anchor": "start", 665 | innerHTML: label + "", 666 | }); 667 | 668 | let region = createSVG("g", { 669 | transform: `translate(0, ${y2})`, 670 | }); 671 | 672 | region.appendChild(rect); 673 | region.appendChild(labelSvg); 674 | 675 | return region; 676 | } 677 | 678 | export function datasetBar( 679 | x, 680 | yTop, 681 | width, 682 | color, 683 | label = "", 684 | index = 0, 685 | offset = 0, 686 | meta = {} 687 | ) { 688 | let [height, y] = getBarHeightAndYAttr(yTop, meta.zeroLine); 689 | y -= offset; 690 | 691 | if (height === 0) { 692 | height = meta.minHeight; 693 | y -= meta.minHeight; 694 | } 695 | 696 | // Preprocess numbers to avoid svg building errors 697 | if (!isValidNumber(x)) x = 0; 698 | if (!isValidNumber(y)) y = 0; 699 | if (!isValidNumber(height, true)) height = 0; 700 | if (!isValidNumber(width, true)) width = 0; 701 | 702 | // x y h w 703 | 704 | // M{x},{y+r} 705 | // q0,-{r} {r},-{r} 706 | // q{r},0 {r},{r} 707 | // v{h-r} 708 | // h-{w}z 709 | 710 | // let radius = width/2; 711 | // let pathStr = `M${x},${y+radius} q0,-${radius} ${radius},-${radius} q${radius},0 ${radius},${radius} v${height-radius} h-${width}z` 712 | 713 | // let rect = createSVG('path', { 714 | // className: 'bar mini', 715 | // d: pathStr, 716 | // styles: { fill: color }, 717 | // x: x, 718 | // y: y, 719 | // 'data-point-index': index, 720 | // }); 721 | 722 | let rect = createSVG("rect", { 723 | className: `bar mini`, 724 | style: `fill: ${color}`, 725 | "data-point-index": index, 726 | x: x, 727 | y: y, 728 | width: width, 729 | height: height, 730 | }); 731 | 732 | label += ""; 733 | 734 | if (!label && !label.length) { 735 | return rect; 736 | } else { 737 | rect.setAttribute("y", 0); 738 | rect.setAttribute("x", 0); 739 | let text = createSVG("text", { 740 | className: "data-point-value", 741 | x: width / 2, 742 | y: 0, 743 | dy: (FONT_SIZE / 2) * -1 + "px", 744 | "font-size": FONT_SIZE + "px", 745 | "text-anchor": "middle", 746 | innerHTML: label, 747 | }); 748 | 749 | let group = createSVG("g", { 750 | "data-point-index": index, 751 | transform: `translate(${x}, ${y})`, 752 | }); 753 | group.appendChild(rect); 754 | group.appendChild(text); 755 | 756 | return group; 757 | } 758 | } 759 | 760 | export function datasetDot(x, y, radius, color, label = "", index = 0) { 761 | let dot = createSVG("circle", { 762 | style: `fill: ${color}`, 763 | "data-point-index": index, 764 | cx: x, 765 | cy: y, 766 | r: radius, 767 | }); 768 | 769 | label += ""; 770 | 771 | if (!label && !label.length) { 772 | return dot; 773 | } else { 774 | dot.setAttribute("cy", 0); 775 | dot.setAttribute("cx", 0); 776 | 777 | let text = createSVG("text", { 778 | className: "data-point-value", 779 | x: 0, 780 | y: 0, 781 | dy: (FONT_SIZE / 2) * -1 - radius + "px", 782 | "font-size": FONT_SIZE + "px", 783 | "text-anchor": "middle", 784 | innerHTML: label, 785 | }); 786 | 787 | let group = createSVG("g", { 788 | "data-point-index": index, 789 | transform: `translate(${x}, ${y})`, 790 | }); 791 | group.appendChild(dot); 792 | group.appendChild(text); 793 | 794 | return group; 795 | } 796 | } 797 | 798 | export function getPaths(xList, yList, color, options = {}, meta = {}) { 799 | let pointsList = yList.map((y, i) => xList[i] + "," + y); 800 | let pointsStr = pointsList.join("L"); 801 | 802 | // Spline 803 | if (options.spline) pointsStr = getSplineCurvePointsStr(xList, yList); 804 | 805 | let path = makePath("M" + pointsStr, "line-graph-path", color); 806 | 807 | // HeatLine 808 | if (options.heatline) { 809 | let gradient_id = makeGradient(meta.svgDefs, color); 810 | path.style.stroke = `url(#${gradient_id})`; 811 | } 812 | 813 | let paths = { 814 | path: path, 815 | }; 816 | 817 | // Region 818 | if (options.regionFill) { 819 | let gradient_id_region = makeGradient(meta.svgDefs, color, true); 820 | 821 | let pathStr = 822 | "M" + 823 | `${xList[0]},${meta.zeroLine}L` + 824 | pointsStr + 825 | `L${xList.slice(-1)[0]},${meta.zeroLine}`; 826 | paths.region = makePath( 827 | pathStr, 828 | `region-fill`, 829 | "none", 830 | `url(#${gradient_id_region})` 831 | ); 832 | } 833 | 834 | return paths; 835 | } 836 | 837 | export let makeOverlay = { 838 | bar: (unit) => { 839 | let transformValue; 840 | if (unit.nodeName !== "rect") { 841 | transformValue = unit.getAttribute("transform"); 842 | unit = unit.childNodes[0]; 843 | } 844 | let overlay = unit.cloneNode(); 845 | overlay.style.fill = "#000000"; 846 | overlay.style.opacity = "0.4"; 847 | 848 | if (transformValue) { 849 | overlay.setAttribute("transform", transformValue); 850 | } 851 | return overlay; 852 | }, 853 | 854 | dot: (unit) => { 855 | let transformValue; 856 | if (unit.nodeName !== "circle") { 857 | transformValue = unit.getAttribute("transform"); 858 | unit = unit.childNodes[0]; 859 | } 860 | let overlay = unit.cloneNode(); 861 | let radius = unit.getAttribute("r"); 862 | let fill = unit.getAttribute("fill"); 863 | overlay.setAttribute("r", parseInt(radius) + DOT_OVERLAY_SIZE_INCR); 864 | overlay.setAttribute("fill", fill); 865 | overlay.style.opacity = "0.6"; 866 | 867 | if (transformValue) { 868 | overlay.setAttribute("transform", transformValue); 869 | } 870 | return overlay; 871 | }, 872 | 873 | heat_square: (unit) => { 874 | let transformValue; 875 | if (unit.nodeName !== "circle") { 876 | transformValue = unit.getAttribute("transform"); 877 | unit = unit.childNodes[0]; 878 | } 879 | let overlay = unit.cloneNode(); 880 | let radius = unit.getAttribute("r"); 881 | let fill = unit.getAttribute("fill"); 882 | overlay.setAttribute("r", parseInt(radius) + DOT_OVERLAY_SIZE_INCR); 883 | overlay.setAttribute("fill", fill); 884 | overlay.style.opacity = "0.6"; 885 | 886 | if (transformValue) { 887 | overlay.setAttribute("transform", transformValue); 888 | } 889 | return overlay; 890 | }, 891 | }; 892 | 893 | export let updateOverlay = { 894 | bar: (unit, overlay) => { 895 | let transformValue; 896 | if (unit.nodeName !== "rect") { 897 | transformValue = unit.getAttribute("transform"); 898 | unit = unit.childNodes[0]; 899 | } 900 | let attributes = ["x", "y", "width", "height"]; 901 | Object.values(unit.attributes) 902 | .filter((attr) => attributes.includes(attr.name) && attr.specified) 903 | .map((attr) => { 904 | overlay.setAttribute(attr.name, attr.nodeValue); 905 | }); 906 | 907 | if (transformValue) { 908 | overlay.setAttribute("transform", transformValue); 909 | } 910 | }, 911 | 912 | dot: (unit, overlay) => { 913 | let transformValue; 914 | if (unit.nodeName !== "circle") { 915 | transformValue = unit.getAttribute("transform"); 916 | unit = unit.childNodes[0]; 917 | } 918 | let attributes = ["cx", "cy"]; 919 | Object.values(unit.attributes) 920 | .filter((attr) => attributes.includes(attr.name) && attr.specified) 921 | .map((attr) => { 922 | overlay.setAttribute(attr.name, attr.nodeValue); 923 | }); 924 | 925 | if (transformValue) { 926 | overlay.setAttribute("transform", transformValue); 927 | } 928 | }, 929 | 930 | heat_square: (unit, overlay) => { 931 | let transformValue; 932 | if (unit.nodeName !== "circle") { 933 | transformValue = unit.getAttribute("transform"); 934 | unit = unit.childNodes[0]; 935 | } 936 | let attributes = ["cx", "cy"]; 937 | Object.values(unit.attributes) 938 | .filter((attr) => attributes.includes(attr.name) && attr.specified) 939 | .map((attr) => { 940 | overlay.setAttribute(attr.name, attr.nodeValue); 941 | }); 942 | 943 | if (transformValue) { 944 | overlay.setAttribute("transform", transformValue); 945 | } 946 | }, 947 | }; 948 | -------------------------------------------------------------------------------- /src/js/utils/export.js: -------------------------------------------------------------------------------- 1 | import { $ } from "../utils/dom"; 2 | import { CSSTEXT } from "../../css/chartsCss"; 3 | 4 | export function downloadFile(filename, data) { 5 | var a = document.createElement("a"); 6 | a.style = "display: none"; 7 | var blob = new Blob(data, { type: "image/svg+xml; charset=utf-8" }); 8 | var url = window.URL.createObjectURL(blob); 9 | a.href = url; 10 | a.download = filename; 11 | document.body.appendChild(a); 12 | a.click(); 13 | setTimeout(function () { 14 | document.body.removeChild(a); 15 | window.URL.revokeObjectURL(url); 16 | }, 300); 17 | } 18 | 19 | export function prepareForExport(svg) { 20 | let clone = svg.cloneNode(true); 21 | clone.classList.add("chart-container"); 22 | clone.setAttribute("xmlns", "http://www.w3.org/2000/svg"); 23 | clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink"); 24 | let styleEl = $.create("style", { 25 | innerHTML: CSSTEXT, 26 | }); 27 | clone.insertBefore(styleEl, clone.firstChild); 28 | 29 | let container = $.create("div"); 30 | container.appendChild(clone); 31 | 32 | return container.innerHTML; 33 | } 34 | -------------------------------------------------------------------------------- /src/js/utils/helpers.js: -------------------------------------------------------------------------------- 1 | import { ANGLE_RATIO } from "./constants"; 2 | 3 | /** 4 | * Returns the value of a number upto 2 decimal places. 5 | * @param {Number} d Any number 6 | */ 7 | export function floatTwo(d) { 8 | return parseFloat(d.toFixed(2)); 9 | } 10 | 11 | /** 12 | * Returns whether or not two given arrays are equal. 13 | * @param {Array} arr1 First array 14 | * @param {Array} arr2 Second array 15 | */ 16 | export function arraysEqual(arr1, arr2) { 17 | if (arr1.length !== arr2.length) return false; 18 | let areEqual = true; 19 | arr1.map((d, i) => { 20 | if (arr2[i] !== d) areEqual = false; 21 | }); 22 | return areEqual; 23 | } 24 | 25 | /** 26 | * Shuffles array in place. ES6 version 27 | * @param {Array} array An array containing the items. 28 | */ 29 | export function shuffle(array) { 30 | // Awesomeness: https://bost.ocks.org/mike/shuffle/ 31 | // https://stackoverflow.com/a/2450976/6495043 32 | // https://stackoverflow.com/questions/6274339/how-can-i-shuffle-an-array?noredirect=1&lq=1 33 | 34 | for (let i = array.length - 1; i > 0; i--) { 35 | let j = Math.floor(Math.random() * (i + 1)); 36 | [array[i], array[j]] = [array[j], array[i]]; 37 | } 38 | 39 | return array; 40 | } 41 | 42 | /** 43 | * Fill an array with extra points 44 | * @param {Array} array Array 45 | * @param {Number} count number of filler elements 46 | * @param {Object} element element to fill with 47 | * @param {Boolean} start fill at start? 48 | */ 49 | export function fillArray(array, count, element, start = false) { 50 | if (element == undefined) { 51 | element = start ? array[0] : array[array.length - 1]; 52 | } 53 | let fillerArray = new Array(Math.abs(count)).fill(element); 54 | array = start ? fillerArray.concat(array) : array.concat(fillerArray); 55 | return array; 56 | } 57 | 58 | /** 59 | * Returns pixel width of string. 60 | * @param {String} string 61 | * @param {Number} charWidth Width of single char in pixels 62 | */ 63 | export function getStringWidth(string, charWidth) { 64 | return (string + "").length * charWidth; 65 | } 66 | 67 | export function bindChange(obj, getFn, setFn) { 68 | return new Proxy(obj, { 69 | set: function (target, prop, value) { 70 | setFn(); 71 | return Reflect.set(target, prop, value); 72 | }, 73 | get: function (target, prop) { 74 | getFn(); 75 | return Reflect.get(target, prop); 76 | }, 77 | }); 78 | } 79 | 80 | // https://stackoverflow.com/a/29325222 81 | export function getRandomBias(min, max, bias, influence) { 82 | const range = max - min; 83 | const biasValue = range * bias + min; 84 | var rnd = Math.random() * range + min, // random in range 85 | mix = Math.random() * influence; // random mixer 86 | return rnd * (1 - mix) + biasValue * mix; // mix full range and bias 87 | } 88 | 89 | export function getPositionByAngle(angle, radius) { 90 | return { 91 | x: Math.sin(angle * ANGLE_RATIO) * radius, 92 | y: Math.cos(angle * ANGLE_RATIO) * radius, 93 | }; 94 | } 95 | 96 | /** 97 | * Check if a number is valid for svg attributes 98 | * @param {object} candidate Candidate to test 99 | * @param {Boolean} nonNegative flag to treat negative number as invalid 100 | */ 101 | export function isValidNumber(candidate, nonNegative = false) { 102 | if (Number.isNaN(candidate)) return false; 103 | else if (candidate === undefined) return false; 104 | else if (!Number.isFinite(candidate)) return false; 105 | else if (nonNegative && candidate < 0) return false; 106 | else return true; 107 | } 108 | 109 | /** 110 | * Round a number to the closes precision, max max precision 4 111 | * @param {Number} d Any Number 112 | */ 113 | export function round(d) { 114 | // https://floating-point-gui.de/ 115 | // https://www.jacklmoore.com/notes/rounding-in-javascript/ 116 | return Number(Math.round(d + "e4") + "e-4"); 117 | } 118 | 119 | /** 120 | * Creates a deep clone of an object 121 | * @param {Object} candidate Any Object 122 | */ 123 | export function deepClone(candidate) { 124 | let cloned, value, key; 125 | 126 | if (candidate instanceof Date) { 127 | return new Date(candidate.getTime()); 128 | } 129 | 130 | if (typeof candidate !== "object" || candidate === null) { 131 | return candidate; 132 | } 133 | 134 | cloned = Array.isArray(candidate) ? [] : {}; 135 | 136 | for (key in candidate) { 137 | value = candidate[key]; 138 | 139 | cloned[key] = deepClone(value); 140 | } 141 | 142 | return cloned; 143 | } 144 | -------------------------------------------------------------------------------- /src/js/utils/intervals.js: -------------------------------------------------------------------------------- 1 | import { floatTwo } from "./helpers"; 2 | 3 | function normalize(x) { 4 | // Calculates mantissa and exponent of a number 5 | // Returns normalized number and exponent 6 | // https://stackoverflow.com/q/9383593/6495043 7 | 8 | if (x === 0) { 9 | return [0, 0]; 10 | } 11 | if (isNaN(x)) { 12 | return { mantissa: -6755399441055744, exponent: 972 }; 13 | } 14 | var sig = x > 0 ? 1 : -1; 15 | if (!isFinite(x)) { 16 | return { mantissa: sig * 4503599627370496, exponent: 972 }; 17 | } 18 | 19 | x = Math.abs(x); 20 | var exp = Math.floor(Math.log10(x)); 21 | var man = x / Math.pow(10, exp); 22 | 23 | return [sig * man, exp]; 24 | } 25 | 26 | function getChartRangeIntervals(max, min = 0) { 27 | let upperBound = Math.ceil(max); 28 | let lowerBound = Math.floor(min); 29 | let range = upperBound - lowerBound; 30 | 31 | let noOfParts = range; 32 | let partSize = 1; 33 | 34 | // To avoid too many partitions 35 | if (range > 5) { 36 | if (range % 2 !== 0) { 37 | upperBound++; 38 | // Recalc range 39 | range = upperBound - lowerBound; 40 | } 41 | noOfParts = range / 2; 42 | partSize = 2; 43 | } 44 | 45 | // Special case: 1 and 2 46 | if (range <= 2) { 47 | noOfParts = 4; 48 | partSize = range / noOfParts; 49 | } 50 | 51 | // Special case: 0 52 | if (range === 0) { 53 | noOfParts = 5; 54 | partSize = 1; 55 | } 56 | 57 | let intervals = []; 58 | for (var i = 0; i <= noOfParts; i++) { 59 | intervals.push(lowerBound + partSize * i); 60 | } 61 | return intervals; 62 | } 63 | 64 | function getChartIntervals(maxValue, minValue = 0) { 65 | let [normalMaxValue, exponent] = normalize(maxValue); 66 | let normalMinValue = minValue ? minValue / Math.pow(10, exponent) : 0; 67 | 68 | // Allow only 7 significant digits 69 | normalMaxValue = normalMaxValue.toFixed(6); 70 | 71 | let intervals = getChartRangeIntervals(normalMaxValue, normalMinValue); 72 | intervals = intervals.map((value) => { 73 | // For negative exponents we want to divide by 10^-exponent to avoid 74 | // floating point arithmetic bugs. For instance, in javascript 75 | // 6 * 10^-1 == 0.6000000000000001, we instead want 6 / 10^1 == 0.6 76 | if (exponent < 0) { 77 | return value / Math.pow(10, -exponent); 78 | } 79 | return value * Math.pow(10, exponent); 80 | }); 81 | return intervals; 82 | } 83 | 84 | export function calcChartIntervals(values, withMinimum = true, overrideCeiling=false, overrideFloor=false) { 85 | //*** Where the magic happens *** 86 | 87 | // Calculates best-fit y intervals from given values 88 | // and returns the interval array 89 | 90 | let maxValue = Math.max(...values); 91 | let minValue = Math.min(...values); 92 | 93 | if (overrideCeiling) { 94 | maxValue = overrideCeiling 95 | } 96 | 97 | if (overrideFloor) { 98 | minValue = overrideFloor 99 | } 100 | 101 | // Exponent to be used for pretty print 102 | let exponent = 0, 103 | intervals = []; // eslint-disable-line no-unused-vars 104 | 105 | function getPositiveFirstIntervals(maxValue, absMinValue) { 106 | let intervals = getChartIntervals(maxValue); 107 | 108 | let intervalSize = intervals[1] - intervals[0]; 109 | 110 | // Then unshift the negative values 111 | let value = 0; 112 | for (var i = 1; value < absMinValue; i++) { 113 | value += intervalSize; 114 | intervals.unshift(-1 * value); 115 | } 116 | return intervals; 117 | } 118 | 119 | // CASE I: Both non-negative 120 | 121 | if (maxValue >= 0 && minValue >= 0) { 122 | exponent = normalize(maxValue)[1]; 123 | if (!withMinimum) { 124 | intervals = getChartIntervals(maxValue); 125 | } else { 126 | intervals = getChartIntervals(maxValue, minValue); 127 | } 128 | } 129 | 130 | // CASE II: Only minValue negative 131 | else if (maxValue > 0 && minValue < 0) { 132 | // `withMinimum` irrelevant in this case, 133 | // We'll be handling both sides of zero separately 134 | // (both starting from zero) 135 | // Because ceil() and floor() behave differently 136 | // in those two regions 137 | 138 | let absMinValue = Math.abs(minValue); 139 | 140 | if (maxValue >= absMinValue) { 141 | exponent = normalize(maxValue)[1]; 142 | intervals = getPositiveFirstIntervals(maxValue, absMinValue); 143 | } else { 144 | // Mirror: maxValue => absMinValue, then change sign 145 | exponent = normalize(absMinValue)[1]; 146 | let posIntervals = getPositiveFirstIntervals(absMinValue, maxValue); 147 | intervals = posIntervals.reverse().map((d) => d * -1); 148 | } 149 | } 150 | 151 | // CASE III: Both non-positive 152 | else if (maxValue <= 0 && minValue <= 0) { 153 | // Mirrored Case I: 154 | // Work with positives, then reverse the sign and array 155 | 156 | let pseudoMaxValue = Math.abs(minValue); 157 | let pseudoMinValue = Math.abs(maxValue); 158 | 159 | exponent = normalize(pseudoMaxValue)[1]; 160 | if (!withMinimum) { 161 | intervals = getChartIntervals(pseudoMaxValue); 162 | } else { 163 | intervals = getChartIntervals(pseudoMaxValue, pseudoMinValue); 164 | } 165 | 166 | intervals = intervals.reverse().map((d) => d * -1); 167 | } 168 | 169 | return intervals.sort((a, b) => a - b); 170 | } 171 | 172 | export function getZeroIndex(yPts) { 173 | let zeroIndex; 174 | let interval = getIntervalSize(yPts); 175 | if (yPts.indexOf(0) >= 0) { 176 | // the range has a given zero 177 | // zero-line on the chart 178 | zeroIndex = yPts.indexOf(0); 179 | } else if (yPts[0] > 0) { 180 | // Minimum value is positive 181 | // zero-line is off the chart: below 182 | let min = yPts[0]; 183 | zeroIndex = (-1 * min) / interval; 184 | } else { 185 | // Maximum value is negative 186 | // zero-line is off the chart: above 187 | let max = yPts[yPts.length - 1]; 188 | zeroIndex = (-1 * max) / interval + (yPts.length - 1); 189 | } 190 | return zeroIndex; 191 | } 192 | 193 | export function getRealIntervals(max, noOfIntervals, min = 0, asc = 1) { 194 | let range = max - min; 195 | let part = (range * 1.0) / noOfIntervals; 196 | let intervals = []; 197 | 198 | for (var i = 0; i <= noOfIntervals; i++) { 199 | intervals.push(min + part * i); 200 | } 201 | 202 | return asc ? intervals : intervals.reverse(); 203 | } 204 | 205 | export function getIntervalSize(orderedArray) { 206 | return orderedArray[1] - orderedArray[0]; 207 | } 208 | 209 | export function getValueRange(orderedArray) { 210 | return orderedArray[orderedArray.length - 1] - orderedArray[0]; 211 | } 212 | 213 | export function scale(val, yAxis) { 214 | return floatTwo(yAxis.zeroLine - val * yAxis.scaleMultiplier); 215 | } 216 | 217 | export function isInRange(val, min, max) { 218 | return val > min && val < max; 219 | } 220 | 221 | export function isInRange2D(coord, minCoord, maxCoord) { 222 | return ( 223 | isInRange(coord[0], minCoord[0], maxCoord[0]) && 224 | isInRange(coord[1], minCoord[1], maxCoord[1]) 225 | ); 226 | } 227 | 228 | export function getClosestInArray(goal, arr, index = false) { 229 | let closest = arr.reduce(function (prev, curr) { 230 | return Math.abs(curr - goal) < Math.abs(prev - goal) ? curr : prev; 231 | }, []); 232 | 233 | return index ? arr.indexOf(closest) : closest; 234 | } 235 | 236 | export function calcDistribution(values, distributionSize) { 237 | // Assume non-negative values, 238 | // implying distribution minimum at zero 239 | 240 | let dataMaxValue = Math.max(...values); 241 | 242 | let distributionStep = 1 / (distributionSize - 1); 243 | let distribution = []; 244 | 245 | for (var i = 0; i < distributionSize; i++) { 246 | let checkpoint = dataMaxValue * (distributionStep * i); 247 | distribution.push(checkpoint); 248 | } 249 | 250 | return distribution; 251 | } 252 | 253 | export function getMaxCheckpoint(value, distribution) { 254 | return distribution.filter((d) => d < value).length; 255 | } 256 | -------------------------------------------------------------------------------- /src/js/utils/test/colors.test.js: -------------------------------------------------------------------------------- 1 | const assert = require("assert"); 2 | const colors = require("../colors"); 3 | 4 | describe("utils.colors", () => { 5 | it("should return #aaabac for RGB()", () => { 6 | assert.equal(colors.getColor("rgb(170, 171, 172)"), "#aaabac"); 7 | }); 8 | it("should return #ff5858 for the named color red", () => { 9 | assert.equal(colors.getColor("red"), "#ff5858d"); 10 | }); 11 | it("should return #1a5c29 for the hex color #1a5c29", () => { 12 | assert.equal(colors.getColor("#1a5c29"), "#1a5c29"); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/js/utils/test/helpers.test.js: -------------------------------------------------------------------------------- 1 | const assert = require("assert"); 2 | const helpers = require("../helpers"); 3 | 4 | describe("utils.helpers", () => { 5 | it("should return a value fixed upto 2 decimals", () => { 6 | assert.equal(helpers.floatTwo(1.234), 1.23); 7 | assert.equal(helpers.floatTwo(1.456), 1.46); 8 | assert.equal(helpers.floatTwo(1), 1.0); 9 | }); 10 | }); 11 | --------------------------------------------------------------------------------