From a7fc02081a75bd15ded4e61839b959114cbd15e7 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Thu, 10 Jun 2021 15:19:08 +0200 Subject: [PATCH] refactor: cleaned up logic and update data method --- examples/example-multiple.html | 95 ------- examples/example.html | 189 ++++++------- src/index.css | 6 +- src/lib/graph.ts | 484 +++++++++++++++------------------ src/lib/path.ts | 10 +- src/lib/points.ts | 109 ++++++++ 6 files changed, 425 insertions(+), 468 deletions(-) delete mode 100644 examples/example-multiple.html create mode 100644 src/lib/points.ts diff --git a/examples/example-multiple.html b/examples/example-multiple.html deleted file mode 100644 index 5666ea6..0000000 --- a/examples/example-multiple.html +++ /dev/null @@ -1,95 +0,0 @@ - - - - - - SVG Funnel - - - - - - - -
-
-
-
-
- - - - - - diff --git a/examples/example.html b/examples/example.html index 303cc20..b250168 100644 --- a/examples/example.html +++ b/examples/example.html @@ -20,7 +20,7 @@ body { margin: 0; - background: #f7f4f0; + background: #f5f3f1; } .funnel { @@ -64,98 +64,103 @@ + diff --git a/src/index.css b/src/index.css index a1695e8..70693d6 100644 --- a/src/index.css +++ b/src/index.css @@ -1,7 +1,7 @@ :root { - --text: #003f5c; - --primary: #a05195; - --accent: #f95d6a; + --text: MidnightBlue; + --primary: MidnightBlue; + --accent: Crimson; --backdrop: rgba(0, 0, 0, 0.75); --backdrop-text: #ffffff; } diff --git a/src/lib/graph.ts b/src/lib/graph.ts index b6742f4..0abba04 100644 --- a/src/lib/graph.ts +++ b/src/lib/graph.ts @@ -1,6 +1,7 @@ import { createSVGElement, generateLegendBackground, getDefaultColors, removeAttrs, setAttrs } from './dom'; import { formatNumber, roundPoint } from './number'; import { createPath, createVerticalPath } from './path'; +import { generateCrossAxisPoints, generateMainAxisPoints, layerMaxLength, layerPercentages, layerSums } from './points'; import { Direction, FunnelData, FunnelDataLayered, FunnelOptions, isLayered } from './types'; export class FunnelGraph { @@ -10,147 +11,115 @@ export class FunnelGraph { private data: FunnelData | FunnelDataLayered; private gradientDirection: Direction; private direction: Direction; - private percentages: number[]; private displayPercent: boolean; - private height: number; private width: number; + private height: number; private subLabelValue: string; constructor(options: FunnelOptions) { 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 = { labels: [], - colors: getDefaultColors(isLayered(options.data) ? options.data.values[0].length : 1), + colors, subLabels: [], ...options.data, }; this.gradientDirection = options.gradientDirection && options.gradientDirection === 'vertical' ? 'vertical' : 'horizontal'; this.direction = options.direction && options.direction === 'vertical' ? 'vertical' : 'horizontal'; - this.percentages = this.getPercentages(); this.displayPercent = options.displayPercent || false; this.width = options.width || 0; this.height = options.height || 0; this.subLabelValue = options.subLabelValue || 'percent'; } - /** - * 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. - */ - getMainAxisPoints() { - const size = this.getDataSize(); - const points = []; - const fullDimension = this.isVertical() ? this.getHeight() : this.getWidth(); - for (let i = 0; i <= size; i++) { - points.push(roundPoint((fullDimension * i) / size)); - } - return points; - } - - getCrossAxisPoints() { - const points: number[][] = []; - const fullDimension = this.getFullDimension(); - // get half of the graph container height or width, since funnel shape is symmetric - // we use this when calculating the "A" shape - const dimension = fullDimension / 2; - if (isLayered(this.data)) { - const totalValues = this.getValues2d(); - const max = Math.max(...totalValues); - - // duplicate last value - totalValues.push([...totalValues].pop() as number); - // get points for path "A" - points.push(totalValues.map((value) => roundPoint(((max - value) / max) * dimension))); - // percentages with duplicated last value - const percentagesFull = this.getPercentages2d(); - const pointsOfFirstPath = points[0]; - - for (let i = 1; i < this.getSubDataSize(); i++) { - const p = points[i - 1]; - const newPoints: number[] = []; - - for (let j = 0; j < this.getDataSize(); j++) { - newPoints.push( - roundPoint( - // eslint-disable-next-line comma-dangle - p[j] + (fullDimension - pointsOfFirstPath[j] * 2) * (percentagesFull[j][i - 1] / 100), - ), - ); - } + //------------------------------------------------------------------------------------ + // RENDER + //------------------------------------------------------------------------------------ - // duplicate the last value as points #2 and #3 have the same value on the cross axis - newPoints.push([...newPoints].pop() as number); - points.push(newPoints); + private createContainer() { + if (!this.container) { + if (!this.containerSelector) { + throw new Error('Container must either be a selector string or an Element.'); } - // add points for path "D", that is simply the "inverted" path "A" - points.push(pointsOfFirstPath.map((point) => fullDimension - 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(...this.data.values); - const values = [...this.data.values].concat([...this.data.values].pop() as number); - // 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) => fullDimension - point)); + this.container = document.querySelector(this.containerSelector); + if (!this.container) { + throw new Error(`Container cannot be found (selector: ${this.containerSelector}).`); + } } - return points; - } + this.container.classList.add('fg'); - getGraphType() { - return isLayered(this.data) ? 'layered' : 'normal'; - } + this.graphContainer = document.createElement('div'); + this.graphContainer.classList.add('fg-container'); + this.container.appendChild(this.graphContainer); - isVertical() { - return this.direction === 'vertical'; + if (this.direction === 'vertical') { + this.container.classList.add('fg--vertical'); + } } - getDataSize() { - return this.data.values.length; - } + private makeSVG() { + if (!this.graphContainer) return; - getSubDataSize() { - if (Array.isArray(this.data.values[0])) return this.data.values[0].length; - return 0; + 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 as string[], i + 1); + } + } + + for (let i = valuesNum; i < paths.length; i++) { + paths[i].remove(); + } } - getFullDimension() { - return this.isVertical() ? this.getWidth() : this.getHeight(); + private 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() { + private addLabels() { if (!this.container) return; const holder = document.createElement('div'); holder.setAttribute('class', 'fg-labels'); - this.percentages.forEach((percentage, index) => { + const percentages = this.getPercentages(); + percentages.forEach((percentage, index) => { const labelElement = document.createElement('div'); labelElement.setAttribute('class', `fg-label`); @@ -161,8 +130,8 @@ export class FunnelGraph { const value = document.createElement('div'); value.setAttribute('class', 'fg-label__value'); - const valueNumber = isLayered(this.data) ? this.getValues2d()[index] : this.data.values[index]; - value.textContent = formatNumber(valueNumber); + 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'); @@ -179,12 +148,14 @@ export class FunnelGraph { segmentPercentages.setAttribute('class', 'fg-label__segments'); let percentageList = '