From dab777785de0507c92c93742b78c124c9a620da4 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Thu, 17 Oct 2019 14:19:36 +0300 Subject: [PATCH 1/4] Migrate Sunburst Renderer to React --- client/app/assets/less/main.less | 2 - client/app/lib/visualizations/sunburst.js | 388 ------------------ client/app/visualizations/sankey/Renderer.jsx | 1 + .../sankey/renderer.less} | 0 .../app/visualizations/sunburst/Renderer.jsx | 26 ++ client/app/visualizations/sunburst/index.js | 43 +- .../visualizations/sunburst/initSunburst.js | 361 ++++++++++++++++ .../sunburst/renderer.less} | 0 8 files changed, 397 insertions(+), 424 deletions(-) delete mode 100644 client/app/lib/visualizations/sunburst.js rename client/app/{assets/less/inc/visualizations/sankey.less => visualizations/sankey/renderer.less} (100%) create mode 100644 client/app/visualizations/sunburst/Renderer.jsx create mode 100644 client/app/visualizations/sunburst/initSunburst.js rename client/app/{assets/less/inc/visualizations/sunburst.less => visualizations/sunburst/renderer.less} (100%) diff --git a/client/app/assets/less/main.less b/client/app/assets/less/main.less index 0f764f8144..75e6775d26 100644 --- a/client/app/assets/less/main.less +++ b/client/app/assets/less/main.less @@ -53,10 +53,8 @@ @import 'inc/schema-browser'; @import 'inc/toast'; @import 'inc/visualizations/box'; -@import 'inc/visualizations/sankey'; @import 'inc/visualizations/pivot-table'; @import 'inc/visualizations/map'; -@import 'inc/visualizations/sunburst'; @import 'inc/visualizations/cohort'; @import 'inc/visualizations/misc'; diff --git a/client/app/lib/visualizations/sunburst.js b/client/app/lib/visualizations/sunburst.js deleted file mode 100644 index 2a5f1ffb36..0000000000 --- a/client/app/lib/visualizations/sunburst.js +++ /dev/null @@ -1,388 +0,0 @@ -import * as d3 from 'd3'; -import _ from 'lodash'; -import angular from 'angular'; - -const exitNode = '<<>>'; -const colors = d3.scale.category10(); - -// helper function colorMap - color gray if "end" is detected -function colorMap(d) { - return colors(d.name); -} - -// Return array of ancestors of nodes, highest first, but excluding the root. -function getAncestors(node) { - const path = []; - let current = node; - - while (current.parent) { - path.unshift(current); - current = current.parent; - } - return path; -} - -// The following is based on @chrisrzhou's example from: http://bl.ocks.org/chrisrzhou/d5bdd8546f64ca0e4366. -function Sunburst(scope, element) { - this.element = element; - this.watches = []; - - // svg dimensions - const width = element.clientWidth; - const height = element.offsetHeight; - - // Breadcrumb dimensions: width, height, spacing, width of tip/tail. - const b = { - w: width / 6, - h: 30, - s: 3, - t: 10, - }; - - const radius = Math.min(width - b.h, height - b.h) / 2 - 5; - if (radius <= 0) { - return; - } - - // margins - const margin = { - top: radius, - bottom: 50, - left: radius, - right: 0, - }; - - /** - * Drawing variables: - * - * e.g. colors, totalSize, partitions, arcs - */ - // Mapping of nodes to colorscale. - - // Total size of all nodes, to be used later when data is loaded - let totalSize = 0; - - // create d3.layout.partition - const partition = d3.layout - .partition() - .size([2 * Math.PI, radius * radius]) - .value(d => d.size); - - // create arcs for drawing D3 paths - const arc = d3.svg - .arc() - .startAngle(d => d.x) - .endAngle(d => d.x + d.dx) - .innerRadius(d => Math.sqrt(d.y)) - .outerRadius(d => Math.sqrt(d.y + d.dy)); - - /** - * Define and initialize D3 select references and div-containers - * - * e.g. vis, breadcrumbs, lastCrumb, summary, sunburst, legend - */ - const vis = d3.select(element); - - // create and position breadcrumbs container and svg - const breadcrumbs = vis - .append('div') - .classed('breadcrumbs-container', true) - .append('svg') - .attr('width', width) - .attr('height', b.h) - .attr('fill', 'white') - .attr('font-weight', 600); - - // create and position SVG - const container = vis.append('div'); - - // create and position summary container - const summary = container - .append('div') - .classed('summary-container', true); - - const sunburst = container - .append('div') - .classed('sunburst-container', true) - .append('svg') - .attr('width', radius * 2) - .attr('height', radius * 2) - .append('g') - .attr('transform', `translate(${margin.left},${margin.top})`); - - // create last breadcrumb element - const lastCrumb = breadcrumbs.append('text').classed('lastCrumb', true); - - // Generate a string representation for drawing a breadcrumb polygon. - function breadcrumbPoints(d, i) { - const points = []; - points.push('0,0'); - points.push(`${b.w},0`); - points.push(`${b.w + b.t},${b.h / 2}`); - points.push(`${b.w},${b.h}`); - points.push(`0,${b.h}`); - - if (i > 0) { - // Leftmost breadcrumb; don't include 6th vertex. - points.push(`${b.t},${b.h / 2}`); - } - return points.join(' '); - } - - // Update the breadcrumb breadcrumbs to show the current sequence and percentage. - function updateBreadcrumbs(ancestors, percentageString) { - // Data join, where primary key = name + depth. - const g = breadcrumbs.selectAll('g').data(ancestors, d => d.name + d.depth); - - // Add breadcrumb and label for entering nodes. - const breadcrumb = g.enter().append('g'); - - breadcrumb - .append('polygon') - .classed('breadcrumbs-shape', true) - .attr('points', breadcrumbPoints) - .attr('fill', colorMap); - - breadcrumb - .append('text') - .classed('breadcrumbs-text', true) - .attr('x', (b.w + b.t) / 2) - .attr('y', b.h / 2) - .attr('dy', '0.35em') - .attr('font-size', '10px') - .attr('text-anchor', 'middle') - .text(d => d.name); - - // Set position for entering and updating nodes. - g.attr('transform', (d, i) => `translate(${i * (b.w + b.s)}, 0)`); - - // Remove exiting nodes. - g.exit().remove(); - - // Update percentage at the lastCrumb. - lastCrumb - .attr('x', (ancestors.length + 0.5) * (b.w + b.s)) - .attr('y', b.h / 2) - .attr('dy', '0.35em') - .attr('text-anchor', 'middle') - .attr('fill', 'black') - .attr('font-weight', 600) - .text(percentageString); - } - - // helper function mouseover to handle mouseover events/animations and calculation - // of ancestor nodes etc - function mouseover(d) { - // build percentage string - const percentage = (100 * d.value / totalSize).toPrecision(3); - let percentageString = `${percentage}%`; - if (percentage < 1) { - percentageString = '< 1.0%'; - } - - // update breadcrumbs (get all ancestors) - const ancestors = getAncestors(d); - updateBreadcrumbs(ancestors, percentageString); - - // update sunburst (Fade all the segments and highlight only ancestors of current segment) - sunburst.selectAll('path').attr('opacity', 0.3); - sunburst - .selectAll('path') - .filter(node => ancestors.indexOf(node) >= 0) - .attr('opacity', 1); - - // update summary - summary.html(` - Stage: ${d.depth} - ${percentageString} - ${d.value} of ${totalSize} - `); - - // display summary and breadcrumbs if hidden - summary.style('visibility', ''); - breadcrumbs.style('visibility', ''); - } - - // helper function click to handle mouseleave events/animations - function click() { - // Deactivate all segments then retransition each segment to full opacity. - sunburst.selectAll('path').on('mouseover', null); - sunburst - .selectAll('path') - .transition() - .duration(1000) - .attr('opacity', 1) - .each('end', function endClick() { - d3.select(this).on('mouseover', mouseover); - }); - - // hide summary and breadcrumbs if visible - breadcrumbs.style('visibility', 'hidden'); - summary.style('visibility', 'hidden'); - } - - // helper function to draw the sunburst and breadcrumbs - function drawSunburst(json) { - // Build only nodes of a threshold "visible" sizes to improve efficiency - // 0.005 radians = 0.29 degrees - const nodes = partition.nodes(json).filter(d => d.dx > 0.005 && d.name !== exitNode); - - // this section is required to update the colors.domain() every time the data updates - const uniqueNames = (function uniqueNames(a) { - const output = []; - a.forEach((d) => { - if (output.indexOf(d.name) === -1) output.push(d.name); - }); - return output; - }(nodes)); - colors.domain(uniqueNames); // update domain colors - - // create path based on nodes - const path = sunburst - .data([json]) - .selectAll('path') - .data(nodes) - .enter() - .append('path') - .classed('nodePath', true) - .attr('display', d => (d.depth ? null : 'none')) - .attr('d', arc) - .attr('fill', colorMap) - .attr('opacity', 1) - .attr('stroke', 'white') - .on('mouseover', mouseover); - - // // trigger mouse click over sunburst to reset visualization summary - vis.on('click', click); - - // Update totalSize of the tree = value of root node from partition. - totalSize = path.node().__data__.value; - } - - // visualize json tree structure - function createVisualization(json) { - drawSunburst(json); // draw sunburst - } - - function removeVisualization() { - sunburst.selectAll('.nodePath').remove(); - // legend.selectAll("g").remove(); - } - - function buildNodes(raw) { - let values; - - if ( - _.has(raw[0], 'sequence') && - _.has(raw[0], 'stage') && - _.has(raw[0], 'node') && - _.has(raw[0], 'value') - ) { - const grouped = _.groupBy(raw, 'sequence'); - - values = _.map(grouped, (value) => { - const sorted = _.sortBy(value, 'stage'); - return { - size: value[0].value || 0, - sequence: value[0].sequence, - nodes: _.map(sorted, i => i.node), - }; - }); - } else { - // ANGULAR_REMOVE_ME $$ check is for Angular's internal properties - const validKey = key => key !== 'value' && key.indexOf('$$') !== 0; - const keys = _.sortBy(_.filter(_.keys(raw[0]), validKey), _.identity); - - values = _.map(raw, (row, sequence) => ({ - size: row.value || 0, - sequence, - nodes: _.compact(_.map(keys, key => row[key])), - })); - } - - return values; - } - - function buildHierarchy(csv) { - const data = buildNodes(csv); - - // build tree - const root = { - name: 'root', - children: [], - }; - - data.forEach((d) => { - const nodes = d.nodes; - const size = parseInt(d.size, 10); - - // build graph, nodes, and child nodes - let currentNode = root; - for (let j = 0; j < nodes.length; j += 1) { - let children = currentNode.children; - const nodeName = nodes[j]; - const isLeaf = j + 1 === nodes.length; - - if (!children) { - currentNode.children = children = []; - children.push({ - name: exitNode, - size: currentNode.size, - }); - } - - let childNode = _.find(children, child => child.name === nodeName); - - if (isLeaf && childNode) { - childNode.children = childNode.children || []; - childNode.children.push({ - name: exitNode, - size, - }); - } else if (isLeaf) { - children.push({ - name: nodeName, - size, - }); - } else { - if (!childNode) { - childNode = { - name: nodeName, - children: [], - }; - children.push(childNode); - } - - currentNode = childNode; - } - } - }); - - return root; - } - - function render(data) { - const json = buildHierarchy(data); // build json tree - removeVisualization(); // remove existing visualization if any - createVisualization(json); // visualize json tree - } - - function refreshData() { - if (scope.$ctrl.data) { - render(scope.$ctrl.data.rows); - } - } - - refreshData(); - this.watches.push(scope.$watch('$ctrl.data', refreshData)); - this.watches.push(scope.$watch('$ctrl.options', refreshData, true)); -} - -Sunburst.prototype.remove = function remove() { - this.watches.forEach((unregister) => { - unregister(); - }); - angular.element(this.element).empty('.vis-container'); -}; - -export default Sunburst; diff --git a/client/app/visualizations/sankey/Renderer.jsx b/client/app/visualizations/sankey/Renderer.jsx index d352719359..6332512a1b 100644 --- a/client/app/visualizations/sankey/Renderer.jsx +++ b/client/app/visualizations/sankey/Renderer.jsx @@ -3,6 +3,7 @@ import resizeObserver from '@/services/resizeObserver'; import { RendererPropTypes } from '@/visualizations'; import initSankey from './initSankey'; +import './renderer.less'; export default function Renderer({ data }) { const [container, setContainer] = useState(null); diff --git a/client/app/assets/less/inc/visualizations/sankey.less b/client/app/visualizations/sankey/renderer.less similarity index 100% rename from client/app/assets/less/inc/visualizations/sankey.less rename to client/app/visualizations/sankey/renderer.less diff --git a/client/app/visualizations/sunburst/Renderer.jsx b/client/app/visualizations/sunburst/Renderer.jsx new file mode 100644 index 0000000000..154dfad110 --- /dev/null +++ b/client/app/visualizations/sunburst/Renderer.jsx @@ -0,0 +1,26 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import resizeObserver from '@/services/resizeObserver'; +import { RendererPropTypes } from '@/visualizations'; + +import initSunburst from './initSunburst'; +import './renderer.less'; + +export default function Renderer({ data }) { + const [container, setContainer] = useState(null); + + const render = useMemo(() => initSunburst(data), [data]); + + useEffect(() => { + if (container) { + render(container); + const unwatch = resizeObserver(container, () => { + render(container); + }); + return unwatch; + } + }, [container, render]); + + return (
); +} + +Renderer.propTypes = RendererPropTypes; diff --git a/client/app/visualizations/sunburst/index.js b/client/app/visualizations/sunburst/index.js index d747eae6ce..00213acea7 100644 --- a/client/app/visualizations/sunburst/index.js +++ b/client/app/visualizations/sunburst/index.js @@ -1,42 +1,17 @@ -import { debounce } from 'lodash'; -import Sunburst from '@/lib/visualizations/sunburst'; -import { angular2react } from 'angular2react'; import { registerVisualization } from '@/visualizations'; +import Renderer from './Renderer'; import Editor from './Editor'; -const SunburstSequenceRenderer = { - template: '
', - bindings: { - data: '<', - options: '<', - }, - controller($scope, $element) { - const container = $element[0].querySelector('.sunburst-visualization-container'); - let sunburst = new Sunburst($scope, container); +export default function init() { + registerVisualization({ + type: 'SUNBURST_SEQUENCE', + name: 'Sunburst Sequence', + getOptions: options => ({ ...options }), + Renderer, + Editor, - function update() { - sunburst.remove(); - sunburst = new Sunburst($scope, container); - } - - $scope.handleResize = debounce(update, 50); - }, -}; - -export default function init(ngModule) { - ngModule.component('sunburstSequenceRenderer', SunburstSequenceRenderer); - - ngModule.run(($injector) => { - registerVisualization({ - type: 'SUNBURST_SEQUENCE', - name: 'Sunburst Sequence', - getOptions: options => ({ ...options }), - Renderer: angular2react('sunburstSequenceRenderer', SunburstSequenceRenderer, $injector), - Editor, - - defaultRows: 7, - }); + defaultRows: 7, }); } diff --git a/client/app/visualizations/sunburst/initSunburst.js b/client/app/visualizations/sunburst/initSunburst.js new file mode 100644 index 0000000000..eda163cc73 --- /dev/null +++ b/client/app/visualizations/sunburst/initSunburst.js @@ -0,0 +1,361 @@ +/** + * The following is based on @chrisrzhou's example from: http://bl.ocks.org/chrisrzhou/d5bdd8546f64ca0e4366. + */ + +import * as d3 from 'd3'; +import { has, map, keys, groupBy, sortBy, filter, find, compact, identity } from 'lodash'; + +const exitNode = '<<>>'; +const colors = d3.scale.category10(); + +// helper function colorMap - color gray if "end" is detected +function colorMap(d) { + return colors(d.name); +} + +// Return array of ancestors of nodes, highest first, but excluding the root. +function getAncestors(node) { + const path = []; + let current = node; + + while (current.parent) { + path.unshift(current); + current = current.parent; + } + return path; +} + +function buildNodes(data) { + let values; + + // TODO: Split to two functions for each type + detection as a separate func + if ( + has(data[0], 'sequence') && + has(data[0], 'stage') && + has(data[0], 'node') && + has(data[0], 'value') + ) { + const grouped = groupBy(data, 'sequence'); + + values = map(grouped, (value) => { + const sorted = sortBy(value, 'stage'); + return { + size: value[0].value || 0, + sequence: value[0].sequence, + nodes: map(sorted, i => i.node), + }; + }); + } else { + // ANGULAR_REMOVE_ME $$ check is for Angular's internal properties + const validKey = key => key !== 'value' && key.indexOf('$$') !== 0; + const dataKeys = sortBy(filter(keys(data[0]), validKey), identity); + + values = map(data, (row, sequence) => ({ + size: row.value || 0, + sequence, + nodes: compact(map(dataKeys, key => row[key])), + })); + } + + return values; +} + +function buildHierarchy(data) { + data = buildNodes(data); + + // build tree + const root = { + name: 'root', + children: [], + }; + + data.forEach((d) => { + const nodes = d.nodes; + const size = parseInt(d.size, 10); + + // build graph, nodes, and child nodes + let currentNode = root; + for (let j = 0; j < nodes.length; j += 1) { + let children = currentNode.children; + const nodeName = nodes[j]; + const isLeaf = j + 1 === nodes.length; + + if (!children) { + currentNode.children = children = []; + children.push({ + name: exitNode, + size: currentNode.size, + }); + } + + let childNode = find(children, child => child.name === nodeName); + + if (isLeaf && childNode) { + childNode.children = childNode.children || []; + childNode.children.push({ + name: exitNode, + size, + }); + } else if (isLeaf) { + children.push({ + name: nodeName, + size, + }); + } else { + if (!childNode) { + childNode = { + name: nodeName, + children: [], + }; + children.push(childNode); + } + + currentNode = childNode; + } + } + }); + + return root; +} + +function isDataValid(data) { + return data && (data.rows.length > 0); +} + +export default function initSunburst(data) { + if (!isDataValid(data)) { + return (element) => { + d3.select(element).selectAll('*').remove(); + }; + } + + data = buildHierarchy(data.rows); + + return (element) => { + d3.select(element).selectAll('*').remove(); + + // svg dimensions + const width = element.clientWidth; + const height = element.offsetHeight; + + // Breadcrumb dimensions: width, height, spacing, width of tip/tail. + const b = { + w: width / 6, + h: 30, + s: 3, + t: 10, + }; + + const radius = Math.min(width - b.h, height - b.h) / 2 - 5; + if (radius <= 0) { + return; + } + + // margins + const margin = { + top: radius, + bottom: 50, + left: radius, + right: 0, + }; + + // Drawing variables: e.g. colors, totalSize, partitions, arcs + + // Total size of all nodes, to be used later when data is loaded + let totalSize = 0; + + // create d3.layout.partition + const partition = d3.layout + .partition() + .size([2 * Math.PI, radius * radius]) + .value(d => d.size); + + // create arcs for drawing D3 paths + const arc = d3.svg + .arc() + .startAngle(d => d.x) + .endAngle(d => d.x + d.dx) + .innerRadius(d => Math.sqrt(d.y)) + .outerRadius(d => Math.sqrt(d.y + d.dy)); + + /** + * Define and initialize D3 select references and div-containers + * + * e.g. vis, breadcrumbs, lastCrumb, summary, sunburst, legend + */ + const vis = d3.select(element); + + // create and position breadcrumbs container and svg + const breadcrumbs = vis + .append('div') + .classed('breadcrumbs-container', true) + .append('svg') + .attr('width', width) + .attr('height', b.h) + .attr('fill', 'white') + .attr('font-weight', 600); + + // create and position SVG + const container = vis.append('div'); + + // create and position summary container + const summary = container + .append('div') + .classed('summary-container', true); + + const sunburst = container + .append('div') + .classed('sunburst-container', true) + .append('svg') + .attr('width', radius * 2) + .attr('height', radius * 2) + .append('g') + .attr('transform', `translate(${margin.left},${margin.top})`); + + // create last breadcrumb element + const lastCrumb = breadcrumbs.append('text').classed('lastCrumb', true); + + // Generate a string representation for drawing a breadcrumb polygon. + function breadcrumbPoints(d, i) { + const points = []; + points.push('0,0'); + points.push(`${b.w},0`); + points.push(`${b.w + b.t},${b.h / 2}`); + points.push(`${b.w},${b.h}`); + points.push(`0,${b.h}`); + + if (i > 0) { + // Leftmost breadcrumb; don't include 6th vertex. + points.push(`${b.t},${b.h / 2}`); + } + return points.join(' '); + } + + // Update the breadcrumb breadcrumbs to show the current sequence and percentage. + function updateBreadcrumbs(ancestors, percentageString) { + // Data join, where primary key = name + depth. + const g = breadcrumbs.selectAll('g').data(ancestors, d => d.name + d.depth); + + // Add breadcrumb and label for entering nodes. + const breadcrumb = g.enter().append('g'); + + breadcrumb + .append('polygon') + .classed('breadcrumbs-shape', true) + .attr('points', breadcrumbPoints) + .attr('fill', colorMap); + + breadcrumb + .append('text') + .classed('breadcrumbs-text', true) + .attr('x', (b.w + b.t) / 2) + .attr('y', b.h / 2) + .attr('dy', '0.35em') + .attr('font-size', '10px') + .attr('text-anchor', 'middle') + .text(d => d.name); + + // Set position for entering and updating nodes. + g.attr('transform', (d, i) => `translate(${i * (b.w + b.s)}, 0)`); + + // Remove exiting nodes. + g.exit().remove(); + + // Update percentage at the lastCrumb. + lastCrumb + .attr('x', (ancestors.length + 0.5) * (b.w + b.s)) + .attr('y', b.h / 2) + .attr('dy', '0.35em') + .attr('text-anchor', 'middle') + .attr('fill', 'black') + .attr('font-weight', 600) + .text(percentageString); + } + + // helper function mouseover to handle mouseover events/animations and calculation + // of ancestor nodes etc + function mouseover(d) { + // build percentage string + const percentage = (100 * d.value / totalSize).toPrecision(3); + let percentageString = `${percentage}%`; + if (percentage < 1) { + percentageString = '< 1.0%'; + } + + // update breadcrumbs (get all ancestors) + const ancestors = getAncestors(d); + updateBreadcrumbs(ancestors, percentageString); + + // update sunburst (Fade all the segments and highlight only ancestors of current segment) + sunburst.selectAll('path').attr('opacity', 0.3); + sunburst + .selectAll('path') + .filter(node => ancestors.indexOf(node) >= 0) + .attr('opacity', 1); + + // update summary + summary.html(` + Stage: ${d.depth} + ${percentageString} + ${d.value} of ${totalSize} + `); + + // display summary and breadcrumbs if hidden + summary.style('visibility', ''); + breadcrumbs.style('visibility', ''); + } + + // helper function click to handle mouseleave events/animations + function click() { + // Deactivate all segments then retransition each segment to full opacity. + sunburst.selectAll('path').on('mouseover', null); + sunburst + .selectAll('path') + .transition() + .duration(1000) + .attr('opacity', 1) + .each('end', function endClick() { + d3.select(this).on('mouseover', mouseover); + }); + + // hide summary and breadcrumbs if visible + breadcrumbs.style('visibility', 'hidden'); + summary.style('visibility', 'hidden'); + } + + // Build only nodes of a threshold "visible" sizes to improve efficiency + // 0.005 radians = 0.29 degrees + const nodes = partition.nodes(data).filter(d => d.dx > 0.005 && d.name !== exitNode); + + // this section is required to update the colors.domain() every time the data updates + const uniqueNames = (function uniqueNames(a) { + const output = []; + a.forEach((d) => { + if (output.indexOf(d.name) === -1) output.push(d.name); + }); + return output; + }(nodes)); + colors.domain(uniqueNames); // update domain colors + + // create path based on nodes + const path = sunburst + .data([data]) + .selectAll('path') + .data(nodes) + .enter() + .append('path') + .classed('nodePath', true) + .attr('display', d => (d.depth ? null : 'none')) + .attr('d', arc) + .attr('fill', colorMap) + .attr('opacity', 1) + .attr('stroke', 'white') + .on('mouseover', mouseover); + + // // trigger mouse click over sunburst to reset visualization summary + vis.on('click', click); + + // Update totalSize of the tree = value of root node from partition. + totalSize = path.node().__data__.value; + }; +} diff --git a/client/app/assets/less/inc/visualizations/sunburst.less b/client/app/visualizations/sunburst/renderer.less similarity index 100% rename from client/app/assets/less/inc/visualizations/sunburst.less rename to client/app/visualizations/sunburst/renderer.less From 5209cc17bb5ae3fcf417f45b43bef1c7a70705a4 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Thu, 17 Oct 2019 14:47:25 +0300 Subject: [PATCH 2/4] Tests: capture snapshot of editor preview instead of query page --- .../integration/visualizations/sankey_sunburst_spec.js | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/client/cypress/integration/visualizations/sankey_sunburst_spec.js b/client/cypress/integration/visualizations/sankey_sunburst_spec.js index aa536ff19c..1e28ea78de 100644 --- a/client/cypress/integration/visualizations/sankey_sunburst_spec.js +++ b/client/cypress/integration/visualizations/sankey_sunburst_spec.js @@ -38,10 +38,8 @@ describe('Sankey and Sunburst', () => { cy.getByTestId('EditVisualizationDialog').contains('button', 'Save').click(); cy.getByTestId('QueryPageVisualizationTabs').contains('li', visualizationName).should('exist'); - cy.getByTestId('QueryPageShowDataOnly').click(); - // wait a bit before taking snapshot - cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting + cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.percySnapshot('Visualizations - Sunburst', { widths: [viewportWidth] }); }); @@ -56,10 +54,8 @@ describe('Sankey and Sunburst', () => { cy.getByTestId('EditVisualizationDialog').contains('button', 'Save').click(); cy.getByTestId('QueryPageVisualizationTabs').contains('li', visualizationName).should('exist'); - cy.getByTestId('QueryPageShowDataOnly').click(); - // wait a bit before taking snapshot - cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting + cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.percySnapshot('Visualizations - Sankey', { widths: [viewportWidth] }); }); }); From ae1ba4fd880e8e0859c459e553cdea8e524f1d3b Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Thu, 17 Oct 2019 14:55:35 +0300 Subject: [PATCH 3/4] Refine code --- .../visualizations/sunburst/initSunburst.js | 66 +++++++++---------- 1 file changed, 32 insertions(+), 34 deletions(-) diff --git a/client/app/visualizations/sunburst/initSunburst.js b/client/app/visualizations/sunburst/initSunburst.js index eda163cc73..cdf95d02b4 100644 --- a/client/app/visualizations/sunburst/initSunburst.js +++ b/client/app/visualizations/sunburst/initSunburst.js @@ -3,7 +3,7 @@ */ import * as d3 from 'd3'; -import { has, map, keys, groupBy, sortBy, filter, find, compact, identity } from 'lodash'; +import { has, map, keys, groupBy, sortBy, filter, find, compact, first, every, identity } from 'lodash'; const exitNode = '<<>>'; const colors = d3.scale.category10(); @@ -25,43 +25,41 @@ function getAncestors(node) { return path; } -function buildNodes(data) { - let values; - - // TODO: Split to two functions for each type + detection as a separate func - if ( - has(data[0], 'sequence') && - has(data[0], 'stage') && - has(data[0], 'node') && - has(data[0], 'value') - ) { - const grouped = groupBy(data, 'sequence'); - - values = map(grouped, (value) => { - const sorted = sortBy(value, 'stage'); - return { - size: value[0].value || 0, - sequence: value[0].sequence, - nodes: map(sorted, i => i.node), - }; - }); - } else { - // ANGULAR_REMOVE_ME $$ check is for Angular's internal properties - const validKey = key => key !== 'value' && key.indexOf('$$') !== 0; - const dataKeys = sortBy(filter(keys(data[0]), validKey), identity); - - values = map(data, (row, sequence) => ({ - size: row.value || 0, - sequence, - nodes: compact(map(dataKeys, key => row[key])), - })); - } +function buildNodesFromHierarchyData(data) { + const grouped = groupBy(data, 'sequence'); + + return map(grouped, (value) => { + const sorted = sortBy(value, 'stage'); + return { + size: value[0].value || 0, + sequence: value[0].sequence, + nodes: map(sorted, i => i.node), + }; + }); +} + +function buildNodesFromTableData(data) { + // ANGULAR_REMOVE_ME $$ check is for Angular's internal properties + const validKey = key => key !== 'value' && key.indexOf('$$') !== 0; + const dataKeys = sortBy(filter(keys(data[0]), validKey), identity); + + return map(data, (row, sequence) => ({ + size: row.value || 0, + sequence, + nodes: compact(map(dataKeys, key => row[key])), + })); +} - return values; +function isDataInHierarchyFormat(data) { + const firstRow = first(data); + return every( + ['sequence', 'stage', 'node', 'value'], + field => has(firstRow, field), + ); } function buildHierarchy(data) { - data = buildNodes(data); + data = isDataInHierarchyFormat(data) ? buildNodesFromHierarchyData(data) : buildNodesFromTableData(data); // build tree const root = { From c427bce3c4922be98fb6d0d665372c1b27919c40 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Thu, 17 Oct 2019 15:26:52 +0300 Subject: [PATCH 4/4] Tests --- .../integration/visualizations/sankey_sunburst_spec.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/client/cypress/integration/visualizations/sankey_sunburst_spec.js b/client/cypress/integration/visualizations/sankey_sunburst_spec.js index 1e28ea78de..2bc3abf6d6 100644 --- a/client/cypress/integration/visualizations/sankey_sunburst_spec.js +++ b/client/cypress/integration/visualizations/sankey_sunburst_spec.js @@ -35,12 +35,13 @@ describe('Sankey and Sunburst', () => { cy.getByTestId('VisualizationType.SUNBURST_SEQUENCE').click(); cy.getByTestId('VisualizationName').clear().type(visualizationName); cy.getByTestId('VisualizationPreview').find('svg').should('exist'); - cy.getByTestId('EditVisualizationDialog').contains('button', 'Save').click(); - cy.getByTestId('QueryPageVisualizationTabs').contains('li', visualizationName).should('exist'); // wait a bit before taking snapshot cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.percySnapshot('Visualizations - Sunburst', { widths: [viewportWidth] }); + + cy.getByTestId('EditVisualizationDialog').contains('button', 'Save').click(); + cy.getByTestId('QueryPageVisualizationTabs').contains('li', visualizationName).should('exist'); }); it('creates Sankey', () => { @@ -51,11 +52,12 @@ describe('Sankey and Sunburst', () => { cy.getByTestId('VisualizationType.SANKEY').click(); cy.getByTestId('VisualizationName').clear().type(visualizationName); cy.getByTestId('VisualizationPreview').find('svg').should('exist'); - cy.getByTestId('EditVisualizationDialog').contains('button', 'Save').click(); - cy.getByTestId('QueryPageVisualizationTabs').contains('li', visualizationName).should('exist'); // wait a bit before taking snapshot cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.percySnapshot('Visualizations - Sankey', { widths: [viewportWidth] }); + + cy.getByTestId('EditVisualizationDialog').contains('button', 'Save').click(); + cy.getByTestId('QueryPageVisualizationTabs').contains('li', visualizationName).should('exist'); }); });