├── .babelrc ├── .eslintrc.json ├── .gitignore ├── .sass-lint.yml ├── .travis.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── dist ├── css │ ├── main.css │ ├── main.min.css │ ├── theme.css │ └── theme.min.css └── js │ ├── funnel-graph.js │ └── funnel-graph.min.js ├── examples ├── example-multiple.html ├── example.html └── favicon.png ├── gulpfile.babel.js ├── index.js ├── package-lock.json ├── package.json ├── src ├── js │ ├── graph.js │ ├── main.js │ ├── number.js │ ├── path.js │ └── random.js └── scss │ ├── _animations.scss │ ├── _variables.scss │ ├── main.scss │ └── theme.scss └── test └── test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["env", {"targets": {"node": "current"}}]] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 6 4 | }, 5 | "extends": "airbnb-base", 6 | "rules": { 7 | "indent": ["error", 4], 8 | "no-plusplus": ["error", { "allowForLoopAfterthoughts": true }], 9 | "max-len": ["error", { "code": 121 }], 10 | "comma-dangle": ["error", "never"] 11 | }, 12 | "globals": { 13 | "window": true, 14 | "document": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | .idea/ 4 | .nyc_output/ 5 | -------------------------------------------------------------------------------- /.sass-lint.yml: -------------------------------------------------------------------------------- 1 | # sass-lint config generated by make-sass-lint-config v0.1.2 2 | # original src: https://github.com/airbnb/css/blob/master/.scss-lint.yml 3 | # 4 | # The following scss-lint Linters are not yet supported by sass-lint: 5 | # PrivateNamingConvention 6 | 7 | options: 8 | formatter: stylish 9 | rules: 10 | indentation: 11 | - 'tab' 12 | - size: 'tab' 13 | bem-depth: 1 14 | border-zero: 15 | - 1 16 | - convention: zero 17 | brace-style: 18 | - 1 19 | - allow-single-line: false 20 | class-name-format: 21 | - 1 22 | - convention: ^(?!js-).* 23 | convention-explanation: should not be written in the form js-* 24 | extends-before-declarations: 0 25 | extends-before-mixins: 0 26 | function-name-format: 1 27 | id-name-format: 28 | - 1 29 | - convention: hyphenatedbem 30 | leading-zero: 0 31 | mixin-name-format: 1 32 | mixins-before-declarations: 0 33 | no-extends: 1 34 | no-qualifying-elements: 0 35 | placeholder-name-format: 36 | - 1 37 | - convention: hyphenatedbem 38 | property-sort-order: 0 39 | quotes: 40 | - 1 41 | - style: double 42 | variable-name-format: 1 43 | nesting-depth: 44 | - 0 45 | no-url-domains: 0 46 | no-url-protocols: 0 47 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | install: 5 | - npm rebuild node-sass 6 | - npm install 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 1.4.2 (Feb 22, 2020) 2 | 3 | * Fix gradient ID collision 4 | * Add ability to pass HTML DOM Element instance as container param 5 | 6 | ### 1.4.1 (Jan 29, 2020) 7 | 8 | * Fix dynamic data update 9 | 10 | ### 1.4.0 (Jan 26, 2020) 11 | 12 | * Add ability to handle data with all zero values 13 | * Allow decimals for values 14 | * Update packages 15 | 16 | ### 1.3.9 (Jun 9, 2019) 17 | 18 | * Improve IE compatibility 19 | 20 | ### 1.3.8 (May 29, 2019) 21 | 22 | * Fix bug with zero values 23 | 24 | ### 1.3.7 (Apr 12, 2019) 25 | 26 | * Add option to display sub-label raw value 27 | 28 | ### 1.3.6 (Mar 6, 2019) 29 | 30 | * Fix theme issue 31 | 32 | ### 1.3.5 (Mar 1, 2019) 33 | 34 | * Add method to create center line for shapes 35 | 36 | ### 1.3.4 (Feb 27, 2019) 37 | 38 | * Update theme 39 | 40 | ### 1.3.3 (Feb 22, 2019) 41 | 42 | * Fix package importing 43 | * Add repository to package.json 44 | * Fix SCSS linting warnings 45 | 46 | ### 1.3.2 (Feb 22, 2019) 47 | 48 | * Move path creation to separate module 49 | 50 | ### 1.3.1 (Feb 19, 2019) 51 | 52 | * Add Browserify and make modules 53 | * Add tests 54 | * Add methods to update graph 55 | * Add segment percentages 56 | 57 | ### 1.2.1 (Feb 3, 2019) 58 | 59 | * Add legend for segments in two-dimensional funnel graph 60 | 61 | ### 1.1.1 (Feb 3, 2019) 62 | 63 | * Add new theme 64 | 65 | ### 1.1.0 (Feb 3, 2019) 66 | 67 | * Add support for two-dimensional funnel graph 68 | 69 | ### 1.0.0 (Jan 25, 2019) 70 | 71 | Initial release includes: 72 | * Simple funnel graph 73 | * Vertical funnel graph 74 | * Solid color and gradient 75 | * Gradient direction control 76 | * Default theme 77 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at greg.hovanesyan@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Greg Hovanesyan 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FunnelGraph.js 2 | 3 | ![npm](https://img.shields.io/npm/v/funnel-graph-js.svg) 4 | [![Build Status](https://travis-ci.org/greghub/funnel-graph-js.svg?branch=master)](https://travis-ci.org/greghub/funnel-graph-js) 5 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/greghub/funnel-graph-js/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/greghub/funnel-graph-js/?branch=master) 6 | ![GitHub file size in bytes](https://img.shields.io/github/size/greghub/funnel-graph-js/dist/js/funnel-graph.min.js.svg) 7 | ![GitHub](https://img.shields.io/github/license/greghub/funnel-graph-js.svg) 8 | ![GitHub last commit](https://img.shields.io/github/last-commit/greghub/funnel-graph-js.svg) 9 | [![Gitter](https://img.shields.io/gitter/room/greghub/funnel-graph-js.svg)](https://gitter.im/funnel-graph-js/community) 10 | 11 | Funnel Graph JS is a library for generating a funnel chart. It generates an SVG chart, adds labels, legend and other info. 12 | Some of the features include generating horizontal and vertical funnel charts, applying solid colors and gradients, 13 | possibility to generate a two-dimensional funnel chart. 14 | 15 | SVG Two Dimensional Funnel Graph 16 | 17 | FunnelGraph.js is also available as a Vue.js component: [Vue Funnel Graph](https://github.com/greghub/vue-funnel-graph-js) 18 | 19 | ## Table of Contents 20 | 21 | - [Installation](#installation) 22 | - [Usage](#usage) 23 | - [Options](#options) 24 | - [Methods](#methods) 25 | 26 | ## Installation 27 | 28 | You can get the code by installing the NPM package, loading files from a CDN or downloading the repo. 29 | 30 | #### NPM 31 | 32 | Run the following script to install: 33 | ``` 34 | npm i funnel-graph-js 35 | ``` 36 | 37 | #### CDN 38 | 39 | ```html 40 | 41 | 42 | 43 | 44 | ``` 45 | 46 | #### Download 47 | 48 | Download the repo ZIP, add `funnel-graph.js` or `funnel-graph.min.js`, and `main.css` or `main.min.css`. 49 | Optionally add `theme.min.css` to include the styling for labels, legend etc. 50 | It is recommended to add the theme, to display the chart correctly. 51 | 52 | FunnelGraph.js is built in a way that most of the styling is controlled by theme file, 53 | so it is possible to adapt every element to your design. The chart is a SVG element and 54 | `colors` property of the options controls the colors of the chart. 55 | 56 | CSS: 57 | ```html 58 | 59 | 60 | ``` 61 | 62 | JS: 63 | ```html 64 | 65 | ``` 66 | 67 | ## Usage 68 | 69 | ```js 70 | var graph = new FunnelGraph({ 71 | container: '.funnel', 72 | gradientDirection: 'horizontal', 73 | data: {...}, 74 | displayPercent: true, 75 | direction: 'horizontal' 76 | }); 77 | 78 | graph.draw(); 79 | ``` 80 | 81 | You can choose how you want to display your data on funnel graph. 82 | You can display exact numbers, you can display percentages or both. 83 | The library will generate percentages automatically, 84 | taking the largest number as 100% and then calculating 85 | other numbers as a fraction of the largest number. 86 | For example: 12000, 5700 and 360 will be displayed as 47.5% and 3% 87 | (100% is skipped in order to avoid redundancy). 88 | 89 | Provided values | 12000 | 5700 | 360 | 90 | |---------------|-------|-------|-----| 91 | Display values | 12,000 | 5,700 | 360 | 92 | Calculated percentages | | 47.5% | 3% | 93 | 94 | If you want to hide percentages you set `displayPercent` to `false`: 95 | 96 | ```js 97 | { 98 | displayPercent: false 99 | } 100 | ``` 101 | 102 | You can also display a vertical funnel graph: 103 | ```js 104 | { 105 | direction: 'vertical' 106 | } 107 | ``` 108 | 109 | If you want to add a solid color to your funnel: 110 | ```js 111 | { 112 | color: '#FF5500' 113 | } 114 | ``` 115 | 116 | And if you want a gradient: 117 | ```js 118 | { 119 | color: ['orange', 'red'] 120 | } 121 | ``` 122 | An array containing only one color will have the same effect 123 | as passing a single color as a string. 124 | 125 | If you are using a gradient you can control the gradient direction using: 126 | 127 | ```js 128 | { 129 | gradientDirection: 'vertical' // defaults to 'horizontal' 130 | } 131 | ``` 132 | 133 | There are 3 ways to define data for the funnel graph. 134 | 135 | The most simple way is do define a data array: 136 | 137 | ```js 138 | data: [12000, 5700, 360] 139 | ``` 140 | 141 | this will create the data without any titles. However you can still display the values as percentages, as number or both. 142 | 143 | If you want to add labels to your numbers pass an array of labels to `data`. 144 | 145 | ```js 146 | data: { 147 | labels: ['Impressions', 'Add To Cart', 'Buy'], 148 | colors: ['orange', 'red'], 149 | values: [12000, 5700, 360] 150 | }, 151 | ``` 152 | 153 | That most explicit way to add data to the funnel graph. 154 | 155 | 156 | SVG Funnel Graph 157 | 158 | If using one of those two ways, you can control the graph 159 | color using `colors` param. Otherwise, the default color will be used. 160 | And if you are using gradient as color, then you can control 161 | gradient direction with `gradientDirection` param. 162 | `colors` shall be passed inside `data`, while `gradientDirection` with other options. 163 | 164 | ```js 165 | data: { 166 | gradientDirection: 'horizontal' 167 | } 168 | ``` 169 | 170 | Otherwise it defaults to horizontal (left to right). 171 | 172 | ## Two-dimensional funnel graph 173 | 174 | If you want to break down your data into more details, 175 | you can use two-dimensional svg funnel graph. It will 176 | generate a graph like this: 177 | 178 | SVG Two Dimensional Funnel Graph 179 | 180 | 181 | In this example we will add more details to the previous example. 182 | We have Impressions, Add To Cart and Buy data, however this time 183 | we also want to visualize the data sources. So we want to see 184 | the traffic sources, how much of them are direct, from ads 185 | and from social media. 186 | 187 | ```js 188 | data: { 189 | labels: ['Impressions', 'Add To Cart', 'Buy'], 190 | subLabels: ['Direct', 'Social Media', 'Ads'], 191 | colors: [ 192 | ['#FFB178', '#FF78B1', '#FF3C8E'], 193 | 'red', 194 | ['blue'] 195 | ], 196 | values: [ 197 | [2000, 4000, 6000], 198 | [3000, 1000, 1700], 199 | [200, 30, 130] 200 | ] 201 | } 202 | ``` 203 | 204 | In a two-dimensional graph each segment shall have it's own color or gradient. 205 | If using a gradient the `gradientDirection` option will be applied to all of the segments. 206 | However all supported ways of defining colors in a simple funnel graph can be used here as 207 | well and you can have both solid colors and gradients applied to segments of a single graph. 208 | In the above example first segment, "Direct", will have a gradient, 209 | "Social Media" will have a solid red color, and "Ads" segment will have a solid blue. 210 | 211 | ## Options 212 | 213 | | Option | Description | Type | Required | Options | Default | Example | 214 | |--------|-------------|------|----------|---------|---------|---------| 215 | | `container` | Selector of the element that will hold the chart | `string` | Yes | | | '.funnel-container' | 216 | | `direction` | Whether the chart visualization is displayed vertically or horizontally | `string` | No | 'vertical', 'horizontal' | 'horizontal' | | 217 | | `gradientDirection` | Whether the gradient applied to the segments of the graph is displayed from top to bottom or from left to right | `string` | No | 'vertical', 'horizontal' | 'horizontal' | 218 | | `displayPercent` | Whether to display the automatically calculated percentage values below the labels | `boolean` | No | `true`, `false` | `true` | | 219 | | `data` | Object containing information about values, labels and colors of the chart | `object` | Yes | | | | 220 | | `width` | Width of the funnel graph | `number` | No | | Container width | 800 | 221 | | `height` | Height of the funnel graph | `number` | No | | Container height | 300 | 222 | | `subLabelValue` | Whether display percentage or real value of segment | `string` | No | `percent`, `raw` | `percent` | 223 | 224 | ## Methods 225 | 226 | | Method | Description | Example | 227 | |--------|-------------|---------| 228 | | `makeVertical()` | Display chart vertically | | 229 | | `makeHorizontal()` | Display chart horizontally | | 230 | | `toggleDirection()` | Toggle direction of chart | | 231 | | `gradientMakeVertical()` | Display gradient on all sections from top to bottom | | 232 | | `gradientMakeHorizontal()` | Display gradient on all sections from left to right | | 233 | | `gradientToggleDirection()` | Toggle direction of gradient on all sections | | 234 | | `updateHeight()` | Update funnel graph height | | 235 | | `updateWidth()` | Update funnel graph width | | 236 | | `updateData({data})` | Update funnel graph data | ```labels: ['Stage 1', 'Stage 2', 'Stage 3']``` | 237 | | `update({options})` | Update funnel options | ```gradientDirection: 'horizontal', data: {...}, displayPercent: true, direction: 'horizontal', height: 300, width: 500``` | 238 | -------------------------------------------------------------------------------- /dist/css/main.css: -------------------------------------------------------------------------------- 1 | .svg-funnel-js { 2 | display: inline-block; 3 | position: relative; } 4 | .svg-funnel-js svg { 5 | display: block; } 6 | .svg-funnel-js .svg-funnel-js__labels { 7 | position: absolute; 8 | display: flex; 9 | width: 100%; 10 | height: 100%; 11 | top: 0; 12 | left: 0; } 13 | .svg-funnel-js.svg-funnel-js--vertical .svg-funnel-js__labels { 14 | flex-direction: column; } 15 | -------------------------------------------------------------------------------- /dist/css/main.min.css: -------------------------------------------------------------------------------- 1 | .svg-funnel-js{display:inline-block;position:relative}.svg-funnel-js svg{display:block}.svg-funnel-js .svg-funnel-js__labels{position:absolute;display:-webkit-box;display:-ms-flexbox;display:flex;width:100%;height:100%;top:0;left:0}.svg-funnel-js.svg-funnel-js--vertical .svg-funnel-js__labels{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column} -------------------------------------------------------------------------------- /dist/css/theme.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css?family=Open+Sans:400,700"); 2 | body { 3 | -webkit-font-smoothing: antialiased; 4 | -moz-osx-font-smoothing: grayscale; } 5 | 6 | .svg-funnel-js { 7 | font-family: "Open Sans", sans-serif; } 8 | .svg-funnel-js .svg-funnel-js__container { 9 | width: 100%; 10 | height: 100%; } 11 | .svg-funnel-js .svg-funnel-js__labels { 12 | width: 100%; 13 | box-sizing: border-box; } 14 | .svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label { 15 | flex: 1 1 0; 16 | position: relative; } 17 | .svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__value { 18 | font-size: 24px; 19 | color: #fff; 20 | line-height: 18px; 21 | margin-bottom: 6px; } 22 | .svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__title { 23 | font-size: 12px; 24 | font-weight: bold; 25 | color: #21ffa2; } 26 | .svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__percentage { 27 | font-size: 16px; 28 | font-weight: bold; 29 | color: #9896dc; } 30 | .svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__segment-percentages { 31 | position: absolute; 32 | top: 50%; 33 | transform: translateY(-50%); 34 | width: 100%; 35 | left: 0; 36 | padding: 8px 24px; 37 | box-sizing: border-box; 38 | background-color: rgba(8, 7, 48, 0.8); 39 | margin-top: 24px; 40 | opacity: 0; 41 | transition: opacity 0.1s ease; 42 | cursor: default; } 43 | .svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__segment-percentages ul { 44 | margin: 0; 45 | padding: 0; 46 | list-style-type: none; } 47 | .svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__segment-percentages ul li { 48 | font-size: 13px; 49 | line-height: 16px; 50 | color: #fff; 51 | margin: 18px 0; } 52 | .svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__segment-percentages ul li .percentage__list-label { 53 | font-weight: bold; 54 | color: #05df9d; } 55 | .svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label:hover .label__segment-percentages { 56 | opacity: 1; } 57 | .svg-funnel-js:not(.svg-funnel-js--vertical) { 58 | padding-top: 64px; 59 | padding-bottom: 16px; } 60 | .svg-funnel-js:not(.svg-funnel-js--vertical) .svg-funnel-js__label { 61 | padding-left: 24px; } 62 | .svg-funnel-js:not(.svg-funnel-js--vertical) .svg-funnel-js__label:not(:first-child) { 63 | border-left: 1px solid #9896dc; } 64 | .svg-funnel-js.svg-funnel-js--vertical { 65 | padding-left: 120px; 66 | padding-right: 16px; } 67 | .svg-funnel-js.svg-funnel-js--vertical .svg-funnel-js__label { 68 | padding-top: 24px; } 69 | .svg-funnel-js.svg-funnel-js--vertical .svg-funnel-js__label:not(:first-child) { 70 | border-top: 1px solid #9896dc; } 71 | .svg-funnel-js.svg-funnel-js--vertical .svg-funnel-js__label .label__segment-percentages { 72 | margin-top: 0; 73 | margin-left: 106px; 74 | width: calc(100% - 106px); } 75 | .svg-funnel-js.svg-funnel-js--vertical .svg-funnel-js__label .label__segment-percentages .segment-percentage__list { 76 | display: flex; 77 | justify-content: space-around; } 78 | .svg-funnel-js .svg-funnel-js__subLabels { 79 | display: flex; 80 | justify-content: center; 81 | margin-top: 24px; 82 | position: absolute; 83 | width: 100%; 84 | left: 0; } 85 | .svg-funnel-js .svg-funnel-js__subLabels .svg-funnel-js__subLabel { 86 | display: flex; 87 | font-size: 12px; 88 | color: #fff; 89 | line-height: 16px; } 90 | .svg-funnel-js .svg-funnel-js__subLabels .svg-funnel-js__subLabel:not(:first-child) { 91 | margin-left: 16px; } 92 | .svg-funnel-js .svg-funnel-js__subLabels .svg-funnel-js__subLabel .svg-funnel-js__subLabel--color { 93 | width: 12px; 94 | height: 12px; 95 | border-radius: 50%; 96 | margin: 2px 8px 2px 0; } 97 | -------------------------------------------------------------------------------- /dist/css/theme.min.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css?family=Open+Sans:400,700");body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.svg-funnel-js{font-family:Open Sans,sans-serif}.svg-funnel-js .svg-funnel-js__container{width:100%;height:100%}.svg-funnel-js .svg-funnel-js__labels{width:100%;-webkit-box-sizing:border-box;box-sizing:border-box}.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label{-webkit-box-flex:1;-ms-flex:1 1 0px;flex:1 1 0;position:relative}.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__value{font-size:24px;color:#fff;line-height:18px;margin-bottom:6px}.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__title{font-size:12px;font-weight:700;color:#21ffa2}.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__percentage{font-size:16px;font-weight:700;color:#9896dc}.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__segment-percentages{position:absolute;top:50%;-webkit-transform:translateY(-50%);transform:translateY(-50%);width:100%;left:0;padding:8px 24px;-webkit-box-sizing:border-box;box-sizing:border-box;background-color:rgba(8,7,48,.8);margin-top:24px;opacity:0;-webkit-transition:opacity .1s ease;transition:opacity .1s ease;cursor:default}.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__segment-percentages ul{margin:0;padding:0;list-style-type:none}.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__segment-percentages ul li{font-size:13px;line-height:16px;color:#fff;margin:18px 0}.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__segment-percentages ul li .percentage__list-label{font-weight:700;color:#05df9d}.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label:hover .label__segment-percentages{opacity:1}.svg-funnel-js:not(.svg-funnel-js--vertical){padding-top:64px;padding-bottom:16px}.svg-funnel-js:not(.svg-funnel-js--vertical) .svg-funnel-js__label{padding-left:24px}.svg-funnel-js:not(.svg-funnel-js--vertical) .svg-funnel-js__label:not(:first-child){border-left:1px solid #9896dc}.svg-funnel-js.svg-funnel-js--vertical{padding-left:120px;padding-right:16px}.svg-funnel-js.svg-funnel-js--vertical .svg-funnel-js__label{padding-top:24px}.svg-funnel-js.svg-funnel-js--vertical .svg-funnel-js__label:not(:first-child){border-top:1px solid #9896dc}.svg-funnel-js.svg-funnel-js--vertical .svg-funnel-js__label .label__segment-percentages{margin-top:0;margin-left:106px;width:calc(100% - 106px)}.svg-funnel-js.svg-funnel-js--vertical .svg-funnel-js__label .label__segment-percentages .segment-percentage__list{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-pack:distribute;justify-content:space-around}.svg-funnel-js .svg-funnel-js__subLabels{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;margin-top:24px;position:absolute;width:100%;left:0}.svg-funnel-js .svg-funnel-js__subLabels .svg-funnel-js__subLabel{display:-webkit-box;display:-ms-flexbox;display:flex;font-size:12px;color:#fff;line-height:16px}.svg-funnel-js .svg-funnel-js__subLabels .svg-funnel-js__subLabel:not(:first-child){margin-left:16px}.svg-funnel-js .svg-funnel-js__subLabels .svg-funnel-js__subLabel .svg-funnel-js__subLabel--color{width:12px;height:12px;border-radius:50%;margin:2px 8px 2px 0} -------------------------------------------------------------------------------- /dist/js/funnel-graph.js: -------------------------------------------------------------------------------- 1 | (function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.FunnelGraph = f()}})(function(){var define,module,exports;return (function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { 28 | attributes[_key - 1] = arguments[_key]; 29 | } 30 | 31 | attributes.forEach(function (attribute) { 32 | element.removeAttribute(attribute); 33 | }); 34 | }; 35 | 36 | exports.removeAttrs = removeAttrs; 37 | 38 | var createSVGElement = function createSVGElement(element, container, attributes) { 39 | var el = document.createElementNS('http://www.w3.org/2000/svg', element); 40 | 41 | if (_typeof(attributes) === 'object') { 42 | setAttrs(el, attributes); 43 | } 44 | 45 | if (typeof container !== 'undefined') { 46 | container.appendChild(el); 47 | } 48 | 49 | return el; 50 | }; 51 | 52 | exports.createSVGElement = createSVGElement; 53 | 54 | var generateLegendBackground = function generateLegendBackground(color) { 55 | var direction = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'horizontal'; 56 | 57 | if (typeof color === 'string') { 58 | return "background-color: ".concat(color); 59 | } 60 | 61 | if (color.length === 1) { 62 | return "background-color: ".concat(color[0]); 63 | } 64 | 65 | return "background-image: linear-gradient(".concat(direction === 'horizontal' ? 'to right, ' : '').concat(color.join(', '), ")"); 66 | }; 67 | 68 | exports.generateLegendBackground = generateLegendBackground; 69 | var defaultColors = ['#FF4589', '#FF5050', '#05DF9D', '#4FF2FD', '#2D9CDB', '#A0BBFF', '#FFD76F', '#F2C94C', '#FF9A9A', '#FFB178']; 70 | exports.defaultColors = defaultColors; 71 | 72 | var getDefaultColors = function getDefaultColors(number) { 73 | var colors = [].concat(defaultColors); 74 | var colorSet = []; 75 | 76 | for (var i = 0; i < number; i++) { 77 | // get a random color 78 | var index = Math.abs(Math.round(Math.random() * (colors.length - 1))); // push it to the list 79 | 80 | colorSet.push(colors[index]); // and remove it, so that it is not chosen again 81 | 82 | colors.splice(index, 1); 83 | } 84 | 85 | return colorSet; 86 | }; 87 | /* 88 | Used in comparing existing values to value provided on update 89 | It is limited to comparing arrays on purpose 90 | Name is slightly unusual, in order not to be confused with Lodash method 91 | */ 92 | 93 | 94 | exports.getDefaultColors = getDefaultColors; 95 | 96 | var areEqual = function areEqual(value, newValue) { 97 | // If values are not of the same type 98 | var type = Object.prototype.toString.call(value); 99 | if (type !== Object.prototype.toString.call(newValue)) return false; 100 | if (type !== '[object Array]') return false; 101 | if (value.length !== newValue.length) return false; 102 | 103 | for (var i = 0; i < value.length; i++) { 104 | // if the it's a two dimensional array 105 | var currentType = Object.prototype.toString.call(value[i]); 106 | if (currentType !== Object.prototype.toString.call(newValue[i])) return false; 107 | 108 | if (currentType === '[object Array]') { 109 | // if row lengths are not equal then arrays are not equal 110 | if (value[i].length !== newValue[i].length) return false; // compare each element in the row 111 | 112 | for (var j = 0; j < value[i].length; j++) { 113 | if (value[i][j] !== newValue[i][j]) { 114 | return false; 115 | } 116 | } 117 | } else if (value[i] !== newValue[i]) { 118 | // if it's a one dimensional array element 119 | return false; 120 | } 121 | } 122 | 123 | return true; 124 | }; 125 | 126 | exports.areEqual = areEqual; 127 | 128 | },{}],3:[function(require,module,exports){ 129 | "use strict"; 130 | 131 | Object.defineProperty(exports, "__esModule", { 132 | value: true 133 | }); 134 | exports.default = void 0; 135 | 136 | var _number = require("./number"); 137 | 138 | var _path = require("./path"); 139 | 140 | var _graph = require("./graph"); 141 | 142 | var _random = _interopRequireDefault(require("./random")); 143 | 144 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 145 | 146 | function _typeof(obj) { "@babel/helpers - typeof"; if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); } 147 | 148 | function _toConsumableArray(arr) { return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _nonIterableSpread(); } 149 | 150 | function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance"); } 151 | 152 | function _iterableToArray(iter) { if (Symbol.iterator in Object(iter) || Object.prototype.toString.call(iter) === "[object Arguments]") return Array.from(iter); } 153 | 154 | function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = new Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } } 155 | 156 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 157 | 158 | function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } 159 | 160 | function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } 161 | 162 | var FunnelGraph = 163 | /*#__PURE__*/ 164 | function () { 165 | function FunnelGraph(options) { 166 | _classCallCheck(this, FunnelGraph); 167 | 168 | this.containerSelector = options.container; 169 | this.gradientDirection = options.gradientDirection && options.gradientDirection === 'vertical' ? 'vertical' : 'horizontal'; 170 | this.direction = options.direction && options.direction === 'vertical' ? 'vertical' : 'horizontal'; 171 | this.labels = FunnelGraph.getLabels(options); 172 | this.subLabels = FunnelGraph.getSubLabels(options); 173 | this.values = FunnelGraph.getValues(options); 174 | this.percentages = this.createPercentages(); 175 | this.colors = options.data.colors || (0, _graph.getDefaultColors)(this.is2d() ? this.getSubDataSize() : 2); 176 | this.displayPercent = options.displayPercent || false; 177 | this.data = options.data; 178 | this.height = options.height; 179 | this.width = options.width; 180 | this.subLabelValue = options.subLabelValue || 'percent'; 181 | } 182 | /** 183 | An example of a two-dimensional funnel graph 184 | #0.................. 185 | ...#1................ 186 | ...... 187 | #0********************#1** #2.........................#3 (A) 188 | ******************* 189 | #2*************************#3 (B) 190 | #2+++++++++++++++++++++++++#3 (C) 191 | +++++++++++++++++++ 192 | #0++++++++++++++++++++#1++ #2-------------------------#3 (D) 193 | ------ 194 | ---#1---------------- 195 | #0----------------- 196 | Main axis is the primary axis of the graph. 197 | In a horizontal graph it's the X axis, and Y is the cross axis. 198 | However we use the names "main" and "cross" axis, 199 | because in a vertical graph the primary axis is the Y axis 200 | and the cross axis is the X axis. 201 | First step of drawing the funnel graph is getting the coordinates of points, 202 | that are used when drawing the paths. 203 | There are 4 paths in the example above: A, B, C and D. 204 | Such funnel has 3 labels and 3 subLabels. 205 | This means that the main axis has 4 points (number of labels + 1) 206 | One the ASCII illustrated graph above, those points are illustrated with a # symbol. 207 | */ 208 | 209 | 210 | _createClass(FunnelGraph, [{ 211 | key: "getMainAxisPoints", 212 | value: function getMainAxisPoints() { 213 | var size = this.getDataSize(); 214 | var points = []; 215 | var fullDimension = this.isVertical() ? this.getHeight() : this.getWidth(); 216 | 217 | for (var i = 0; i <= size; i++) { 218 | points.push((0, _number.roundPoint)(fullDimension * i / size)); 219 | } 220 | 221 | return points; 222 | } 223 | }, { 224 | key: "getCrossAxisPoints", 225 | value: function getCrossAxisPoints() { 226 | var points = []; 227 | var fullDimension = this.getFullDimension(); // get half of the graph container height or width, since funnel shape is symmetric 228 | // we use this when calculating the "A" shape 229 | 230 | var dimension = fullDimension / 2; 231 | 232 | if (this.is2d()) { 233 | var totalValues = this.getValues2d(); 234 | var max = Math.max.apply(Math, _toConsumableArray(totalValues)); // duplicate last value 235 | 236 | totalValues.push(_toConsumableArray(totalValues).pop()); // get points for path "A" 237 | 238 | points.push(totalValues.map(function (value) { 239 | return (0, _number.roundPoint)((max - value) / max * dimension); 240 | })); // percentages with duplicated last value 241 | 242 | var percentagesFull = this.getPercentages2d(); 243 | var pointsOfFirstPath = points[0]; 244 | 245 | for (var i = 1; i < this.getSubDataSize(); i++) { 246 | var p = points[i - 1]; 247 | var newPoints = []; 248 | 249 | for (var j = 0; j < this.getDataSize(); j++) { 250 | newPoints.push((0, _number.roundPoint)( // eslint-disable-next-line comma-dangle 251 | p[j] + (fullDimension - pointsOfFirstPath[j] * 2) * (percentagesFull[j][i - 1] / 100))); 252 | } // duplicate the last value as points #2 and #3 have the same value on the cross axis 253 | 254 | 255 | newPoints.push([].concat(newPoints).pop()); 256 | points.push(newPoints); 257 | } // add points for path "D", that is simply the "inverted" path "A" 258 | 259 | 260 | points.push(pointsOfFirstPath.map(function (point) { 261 | return fullDimension - point; 262 | })); 263 | } else { 264 | // As you can see on the visualization above points #2 and #3 have the same cross axis coordinate 265 | // so we duplicate the last value 266 | var _max = Math.max.apply(Math, _toConsumableArray(this.values)); 267 | 268 | var values = _toConsumableArray(this.values).concat(_toConsumableArray(this.values).pop()); // if the graph is simple (not two-dimensional) then we have only paths "A" and "D" 269 | // which are symmetric. So we get the points for "A" and then get points for "D" by subtracting "A" 270 | // points from graph cross dimension length 271 | 272 | 273 | points.push(values.map(function (value) { 274 | return (0, _number.roundPoint)((_max - value) / _max * dimension); 275 | })); 276 | points.push(points[0].map(function (point) { 277 | return fullDimension - point; 278 | })); 279 | } 280 | 281 | return points; 282 | } 283 | }, { 284 | key: "getGraphType", 285 | value: function getGraphType() { 286 | return this.values && this.values[0] instanceof Array ? '2d' : 'normal'; 287 | } 288 | }, { 289 | key: "is2d", 290 | value: function is2d() { 291 | return this.getGraphType() === '2d'; 292 | } 293 | }, { 294 | key: "isVertical", 295 | value: function isVertical() { 296 | return this.direction === 'vertical'; 297 | } 298 | }, { 299 | key: "getDataSize", 300 | value: function getDataSize() { 301 | return this.values.length; 302 | } 303 | }, { 304 | key: "getSubDataSize", 305 | value: function getSubDataSize() { 306 | return this.values[0].length; 307 | } 308 | }, { 309 | key: "getFullDimension", 310 | value: function getFullDimension() { 311 | return this.isVertical() ? this.getWidth() : this.getHeight(); 312 | } 313 | }, { 314 | key: "addLabels", 315 | value: function addLabels() { 316 | var _this = this; 317 | 318 | var holder = document.createElement('div'); 319 | holder.setAttribute('class', 'svg-funnel-js__labels'); 320 | this.percentages.forEach(function (percentage, index) { 321 | var labelElement = document.createElement('div'); 322 | labelElement.setAttribute('class', "svg-funnel-js__label label-".concat(index + 1)); 323 | var title = document.createElement('div'); 324 | title.setAttribute('class', 'label__title'); 325 | title.textContent = _this.labels[index] || ''; 326 | var value = document.createElement('div'); 327 | value.setAttribute('class', 'label__value'); 328 | var valueNumber = _this.is2d() ? _this.getValues2d()[index] : _this.values[index]; 329 | value.textContent = (0, _number.formatNumber)(valueNumber); 330 | var percentageValue = document.createElement('div'); 331 | percentageValue.setAttribute('class', 'label__percentage'); 332 | percentageValue.textContent = "".concat(percentage.toString(), "%"); 333 | labelElement.appendChild(value); 334 | labelElement.appendChild(title); 335 | 336 | if (_this.displayPercent) { 337 | labelElement.appendChild(percentageValue); 338 | } 339 | 340 | if (_this.is2d()) { 341 | var segmentPercentages = document.createElement('div'); 342 | segmentPercentages.setAttribute('class', 'label__segment-percentages'); 343 | var percentageList = ''; 353 | segmentPercentages.innerHTML = percentageList; 354 | labelElement.appendChild(segmentPercentages); 355 | } 356 | 357 | holder.appendChild(labelElement); 358 | }); 359 | this.container.appendChild(holder); 360 | } 361 | }, { 362 | key: "addSubLabels", 363 | value: function addSubLabels() { 364 | var _this2 = this; 365 | 366 | if (this.subLabels) { 367 | var subLabelsHolder = document.createElement('div'); 368 | subLabelsHolder.setAttribute('class', 'svg-funnel-js__subLabels'); 369 | var subLabelsHTML = ''; 370 | this.subLabels.forEach(function (subLabel, index) { 371 | subLabelsHTML += "
\n
\n
").concat(subLabel, "
\n
"); 372 | }); 373 | subLabelsHolder.innerHTML = subLabelsHTML; 374 | this.container.appendChild(subLabelsHolder); 375 | } 376 | } 377 | }, { 378 | key: "createContainer", 379 | value: function createContainer() { 380 | if (!this.containerSelector) { 381 | throw new Error('Container is missing'); 382 | } 383 | 384 | if (typeof this.containerSelector === 'string') { 385 | this.container = document.querySelector(this.containerSelector); 386 | 387 | if (!this.container) { 388 | throw new Error("Container cannot be found (selector: ".concat(this.containerSelector, ").")); 389 | } 390 | } else if (this.container instanceof HTMLElement) { 391 | this.container = this.containerSelector; 392 | } else { 393 | throw new Error('Container must either be a selector string or an HTMLElement.'); 394 | } 395 | 396 | this.container.classList.add('svg-funnel-js'); 397 | this.graphContainer = document.createElement('div'); 398 | this.graphContainer.classList.add('svg-funnel-js__container'); 399 | this.container.appendChild(this.graphContainer); 400 | 401 | if (this.direction === 'vertical') { 402 | this.container.classList.add('svg-funnel-js--vertical'); 403 | } 404 | } 405 | }, { 406 | key: "setValues", 407 | value: function setValues(v) { 408 | this.values = v; 409 | return this; 410 | } 411 | }, { 412 | key: "setDirection", 413 | value: function setDirection(d) { 414 | this.direction = d; 415 | return this; 416 | } 417 | }, { 418 | key: "setHeight", 419 | value: function setHeight(h) { 420 | this.height = h; 421 | return this; 422 | } 423 | }, { 424 | key: "setWidth", 425 | value: function setWidth(w) { 426 | this.width = w; 427 | return this; 428 | } 429 | }, { 430 | key: "getValues2d", 431 | value: function getValues2d() { 432 | var values = []; 433 | this.values.forEach(function (valueSet) { 434 | values.push(valueSet.reduce(function (sum, value) { 435 | return sum + value; 436 | }, 0)); 437 | }); 438 | return values; 439 | } 440 | }, { 441 | key: "getPercentages2d", 442 | value: function getPercentages2d() { 443 | var percentages = []; 444 | this.values.forEach(function (valueSet) { 445 | var total = valueSet.reduce(function (sum, value) { 446 | return sum + value; 447 | }, 0); 448 | percentages.push(valueSet.map(function (value) { 449 | return total === 0 ? 0 : (0, _number.roundPoint)(value * 100 / total); 450 | })); 451 | }); 452 | return percentages; 453 | } 454 | }, { 455 | key: "createPercentages", 456 | value: function createPercentages() { 457 | var values = []; 458 | 459 | if (this.is2d()) { 460 | values = this.getValues2d(); 461 | } else { 462 | values = _toConsumableArray(this.values); 463 | } 464 | 465 | var max = Math.max.apply(Math, _toConsumableArray(values)); 466 | return values.map(function (value) { 467 | return value === 0 ? 0 : (0, _number.roundPoint)(value * 100 / max); 468 | }); 469 | } 470 | }, { 471 | key: "applyGradient", 472 | value: function applyGradient(svg, path, colors, index) { 473 | var defs = svg.querySelector('defs') === null ? (0, _graph.createSVGElement)('defs', svg) : svg.querySelector('defs'); 474 | var gradientName = (0, _random.default)("funnelGradient-".concat(index, "-")); 475 | var gradient = (0, _graph.createSVGElement)('linearGradient', defs, { 476 | id: gradientName 477 | }); 478 | 479 | if (this.gradientDirection === 'vertical') { 480 | (0, _graph.setAttrs)(gradient, { 481 | x1: '0', 482 | x2: '0', 483 | y1: '0', 484 | y2: '1' 485 | }); 486 | } 487 | 488 | var numberOfColors = colors.length; 489 | 490 | for (var i = 0; i < numberOfColors; i++) { 491 | (0, _graph.createSVGElement)('stop', gradient, { 492 | 'stop-color': colors[i], 493 | offset: "".concat(Math.round(100 * i / (numberOfColors - 1)), "%") 494 | }); 495 | } 496 | 497 | (0, _graph.setAttrs)(path, { 498 | fill: "url(\"#".concat(gradientName, "\")"), 499 | stroke: "url(\"#".concat(gradientName, "\")") 500 | }); 501 | } 502 | }, { 503 | key: "makeSVG", 504 | value: function makeSVG() { 505 | var svg = (0, _graph.createSVGElement)('svg', this.graphContainer, { 506 | width: this.getWidth(), 507 | height: this.getHeight() 508 | }); 509 | var valuesNum = this.getCrossAxisPoints().length - 1; 510 | 511 | for (var i = 0; i < valuesNum; i++) { 512 | var path = (0, _graph.createSVGElement)('path', svg); 513 | var color = this.is2d() ? this.colors[i] : this.colors; 514 | var fillMode = typeof color === 'string' || color.length === 1 ? 'solid' : 'gradient'; 515 | 516 | if (fillMode === 'solid') { 517 | (0, _graph.setAttrs)(path, { 518 | fill: color, 519 | stroke: color 520 | }); 521 | } else if (fillMode === 'gradient') { 522 | this.applyGradient(svg, path, color, i + 1); 523 | } 524 | 525 | svg.appendChild(path); 526 | } 527 | 528 | this.graphContainer.appendChild(svg); 529 | } 530 | }, { 531 | key: "getSVG", 532 | value: function getSVG() { 533 | var svg = this.container.querySelector('svg'); 534 | 535 | if (!svg) { 536 | throw new Error('No SVG found inside of the container'); 537 | } 538 | 539 | return svg; 540 | } 541 | }, { 542 | key: "getWidth", 543 | value: function getWidth() { 544 | return this.width || this.graphContainer.clientWidth; 545 | } 546 | }, { 547 | key: "getHeight", 548 | value: function getHeight() { 549 | return this.height || this.graphContainer.clientHeight; 550 | } 551 | }, { 552 | key: "getPathDefinitions", 553 | value: function getPathDefinitions() { 554 | var valuesNum = this.getCrossAxisPoints().length - 1; 555 | var paths = []; 556 | 557 | for (var i = 0; i < valuesNum; i++) { 558 | if (this.isVertical()) { 559 | var X = this.getCrossAxisPoints()[i]; 560 | var XNext = this.getCrossAxisPoints()[i + 1]; 561 | var Y = this.getMainAxisPoints(); 562 | var d = (0, _path.createVerticalPath)(i, X, XNext, Y); 563 | paths.push(d); 564 | } else { 565 | var _X = this.getMainAxisPoints(); 566 | 567 | var _Y = this.getCrossAxisPoints()[i]; 568 | var YNext = this.getCrossAxisPoints()[i + 1]; 569 | 570 | var _d = (0, _path.createPath)(i, _X, _Y, YNext); 571 | 572 | paths.push(_d); 573 | } 574 | } 575 | 576 | return paths; 577 | } 578 | }, { 579 | key: "getPathMedian", 580 | value: function getPathMedian(i) { 581 | if (this.isVertical()) { 582 | var _cross = this.getCrossAxisPoints()[i]; 583 | var _next = this.getCrossAxisPoints()[i + 1]; 584 | 585 | var _Y2 = this.getMainAxisPoints(); 586 | 587 | var _X2 = []; 588 | var XNext = []; 589 | 590 | _cross.forEach(function (point, index) { 591 | var m = (point + _next[index]) / 2; 592 | 593 | _X2.push(m - 1); 594 | 595 | XNext.push(m + 1); 596 | }); 597 | 598 | return (0, _path.createVerticalPath)(i, _X2, XNext, _Y2); 599 | } 600 | 601 | var X = this.getMainAxisPoints(); 602 | var cross = this.getCrossAxisPoints()[i]; 603 | var next = this.getCrossAxisPoints()[i + 1]; 604 | var Y = []; 605 | var YNext = []; 606 | cross.forEach(function (point, index) { 607 | var m = (point + next[index]) / 2; 608 | Y.push(m - 1); 609 | YNext.push(m + 1); 610 | }); 611 | return (0, _path.createPath)(i, X, Y, YNext); 612 | } 613 | }, { 614 | key: "drawPaths", 615 | value: function drawPaths() { 616 | var svg = this.getSVG(); 617 | var paths = svg.querySelectorAll('path'); 618 | var definitions = this.getPathDefinitions(); 619 | definitions.forEach(function (definition, index) { 620 | paths[index].setAttribute('d', definition); 621 | }); 622 | } 623 | }, { 624 | key: "draw", 625 | value: function draw() { 626 | this.createContainer(); 627 | this.makeSVG(); 628 | this.addLabels(); 629 | 630 | if (this.is2d()) { 631 | this.addSubLabels(); 632 | } 633 | 634 | this.drawPaths(); 635 | } 636 | /* 637 | Methods 638 | */ 639 | 640 | }, { 641 | key: "makeVertical", 642 | value: function makeVertical() { 643 | if (this.direction === 'vertical') return true; 644 | this.direction = 'vertical'; 645 | this.container.classList.add('svg-funnel-js--vertical'); 646 | var svg = this.getSVG(); 647 | var height = this.getHeight(); 648 | var width = this.getWidth(); 649 | (0, _graph.setAttrs)(svg, { 650 | height: height, 651 | width: width 652 | }); 653 | this.drawPaths(); 654 | return true; 655 | } 656 | }, { 657 | key: "makeHorizontal", 658 | value: function makeHorizontal() { 659 | if (this.direction === 'horizontal') return true; 660 | this.direction = 'horizontal'; 661 | this.container.classList.remove('svg-funnel-js--vertical'); 662 | var svg = this.getSVG(); 663 | var height = this.getHeight(); 664 | var width = this.getWidth(); 665 | (0, _graph.setAttrs)(svg, { 666 | height: height, 667 | width: width 668 | }); 669 | this.drawPaths(); 670 | return true; 671 | } 672 | }, { 673 | key: "toggleDirection", 674 | value: function toggleDirection() { 675 | if (this.direction === 'horizontal') { 676 | this.makeVertical(); 677 | } else { 678 | this.makeHorizontal(); 679 | } 680 | } 681 | }, { 682 | key: "gradientMakeVertical", 683 | value: function gradientMakeVertical() { 684 | if (this.gradientDirection === 'vertical') return true; 685 | this.gradientDirection = 'vertical'; 686 | var gradients = this.graphContainer.querySelectorAll('linearGradient'); 687 | 688 | for (var i = 0; i < gradients.length; i++) { 689 | (0, _graph.setAttrs)(gradients[i], { 690 | x1: '0', 691 | x2: '0', 692 | y1: '0', 693 | y2: '1' 694 | }); 695 | } 696 | 697 | return true; 698 | } 699 | }, { 700 | key: "gradientMakeHorizontal", 701 | value: function gradientMakeHorizontal() { 702 | if (this.gradientDirection === 'horizontal') return true; 703 | this.gradientDirection = 'horizontal'; 704 | var gradients = this.graphContainer.querySelectorAll('linearGradient'); 705 | 706 | for (var i = 0; i < gradients.length; i++) { 707 | (0, _graph.removeAttrs)(gradients[i], 'x1', 'x2', 'y1', 'y2'); 708 | } 709 | 710 | return true; 711 | } 712 | }, { 713 | key: "gradientToggleDirection", 714 | value: function gradientToggleDirection() { 715 | if (this.gradientDirection === 'horizontal') { 716 | this.gradientMakeVertical(); 717 | } else { 718 | this.gradientMakeHorizontal(); 719 | } 720 | } 721 | }, { 722 | key: "updateWidth", 723 | value: function updateWidth(w) { 724 | this.width = w; 725 | var svg = this.getSVG(); 726 | var width = this.getWidth(); 727 | (0, _graph.setAttrs)(svg, { 728 | width: width 729 | }); 730 | this.drawPaths(); 731 | return true; 732 | } 733 | }, { 734 | key: "updateHeight", 735 | value: function updateHeight(h) { 736 | this.height = h; 737 | var svg = this.getSVG(); 738 | var height = this.getHeight(); 739 | (0, _graph.setAttrs)(svg, { 740 | height: height 741 | }); 742 | this.drawPaths(); 743 | return true; 744 | } // @TODO: refactor data update 745 | 746 | }, { 747 | key: "updateData", 748 | value: function updateData(d) { 749 | var labels = this.container.querySelector('.svg-funnel-js__labels'); 750 | var subLabels = this.container.querySelector('.svg-funnel-js__subLabels'); 751 | if (labels) labels.remove(); 752 | if (subLabels) subLabels.remove(); 753 | this.labels = []; 754 | this.colors = (0, _graph.getDefaultColors)(this.is2d() ? this.getSubDataSize() : 2); 755 | this.values = []; 756 | this.percentages = []; 757 | 758 | if (typeof d.labels !== 'undefined') { 759 | this.labels = FunnelGraph.getLabels({ 760 | data: d 761 | }); 762 | } 763 | 764 | if (typeof d.colors !== 'undefined') { 765 | this.colors = d.colors || (0, _graph.getDefaultColors)(this.is2d() ? this.getSubDataSize() : 2); 766 | } 767 | 768 | if (typeof d.values !== 'undefined') { 769 | if (Object.prototype.toString.call(d.values[0]) !== Object.prototype.toString.call(this.values[0])) { 770 | this.container.querySelector('svg').remove(); 771 | this.values = FunnelGraph.getValues({ 772 | data: d 773 | }); 774 | this.makeSVG(); 775 | } else { 776 | this.values = FunnelGraph.getValues({ 777 | data: d 778 | }); 779 | } 780 | 781 | this.drawPaths(); 782 | } 783 | 784 | this.percentages = this.createPercentages(); 785 | this.addLabels(); 786 | 787 | if (typeof d.subLabels !== 'undefined') { 788 | this.subLabels = FunnelGraph.getSubLabels({ 789 | data: d 790 | }); 791 | this.addSubLabels(); 792 | } 793 | } 794 | }, { 795 | key: "update", 796 | value: function update(o) { 797 | var _this3 = this; 798 | 799 | if (typeof o.displayPercent !== 'undefined') { 800 | if (this.displayPercent !== o.displayPercent) { 801 | if (this.displayPercent === true) { 802 | this.container.querySelectorAll('.label__percentage').forEach(function (label) { 803 | label.remove(); 804 | }); 805 | } else { 806 | this.container.querySelectorAll('.svg-funnel-js__label').forEach(function (label, index) { 807 | var percentage = _this3.percentages[index]; 808 | var percentageValue = document.createElement('div'); 809 | percentageValue.setAttribute('class', 'label__percentage'); 810 | 811 | if (percentage !== 100) { 812 | percentageValue.textContent = "".concat(percentage.toString(), "%"); 813 | label.appendChild(percentageValue); 814 | } 815 | }); 816 | } 817 | } 818 | } 819 | 820 | if (typeof o.height !== 'undefined') { 821 | this.updateHeight(o.height); 822 | } 823 | 824 | if (typeof o.width !== 'undefined') { 825 | this.updateWidth(o.width); 826 | } 827 | 828 | if (typeof o.gradientDirection !== 'undefined') { 829 | if (o.gradientDirection === 'vertical') { 830 | this.gradientMakeVertical(); 831 | } else if (o.gradientDirection === 'horizontal') { 832 | this.gradientMakeHorizontal(); 833 | } 834 | } 835 | 836 | if (typeof o.direction !== 'undefined') { 837 | if (o.direction === 'vertical') { 838 | this.makeVertical(); 839 | } else if (o.direction === 'horizontal') { 840 | this.makeHorizontal(); 841 | } 842 | } 843 | 844 | if (typeof o.data !== 'undefined') { 845 | this.updateData(o.data); 846 | } 847 | } 848 | }], [{ 849 | key: "getSubLabels", 850 | value: function getSubLabels(options) { 851 | if (!options.data) { 852 | throw new Error('Data is missing'); 853 | } 854 | 855 | var data = options.data; 856 | if (typeof data.subLabels === 'undefined') return []; 857 | return data.subLabels; 858 | } 859 | }, { 860 | key: "getLabels", 861 | value: function getLabels(options) { 862 | if (!options.data) { 863 | throw new Error('Data is missing'); 864 | } 865 | 866 | var data = options.data; 867 | if (typeof data.labels === 'undefined') return []; 868 | return data.labels; 869 | } 870 | }, { 871 | key: "getValues", 872 | value: function getValues(options) { 873 | if (!options.data) { 874 | return []; 875 | } 876 | 877 | var data = options.data; 878 | 879 | if (_typeof(data) === 'object') { 880 | return data.values; 881 | } 882 | 883 | return []; 884 | } 885 | }]); 886 | 887 | return FunnelGraph; 888 | }(); 889 | 890 | var _default = FunnelGraph; 891 | exports.default = _default; 892 | 893 | },{"./graph":2,"./number":4,"./path":5,"./random":6}],4:[function(require,module,exports){ 894 | "use strict"; 895 | 896 | Object.defineProperty(exports, "__esModule", { 897 | value: true 898 | }); 899 | exports.formatNumber = exports.roundPoint = void 0; 900 | 901 | var roundPoint = function roundPoint(number) { 902 | return Math.round(number * 10) / 10; 903 | }; 904 | 905 | exports.roundPoint = roundPoint; 906 | 907 | var formatNumber = function formatNumber(number) { 908 | return Number(number).toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,'); 909 | }; 910 | 911 | exports.formatNumber = formatNumber; 912 | 913 | },{}],5:[function(require,module,exports){ 914 | "use strict"; 915 | 916 | Object.defineProperty(exports, "__esModule", { 917 | value: true 918 | }); 919 | exports.createVerticalPath = exports.createPath = exports.createVerticalCurves = exports.createCurves = void 0; 920 | 921 | var _number = require("./number"); 922 | 923 | function _toConsumableArray(arr) { return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _nonIterableSpread(); } 924 | 925 | function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance"); } 926 | 927 | function _iterableToArray(iter) { if (Symbol.iterator in Object(iter) || Object.prototype.toString.call(iter) === "[object Arguments]") return Array.from(iter); } 928 | 929 | function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = new Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } } 930 | 931 | var createCurves = function createCurves(x1, y1, x2, y2) { 932 | return " C".concat((0, _number.roundPoint)((x2 + x1) / 2), ",").concat(y1, " ") + "".concat((0, _number.roundPoint)((x2 + x1) / 2), ",").concat(y2, " ").concat(x2, ",").concat(y2); 933 | }; 934 | 935 | exports.createCurves = createCurves; 936 | 937 | var createVerticalCurves = function createVerticalCurves(x1, y1, x2, y2) { 938 | return " C".concat(x1, ",").concat((0, _number.roundPoint)((y2 + y1) / 2), " ") + "".concat(x2, ",").concat((0, _number.roundPoint)((y2 + y1) / 2), " ").concat(x2, ",").concat(y2); 939 | }; 940 | /* 941 | A funnel segment is draw in a clockwise direction. 942 | Path 1-2 is drawn, 943 | then connected with a straight vertical line 2-3, 944 | then a line 3-4 is draw (using YNext points going in backwards direction) 945 | then path is closed (connected with the starting point 1). 946 | 947 | 1---------->2 948 | ^ | 949 | | v 950 | 4<----------3 951 | 952 | On the graph on line 20 it works like this: 953 | A#0, A#1, A#2, A#3, B#3, B#2, B#1, B#0, close the path. 954 | 955 | Points for path "B" are passed as the YNext param. 956 | */ 957 | 958 | 959 | exports.createVerticalCurves = createVerticalCurves; 960 | 961 | var createPath = function createPath(index, X, Y, YNext) { 962 | var str = "M".concat(X[0], ",").concat(Y[0]); 963 | 964 | for (var i = 0; i < X.length - 1; i++) { 965 | str += createCurves(X[i], Y[i], X[i + 1], Y[i + 1]); 966 | } 967 | 968 | str += " L".concat(_toConsumableArray(X).pop(), ",").concat(_toConsumableArray(YNext).pop()); 969 | 970 | for (var _i = X.length - 1; _i > 0; _i--) { 971 | str += createCurves(X[_i], YNext[_i], X[_i - 1], YNext[_i - 1]); 972 | } 973 | 974 | str += ' Z'; 975 | return str; 976 | }; 977 | /* 978 | In a vertical path we go counter-clockwise 979 | 980 | 1<----------4 981 | | ^ 982 | v | 983 | 2---------->3 984 | */ 985 | 986 | 987 | exports.createPath = createPath; 988 | 989 | var createVerticalPath = function createVerticalPath(index, X, XNext, Y) { 990 | var str = "M".concat(X[0], ",").concat(Y[0]); 991 | 992 | for (var i = 0; i < X.length - 1; i++) { 993 | str += createVerticalCurves(X[i], Y[i], X[i + 1], Y[i + 1]); 994 | } 995 | 996 | str += " L".concat(_toConsumableArray(XNext).pop(), ",").concat(_toConsumableArray(Y).pop()); 997 | 998 | for (var _i2 = X.length - 1; _i2 > 0; _i2--) { 999 | str += createVerticalCurves(XNext[_i2], Y[_i2], XNext[_i2 - 1], Y[_i2 - 1]); 1000 | } 1001 | 1002 | str += ' Z'; 1003 | return str; 1004 | }; 1005 | 1006 | exports.createVerticalPath = createVerticalPath; 1007 | 1008 | },{"./number":4}],6:[function(require,module,exports){ 1009 | "use strict"; 1010 | 1011 | Object.defineProperty(exports, "__esModule", { 1012 | value: true 1013 | }); 1014 | exports.default = void 0; 1015 | 1016 | var generateRandomIdString = function generateRandomIdString(prefix) { 1017 | return Math.random().toString(36).replace('0.', prefix || ''); 1018 | }; 1019 | 1020 | var _default = generateRandomIdString; 1021 | exports.default = _default; 1022 | 1023 | },{}]},{},[1])(1) 1024 | }); 1025 | -------------------------------------------------------------------------------- /dist/js/funnel-graph.min.js: -------------------------------------------------------------------------------- 1 | !function(t){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).FunnelGraph=t()}}(function(){return function a(o,s,c){function l(e,t){if(!s[e]){if(!o[e]){var n="function"==typeof require&&require;if(!t&&n)return n(e,!0);if(u)return u(e,!0);var r=new Error("Cannot find module '"+e+"'");throw r.code="MODULE_NOT_FOUND",r}var i=s[e]={exports:{}};o[e][0].call(i.exports,function(t){return l(o[e][1][t]||t)},i,i.exports,a,o,s,c)}return s[e].exports}for(var u="function"==typeof require&&require,t=0;t".concat(u.subLabels[e],':\n ').concat(n,"\n ")}),c+="",s.innerHTML=c,e.appendChild(s)}h.appendChild(e)}),this.container.appendChild(h)}},{key:"addSubLabels",value:function(){var n=this;if(this.subLabels){var t=document.createElement("div");t.setAttribute("class","svg-funnel-js__subLabels");var r="";this.subLabels.forEach(function(t,e){r+='
\n
\n
').concat(t,"
\n
")}),t.innerHTML=r,this.container.appendChild(t)}}},{key:"createContainer",value:function(){if(!this.containerSelector)throw new Error("Container is missing");if("string"==typeof this.containerSelector){if(this.container=document.querySelector(this.containerSelector),!this.container)throw new Error("Container cannot be found (selector: ".concat(this.containerSelector,")."))}else{if(!(this.container instanceof HTMLElement))throw new Error("Container must either be a selector string or an HTMLElement.");this.container=this.containerSelector}this.container.classList.add("svg-funnel-js"),this.graphContainer=document.createElement("div"),this.graphContainer.classList.add("svg-funnel-js__container"),this.container.appendChild(this.graphContainer),"vertical"===this.direction&&this.container.classList.add("svg-funnel-js--vertical")}},{key:"setValues",value:function(t){return this.values=t,this}},{key:"setDirection",value:function(t){return this.direction=t,this}},{key:"setHeight",value:function(t){return this.height=t,this}},{key:"setWidth",value:function(t){return this.width=t,this}},{key:"getValues2d",value:function(){var e=[];return this.values.forEach(function(t){e.push(t.reduce(function(t,e){return t+e},0))}),e}},{key:"getPercentages2d",value:function(){var n=[];return this.values.forEach(function(t){var e=t.reduce(function(t,e){return t+e},0);n.push(t.map(function(t){return 0===e?0:(0,f.roundPoint)(100*t/e)}))}),n}},{key:"createPercentages",value:function(){var t=[];t=this.is2d()?this.getValues2d():g(this.values);var e=Math.max.apply(Math,g(t));return t.map(function(t){return 0===t?0:(0,f.roundPoint)(100*t/e)})}},{key:"applyGradient",value:function(t,e,n,r){var i=null===t.querySelector("defs")?(0,l.createSVGElement)("defs",t):t.querySelector("defs"),a=(0,u.default)("funnelGradient-".concat(r,"-")),o=(0,l.createSVGElement)("linearGradient",i,{id:a});"vertical"===this.gradientDirection&&(0,l.setAttrs)(o,{x1:"0",x2:"0",y1:"0",y2:"1"});for(var s=n.length,c=0;c 2 | 3 | 4 | 5 | SVG Funnel 6 | 7 | 8 | 9 | 10 | 29 | 30 | 31 |
32 |
33 |
34 |
35 |
36 | 37 | 38 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /examples/example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SVG Funnel 6 | 7 | 8 | 9 | 10 | 39 | 40 | 41 |
42 |
43 |
44 |
45 | 46 |
47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 |
57 | 58 | 59 | 143 | 144 | 145 | -------------------------------------------------------------------------------- /examples/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/greghub/funnel-graph-js/d8537ef1f26850d8db157fe972af3c04f3a3c9d1/examples/favicon.png -------------------------------------------------------------------------------- /gulpfile.babel.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp'; 2 | import browserSync from 'browser-sync'; 3 | import rename from 'gulp-rename'; 4 | import sass from 'gulp-sass'; 5 | import postcss from 'gulp-postcss'; 6 | import cssnano from 'cssnano'; 7 | import autoprefixer from 'autoprefixer'; 8 | import eslint from 'gulp-eslint'; 9 | import sasslint from 'gulp-sass-lint'; 10 | import browserify from 'browserify'; 11 | import babelify from 'babelify'; 12 | import source from 'vinyl-source-stream'; 13 | import streamify from 'gulp-streamify'; 14 | import uglify from 'gulp-uglify'; 15 | 16 | const server = browserSync.create(); 17 | 18 | const styles = () => { 19 | const plugins = [autoprefixer(), cssnano()]; 20 | 21 | return ( 22 | gulp.src(['./src/scss/main.scss', './src/scss/theme.scss']) 23 | .pipe(sass().on('error', sass.logError)) 24 | .pipe(gulp.dest('./dist/css')) 25 | .pipe(postcss(plugins)) 26 | .pipe(rename({ suffix: '.min' })) 27 | .pipe(gulp.dest('./dist/css')) 28 | .pipe(server.stream()) 29 | ); 30 | }; 31 | 32 | const scripts = () => browserify({ 33 | entries: './index.js', 34 | standalone: 'FunnelGraph' 35 | }).transform(babelify, { presets: ['@babel/preset-env'] }) 36 | .bundle() 37 | .pipe(source('funnel-graph.js')) 38 | .pipe(gulp.dest('dist/js')) 39 | .pipe(streamify(uglify())) 40 | .pipe(rename({ suffix: '.min' })) 41 | .pipe(gulp.dest('dist/js')) 42 | .pipe(server.stream()); 43 | 44 | const scriptsLint = () => gulp.src('./src/js/*.js') 45 | .pipe(eslint()) 46 | .pipe(eslint.format()); 47 | 48 | const stylesLint = () => gulp.src('./src/scss/**/*.scss') 49 | .pipe(sasslint()) 50 | .pipe(sasslint.format()); 51 | 52 | const startServer = () => server.init({ 53 | server: { 54 | baseDir: './' 55 | } 56 | }); 57 | 58 | const watchHTML = () => gulp.watch('./*.html').on('change', server.reload); 59 | const watchScripts = () => gulp.watch('./src/js/*.js', gulp.series('scriptsLint', 'scripts')); 60 | const watchStyles = () => gulp.watch('./src/scss/**/*.scss', gulp.series('stylesLint', 'styles')); 61 | 62 | const compile = gulp.parallel(styles, scripts); 63 | const lint = gulp.parallel(scriptsLint, stylesLint); 64 | const serve = gulp.series(compile, startServer); 65 | const watch = gulp.series(lint, gulp.parallel(watchHTML, watchScripts, watchStyles)); 66 | const defaultTasks = gulp.parallel(serve, watch); 67 | 68 | export { 69 | styles, 70 | scripts, 71 | scriptsLint, 72 | stylesLint, 73 | watchHTML, 74 | watchScripts, 75 | watchStyles, 76 | startServer, 77 | serve, 78 | watch, 79 | compile, 80 | lint 81 | }; 82 | 83 | export default defaultTasks; 84 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./src/js/main').default; 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "funnel-graph-js", 3 | "version": "1.4.2", 4 | "description": "SVG Funnel Graph Javascript Library", 5 | "main": "main.js", 6 | "scripts": { 7 | "start": "gulp", 8 | "test": "nyc mocha --compilers js:babel-core/register" 9 | }, 10 | "author": "Greg Hovanesyan", 11 | "license": "MIT", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/greghub/funnel-graph-js.git" 15 | }, 16 | "keywords": [ 17 | "funnel", 18 | "chart", 19 | "graph", 20 | "funnel-chart", 21 | "funnel-graph", 22 | "svg-funnel-chart", 23 | "svg-funnel-graph" 24 | ], 25 | "browserslist": [ 26 | "last 2 versions" 27 | ], 28 | "devDependencies": { 29 | "@babel/core": "^7.8.3", 30 | "@babel/preset-env": "^7.8.3", 31 | "@babel/register": "^7.8.3", 32 | "autoprefixer": "^9.7.4", 33 | "babel-cli": "^6.26.0", 34 | "babel-preset-env": "^1.2.0", 35 | "babelify": "^10.0.0", 36 | "browser-sync": "^2.26.7", 37 | "browserify": "^16.5.0", 38 | "chai": "^4.2.0", 39 | "cssnano": "^4.1.10", 40 | "eslint": "^5.16.0", 41 | "eslint-config-airbnb-base": "^13.2.0", 42 | "eslint-plugin-import": "^2.20.0", 43 | "eslint-plugin-prettier": "^3.1.2", 44 | "gulp": "^4.0.2", 45 | "gulp-babel": "^8.0.0", 46 | "gulp-eslint": "^5.0.0", 47 | "gulp-postcss": "^8.0.0", 48 | "gulp-rename": "^1.2.2", 49 | "gulp-sass": "^4.0.2", 50 | "gulp-sass-lint": "^1.4.0", 51 | "gulp-streamify": "^1.0.2", 52 | "gulp-uglify": "^3.0.2", 53 | "gulp-util": "^3.0.8", 54 | "mocha": "^5.2.0", 55 | "nyc": "^13.3.0", 56 | "vinyl-source-stream": "^2.0.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/js/graph.js: -------------------------------------------------------------------------------- 1 | const setAttrs = (element, attributes) => { 2 | if (typeof attributes === 'object') { 3 | Object.keys(attributes).forEach((key) => { 4 | element.setAttribute(key, attributes[key]); 5 | }); 6 | } 7 | }; 8 | 9 | const removeAttrs = (element, ...attributes) => { 10 | attributes.forEach((attribute) => { 11 | element.removeAttribute(attribute); 12 | }); 13 | }; 14 | 15 | const createSVGElement = (element, container, attributes) => { 16 | const el = document.createElementNS('http://www.w3.org/2000/svg', element); 17 | 18 | if (typeof attributes === 'object') { 19 | setAttrs(el, attributes); 20 | } 21 | 22 | if (typeof container !== 'undefined') { 23 | container.appendChild(el); 24 | } 25 | 26 | return el; 27 | }; 28 | 29 | const generateLegendBackground = (color, direction = 'horizontal') => { 30 | if (typeof color === 'string') { 31 | return `background-color: ${color}`; 32 | } 33 | 34 | if (color.length === 1) { 35 | return `background-color: ${color[0]}`; 36 | } 37 | 38 | return `background-image: linear-gradient(${direction === 'horizontal' 39 | ? 'to right, ' 40 | : ''}${color.join(', ')})`; 41 | }; 42 | 43 | const defaultColors = ['#FF4589', '#FF5050', 44 | '#05DF9D', '#4FF2FD', 45 | '#2D9CDB', '#A0BBFF', 46 | '#FFD76F', '#F2C94C', 47 | '#FF9A9A', '#FFB178']; 48 | 49 | const getDefaultColors = (number) => { 50 | const colors = [...defaultColors]; 51 | const colorSet = []; 52 | 53 | for (let i = 0; i < number; i++) { 54 | // get a random color 55 | const index = Math.abs(Math.round(Math.random() * (colors.length - 1))); 56 | // push it to the list 57 | colorSet.push(colors[index]); 58 | // and remove it, so that it is not chosen again 59 | colors.splice(index, 1); 60 | } 61 | return colorSet; 62 | }; 63 | 64 | /* 65 | Used in comparing existing values to value provided on update 66 | It is limited to comparing arrays on purpose 67 | Name is slightly unusual, in order not to be confused with Lodash method 68 | */ 69 | const areEqual = (value, newValue) => { 70 | // If values are not of the same type 71 | const type = Object.prototype.toString.call(value); 72 | if (type !== Object.prototype.toString.call(newValue)) return false; 73 | if (type !== '[object Array]') return false; 74 | 75 | if (value.length !== newValue.length) return false; 76 | 77 | for (let i = 0; i < value.length; i++) { 78 | // if the it's a two dimensional array 79 | const currentType = Object.prototype.toString.call(value[i]); 80 | if (currentType !== Object.prototype.toString.call(newValue[i])) return false; 81 | if (currentType === '[object Array]') { 82 | // if row lengths are not equal then arrays are not equal 83 | if (value[i].length !== newValue[i].length) return false; 84 | // compare each element in the row 85 | for (let j = 0; j < value[i].length; j++) { 86 | if (value[i][j] !== newValue[i][j]) { 87 | return false; 88 | } 89 | } 90 | } else if (value[i] !== newValue[i]) { 91 | // if it's a one dimensional array element 92 | return false; 93 | } 94 | } 95 | 96 | return true; 97 | }; 98 | 99 | export { 100 | generateLegendBackground, getDefaultColors, areEqual, createSVGElement, setAttrs, removeAttrs, defaultColors 101 | }; 102 | -------------------------------------------------------------------------------- /src/js/main.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-trailing-spaces */ 2 | /* global HTMLElement */ 3 | import { roundPoint, formatNumber } from './number'; 4 | import { createPath, createVerticalPath } from './path'; 5 | import { 6 | generateLegendBackground, getDefaultColors, createSVGElement, setAttrs, removeAttrs 7 | } from './graph'; 8 | import generateRandomIdString from './random'; 9 | 10 | class FunnelGraph { 11 | constructor(options) { 12 | this.containerSelector = options.container; 13 | this.gradientDirection = (options.gradientDirection && options.gradientDirection === 'vertical') 14 | ? 'vertical' 15 | : 'horizontal'; 16 | this.direction = (options.direction && options.direction === 'vertical') ? 'vertical' : 'horizontal'; 17 | this.labels = FunnelGraph.getLabels(options); 18 | this.subLabels = FunnelGraph.getSubLabels(options); 19 | this.values = FunnelGraph.getValues(options); 20 | this.percentages = this.createPercentages(); 21 | this.colors = options.data.colors || getDefaultColors(this.is2d() ? this.getSubDataSize() : 2); 22 | this.displayPercent = options.displayPercent || false; 23 | this.data = options.data; 24 | this.height = options.height; 25 | this.width = options.width; 26 | this.subLabelValue = options.subLabelValue || 'percent'; 27 | } 28 | 29 | /** 30 | An example of a two-dimensional funnel graph 31 | #0.................. 32 | ...#1................ 33 | ...... 34 | #0********************#1** #2.........................#3 (A) 35 | ******************* 36 | #2*************************#3 (B) 37 | #2+++++++++++++++++++++++++#3 (C) 38 | +++++++++++++++++++ 39 | #0++++++++++++++++++++#1++ #2-------------------------#3 (D) 40 | ------ 41 | ---#1---------------- 42 | #0----------------- 43 | Main axis is the primary axis of the graph. 44 | In a horizontal graph it's the X axis, and Y is the cross axis. 45 | However we use the names "main" and "cross" axis, 46 | because in a vertical graph the primary axis is the Y axis 47 | and the cross axis is the X axis. 48 | First step of drawing the funnel graph is getting the coordinates of points, 49 | that are used when drawing the paths. 50 | There are 4 paths in the example above: A, B, C and D. 51 | Such funnel has 3 labels and 3 subLabels. 52 | This means that the main axis has 4 points (number of labels + 1) 53 | One the ASCII illustrated graph above, those points are illustrated with a # symbol. 54 | */ 55 | getMainAxisPoints() { 56 | const size = this.getDataSize(); 57 | const points = []; 58 | const fullDimension = this.isVertical() ? this.getHeight() : this.getWidth(); 59 | for (let i = 0; i <= size; i++) { 60 | points.push(roundPoint(fullDimension * i / size)); 61 | } 62 | return points; 63 | } 64 | 65 | getCrossAxisPoints() { 66 | const points = []; 67 | const fullDimension = this.getFullDimension(); 68 | // get half of the graph container height or width, since funnel shape is symmetric 69 | // we use this when calculating the "A" shape 70 | const dimension = fullDimension / 2; 71 | if (this.is2d()) { 72 | const totalValues = this.getValues2d(); 73 | const max = Math.max(...totalValues); 74 | 75 | // duplicate last value 76 | totalValues.push([...totalValues].pop()); 77 | // get points for path "A" 78 | points.push(totalValues.map(value => roundPoint((max - value) / max * dimension))); 79 | // percentages with duplicated last value 80 | const percentagesFull = this.getPercentages2d(); 81 | const pointsOfFirstPath = points[0]; 82 | 83 | for (let i = 1; i < this.getSubDataSize(); i++) { 84 | const p = points[i - 1]; 85 | const newPoints = []; 86 | 87 | for (let j = 0; j < this.getDataSize(); j++) { 88 | newPoints.push(roundPoint( 89 | // eslint-disable-next-line comma-dangle 90 | p[j] + (fullDimension - pointsOfFirstPath[j] * 2) * (percentagesFull[j][i - 1] / 100) 91 | )); 92 | } 93 | 94 | // duplicate the last value as points #2 and #3 have the same value on the cross axis 95 | newPoints.push([...newPoints].pop()); 96 | points.push(newPoints); 97 | } 98 | 99 | // add points for path "D", that is simply the "inverted" path "A" 100 | points.push(pointsOfFirstPath.map(point => fullDimension - point)); 101 | } else { 102 | // As you can see on the visualization above points #2 and #3 have the same cross axis coordinate 103 | // so we duplicate the last value 104 | const max = Math.max(...this.values); 105 | const values = [...this.values].concat([...this.values].pop()); 106 | // if the graph is simple (not two-dimensional) then we have only paths "A" and "D" 107 | // which are symmetric. So we get the points for "A" and then get points for "D" by subtracting "A" 108 | // points from graph cross dimension length 109 | points.push(values.map(value => roundPoint((max - value) / max * dimension))); 110 | points.push(points[0].map(point => fullDimension - point)); 111 | } 112 | 113 | return points; 114 | } 115 | 116 | getGraphType() { 117 | return this.values && this.values[0] instanceof Array ? '2d' : 'normal'; 118 | } 119 | 120 | is2d() { 121 | return this.getGraphType() === '2d'; 122 | } 123 | 124 | isVertical() { 125 | return this.direction === 'vertical'; 126 | } 127 | 128 | getDataSize() { 129 | return this.values.length; 130 | } 131 | 132 | getSubDataSize() { 133 | return this.values[0].length; 134 | } 135 | 136 | getFullDimension() { 137 | return this.isVertical() ? this.getWidth() : this.getHeight(); 138 | } 139 | 140 | static getSubLabels(options) { 141 | if (!options.data) { 142 | throw new Error('Data is missing'); 143 | } 144 | 145 | const { data } = options; 146 | 147 | if (typeof data.subLabels === 'undefined') return []; 148 | 149 | return data.subLabels; 150 | } 151 | 152 | static getLabels(options) { 153 | if (!options.data) { 154 | throw new Error('Data is missing'); 155 | } 156 | 157 | const { data } = options; 158 | 159 | if (typeof data.labels === 'undefined') return []; 160 | 161 | return data.labels; 162 | } 163 | 164 | addLabels() { 165 | const holder = document.createElement('div'); 166 | holder.setAttribute('class', 'svg-funnel-js__labels'); 167 | 168 | this.percentages.forEach((percentage, index) => { 169 | const labelElement = document.createElement('div'); 170 | labelElement.setAttribute('class', `svg-funnel-js__label label-${index + 1}`); 171 | 172 | const title = document.createElement('div'); 173 | title.setAttribute('class', 'label__title'); 174 | title.textContent = this.labels[index] || ''; 175 | 176 | const value = document.createElement('div'); 177 | value.setAttribute('class', 'label__value'); 178 | 179 | const valueNumber = this.is2d() ? this.getValues2d()[index] : this.values[index]; 180 | value.textContent = formatNumber(valueNumber); 181 | 182 | const percentageValue = document.createElement('div'); 183 | percentageValue.setAttribute('class', 'label__percentage'); 184 | percentageValue.textContent = `${percentage.toString()}%`; 185 | 186 | labelElement.appendChild(value); 187 | labelElement.appendChild(title); 188 | if (this.displayPercent) { 189 | labelElement.appendChild(percentageValue); 190 | } 191 | 192 | if (this.is2d()) { 193 | const segmentPercentages = document.createElement('div'); 194 | segmentPercentages.setAttribute('class', 'label__segment-percentages'); 195 | let percentageList = '
    '; 196 | 197 | const twoDimPercentages = this.getPercentages2d(); 198 | 199 | this.subLabels.forEach((subLabel, j) => { 200 | const subLabelDisplayValue = this.subLabelValue === 'percent' 201 | ? `${twoDimPercentages[index][j]}%` 202 | : formatNumber(this.values[index][j]); 203 | percentageList += `
  • ${this.subLabels[j]}: 204 | ${subLabelDisplayValue} 205 |
  • `; 206 | }); 207 | percentageList += '
'; 208 | segmentPercentages.innerHTML = percentageList; 209 | labelElement.appendChild(segmentPercentages); 210 | } 211 | 212 | holder.appendChild(labelElement); 213 | }); 214 | 215 | this.container.appendChild(holder); 216 | } 217 | 218 | addSubLabels() { 219 | if (this.subLabels) { 220 | const subLabelsHolder = document.createElement('div'); 221 | subLabelsHolder.setAttribute('class', 'svg-funnel-js__subLabels'); 222 | 223 | let subLabelsHTML = ''; 224 | 225 | this.subLabels.forEach((subLabel, index) => { 226 | subLabelsHTML += `
227 |
229 |
${subLabel}
230 |
`; 231 | }); 232 | 233 | subLabelsHolder.innerHTML = subLabelsHTML; 234 | this.container.appendChild(subLabelsHolder); 235 | } 236 | } 237 | 238 | createContainer() { 239 | if (!this.containerSelector) { 240 | throw new Error('Container is missing'); 241 | } 242 | 243 | if (typeof this.containerSelector === 'string') { 244 | this.container = document.querySelector(this.containerSelector); 245 | if (!this.container) { 246 | throw new Error(`Container cannot be found (selector: ${this.containerSelector}).`); 247 | } 248 | } else if (this.container instanceof HTMLElement) { 249 | this.container = this.containerSelector; 250 | } else { 251 | throw new Error('Container must either be a selector string or an HTMLElement.'); 252 | } 253 | 254 | this.container.classList.add('svg-funnel-js'); 255 | 256 | this.graphContainer = document.createElement('div'); 257 | this.graphContainer.classList.add('svg-funnel-js__container'); 258 | this.container.appendChild(this.graphContainer); 259 | 260 | if (this.direction === 'vertical') { 261 | this.container.classList.add('svg-funnel-js--vertical'); 262 | } 263 | } 264 | 265 | setValues(v) { 266 | this.values = v; 267 | return this; 268 | } 269 | 270 | setDirection(d) { 271 | this.direction = d; 272 | return this; 273 | } 274 | 275 | setHeight(h) { 276 | this.height = h; 277 | return this; 278 | } 279 | 280 | setWidth(w) { 281 | this.width = w; 282 | return this; 283 | } 284 | 285 | static getValues(options) { 286 | if (!options.data) { 287 | return []; 288 | } 289 | 290 | const { data } = options; 291 | 292 | if (typeof data === 'object') { 293 | return data.values; 294 | } 295 | 296 | return []; 297 | } 298 | 299 | getValues2d() { 300 | const values = []; 301 | 302 | this.values.forEach((valueSet) => { 303 | values.push(valueSet.reduce((sum, value) => sum + value, 0)); 304 | }); 305 | 306 | return values; 307 | } 308 | 309 | getPercentages2d() { 310 | const percentages = []; 311 | 312 | this.values.forEach((valueSet) => { 313 | const total = valueSet.reduce((sum, value) => sum + value, 0); 314 | percentages.push(valueSet.map(value => (total === 0 ? 0 : roundPoint(value * 100 / total)))); 315 | }); 316 | 317 | return percentages; 318 | } 319 | 320 | createPercentages() { 321 | let values = []; 322 | 323 | if (this.is2d()) { 324 | values = this.getValues2d(); 325 | } else { 326 | values = [...this.values]; 327 | } 328 | 329 | const max = Math.max(...values); 330 | return values.map(value => (value === 0 ? 0 : roundPoint(value * 100 / max))); 331 | } 332 | 333 | applyGradient(svg, path, colors, index) { 334 | const defs = (svg.querySelector('defs') === null) 335 | ? createSVGElement('defs', svg) 336 | : svg.querySelector('defs'); 337 | 338 | const gradientName = generateRandomIdString(`funnelGradient-${index}-`); 339 | 340 | const gradient = createSVGElement('linearGradient', defs, { 341 | id: gradientName 342 | }); 343 | 344 | if (this.gradientDirection === 'vertical') { 345 | setAttrs(gradient, { 346 | x1: '0', 347 | x2: '0', 348 | y1: '0', 349 | y2: '1' 350 | }); 351 | } 352 | 353 | const numberOfColors = colors.length; 354 | 355 | for (let i = 0; i < numberOfColors; i++) { 356 | createSVGElement('stop', gradient, { 357 | 'stop-color': colors[i], 358 | offset: `${Math.round(100 * i / (numberOfColors - 1))}%` 359 | }); 360 | } 361 | 362 | setAttrs(path, { 363 | fill: `url("#${gradientName}")`, 364 | stroke: `url("#${gradientName}")` 365 | }); 366 | } 367 | 368 | makeSVG() { 369 | const svg = createSVGElement('svg', this.graphContainer, { 370 | width: this.getWidth(), 371 | height: this.getHeight() 372 | }); 373 | 374 | const valuesNum = this.getCrossAxisPoints().length - 1; 375 | for (let i = 0; i < valuesNum; i++) { 376 | const path = createSVGElement('path', svg); 377 | 378 | const color = (this.is2d()) ? this.colors[i] : this.colors; 379 | const fillMode = (typeof color === 'string' || color.length === 1) ? 'solid' : 'gradient'; 380 | 381 | if (fillMode === 'solid') { 382 | setAttrs(path, { 383 | fill: color, 384 | stroke: color 385 | }); 386 | } else if (fillMode === 'gradient') { 387 | this.applyGradient(svg, path, color, i + 1); 388 | } 389 | 390 | svg.appendChild(path); 391 | } 392 | 393 | this.graphContainer.appendChild(svg); 394 | } 395 | 396 | getSVG() { 397 | const svg = this.container.querySelector('svg'); 398 | 399 | if (!svg) { 400 | throw new Error('No SVG found inside of the container'); 401 | } 402 | 403 | return svg; 404 | } 405 | 406 | getWidth() { 407 | return this.width || this.graphContainer.clientWidth; 408 | } 409 | 410 | getHeight() { 411 | return this.height || this.graphContainer.clientHeight; 412 | } 413 | 414 | getPathDefinitions() { 415 | const valuesNum = this.getCrossAxisPoints().length - 1; 416 | const paths = []; 417 | for (let i = 0; i < valuesNum; i++) { 418 | if (this.isVertical()) { 419 | const X = this.getCrossAxisPoints()[i]; 420 | const XNext = this.getCrossAxisPoints()[i + 1]; 421 | const Y = this.getMainAxisPoints(); 422 | 423 | const d = createVerticalPath(i, X, XNext, Y); 424 | paths.push(d); 425 | } else { 426 | const X = this.getMainAxisPoints(); 427 | const Y = this.getCrossAxisPoints()[i]; 428 | const YNext = this.getCrossAxisPoints()[i + 1]; 429 | 430 | const d = createPath(i, X, Y, YNext); 431 | paths.push(d); 432 | } 433 | } 434 | 435 | return paths; 436 | } 437 | 438 | getPathMedian(i) { 439 | if (this.isVertical()) { 440 | const cross = this.getCrossAxisPoints()[i]; 441 | const next = this.getCrossAxisPoints()[i + 1]; 442 | const Y = this.getMainAxisPoints(); 443 | const X = []; 444 | const XNext = []; 445 | 446 | cross.forEach((point, index) => { 447 | const m = (point + next[index]) / 2; 448 | X.push(m - 1); 449 | XNext.push(m + 1); 450 | }); 451 | 452 | return createVerticalPath(i, X, XNext, Y); 453 | } 454 | 455 | const X = this.getMainAxisPoints(); 456 | const cross = this.getCrossAxisPoints()[i]; 457 | const next = this.getCrossAxisPoints()[i + 1]; 458 | const Y = []; 459 | const YNext = []; 460 | 461 | cross.forEach((point, index) => { 462 | const m = (point + next[index]) / 2; 463 | Y.push(m - 1); 464 | YNext.push(m + 1); 465 | }); 466 | 467 | return createPath(i, X, Y, YNext); 468 | } 469 | 470 | drawPaths() { 471 | const svg = this.getSVG(); 472 | const paths = svg.querySelectorAll('path'); 473 | const definitions = this.getPathDefinitions(); 474 | 475 | definitions.forEach((definition, index) => { 476 | paths[index].setAttribute('d', definition); 477 | }); 478 | } 479 | 480 | draw() { 481 | this.createContainer(); 482 | this.makeSVG(); 483 | 484 | this.addLabels(); 485 | 486 | if (this.is2d()) { 487 | this.addSubLabels(); 488 | } 489 | 490 | this.drawPaths(); 491 | } 492 | 493 | /* 494 | Methods 495 | */ 496 | 497 | makeVertical() { 498 | if (this.direction === 'vertical') return true; 499 | 500 | this.direction = 'vertical'; 501 | this.container.classList.add('svg-funnel-js--vertical'); 502 | 503 | const svg = this.getSVG(); 504 | const height = this.getHeight(); 505 | const width = this.getWidth(); 506 | setAttrs(svg, { height, width }); 507 | 508 | this.drawPaths(); 509 | 510 | return true; 511 | } 512 | 513 | makeHorizontal() { 514 | if (this.direction === 'horizontal') return true; 515 | 516 | this.direction = 'horizontal'; 517 | this.container.classList.remove('svg-funnel-js--vertical'); 518 | 519 | const svg = this.getSVG(); 520 | const height = this.getHeight(); 521 | const width = this.getWidth(); 522 | setAttrs(svg, { height, width }); 523 | 524 | this.drawPaths(); 525 | 526 | return true; 527 | } 528 | 529 | toggleDirection() { 530 | if (this.direction === 'horizontal') { 531 | this.makeVertical(); 532 | } else { 533 | this.makeHorizontal(); 534 | } 535 | } 536 | 537 | gradientMakeVertical() { 538 | if (this.gradientDirection === 'vertical') return true; 539 | 540 | this.gradientDirection = 'vertical'; 541 | const gradients = this.graphContainer.querySelectorAll('linearGradient'); 542 | 543 | for (let i = 0; i < gradients.length; i++) { 544 | setAttrs(gradients[i], { 545 | x1: '0', 546 | x2: '0', 547 | y1: '0', 548 | y2: '1' 549 | }); 550 | } 551 | 552 | return true; 553 | } 554 | 555 | gradientMakeHorizontal() { 556 | if (this.gradientDirection === 'horizontal') return true; 557 | 558 | this.gradientDirection = 'horizontal'; 559 | const gradients = this.graphContainer.querySelectorAll('linearGradient'); 560 | 561 | for (let i = 0; i < gradients.length; i++) { 562 | removeAttrs(gradients[i], 'x1', 'x2', 'y1', 'y2'); 563 | } 564 | 565 | return true; 566 | } 567 | 568 | gradientToggleDirection() { 569 | if (this.gradientDirection === 'horizontal') { 570 | this.gradientMakeVertical(); 571 | } else { 572 | this.gradientMakeHorizontal(); 573 | } 574 | } 575 | 576 | updateWidth(w) { 577 | this.width = w; 578 | const svg = this.getSVG(); 579 | const width = this.getWidth(); 580 | setAttrs(svg, { width }); 581 | 582 | this.drawPaths(); 583 | 584 | return true; 585 | } 586 | 587 | updateHeight(h) { 588 | this.height = h; 589 | const svg = this.getSVG(); 590 | const height = this.getHeight(); 591 | setAttrs(svg, { height }); 592 | 593 | this.drawPaths(); 594 | 595 | return true; 596 | } 597 | 598 | // @TODO: refactor data update 599 | updateData(d) { 600 | const labels = this.container.querySelector('.svg-funnel-js__labels'); 601 | const subLabels = this.container.querySelector('.svg-funnel-js__subLabels'); 602 | 603 | if (labels) labels.remove(); 604 | if (subLabels) subLabels.remove(); 605 | 606 | this.labels = []; 607 | this.colors = getDefaultColors(this.is2d() ? this.getSubDataSize() : 2); 608 | this.values = []; 609 | this.percentages = []; 610 | 611 | if (typeof d.labels !== 'undefined') { 612 | this.labels = FunnelGraph.getLabels({ data: d }); 613 | } 614 | if (typeof d.colors !== 'undefined') { 615 | this.colors = d.colors || getDefaultColors(this.is2d() ? this.getSubDataSize() : 2); 616 | } 617 | if (typeof d.values !== 'undefined') { 618 | if (Object.prototype.toString.call(d.values[0]) !== Object.prototype.toString.call(this.values[0])) { 619 | this.container.querySelector('svg').remove(); 620 | this.values = FunnelGraph.getValues({ data: d }); 621 | this.makeSVG(); 622 | } else { 623 | this.values = FunnelGraph.getValues({ data: d }); 624 | } 625 | this.drawPaths(); 626 | } 627 | this.percentages = this.createPercentages(); 628 | 629 | this.addLabels(); 630 | 631 | if (typeof d.subLabels !== 'undefined') { 632 | this.subLabels = FunnelGraph.getSubLabels({ data: d }); 633 | this.addSubLabels(); 634 | } 635 | } 636 | 637 | update(o) { 638 | if (typeof o.displayPercent !== 'undefined') { 639 | if (this.displayPercent !== o.displayPercent) { 640 | if (this.displayPercent === true) { 641 | this.container.querySelectorAll('.label__percentage').forEach((label) => { 642 | label.remove(); 643 | }); 644 | } else { 645 | this.container.querySelectorAll('.svg-funnel-js__label').forEach((label, index) => { 646 | const percentage = this.percentages[index]; 647 | const percentageValue = document.createElement('div'); 648 | percentageValue.setAttribute('class', 'label__percentage'); 649 | 650 | if (percentage !== 100) { 651 | percentageValue.textContent = `${percentage.toString()}%`; 652 | label.appendChild(percentageValue); 653 | } 654 | }); 655 | } 656 | } 657 | } 658 | if (typeof o.height !== 'undefined') { 659 | this.updateHeight(o.height); 660 | } 661 | if (typeof o.width !== 'undefined') { 662 | this.updateWidth(o.width); 663 | } 664 | if (typeof o.gradientDirection !== 'undefined') { 665 | if (o.gradientDirection === 'vertical') { 666 | this.gradientMakeVertical(); 667 | } else if (o.gradientDirection === 'horizontal') { 668 | this.gradientMakeHorizontal(); 669 | } 670 | } 671 | if (typeof o.direction !== 'undefined') { 672 | if (o.direction === 'vertical') { 673 | this.makeVertical(); 674 | } else if (o.direction === 'horizontal') { 675 | this.makeHorizontal(); 676 | } 677 | } 678 | if (typeof o.data !== 'undefined') { 679 | this.updateData(o.data); 680 | } 681 | } 682 | } 683 | 684 | export default FunnelGraph; 685 | -------------------------------------------------------------------------------- /src/js/number.js: -------------------------------------------------------------------------------- 1 | const roundPoint = number => Math.round(number * 10) / 10; 2 | const formatNumber = number => Number(number).toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,'); 3 | 4 | export { roundPoint, formatNumber }; 5 | -------------------------------------------------------------------------------- /src/js/path.js: -------------------------------------------------------------------------------- 1 | import { roundPoint } from './number'; 2 | 3 | const createCurves = (x1, y1, x2, y2) => ` C${roundPoint((x2 + x1) / 2)},${y1} ` 4 | + `${roundPoint((x2 + x1) / 2)},${y2} ${x2},${y2}`; 5 | 6 | const createVerticalCurves = (x1, y1, x2, y2) => ` C${x1},${roundPoint((y2 + y1) / 2)} ` 7 | + `${x2},${roundPoint((y2 + y1) / 2)} ${x2},${y2}`; 8 | 9 | /* 10 | A funnel segment is draw in a clockwise direction. 11 | Path 1-2 is drawn, 12 | then connected with a straight vertical line 2-3, 13 | then a line 3-4 is draw (using YNext points going in backwards direction) 14 | then path is closed (connected with the starting point 1). 15 | 16 | 1---------->2 17 | ^ | 18 | | v 19 | 4<----------3 20 | 21 | On the graph on line 20 it works like this: 22 | A#0, A#1, A#2, A#3, B#3, B#2, B#1, B#0, close the path. 23 | 24 | Points for path "B" are passed as the YNext param. 25 | */ 26 | 27 | const createPath = (index, X, Y, YNext) => { 28 | let str = `M${X[0]},${Y[0]}`; 29 | 30 | for (let i = 0; i < X.length - 1; i++) { 31 | str += createCurves(X[i], Y[i], X[i + 1], Y[i + 1]); 32 | } 33 | 34 | str += ` L${[...X].pop()},${[...YNext].pop()}`; 35 | 36 | for (let i = X.length - 1; i > 0; i--) { 37 | str += createCurves(X[i], YNext[i], X[i - 1], YNext[i - 1]); 38 | } 39 | 40 | str += ' Z'; 41 | 42 | return str; 43 | }; 44 | 45 | /* 46 | In a vertical path we go counter-clockwise 47 | 48 | 1<----------4 49 | | ^ 50 | v | 51 | 2---------->3 52 | */ 53 | 54 | const createVerticalPath = (index, X, XNext, Y) => { 55 | let str = `M${X[0]},${Y[0]}`; 56 | 57 | for (let i = 0; i < X.length - 1; i++) { 58 | str += createVerticalCurves(X[i], Y[i], X[i + 1], Y[i + 1]); 59 | } 60 | 61 | str += ` L${[...XNext].pop()},${[...Y].pop()}`; 62 | 63 | for (let i = X.length - 1; i > 0; i--) { 64 | str += createVerticalCurves(XNext[i], Y[i], XNext[i - 1], Y[i - 1]); 65 | } 66 | 67 | str += ' Z'; 68 | 69 | return str; 70 | }; 71 | 72 | export { 73 | createCurves, createVerticalCurves, createPath, createVerticalPath 74 | }; 75 | -------------------------------------------------------------------------------- /src/js/random.js: -------------------------------------------------------------------------------- 1 | const generateRandomIdString = prefix => Math.random().toString(36).replace('0.', prefix || ''); 2 | 3 | export default generateRandomIdString; 4 | -------------------------------------------------------------------------------- /src/scss/_animations.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/greghub/funnel-graph-js/d8537ef1f26850d8db157fe972af3c04f3a3c9d1/src/scss/_animations.scss -------------------------------------------------------------------------------- /src/scss/_variables.scss: -------------------------------------------------------------------------------- 1 | // colors 2 | $shadow-light: rgba(0, 0, 0, .07); 3 | $shadow-medium: rgba(0, 0, 0, 0.32); 4 | $shadow-dark: rgba(0, 0, 0, 0.62); 5 | $shadow-white-medium: rgba(255, 255, 255, 0.32); 6 | $shadow-white-dark: rgba(255, 255, 255, 0.62); 7 | $white: #fff; 8 | $primary: #05df9d; 9 | $light-gray: #b0b0b0; 10 | $lighter-gray: #d8d8d8; 11 | $text: #55606e; 12 | $value: #21ffa2; 13 | $secondary: #9896dc; 14 | $percentage-hover: rgba(8, 7, 48, 0.8); 15 | -------------------------------------------------------------------------------- /src/scss/main.scss: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | @import "animations"; 3 | 4 | 5 | .svg-funnel-js { 6 | display: inline-block; 7 | position: relative; 8 | 9 | svg { 10 | display: block; 11 | } 12 | 13 | .svg-funnel-js__labels { 14 | position: absolute; 15 | display: flex; 16 | width: 100%; 17 | height: 100%; 18 | top: 0; 19 | left: 0; 20 | } 21 | 22 | &.svg-funnel-js--vertical { 23 | .svg-funnel-js__labels { 24 | flex-direction: column; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/scss/theme.scss: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | @import url("https://fonts.googleapis.com/css?family=Open+Sans:400,700"); 3 | 4 | body { 5 | // sass-lint:disable-block no-vendor-prefixes 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | .svg-funnel-js { 11 | font-family: "Open Sans", sans-serif; 12 | 13 | .svg-funnel-js__container { 14 | width: 100%; 15 | height: 100%; 16 | } 17 | 18 | .svg-funnel-js__labels { 19 | width: 100%; 20 | box-sizing: border-box; 21 | 22 | .svg-funnel-js__label { 23 | flex: 1 1 0; 24 | position: relative; 25 | 26 | .label__value { 27 | font-size: 24px; 28 | color: $white; 29 | line-height: 18px; 30 | margin-bottom: 6px; 31 | } 32 | 33 | .label__title { 34 | font-size: 12px; 35 | font-weight: bold; 36 | color: $value; 37 | } 38 | 39 | .label__percentage { 40 | font-size: 16px; 41 | font-weight: bold; 42 | color: $secondary; 43 | } 44 | 45 | .label__segment-percentages { 46 | position: absolute; 47 | top: 50%; 48 | transform: translateY(-50%); 49 | width: 100%; 50 | left: 0; 51 | padding: 8px 24px; 52 | box-sizing: border-box; 53 | background-color: $percentage-hover; 54 | margin-top: 24px; 55 | opacity: 0; 56 | transition: opacity 0.1s ease; 57 | cursor: default; 58 | 59 | ul { 60 | margin: 0; 61 | padding: 0; 62 | list-style-type: none; 63 | 64 | li { 65 | font-size: 13px; 66 | line-height: 16px; 67 | color: $white; 68 | margin: 18px 0; 69 | 70 | .percentage__list-label { 71 | font-weight: bold; 72 | color: $primary; 73 | } 74 | } 75 | } 76 | } 77 | 78 | &:hover { 79 | .label__segment-percentages { 80 | opacity: 1; 81 | } 82 | } 83 | } 84 | } 85 | 86 | &:not(.svg-funnel-js--vertical) { 87 | padding-top: 64px; 88 | padding-bottom: 16px; 89 | 90 | .svg-funnel-js__label { 91 | padding-left: 24px; 92 | 93 | &:not(:first-child) { 94 | border-left: 1px solid $secondary; 95 | } 96 | } 97 | } 98 | 99 | &.svg-funnel-js--vertical { 100 | padding-left: 120px; 101 | padding-right: 16px; 102 | 103 | .svg-funnel-js__label { 104 | padding-top: 24px; 105 | 106 | &:not(:first-child) { 107 | border-top: 1px solid $secondary; 108 | } 109 | 110 | .label__segment-percentages { 111 | margin-top: 0; 112 | margin-left: 106px; 113 | width: calc(100% - 106px); 114 | 115 | .segment-percentage__list { 116 | display: flex; 117 | justify-content: space-around; 118 | } 119 | } 120 | } 121 | } 122 | 123 | .svg-funnel-js__subLabels { 124 | display: flex; 125 | justify-content: center; 126 | margin-top: 24px; 127 | position: absolute; 128 | width: 100%; 129 | left: 0; 130 | 131 | .svg-funnel-js__subLabel { 132 | display: flex; 133 | font-size: 12px; 134 | color: $white; 135 | line-height: 16px; 136 | 137 | &:not(:first-child) { 138 | margin-left: 16px; 139 | } 140 | 141 | .svg-funnel-js__subLabel--color { 142 | width: 12px; 143 | height: 12px; 144 | border-radius: 50%; 145 | margin: 2px 8px 2px 0; 146 | } 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | import { roundPoint, formatNumber } from '../src/js/number'; 3 | import { createCurves, createVerticalCurves, createPath } from '../src/js/path'; 4 | import { generateLegendBackground, areEqual } from '../src/js/graph'; 5 | import generateRandomIdString from '../src/js/random'; 6 | import FunnelGraph from '../index'; 7 | 8 | const assert = require('assert'); 9 | 10 | describe('Check randomly generated ids', () => { 11 | const generatedIds = []; 12 | it('don\'t collide often', () => { 13 | for(let i = 0; i < 1000; i++) { 14 | const newlyGeneratedId = generateRandomIdString(); 15 | for(let j = 0; j < generatedIds.length; j++) { 16 | const previouslyGeneratedId = generatedIds[j]; 17 | assert.notEqual(newlyGeneratedId, previouslyGeneratedId); 18 | } 19 | generatedIds.push(newlyGeneratedId); 20 | } 21 | }); 22 | it('have correct prefix', () => { 23 | ['test', '__', '-prefix-'].forEach(prefix => { 24 | const generatedId = generateRandomIdString(prefix); 25 | assert.equal(0, generatedId.indexOf(prefix)); 26 | }); 27 | }); 28 | it('are longer than just the prefix', () => { 29 | ['test', '__', '-prefix-', ''].forEach(prefix => { 30 | const generatedId = generateRandomIdString(prefix); 31 | assert.equal(true, generatedId.length > prefix.length); 32 | }); 33 | }); 34 | }); 35 | 36 | describe('Test number functions', () => { 37 | it('round number test', () => { 38 | assert.equal(roundPoint(19.99999999998), 20); 39 | }); 40 | 41 | it('number format test', () => { 42 | assert.equal(formatNumber(12500), '12,500'); 43 | }); 44 | }); 45 | 46 | describe('Add tests for paths', () => { 47 | it('can create points for curves', () => { 48 | assert.equal(createCurves(0, 0, 6, 2), ' C3,0 3,2 6,2'); 49 | }); 50 | 51 | it('can create points for vertical curves', () => { 52 | assert.equal(createVerticalCurves(0, 0, 6, 2), ' C0,1 6,1 6,2'); 53 | }); 54 | }); 55 | 56 | describe('Add tests for background color generator', () => { 57 | it('can generate a solid background', () => { 58 | assert.equal(generateLegendBackground('red'), 'background-color: red'); 59 | }); 60 | 61 | it('can generate a solid background from an array with single element', () => { 62 | assert.equal(generateLegendBackground(['red']), 'background-color: red'); 63 | }); 64 | 65 | it('can generate a gradient background', () => { 66 | assert.equal( 67 | generateLegendBackground(['red', 'orange']), 68 | 'background-image: linear-gradient(to right, red, orange)' 69 | ); 70 | }); 71 | 72 | it('can generate a vertical gradient background', () => { 73 | assert.equal( 74 | generateLegendBackground(['red', 'orange'], 'vertical'), 75 | 'background-image: linear-gradient(red, orange)' 76 | ); 77 | }); 78 | }); 79 | 80 | describe('Add tests for equality method', () => { 81 | it('can compare one dimensional arrays', () => { 82 | assert.strictEqual(areEqual([10, 20, 30], [10, 20, 30]), true); 83 | assert.notStrictEqual(areEqual([10, 20, 31], [10, 20, 30]), true); 84 | assert.notStrictEqual(areEqual([10, 20, 30, 40], [10, 20, 30]), true); 85 | }); 86 | it('can compare two dimensional arrays', () => { 87 | assert.strictEqual(areEqual([ 88 | [10, 20, 30], ['a', 'b', 'c'], [1, 'b', 0] 89 | ], [ 90 | [10, 20, 30], ['a', 'b', 'c'], [1, 'b', 0] 91 | ]), true); 92 | assert.notStrictEqual(areEqual([ 93 | [10, 20, 30], ['a', 'b', 'c'], [1, 'b', 0] 94 | ], [ 95 | [10, 20, 30], ['a', 'b', 'c'], [1, 'b', 'c'] 96 | ]), true); 97 | }); 98 | }); 99 | 100 | describe('Add tests for paths', () => { 101 | const data = { 102 | labels: ['Impressions', 'Add To Cart', 'Buy'], 103 | subLabels: ['Direct', 'Social Media', 'Ads', 'Other'], 104 | colors: [ 105 | ['#FFB178', '#FF78B1', '#FF3C8E'], 106 | ['#A0BBFF', '#EC77FF'], 107 | ['#A0F9FF', '#B377FF'], 108 | '#E478FF' 109 | ], 110 | values: [ 111 | [2000, 4000, 6000, 500], 112 | [3000, 1000, 1700, 600], 113 | [800, 300, 130, 400] 114 | ] 115 | }; 116 | 117 | const graph = new FunnelGraph({ 118 | container: '.funnel', 119 | gradientDirection: 'horizontal', 120 | data, 121 | displayPercent: true, 122 | direction: 'horizontal', 123 | width: 90, 124 | height: 60 125 | }); 126 | 127 | it('can create main axis points for curves', () => { 128 | assert.deepEqual(graph.getMainAxisPoints(), [0, 30, 60, 90]); 129 | }); 130 | 131 | it('can create main axis points for curves', () => { 132 | assert.deepEqual(graph.getCrossAxisPoints(), [ 133 | [0, 14.9, 26.1, 26.1], 134 | [9.6, 29.3, 29.9, 29.9], 135 | [28.8, 34.1, 31.3, 31.3], 136 | [57.6, 42.3, 31.9, 31.9], 137 | [60, 45.1, 33.9, 33.9] 138 | ]); 139 | }); 140 | 141 | it('can create all paths', () => { 142 | const length = graph.getCrossAxisPoints().length - 1; 143 | const paths = []; 144 | 145 | for (let i = 0; i < length; i++) { 146 | const X = graph.getMainAxisPoints(); 147 | const Y = graph.getCrossAxisPoints()[i]; 148 | const YNext = graph.getCrossAxisPoints()[i + 1]; 149 | const d = createPath(i, X, Y, YNext); 150 | 151 | paths.push(d); 152 | } 153 | assert.deepEqual(paths, ['M0,0 C15,0 15,14.9 30,14.9 C45,14.9 45,26.1 60,26.1 C75,26.1 75,26.1 90,26.1 L90,29.9 C75,29.9 75,29.9 60,29.9 C45,29.9 45,29.3 30,29.3 C15,29.3 15,9.6 0,9.6 Z', 154 | 'M0,9.6 C15,9.6 15,29.3 30,29.3 C45,29.3 45,29.9 60,29.9 C75,29.9 75,29.9 90,29.9 L90,31.3 C75,31.3 75,31.3 60,31.3 C45,31.3 45,34.1 30,34.1 C15,34.1 15,28.8 0,28.8 Z', 155 | 'M0,28.8 C15,28.8 15,34.1 30,34.1 C45,34.1 45,31.3 60,31.3 C75,31.3 75,31.3 90,31.3 L90,31.9 C75,31.9 75,31.9 60,31.9 C45,31.9 45,42.3 30,42.3 C15,42.3 15,57.6 0,57.6 Z', 156 | 'M0,57.6 C15,57.6 15,42.3 30,42.3 C45,42.3 45,31.9 60,31.9 C75,31.9 75,31.9 90,31.9 L90,33.9 C75,33.9 75,33.9 60,33.9 C45,33.9 45,45.1 30,45.1 C15,45.1 15,60 0,60 Z']); 157 | }); 158 | 159 | it('can update data', () => { 160 | const updatedData = { 161 | values: [ 162 | [3500, 3500, 7500], 163 | [3300, 5400, 5000], 164 | [600, 600, 6730] 165 | ] 166 | }; 167 | 168 | graph.values = FunnelGraph.getValues({ data: updatedData }); 169 | 170 | assert.deepEqual(graph.getMainAxisPoints(), [0, 30, 60, 90]); 171 | assert.deepEqual(graph.getCrossAxisPoints(), [ 172 | [0, 1.7, 13.6, 13.6], 173 | [14.5, 15.3, 16.1, 16.1], 174 | [29, 37.6, 18.6, 18.6], 175 | [60, 58.3, 46.4, 46.4] 176 | ]); 177 | }); 178 | }); 179 | --------------------------------------------------------------------------------