UNPKG

@trutoo/funnel-graph

Version:

SVG Funnel Graph TypeScript Library.

780 lines (767 loc) 30.1 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); const setAttrs = (element, attributes) => { if (typeof attributes === 'object') { Object.keys(attributes).forEach((key) => { element.setAttribute(key, attributes[key]); }); } }; const removeAttrs = (element, ...attributes) => { attributes.forEach((attribute) => { element.removeAttribute(attribute); }); }; const createSVGElement = (element, container, attributes) => { const el = document.createElementNS('http://www.w3.org/2000/svg', element); if (typeof attributes === 'object') { setAttrs(el, attributes); } if (container) { container.appendChild(el); } return el; }; const generateLegendBackground = (color, direction = 'horizontal') => { if (typeof color === 'string') { return `background-color: ${color}`; } if (color.length === 1) { return `background-color: ${color[0]}`; } return `background-image: linear-gradient(${direction === 'horizontal' ? 'to right, ' : ''}${color.join(', ')})`; }; const defaultColors = ['#003f5c', '#2f4b7c', '#665191', '#a05195', '#d45087', '#f95d6a', '#ff7c43', '#ffa600']; const getDefaultColors = (sets) => { if (sets == 1) return [defaultColors[0], defaultColors[3]]; const colors = []; const len = defaultColors.length; for (let i = 0; i < sets; i++) { const colorIndex = Math.round((len / Math.min(sets, len)) * (i % len)); colors.push(defaultColors[colorIndex]); } return colors; }; /* Used in comparing existing values to value provided on update It is limited to comparing arrays on purpose Name is slightly unusual, in order not to be confused with Lodash method */ const areEqual = (value, newValue) => { // If values are not of the same type const type = Object.prototype.toString.call(value); if (type !== Object.prototype.toString.call(newValue)) return false; if (type !== '[object Array]') return false; if (value.length !== newValue.length) return false; for (let i = 0; i < value.length; i++) { // if the it's a two dimensional array const currentType = Object.prototype.toString.call(value[i]); if (currentType !== Object.prototype.toString.call(newValue[i])) return false; if (currentType === '[object Array]') { // if row lengths are not equal then arrays are not equal if (value[i].length !== newValue[i].length) return false; // compare each element in the row for (let j = 0; j < value[i].length; j++) { if (value[i][j] !== newValue[i][j]) { return false; } } } else if (value[i] !== newValue[i]) { // if it's a one dimensional array element return false; } } return true; }; const roundPoint = (number) => Math.round(number * 10) / 10; const formatNumber = (number) => number.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,'); const createCurves = (x1, y1, x2, y2) => ` C${roundPoint((x2 + x1) / 2)},${y1} ` + `${roundPoint((x2 + x1) / 2)},${y2} ${x2},${y2}`; const createVerticalCurves = (x1, y1, x2, y2) => ` C${x1},${roundPoint((y2 + y1) / 2)} ` + `${x2},${roundPoint((y2 + y1) / 2)} ${x2},${y2}`; /* A funnel segment is draw in a clockwise direction. Path 1-2 is drawn, then connected with a straight vertical line 2-3, then a line 3-4 is draw (using YNext points going in backwards direction) then path is closed (connected with the starting point 1). 1---------->2 ^ | | v 4<----------3 On the graph on line 20 it works like this: A#0, A#1, A#2, A#3, B#3, B#2, B#1, B#0, close the path. Points for path "B" are passed as the YNext param. */ const createPath = (X, Y, YNext) => { let str = `M${X[0]},${Y[0]}`; for (let i = 0; i < X.length - 1; i++) { str += createCurves(X[i], Y[i], X[i + 1], Y[i + 1]); } str += ` L${[...X].pop()},${[...YNext].pop()}`; for (let i = X.length - 1; i > 0; i--) { str += createCurves(X[i], YNext[i], X[i - 1], YNext[i - 1]); } str += ' Z'; return str; }; /* In a vertical path we go counter-clockwise 1<----------4 | ^ v | 2---------->3 */ const createVerticalPath = (X, XNext, Y) => { let str = `M${X[0]},${Y[0]}`; for (let i = 0; i < X.length - 1; i++) { str += createVerticalCurves(X[i], Y[i], X[i + 1], Y[i + 1]); } str += ` L${[...XNext].pop()},${[...Y].pop()}`; for (let i = X.length - 1; i > 0; i--) { str += createVerticalCurves(XNext[i], Y[i], XNext[i - 1], Y[i - 1]); } str += ' Z'; return str; }; const isLayered = (data) => { return !!data.values && Array.isArray(data.values[0]); }; const layerMaxLength = (data) => { return data.values.reduce((max, valueSet) => { return Math.max(max, valueSet.length); }, 0); }; const layerSums = (data) => { return data.values.map((valueSet) => { return valueSet.reduce((sum, value) => sum + (value || 0), 0); }); }; const layerPercentages = (data) => { return data.values.map((valueSet) => { const total = valueSet.reduce((sum, value) => sum + (value || 0), 0); return valueSet.map((value) => (total === 0 ? 0 : roundPoint(((value || 0) * 100) / total))); }); }; /** * An example of a two-dimensional funnel graph * #0.................. * ...#1................ * ...... * #0********************#1** #2.........................#3 (A) * ******************* * #2*************************#3 (B) * #2+++++++++++++++++++++++++#3 (C) * +++++++++++++++++++ * #0++++++++++++++++++++#1++ #2-------------------------#3 (D) * ------ * ---#1---------------- * #0----------------- * Main axis is the primary axis of the graph. * In a horizontal graph it's the X axis, and Y is the cross axis. * However we use the names "main" and "cross" axis, * because in a vertical graph the primary axis is the Y axis * and the cross axis is the X axis. * First step of drawing the funnel graph is getting the coordinates of points, * that are used when drawing the paths. * There are 4 paths in the example above: A, B, C and D. * Such funnel has 3 labels and 3 subLabels. * This means that the main axis has 4 points (number of labels + 1) * One the ASCII illustrated graph above, those points are illustrated with a # symbol. */ const generateMainAxisPoints = (data, size) => { const points = []; for (let i = 0; i <= data.values.length; i++) { points.push(roundPoint((size * i) / data.values.length)); } return points; }; const generateCrossAxisPoints = (data, size) => { const points = []; // get half of the graph container height or width, since funnel shape is symmetric // we use this when calculating the "A" shape const dimension = size / 2; if (isLayered(data)) { const totalValues = layerSums(data); const max = Math.max(...totalValues); // duplicate last value totalValues.push([...totalValues].pop()); // get points for path "A" points.push(totalValues.map((value) => roundPoint(((max - value) / max) * dimension))); // percentages with duplicated last value const percentagesFull = layerPercentages(data); const pointsOfFirstPath = points[0]; const length = layerMaxLength(data); for (let i = 1; i < length; i++) { const p = points[i - 1]; const newPoints = []; for (let j = 0; j < data.values.length; j++) { const percentage = percentagesFull[j][i - 1] || 0; newPoints.push(roundPoint( // eslint-disable-next-line comma-dangle p[j] + (size - pointsOfFirstPath[j] * 2) * (percentage / 100))); } // duplicate the last value as points #2 and #3 have the same value on the cross axis newPoints.push([...newPoints].pop()); points.push(newPoints); } // add points for path "D", that is simply the "inverted" path "A" points.push(pointsOfFirstPath.map((point) => size - point)); } else { // As you can see on the visualization above points #2 and #3 have the same cross axis coordinate // so we duplicate the last value const max = Math.max(...data.values); const values = [...data.values].concat([...data.values].pop()); // if the graph is simple (not two-dimensional) then we have only paths "A" and "D" // which are symmetric. So we get the points for "A" and then get points for "D" by subtracting "A" // points from graph cross dimension length points.push(values.map((value) => roundPoint(((max - value) / max) * dimension))); points.push(points[0].map((point) => size - point)); } return points; }; class FunnelGraph { constructor(options) { this.container = null; this.graphContainer = null; this.containerSelector = ''; if (options.container instanceof Element) this.container = options.container; else this.containerSelector = options.container; const colors = getDefaultColors(isLayered(options.data) ? layerMaxLength(options.data) : 1); this.data = Object.assign({ labels: [], colors, subLabels: [] }, options.data); this.gradientDirection = options.gradientDirection && options.gradientDirection === 'vertical' ? 'vertical' : 'horizontal'; this.direction = options.direction && options.direction === 'vertical' ? 'vertical' : 'horizontal'; this.displayPercent = options.displayPercent || false; this.width = options.width || 0; this.height = options.height || 0; this.subLabelValue = options.subLabelValue || 'percent'; } //------------------------------------------------------------------------------------ // RENDER //------------------------------------------------------------------------------------ createContainer() { if (!this.container) { if (!this.containerSelector) { throw new Error('Container must either be a selector string or an Element.'); } this.container = document.querySelector(this.containerSelector); if (!this.container) { throw new Error(`Container cannot be found (selector: ${this.containerSelector}).`); } } this.container.classList.add('fg'); this.graphContainer = document.createElement('div'); this.graphContainer.classList.add('fg-container'); this.container.appendChild(this.graphContainer); if (this.direction === 'vertical') { this.container.classList.add('fg--vertical'); } } makeSVG() { if (!this.graphContainer) return; let svg = this.graphContainer.querySelector('svg'); if (!svg) { svg = createSVGElement('svg', this.graphContainer, { width: this.getWidth().toString(), height: this.getHeight().toString(), }); this.graphContainer.appendChild(svg); } const paths = svg.querySelectorAll('path'); const valuesNum = this.getCrossAxisPoints().length - 1; for (let i = 0; i < valuesNum; i++) { let path = paths[i]; if (!path) { path = createSVGElement('path', svg); svg.appendChild(path); } const color = isLayered(this.data) ? this.data.colors[i] : this.data.colors; const fillMode = typeof color === 'string' || color.length === 1 ? 'solid' : 'gradient'; if (fillMode === 'solid') { setAttrs(path, { fill: Array.isArray(color) ? color[0] : color, stroke: Array.isArray(color) ? color[0] : color, }); } else if (fillMode === 'gradient') { this.applyGradient(svg, path, color, i + 1); } } for (let i = valuesNum; i < paths.length; i++) { paths[i].remove(); } } drawPaths() { const svg = this.getSVG(); if (!svg) return; const paths = svg.querySelectorAll('path'); const definitions = this.getPathDefinitions(); definitions.forEach((definition, index) => { paths[index].setAttribute('d', definition); }); } addLabels() { if (!this.container) return; const holder = document.createElement('div'); holder.setAttribute('class', 'fg-labels'); const percentages = this.getPercentages(); percentages.forEach((percentage, index) => { var _a; const labelElement = document.createElement('div'); labelElement.setAttribute('class', `fg-label`); const title = document.createElement('div'); title.setAttribute('class', 'fg-label__title'); title.textContent = this.data.labels[index] || ''; const value = document.createElement('div'); value.setAttribute('class', 'fg-label__value'); const valueNumber = isLayered(this.data) ? this.getLayerSums()[index] : this.data.values[index]; value.textContent = formatNumber(valueNumber || 0); const percentageValue = document.createElement('div'); percentageValue.setAttribute('class', 'fg-label__percentage'); percentageValue.textContent = `${percentage.toString()}%`; labelElement.appendChild(value); labelElement.appendChild(title); if (this.displayPercent) { labelElement.appendChild(percentageValue); } if (isLayered(this.data) && ((_a = this.data.subLabels) === null || _a === void 0 ? void 0 : _a.length)) { const segmentPercentages = document.createElement('div'); segmentPercentages.setAttribute('class', 'fg-label__segments'); let percentageList = '<ul class="fg-label__segment-list">'; const twoDimPercentages = this.getLayerPercentages(); this.data.subLabels.forEach((subLabel, j) => { const data = this.data; const subLabelDisplayValue = this.subLabelValue === 'percent' ? `${twoDimPercentages[index][j] || 0}%` : formatNumber(data.values[index][j] || 0); percentageList += ` <li class="fg-label__segment-item">${subLabel}: <span class="fg-label__segment-label">${subLabelDisplayValue}</span> </li>`; }); percentageList += '</ul>'; segmentPercentages.innerHTML = percentageList; labelElement.appendChild(segmentPercentages); } holder.appendChild(labelElement); }); this.container.appendChild(holder); } addSubLabels() { var _a; if (!this.container || !isLayered(this.data) || !((_a = this.data.subLabels) === null || _a === void 0 ? void 0 : _a.length)) return; const subLabelsHolder = document.createElement('div'); subLabelsHolder.setAttribute('class', 'fg-sub-labels'); let subLabelsHTML = ''; this.data.subLabels.forEach((subLabel, index) => { if (!this.data.colors) return; subLabelsHTML += ` <div class="fg-sub-label"> <div class="fg-sub-label__color" style="${generateLegendBackground(this.data.colors[index], this.gradientDirection)}"></div> <div class="fg-sub-label__title">${subLabel}</div> </div>`; }); subLabelsHolder.innerHTML = subLabelsHTML; this.container.appendChild(subLabelsHolder); } applyGradient(svg, path, colors, index = 0) { let defs = svg.querySelector('defs'); if (!defs) defs = createSVGElement('defs', svg); const gradientName = `funnelGradient-${index}`; let gradient = defs.querySelector(`#${gradientName}`); if (!gradient) { gradient = createSVGElement('linearGradient', defs, { id: gradientName, }); } if (this.gradientDirection === 'vertical') { setAttrs(gradient, { x1: '0', x2: '0', y1: '0', y2: '1', }); } const stops = gradient.querySelectorAll(`stop`); const numberOfColors = colors.length; for (let i = 0; i < numberOfColors; i++) { const stop = stops[i]; const attributes = { 'stop-color': colors[i], offset: `${Math.round((100 * i) / (numberOfColors - 1))}%`, }; if (stop) { setAttrs(stop, attributes); } else { createSVGElement('stop', gradient, attributes); } } for (let i = numberOfColors; i < stops.length; i++) { stops[i].remove(); } setAttrs(path, { fill: `url("#${gradientName}")`, stroke: `url("#${gradientName}")`, }); } //------------------------------------------------------------------------------------ // GETTERS //------------------------------------------------------------------------------------ getMainAxisPoints() { const fullDimension = this.isVertical() ? this.getHeight() : this.getWidth(); return generateMainAxisPoints(this.data, fullDimension); } getCrossAxisPoints() { const fullDimension = this.isVertical() ? this.getWidth() : this.getHeight(); return generateCrossAxisPoints(this.data, fullDimension); } getGraphType() { return isLayered(this.data) ? 'layered' : 'normal'; } isVertical() { return this.direction === 'vertical'; } getDataSize() { return this.data.values.length; } getSubDataSize() { if (Array.isArray(this.data.values[0])) return this.data.values[0].length; return 0; } getFullDimension() { return this.isVertical() ? this.getHeight() : this.getWidth(); } setValues(values) { this.data.values = values; return this; } setDirection(direction) { this.direction = direction; return this; } setHeight(height) { this.height = height; return this; } setWidth(width) { this.width = width; return this; } getLayerSums() { if (!isLayered(this.data)) return []; return layerSums(this.data); } getLayerPercentages() { if (!isLayered(this.data)) return []; return layerPercentages(this.data); } getPercentages() { let values = []; if (isLayered(this.data)) { values = this.getLayerSums(); } else { values = [...this.data.values]; } const max = Math.max(...values); return values.map((value) => (value ? roundPoint((value * 100) / max) : 0)); } getSVG() { if (!this.container) return; const svg = this.container.querySelector('svg'); if (!svg) { throw new Error('No SVG found inside of the container'); } return svg; } getWidth() { var _a; return this.width || ((_a = this.graphContainer) === null || _a === void 0 ? void 0 : _a.clientWidth) || 0; } getHeight() { var _a; return this.height || ((_a = this.graphContainer) === null || _a === void 0 ? void 0 : _a.clientHeight) || 0; } getPathDefinitions() { const crossAxisPoints = this.getCrossAxisPoints(); const valuesNum = crossAxisPoints.length - 1; const paths = []; for (let i = 0; i < valuesNum; i++) { if (this.isVertical()) { const x = crossAxisPoints[i]; const xNext = crossAxisPoints[i + 1]; const y = this.getMainAxisPoints(); const d = createVerticalPath(x, xNext, y); paths.push(d); } else { const x = this.getMainAxisPoints(); const y = crossAxisPoints[i]; const yNext = crossAxisPoints[i + 1]; const d = createPath(x, y, yNext); paths.push(d); } } return paths; } getPathMedian(i) { const crossAxisPoints = this.getCrossAxisPoints(); if (this.isVertical()) { const cross = crossAxisPoints[i]; const next = crossAxisPoints[i + 1]; const y = this.getMainAxisPoints(); const x = []; const xNext = []; cross.forEach((point, index) => { const m = (point + next[index]) / 2; x.push(m - 1); xNext.push(m + 1); }); return createVerticalPath(x, xNext, y); } const x = this.getMainAxisPoints(); const cross = crossAxisPoints[i]; const next = crossAxisPoints[i + 1]; const y = []; const yNext = []; cross.forEach((point, index) => { const m = (point + next[index]) / 2; y.push(m - 1); yNext.push(m + 1); }); return createPath(x, y, yNext); } //------------------------------------------------------------------------------------ // PUBLIC API //------------------------------------------------------------------------------------ makeVertical() { const svg = this.getSVG(); if (!this.container || !svg) return; if (this.direction === 'vertical') return true; this.direction = 'vertical'; this.container.classList.add('fg--vertical'); const height = this.getHeight().toString(); const width = this.getWidth().toString(); setAttrs(svg, { height, width }); this.drawPaths(); return true; } makeHorizontal() { const svg = this.getSVG(); if (!this.container || !svg) return; if (this.direction === 'horizontal') return true; this.direction = 'horizontal'; this.container.classList.remove('fg--vertical'); const height = this.getHeight().toString(); const width = this.getWidth().toString(); setAttrs(svg, { height, width }); this.drawPaths(); return true; } toggleDirection() { if (this.direction === 'horizontal') { this.makeVertical(); } else { this.makeHorizontal(); } } gradientMakeVertical() { if (!this.graphContainer) return false; if (this.gradientDirection === 'vertical') return true; this.gradientDirection = 'vertical'; const gradients = this.graphContainer.querySelectorAll('linearGradient'); for (let i = 0; i < gradients.length; i++) { setAttrs(gradients[i], { x1: '0', x2: '0', y1: '0', y2: '1', }); } return true; } gradientMakeHorizontal() { if (!this.graphContainer) return false; if (this.gradientDirection === 'horizontal') return true; this.gradientDirection = 'horizontal'; const gradients = this.graphContainer.querySelectorAll('linearGradient'); for (let i = 0; i < gradients.length; i++) { removeAttrs(gradients[i], 'x1', 'x2', 'y1', 'y2'); } return true; } gradientToggleDirection() { if (this.gradientDirection === 'horizontal') { this.gradientMakeVertical(); } else { this.gradientMakeHorizontal(); } } updateWidth(width) { const svg = this.getSVG(); if (!svg) return; this.width = width; setAttrs(svg, { width: width.toString() }); this.drawPaths(); return true; } updateHeight(height) { const svg = this.getSVG(); if (!svg) return; this.height = height; setAttrs(svg, { height: height.toString() }); this.drawPaths(); return true; } updateData(data, reset = false) { if (!this.container) return; let redraw = false; if (reset) { this.data = { values: [], labels: [], subLabels: [], colors: [], }; redraw = true; } if (data.colors) { this.data.colors = data.colors; redraw = true; } else if (data.values && data.values.length != this.data.values.length) { this.data.colors = getDefaultColors(isLayered(data) ? layerMaxLength(data) : 1); redraw = true; } if (data.values) { this.data.values = data.values; redraw = true; } if (isLayered(data) && data.subLabels) { const subLabels = this.container.querySelector('.fg-sub-labels'); if (subLabels) subLabels.remove(); this.data.subLabels = data.subLabels; this.addSubLabels(); } if (data.labels) { const labels = this.container.querySelector('.fg-labels'); if (labels) labels.remove(); this.data.labels = data.labels; this.addLabels(); } if (redraw) { this.makeSVG(); this.drawPaths(); } } update(options) { if (!this.container) return; if (typeof options.displayPercent !== 'undefined') { if (this.displayPercent !== options.displayPercent) { if (this.displayPercent === true) { this.container.querySelectorAll('.fg-label__percentage').forEach((label) => { label.remove(); }); } else { const percentages = this.getPercentages(); this.container.querySelectorAll('.fg-label').forEach((label, index) => { const percentage = percentages[index]; const percentageValue = document.createElement('div'); percentageValue.setAttribute('class', 'fg-label__percentage'); if (percentage !== 100) { percentageValue.textContent = `${percentage.toString()}%`; label.appendChild(percentageValue); } }); } } } if (typeof options.height !== 'undefined') { this.updateHeight(options.height); } if (typeof options.width !== 'undefined') { this.updateWidth(options.width); } if (typeof options.gradientDirection !== 'undefined') { if (options.gradientDirection === 'vertical') { this.gradientMakeVertical(); } else if (options.gradientDirection === 'horizontal') { this.gradientMakeHorizontal(); } } if (typeof options.direction !== 'undefined') { if (options.direction === 'vertical') { this.makeVertical(); } else if (options.direction === 'horizontal') { this.makeHorizontal(); } } if (typeof options.data !== 'undefined') { this.updateData(options.data); } } draw() { this.createContainer(); this.makeSVG(); this.addLabels(); if (isLayered(this.data)) { this.addSubLabels(); } this.drawPaths(); } } exports.FunnelGraph = FunnelGraph; exports.areEqual = areEqual; exports.createCurves = createCurves; exports.createPath = createPath; exports.createSVGElement = createSVGElement; exports.createVerticalCurves = createVerticalCurves; exports.createVerticalPath = createVerticalPath; exports.defaultColors = defaultColors; exports.formatNumber = formatNumber; exports.generateLegendBackground = generateLegendBackground; exports.getDefaultColors = getDefaultColors; exports.isLayered = isLayered; exports.removeAttrs = removeAttrs; exports.roundPoint = roundPoint; exports.setAttrs = setAttrs;