Skip to content

Commit

Permalink
Migrate Sankey renderer to React (#4255)
Browse files Browse the repository at this point in the history
  • Loading branch information
kravets-levko authored Oct 17, 2019
1 parent 79b37e8 commit 0aca649
Show file tree
Hide file tree
Showing 6 changed files with 291 additions and 267 deletions.
7 changes: 6 additions & 1 deletion client/app/pages/queries/query.html
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,12 @@ <h3>
<a ng-show="!sourceMode" ng-href="{{query.getUrl(true, selectedTab)}}" class="btn btn-default btn--showhide">
<i class="fa fa-pencil-square-o" aria-hidden="true"></i> Edit Source</i>
</a>
<a ng-show="sourceMode" ng-href="{{query.getUrl(false, selectedTab)}}" class="btn btn-default btn--showhide">
<a
ng-show="sourceMode"
ng-href="{{query.getUrl(false, selectedTab)}}"
class="btn btn-default btn--showhide"
data-test="QueryPageShowDataOnly"
>
<i class="fa fa-table" aria-hidden="true"></i> Show Data Only</i>
</a>
</span>
Expand Down
25 changes: 25 additions & 0 deletions client/app/visualizations/sankey/Renderer.jsx
Original file line number Diff line number Diff line change
@@ -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 (<div className="sankey-visualization-container" ref={setContainer} />);
}

Renderer.propTypes = RendererPropTypes;
File renamed without changes.
275 changes: 9 additions & 266 deletions client/app/visualizations/sankey/index.js
Original file line number Diff line number Diff line change
@@ -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: '<div class="sankey-visualization-container" resize-event="handleResize()"></div>',
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,
});
}

Expand Down
Loading

0 comments on commit 0aca649

Please sign in to comment.