2 |
3 |
4 |
5 | SVG Funnel
6 |
7 |
8 |
9 |
10 |
29 |
30 |
31 |
36 |
37 |
38 |
83 |
84 |
85 |
--------------------------------------------------------------------------------
/examples/example.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | SVG Funnel
6 |
7 |
8 |
9 |
10 |
39 |
40 |
41 |
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 |
--------------------------------------------------------------------------------