From 0aca649cb5379d768a607e99de8e0efa0f78d681 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Thu, 17 Oct 2019 13:19:29 +0300 Subject: [PATCH] Migrate Sankey renderer to React (#4255) --- client/app/pages/queries/query.html | 7 +- client/app/visualizations/sankey/Renderer.jsx | 25 ++ .../sankey}/d3sankey.js | 0 client/app/visualizations/sankey/index.js | 275 +----------------- .../app/visualizations/sankey/initSankey.js | 237 +++++++++++++++ .../visualizations/sankey_sunburst_spec.js | 14 + 6 files changed, 291 insertions(+), 267 deletions(-) create mode 100644 client/app/visualizations/sankey/Renderer.jsx rename client/app/{lib/visualizations => visualizations/sankey}/d3sankey.js (100%) create mode 100644 client/app/visualizations/sankey/initSankey.js diff --git a/client/app/pages/queries/query.html b/client/app/pages/queries/query.html index 97148729b9..15ecb22e33 100644 --- a/client/app/pages/queries/query.html +++ b/client/app/pages/queries/query.html @@ -42,7 +42,12 @@

Edit Source - + Show Data Only diff --git a/client/app/visualizations/sankey/Renderer.jsx b/client/app/visualizations/sankey/Renderer.jsx new file mode 100644 index 0000000000..d352719359 --- /dev/null +++ b/client/app/visualizations/sankey/Renderer.jsx @@ -0,0 +1,25 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import resizeObserver from '@/services/resizeObserver'; +import { RendererPropTypes } from '@/visualizations'; + +import initSankey from './initSankey'; + +export default function Renderer({ data }) { + const [container, setContainer] = useState(null); + + const render = useMemo(() => initSankey(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/lib/visualizations/d3sankey.js b/client/app/visualizations/sankey/d3sankey.js similarity index 100% rename from client/app/lib/visualizations/d3sankey.js rename to client/app/visualizations/sankey/d3sankey.js diff --git a/client/app/visualizations/sankey/index.js b/client/app/visualizations/sankey/index.js index 893b9c2f82..b64e8d0685 100644 --- a/client/app/visualizations/sankey/index.js +++ b/client/app/visualizations/sankey/index.js @@ -1,274 +1,17 @@ -import angular from 'angular'; -import _ from 'lodash'; -import d3 from 'd3'; -import { angular2react } from 'angular2react'; import { registerVisualization } from '@/visualizations'; -import d3sankey from '@/lib/visualizations/d3sankey'; - +import Renderer from './Renderer'; import Editor from './Editor'; -function getConnectedNodes(node) { - // source link = this node is the source, I need the targets - const nodes = []; - node.sourceLinks.forEach((link) => { - nodes.push(link.target); - }); - node.targetLinks.forEach((link) => { - nodes.push(link.source); - }); - - return nodes; -} - -function graph(data) { - const nodesDict = {}; - const links = {}; - const nodes = []; - - // ANGULAR_REMOVE_ME $$ check is for Angular's internal properties - const validKey = key => key !== 'value' && key.indexOf('$$') !== 0; - const keys = _.sortBy(_.filter(_.keys(data[0]), validKey), _.identity); - - function normalizeName(name) { - if (!_.isNil(name)) { - return '' + name; - } - - return 'Exit'; - } - - function getNode(name, level) { - name = normalizeName(name); - const key = `${name}:${String(level)}`; - let node = nodesDict[key]; - if (!node) { - node = { name }; - node.id = nodes.push(node) - 1; - nodesDict[key] = node; - } - return node; - } - - function getLink(source, target) { - let link = links[[source, target]]; - if (!link) { - link = { target, source, value: 0 }; - links[[source, target]] = link; - } - - return link; - } - - function addLink(sourceName, targetName, value, depth) { - if ((sourceName === '' || !sourceName) && depth > 1) { - return; - } - - const source = getNode(sourceName, depth); - const target = getNode(targetName, depth + 1); - const link = getLink(source.id, target.id); - link.value += parseInt(value, 10); - } - - data.forEach((row) => { - addLink(row[keys[0]], row[keys[1]], row.value || 0, 1); - addLink(row[keys[1]], row[keys[2]], row.value || 0, 2); - addLink(row[keys[2]], row[keys[3]], row.value || 0, 3); - addLink(row[keys[3]], row[keys[4]], row.value || 0, 4); - }); - - return { nodes, links: _.values(links) }; -} - -function spreadNodes(height, data) { - const nodesByBreadth = d3 - .nest() - .key(d => d.x) - .entries(data.nodes) - .map(d => d.values); - - nodesByBreadth.forEach((nodes) => { - nodes = _.filter(_.sortBy(nodes, node => -node.value), node => node.name !== 'Exit'); - - const sum = d3.sum(nodes, o => o.dy); - const padding = (height - sum) / nodes.length; - - _.reduce( - nodes, - (y0, node) => { - node.y = y0; - return y0 + node.dy + padding; - }, - 0, - ); - }); -} - -function createSankey(element, data) { - const margin = { - top: 10, - right: 10, - bottom: 10, - left: 10, - }; - const width = element.offsetWidth - margin.left - margin.right; - const height = element.offsetHeight - margin.top - margin.bottom; - - if (width <= 0 || height <= 0) { - return; - } - - const format = d => d3.format(',.0f')(d); - const color = d3.scale.category20(); - - data = graph(data); - data.nodes = _.map(data.nodes, d => _.extend(d, { color: color(d.name.replace(/ .*/, '')) })); - - // append the svg canvas to the page - const svg = d3 - .select(element) - .append('svg') - .attr('class', 'sankey') - .attr('width', width + margin.left + margin.right) - .attr('height', height + margin.top + margin.bottom) - .append('g') - .attr('transform', `translate(${margin.left},${margin.top})`); - - // Set the sankey diagram properties - const sankey = d3sankey() - .nodeWidth(15) - .nodePadding(10) - .size([width, height]); - - const path = sankey.link(); - - sankey - .nodes(data.nodes) - .links(data.links) - .layout(0); - - spreadNodes(height, data); - sankey.relayout(); - - // add in the links - const link = svg - .append('g') - .selectAll('.link') - .data(data.links) - .enter() - .append('path') - .filter(l => l.target.name !== 'Exit') - .attr('class', 'link') - .attr('d', path) - .style('stroke-width', d => Math.max(1, d.dy)) - .sort((a, b) => b.dy - a.dy); - - // add the link titles - link.append('title').text(d => `${d.source.name} → ${d.target.name}\n${format(d.value)}`); - - const node = svg - .append('g') - .selectAll('.node') - .data(data.nodes) - .enter() - .append('g') - .filter(n => n.name !== 'Exit') - .attr('class', 'node') - .attr('transform', d => `translate(${d.x},${d.y})`); - - function nodeMouseOver(currentNode) { - let nodes = getConnectedNodes(currentNode); - nodes = _.map(nodes, i => i.id); - node - .filter((d) => { - if (d === currentNode) { - return false; - } - return !_.includes(nodes, d.id); - }) - .style('opacity', 0.2); - link - .filter(l => !(_.includes(currentNode.sourceLinks, l) || _.includes(currentNode.targetLinks, l))) - .style('opacity', 0.2); - } - - function nodeMouseOut() { - node.style('opacity', 1); - link.style('opacity', 1); - } - - // add in the nodes - node.on('mouseover', nodeMouseOver).on('mouseout', nodeMouseOut); - - // add the rectangles for the nodes - node - .append('rect') - .attr('height', d => d.dy) - .attr('width', sankey.nodeWidth()) - .style('fill', d => d.color) - .style('stroke', d => d3.rgb(d.color).darker(2)) - .append('title') - .text(d => `${d.name}\n${format(d.value)}`); - - // add in the title for the nodes - node - .append('text') - .attr('x', -6) - .attr('y', d => d.dy / 2) - .attr('dy', '.35em') - .attr('text-anchor', 'end') - .attr('transform', null) - .text(d => d.name) - .filter(d => d.x < width / 2) - .attr('x', 6 + sankey.nodeWidth()) - .attr('text-anchor', 'start'); -} - -function isDataValid(data) { - // data should contain column named 'value', otherwise no reason to render anything at all - return _.find(data.columns, c => c.name === 'value'); -} - -const SankeyRenderer = { - template: '
', - bindings: { - data: '<', - options: '<', - }, - controller($scope, $element) { - const container = $element[0].querySelector('.sankey-visualization-container'); - - const update = () => { - if (this.data) { - // do the render logic. - angular.element(container).empty(); - if (isDataValid(this.data)) { - createSankey(container, this.data.rows); - } - } - }; - - $scope.handleResize = _.debounce(update, 50); - - $scope.$watch('$ctrl.data', update); - $scope.$watch('$ctrl.options', update, true); - }, -}; - -export default function init(ngModule) { - ngModule.component('sankeyRenderer', SankeyRenderer); - - ngModule.run(($injector) => { - registerVisualization({ - type: 'SANKEY', - name: 'Sankey', - getOptions: options => ({ ...options }), - Renderer: angular2react('sankeyRenderer', SankeyRenderer, $injector), - Editor, +export default function init() { + registerVisualization({ + type: 'SANKEY', + name: 'Sankey', + getOptions: options => ({ ...options }), + Renderer, + Editor, - defaultRows: 7, - }); + defaultRows: 7, }); } diff --git a/client/app/visualizations/sankey/initSankey.js b/client/app/visualizations/sankey/initSankey.js new file mode 100644 index 0000000000..21301159a8 --- /dev/null +++ b/client/app/visualizations/sankey/initSankey.js @@ -0,0 +1,237 @@ +import { isNil, map, extend, sortBy, includes, filter, reduce, find, keys, values, identity } from 'lodash'; +import d3 from 'd3'; +import d3sankey from './d3sankey'; + +function getConnectedNodes(node) { + // source link = this node is the source, I need the targets + const nodes = []; + node.sourceLinks.forEach((link) => { + nodes.push(link.target); + }); + node.targetLinks.forEach((link) => { + nodes.push(link.source); + }); + + return nodes; +} + +function graph(data) { + const nodesDict = {}; + const links = {}; + const nodes = []; + + // 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); + + function normalizeName(name) { + if (!isNil(name)) { + return '' + name; + } + + return 'Exit'; + } + + function getNode(name, level) { + name = normalizeName(name); + const key = `${name}:${String(level)}`; + let node = nodesDict[key]; + if (!node) { + node = { name }; + node.id = nodes.push(node) - 1; + nodesDict[key] = node; + } + return node; + } + + function getLink(source, target) { + let link = links[[source, target]]; + if (!link) { + link = { target, source, value: 0 }; + links[[source, target]] = link; + } + + return link; + } + + function addLink(sourceName, targetName, value, depth) { + if ((sourceName === '' || !sourceName) && depth > 1) { + return; + } + + const source = getNode(sourceName, depth); + const target = getNode(targetName, depth + 1); + const link = getLink(source.id, target.id); + link.value += parseInt(value, 10); + } + + data.forEach((row) => { + addLink(row[dataKeys[0]], row[dataKeys[1]], row.value || 0, 1); + addLink(row[dataKeys[1]], row[dataKeys[2]], row.value || 0, 2); + addLink(row[dataKeys[2]], row[dataKeys[3]], row.value || 0, 3); + addLink(row[dataKeys[3]], row[dataKeys[4]], row.value || 0, 4); + }); + + const color = d3.scale.category20(); + + return { + nodes: map(nodes, d => extend(d, { color: color(d.name.replace(/ .*/, '')) })), + links: values(links), + }; +} + +function spreadNodes(height, data) { + const nodesByBreadth = d3 + .nest() + .key(d => d.x) + .entries(data.nodes) + .map(d => d.values); + + nodesByBreadth.forEach((nodes) => { + nodes = filter(sortBy(nodes, node => -node.value), node => node.name !== 'Exit'); + + const sum = d3.sum(nodes, o => o.dy); + const padding = (height - sum) / nodes.length; + + reduce( + nodes, + (y0, node) => { + node.y = y0; + return y0 + node.dy + padding; + }, + 0, + ); + }); +} + +function isDataValid(data) { + // data should contain column named 'value', otherwise no reason to render anything at all + return data && !!find(data.columns, c => c.name === 'value'); +} + +export default function initSankey(data) { + if (!isDataValid(data)) { + return (element) => { + d3.select(element).selectAll('*').remove(); + }; + } + + data = graph(data.rows); + const format = d => d3.format(',.0f')(d); // TODO: editor option ? + + return (element) => { + d3.select(element).selectAll('*').remove(); + + const margin = { + top: 10, + right: 10, + bottom: 10, + left: 10, + }; + const width = element.offsetWidth - margin.left - margin.right; + const height = element.offsetHeight - margin.top - margin.bottom; + + if (width <= 0 || height <= 0) { + return; + } + + // append the svg canvas to the page + const svg = d3 + .select(element) + .append('svg') + .attr('class', 'sankey') + .attr('width', width + margin.left + margin.right) + .attr('height', height + margin.top + margin.bottom) + .append('g') + .attr('transform', `translate(${margin.left},${margin.top})`); + + // Set the sankey diagram properties + const sankey = d3sankey() + .nodeWidth(15) + .nodePadding(10) + .size([width, height]); + + const path = sankey.link(); + + sankey + .nodes(data.nodes) + .links(data.links) + .layout(0); + + spreadNodes(height, data); + sankey.relayout(); + + // add in the links + const link = svg + .append('g') + .selectAll('.link') + .data(data.links) + .enter() + .append('path') + .filter(l => l.target.name !== 'Exit') + .attr('class', 'link') + .attr('d', path) + .style('stroke-width', d => Math.max(1, d.dy)) + .sort((a, b) => b.dy - a.dy); + + // add the link titles + link.append('title').text(d => `${d.source.name} → ${d.target.name}\n${format(d.value)}`); + + const node = svg + .append('g') + .selectAll('.node') + .data(data.nodes) + .enter() + .append('g') + .filter(n => n.name !== 'Exit') + .attr('class', 'node') + .attr('transform', d => `translate(${d.x},${d.y})`); + + function nodeMouseOver(currentNode) { + let nodes = getConnectedNodes(currentNode); + nodes = map(nodes, i => i.id); + node + .filter((d) => { + if (d === currentNode) { + return false; + } + return !includes(nodes, d.id); + }) + .style('opacity', 0.2); + link + .filter(l => !(includes(currentNode.sourceLinks, l) || includes(currentNode.targetLinks, l))) + .style('opacity', 0.2); + } + + function nodeMouseOut() { + node.style('opacity', 1); + link.style('opacity', 1); + } + + // add in the nodes + node.on('mouseover', nodeMouseOver).on('mouseout', nodeMouseOut); + + // add the rectangles for the nodes + node + .append('rect') + .attr('height', d => d.dy) + .attr('width', sankey.nodeWidth()) + .style('fill', d => d.color) + .style('stroke', d => d3.rgb(d.color).darker(2)) + .append('title') + .text(d => `${d.name}\n${format(d.value)}`); + + // add in the title for the nodes + node + .append('text') + .attr('x', -6) + .attr('y', d => d.dy / 2) + .attr('dy', '.35em') + .attr('text-anchor', 'end') + .attr('transform', null) + .text(d => d.name) + .filter(d => d.x < width / 2) + .attr('x', 6 + sankey.nodeWidth()) + .attr('text-anchor', 'start'); + }; +} diff --git a/client/cypress/integration/visualizations/sankey_sunburst_spec.js b/client/cypress/integration/visualizations/sankey_sunburst_spec.js index 2d3a70db42..aa536ff19c 100644 --- a/client/cypress/integration/visualizations/sankey_sunburst_spec.js +++ b/client/cypress/integration/visualizations/sankey_sunburst_spec.js @@ -17,6 +17,8 @@ const SQL = ` `; describe('Sankey and Sunburst', () => { + const viewportWidth = Cypress.config('viewportWidth'); + beforeEach(() => { cy.login(); createQuery({ query: SQL }).then(({ id }) => { @@ -35,6 +37,12 @@ describe('Sankey and Sunburst', () => { cy.getByTestId('VisualizationPreview').find('svg').should('exist'); 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.percySnapshot('Visualizations - Sunburst', { widths: [viewportWidth] }); }); it('creates Sankey', () => { @@ -47,5 +55,11 @@ describe('Sankey and Sunburst', () => { cy.getByTestId('VisualizationPreview').find('svg').should('exist'); 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.percySnapshot('Visualizations - Sankey', { widths: [viewportWidth] }); }); });