From d5f3cd5f213da1667eb352a22691d10ab8e5fb73 Mon Sep 17 00:00:00 2001 From: Antoine Roy-Gobeil Date: Tue, 19 Feb 2019 10:42:21 -0500 Subject: [PATCH 01/13] sankey: grouping with animations --- src/snapshot/helpers.js | 1 + src/traces/sankey/attributes.js | 10 ++ src/traces/sankey/calc.js | 92 +++++++++++----- src/traces/sankey/constants.js | 4 +- src/traces/sankey/defaults.js | 1 + src/traces/sankey/render.js | 75 +++++++++---- test/image/baselines/sankey_groups.png | Bin 0 -> 45947 bytes test/image/mocks/sankey_groups.json | 144 +++++++++++++++++++++++++ test/jasmine/tests/sankey_test.js | 39 ++++++- 9 files changed, 318 insertions(+), 48 deletions(-) create mode 100644 test/image/baselines/sankey_groups.png create mode 100644 test/image/mocks/sankey_groups.json diff --git a/src/snapshot/helpers.js b/src/snapshot/helpers.js index b6f6ae21423..93af480d623 100644 --- a/src/snapshot/helpers.js +++ b/src/snapshot/helpers.js @@ -15,6 +15,7 @@ exports.getDelay = function(fullLayout) { return ( fullLayout._has('gl3d') || fullLayout._has('gl2d') || + fullLayout._has('sankey') || fullLayout._has('mapbox') ) ? 500 : 0; }; diff --git a/src/traces/sankey/attributes.js b/src/traces/sankey/attributes.js index b9144ad0e4c..109934f28f0 100644 --- a/src/traces/sankey/attributes.js +++ b/src/traces/sankey/attributes.js @@ -89,6 +89,16 @@ var attrs = module.exports = overrideAll({ role: 'info', description: 'The shown name of the node.' }, + groups: { + valType: 'data_array', + dflt: [], + role: 'calc', + description: [ + 'Groups of nodes.', + 'Each group is defined by an array with the indices of the nodes it contains.', + 'Multiple groups can be specified.' + ].join(' ') + }, color: { valType: 'color', role: 'style', diff --git a/src/traces/sankey/calc.js b/src/traces/sankey/calc.js index 861ce4f0397..485ac87de2d 100644 --- a/src/traces/sankey/calc.js +++ b/src/traces/sankey/calc.js @@ -17,8 +17,10 @@ var isIndex = Lib.isIndex; var Colorscale = require('../../components/colorscale'); function convertToD3Sankey(trace) { - var nodeSpec = trace.node; - var linkSpec = trace.link; + // var nodeSpec = trace.node; + // var linkSpec = trace.link; + var nodeSpec = Lib.extendDeep({}, trace.node); + var linkSpec = Lib.extendDeep({}, trace.link); var links = []; var hasLinkColorArray = isArrayOrTypedArray(linkSpec.color); @@ -34,7 +36,32 @@ function convertToD3Sankey(trace) { components[cscale.label] = scale; } - var nodeCount = nodeSpec.label.length; + var maxNodeId = 0; + for(i = 0; i < linkSpec.value.length; i++) { + if(linkSpec.source[i] > maxNodeId) maxNodeId = linkSpec.source[i]; + if(linkSpec.target[i] > maxNodeId) maxNodeId = linkSpec.target[i]; + } + var nodeCount = maxNodeId + 1; + + // Group nodes + var j; + var groups = trace.node.groups; + var groupLookup = {}; + for(i = 0; i < groups.length; i++) { + var group = groups[i]; + // Build a lookup table to quickly find in which group a node is + if(Array.isArray(group)) { + for(j = 0; j < group.length; j++) { + var nodeIndex = group[j]; + var groupIndex = nodeCount + i; + groupLookup[nodeIndex] = groupIndex; + } + } else { + Lib.warn('node.groups must be an array, default to empty array []'); + } + } + + // Process links for(i = 0; i < linkSpec.value.length; i++) { var val = linkSpec.value[i]; // remove negative values, but keep zeros with special treatment @@ -44,6 +71,22 @@ function convertToD3Sankey(trace) { continue; } + // Remove links that are within the same group + if(groupLookup.hasOwnProperty(source) && groupLookup.hasOwnProperty(target) && groupLookup[source] === groupLookup[target]) { + continue; + } + + // if link targets a node in the group, relink target to that group + if(groupLookup.hasOwnProperty(target)) { + target = groupLookup[target]; + } + + // if link originates from a node in a group, relink source to that group + // if(group.indexOf(source) !== -1) { + if(groupLookup.hasOwnProperty(source)) { + source = groupLookup[source]; + } + source = +source; target = +target; linkedNodes[source] = linkedNodes[target] = true; @@ -65,34 +108,29 @@ function convertToD3Sankey(trace) { }); } + // Process nodes + var totalCount = nodeCount + groups.length; var hasNodeColorArray = isArrayOrTypedArray(nodeSpec.color); var nodes = []; - var removedNodes = false; - var nodeIndices = {}; - - for(i = 0; i < nodeCount; i++) { - if(linkedNodes[i]) { - var l = nodeSpec.label[i]; - nodeIndices[i] = nodes.length; - nodes.push({ - pointNumber: i, - label: l, - color: hasNodeColorArray ? nodeSpec.color[i] : nodeSpec.color - }); - } else removedNodes = true; - } + for(i = 0; i < totalCount; i++) { + if(!linkedNodes[i]) continue; + var l = nodeSpec.label[i]; - // need to re-index links now, since we didn't put all the nodes in - if(removedNodes) { - for(i = 0; i < links.length; i++) { - links[i].source = nodeIndices[links[i].source]; - links[i].target = nodeIndices[links[i].target]; - } + nodes.push({ + group: (i > nodeCount - 1), + pointNumber: i, + label: l, + color: hasNodeColorArray ? nodeSpec.color[i] : nodeSpec.color + }); } return { links: links, - nodes: nodes + nodes: nodes, + + // Data structure for groups + groups: groups, + groupLookup: groupLookup }; } @@ -130,6 +168,10 @@ module.exports = function calc(gd, trace) { return wrap({ circular: circular, _nodes: result.nodes, - _links: result.links + _links: result.links, + + // Data structure for grouping + _groups: result.groups, + _groupLookup: result.groupLookup, }); }; diff --git a/src/traces/sankey/constants.js b/src/traces/sankey/constants.js index 2815475a341..bffd9294237 100644 --- a/src/traces/sankey/constants.js +++ b/src/traces/sankey/constants.js @@ -15,8 +15,8 @@ module.exports = { sankeyIterations: 50, forceIterations: 5, forceTicksPerFrame: 10, - duration: 500, - ease: 'cubic-in-out', + duration: 350, + ease: 'quart-in-out', cn: { sankey: 'sankey', sankeyLinks: 'sankey-links', diff --git a/src/traces/sankey/defaults.js b/src/traces/sankey/defaults.js index c5283910ade..531dfffcf50 100644 --- a/src/traces/sankey/defaults.js +++ b/src/traces/sankey/defaults.js @@ -32,6 +32,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout return Lib.coerce(nodeIn, nodeOut, attributes.node, attr, dflt); } coerceNode('label'); + coerceNode('groups'); coerceNode('pad'); coerceNode('thickness'); coerceNode('line.color'); diff --git a/src/traces/sankey/render.js b/src/traces/sankey/render.js index 88b27dbdafc..b9b41ef5be0 100644 --- a/src/traces/sankey/render.js +++ b/src/traces/sankey/render.js @@ -45,10 +45,7 @@ function sankeyModel(layout, d, traceIndex) { if(circular) { sankey = d3SankeyCircular .sankeyCircular() - .circularLinkGap(0) - .nodeId(function(d) { - return d.pointNumber; - }); + .circularLinkGap(0); } else { sankey = d3Sankey.sankey(); } @@ -58,6 +55,9 @@ function sankeyModel(layout, d, traceIndex) { .size(horizontal ? [width, height] : [height, width]) .nodeWidth(nodeThickness) .nodePadding(nodePad) + .nodeId(function(d) { + return d.pointNumber; + }) .nodes(nodes) .links(links); @@ -67,6 +67,30 @@ function sankeyModel(layout, d, traceIndex) { Lib.warn('node.pad was reduced to ', sankey.nodePadding(), ' to fit within the figure.'); } + // Create transient nodes for animations + Object.keys(calcData._groupLookup).forEach(function(nodePointNumber) { + var groupIndex = parseInt(calcData._groupLookup[nodePointNumber]); + + var groupingNode; + for(var i = 0; i < graph.nodes.length; i++) { + if(graph.nodes[i].pointNumber === groupIndex) { + groupingNode = graph.nodes[i]; + break; + } + } + + graph.nodes.push({ + pointNumber: parseInt(nodePointNumber), + x0: groupingNode.x0, + x1: groupingNode.x1, + y0: groupingNode.y0, + y1: groupingNode.y1, + partOfGroup: true, + sourceLinks: [], + targetLinks: [] + }); + }); + function computeLinkConcentrations() { var i, j, k; for(i = 0; i < graph.nodes.length; i++) { @@ -343,7 +367,7 @@ function linkPath() { return path; } -function nodeModel(d, n, i) { +function nodeModel(d, n) { var tc = tinycolor(n.color); var zoneThicknessPad = c.nodePadAcross; var zoneLengthPad = d.nodePad / 2; @@ -352,8 +376,11 @@ function nodeModel(d, n, i) { var visibleThickness = n.dx; var visibleLength = Math.max(0.5, n.dy); - var basicKey = n.label; - var key = basicKey + '__' + i; + var key = 'node_' + n.pointNumber; + // If it's a group, it's mutable and should be unique + if(n.group) { + key = 'group_' + Math.floor(1e12 * (1 + Math.random())); + } // for event data n.trace = d.trace; @@ -362,6 +389,8 @@ function nodeModel(d, n, i) { return { index: n.pointNumber, key: key, + partOfGroup: n.partOfGroup || false, + group: n.group, traceId: d.key, node: n, nodePad: d.nodePad, @@ -540,7 +569,10 @@ function attachDragHandler(sankeyNode, sankeyLink, callbacks) { function attachForce(sankeyNode, forceKey, d) { // Attach force to nodes in the same column (same x coordinate) switchToForceFormat(d.graph.nodes); - var nodes = d.graph.nodes.filter(function(n) {return n.originalX === d.node.originalX;}); + var nodes = d.graph.nodes + .filter(function(n) {return n.originalX === d.node.originalX;}) + // Filter out children + .filter(function(n) {return !n.partOfGroup;}); d.forceLayouts[forceKey] = d3Force.forceSimulation(nodes) .alphaDecay(0) .force('collide', d3Force.forceCollide() @@ -683,7 +715,6 @@ module.exports = function(gd, svg, calcData, layout, callbacks) { sankeyLink .enter().append('path') .classed(c.cn.sankeyLink, true) - .attr('d', linkPath()) .call(attachPointerEvents, sankey, callbacks.linkEvents); sankeyLink @@ -701,13 +732,17 @@ module.exports = function(gd, svg, calcData, layout, callbacks) { }) .style('stroke-width', function(d) { return salientEnough(d) ? d.linkLineWidth : 1; - }); + }) + .attr('d', linkPath()); - sankeyLink.transition() - .ease(c.ease).duration(c.duration) - .attr('d', linkPath()); + sankeyLink + .style('opacity', 0) + .transition() + .ease(c.ease).duration(c.duration) + .style('opacity', 1); - sankeyLink.exit().transition() + sankeyLink.exit() + .transition() .ease(c.ease).duration(c.duration) .style('opacity', 0) .remove(); @@ -733,24 +768,26 @@ module.exports = function(gd, svg, calcData, layout, callbacks) { var nodes = d.graph.nodes; persistOriginalPlace(nodes); return nodes - .filter(function(n) {return n.value;}) - .map(nodeModel.bind(null, d)); + .map(nodeModel.bind(null, d)); }, keyFun); sankeyNode.enter() .append('g') .classed(c.cn.sankeyNode, true) .call(updateNodePositions) - .call(attachPointerEvents, sankey, callbacks.nodeEvents); + .style('opacity', 0); sankeyNode + .call(attachPointerEvents, sankey, callbacks.nodeEvents) .call(attachDragHandler, sankeyLink, callbacks); // has to be here as it binds sankeyLink sankeyNode.transition() .ease(c.ease).duration(c.duration) - .call(updateNodePositions); + .call(updateNodePositions) + .style('opacity', function(n) { return n.partOfGroup ? 0 : 1;}); - sankeyNode.exit().transition() + sankeyNode.exit() + .transition() .ease(c.ease).duration(c.duration) .style('opacity', 0) .remove(); diff --git a/test/image/baselines/sankey_groups.png b/test/image/baselines/sankey_groups.png new file mode 100644 index 0000000000000000000000000000000000000000..ee05b7d512e70d47d72de1b6f2dd83ee4e1556e8 GIT binary patch literal 45947 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 literal 0 HcmV?d00001 diff --git a/test/image/mocks/sankey_groups.json b/test/image/mocks/sankey_groups.json new file mode 100644 index 00000000000..a58b8857d3a --- /dev/null +++ b/test/image/mocks/sankey_groups.json @@ -0,0 +1,144 @@ +{ + "data": [ + { + "type": "sankey", + "node": { + "pad": 25, + "line": { + "color": "white", + "width": 2 + }, + "color": ["black", "black", "black", "black", "black", "orange" ], + "label": ["process0", "process1", "process2", "process3", "process4", "Group A"], + "groups": [[2, 3, 4]] + }, + "link": { + "source": [ + 0, 0, 0, 0, + 1, 1, 1, 1, + 1, 1, 1, 1, + 1, 1, + 2 + ], + "target": [ + 1, 1, 1, 1, + 2, 2, 2, 2, + 3, 3, 3, 3, + 4, 4, + 0 + ], + "value": [ + 10, 20, 40, 30, + 10, 5, 10, 20, + 0, 10, 10, 10, + 15, 5, + 20 + + ], + "label": [ + "elementA", "elementB", "elementC", "elementD", + "elementA", "elementB", "elementC", "elementD", + "elementA", "elementB", "elementC", "elementD", + "elementC", "elementC", + "elementA" + ], + "line": { + "color": "white", + "width": 2 + }, + "colorscales": [ + { + "label": "elementA", + "colorscale": [[0, "white"], [1, "blue"]] + }, + { + "label": "elementB", + "colorscale": [[0, "white"], [1, "red"]] + }, + { + "label": "elementC", + "colorscale": [[0, "white"], [1, "green"]] + }, + { + "label": "elementD" + } + ], + + "hovertemplate": "%{label}
flow.labelConcentration: %{flow.labelConcentration:%0.2f}
flow.concentration: %{flow.concentration:%0.2f}
flow.value: %{flow.value}" + } + + }], + "layout": { + "title": "Sankey diagram with links colored based on their concentration within a flow", + "width": 800, + "height": 800, + "updatemenus": [{ + "y": 1, + "x": 0, + "active": 1, + "buttons": [{ + "label": "Ungroup [[]]", + "method": "restyle", + "args": ["node.groups", [ + [] + ]] + }, + { + "label": "Group [[2, 3, 4]]", + "method": "restyle", + "args": ["node.groups", [ + [ + [2, 3, 4] + ] + ]] + }, + { + "label": "Group [[3, 4]]", + "method": "restyle", + "args": ["node.groups", [ + [ + [3, 4] + ] + ]] + }, + { + "label": "Group [[1, 2]]", + "method": "restyle", + "args": ["node.groups", [ + [ + [1, 2] + ] + ]] + }, + { + "label": "Group [[2, 4]]]", + "method": "restyle", + "args": ["node.groups", [ + [ + [2, 4] + ] + ]] + }, + { + "label": "Group [[0, 2]]]", + "method": "restyle", + "args": ["node.groups", [ + [ + [0, 2] + ] + ]] + }, + { + "label": "Group [[1, 2], [3, 4]]", + "method": "restyle", + "args": ["node.groups", [ + [ + [1, 2], + [3, 4] + ] + ]] + } + ] + }] + } +} diff --git a/test/jasmine/tests/sankey_test.js b/test/jasmine/tests/sankey_test.js index 6471906d6e1..238344b3a38 100644 --- a/test/jasmine/tests/sankey_test.js +++ b/test/jasmine/tests/sankey_test.js @@ -263,7 +263,7 @@ describe('sankey tests', function() { label: ['a', 'b', 'c', 'd', 'e'] }, link: { - value: [1, 1, 1, 1, 1, 1, 1, 1], + value: [1, 1, 1, 1], source: [0, 1, 2, 3], target: [1, 2, 0, 4] } @@ -277,13 +277,32 @@ describe('sankey tests', function() { label: ['a', 'b', 'c', 'd', 'e'] }, link: { - value: [1, 1, 1, 1, 1, 1, 1, 1], + value: [1, 1, 1, 1], source: [0, 1, 2, 3], target: [1, 2, 4, 4] } })); expect(calcData[0].circular).toBe(false); }); + + it('keep an index of groups', function() { + var calcData = _calc(Lib.extendDeep({}, base, { + node: { + label: ['a', 'b', 'c', 'd', 'e'], + groups: [[0, 1], [2, 3]] + }, + link: { + value: [1, 1, 1, 1], + source: [0, 1, 2, 3], + target: [1, 2, 4, 4] + } + })); + var groups = calcData[0]._nodes.filter(function(node) { + return node.group; + }); + expect(groups.length).toBe(2); + expect(calcData[0].circular).toBe(false); + }); }); describe('lifecycle methods', function() { @@ -404,6 +423,22 @@ describe('sankey tests', function() { done(); }); }); + + it('can create groups', function(done) { + var gd = createGraphDiv(); + var mockCircularCopy = Lib.extendDeep({}, mockCircular); + mockCircularCopy.data[0].node.groups = [[2, 3], [0, 1]]; + Plotly.plot(gd, mockCircularCopy) + .then(function() { + expect(gd._fullData[0].node.groups).toEqual([[2, 3], [0, 1]]); + return Plotly.restyle(gd, {'node.groups': [[[3, 4]]]}); + }) + .then(function() { + expect(gd._fullData[0].node.groups).toEqual([[3, 4]]); + destroyGraphDiv(); + done(); + }); + }); }); describe('Test hover/click interactions:', function() { From 85470a0d8bfb04ff168cd7ce2ce3b46ede7dc23f Mon Sep 17 00:00:00 2001 From: Antoine Roy-Gobeil Date: Tue, 19 Feb 2019 13:01:28 -0500 Subject: [PATCH 02/13] nodes that are part of a group should be in the back --- src/traces/sankey/calc.js | 1 - src/traces/sankey/render.js | 10 +++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/traces/sankey/calc.js b/src/traces/sankey/calc.js index 485ac87de2d..187185a567a 100644 --- a/src/traces/sankey/calc.js +++ b/src/traces/sankey/calc.js @@ -82,7 +82,6 @@ function convertToD3Sankey(trace) { } // if link originates from a node in a group, relink source to that group - // if(group.indexOf(source) !== -1) { if(groupLookup.hasOwnProperty(source)) { source = groupLookup[source]; } diff --git a/src/traces/sankey/render.js b/src/traces/sankey/render.js index b9b41ef5be0..6d3c618b12b 100644 --- a/src/traces/sankey/render.js +++ b/src/traces/sankey/render.js @@ -79,7 +79,7 @@ function sankeyModel(layout, d, traceIndex) { } } - graph.nodes.push({ + graph.nodes.unshift({ pointNumber: parseInt(nodePointNumber), x0: groupingNode.x0, x1: groupingNode.x1, @@ -474,19 +474,19 @@ function attachPointerEvents(selection, sankey, eventSet) { selection .on('.basic', null) // remove any preexisting handlers .on('mouseover.basic', function(d) { - if(!d.interactionState.dragInProgress) { + if(!d.interactionState.dragInProgress && !d.partOfGroup) { eventSet.hover(this, d, sankey); d.interactionState.hovered = [this, d]; } }) .on('mousemove.basic', function(d) { - if(!d.interactionState.dragInProgress) { + if(!d.interactionState.dragInProgress && !d.partOfGroup) { eventSet.follow(this, d); d.interactionState.hovered = [this, d]; } }) .on('mouseout.basic', function(d) { - if(!d.interactionState.dragInProgress) { + if(!d.interactionState.dragInProgress && !d.partOfGroup) { eventSet.unhover(this, d, sankey); d.interactionState.hovered = false; } @@ -496,7 +496,7 @@ function attachPointerEvents(selection, sankey, eventSet) { eventSet.unhover(this, d, sankey); d.interactionState.hovered = false; } - if(!d.interactionState.dragInProgress) { + if(!d.interactionState.dragInProgress && !d.partOfGroup) { eventSet.select(this, d, sankey); } }); From 1cd206f7fdba75b010650331fc1f7a726ec488df Mon Sep 17 00:00:00 2001 From: Antoine Roy-Gobeil Date: Tue, 19 Feb 2019 14:51:32 -0500 Subject: [PATCH 03/13] sankey: turn `node.groups` into a info_array, use for loops --- src/traces/sankey/attributes.js | 5 ++++- src/traces/sankey/calc.js | 18 ++++++------------ src/traces/sankey/render.js | 8 ++++---- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/src/traces/sankey/attributes.js b/src/traces/sankey/attributes.js index 109934f28f0..ccd14455e81 100644 --- a/src/traces/sankey/attributes.js +++ b/src/traces/sankey/attributes.js @@ -90,8 +90,11 @@ var attrs = module.exports = overrideAll({ description: 'The shown name of the node.' }, groups: { - valType: 'data_array', + valType: 'info_array', + dimensions: 2, + freeLength: true, dflt: [], + items: {valType: 'number', editType: 'calc'}, role: 'calc', description: [ 'Groups of nodes.', diff --git a/src/traces/sankey/calc.js b/src/traces/sankey/calc.js index 187185a567a..cebea266e37 100644 --- a/src/traces/sankey/calc.js +++ b/src/traces/sankey/calc.js @@ -17,10 +17,8 @@ var isIndex = Lib.isIndex; var Colorscale = require('../../components/colorscale'); function convertToD3Sankey(trace) { - // var nodeSpec = trace.node; - // var linkSpec = trace.link; - var nodeSpec = Lib.extendDeep({}, trace.node); - var linkSpec = Lib.extendDeep({}, trace.link); + var nodeSpec = trace.node; + var linkSpec = trace.link; var links = []; var hasLinkColorArray = isArrayOrTypedArray(linkSpec.color); @@ -50,14 +48,10 @@ function convertToD3Sankey(trace) { for(i = 0; i < groups.length; i++) { var group = groups[i]; // Build a lookup table to quickly find in which group a node is - if(Array.isArray(group)) { - for(j = 0; j < group.length; j++) { - var nodeIndex = group[j]; - var groupIndex = nodeCount + i; - groupLookup[nodeIndex] = groupIndex; - } - } else { - Lib.warn('node.groups must be an array, default to empty array []'); + for(j = 0; j < group.length; j++) { + var nodeIndex = group[j]; + var groupIndex = nodeCount + i; + groupLookup[nodeIndex] = groupIndex; } } diff --git a/src/traces/sankey/render.js b/src/traces/sankey/render.js index 6d3c618b12b..871fcd27956 100644 --- a/src/traces/sankey/render.js +++ b/src/traces/sankey/render.js @@ -68,7 +68,7 @@ function sankeyModel(layout, d, traceIndex) { } // Create transient nodes for animations - Object.keys(calcData._groupLookup).forEach(function(nodePointNumber) { + for(var nodePointNumber in calcData._groupLookup) { var groupIndex = parseInt(calcData._groupLookup[nodePointNumber]); var groupingNode; @@ -89,7 +89,7 @@ function sankeyModel(layout, d, traceIndex) { sourceLinks: [], targetLinks: [] }); - }); + } function computeLinkConcentrations() { var i, j, k; @@ -161,7 +161,7 @@ function sankeyModel(layout, d, traceIndex) { circular: circular, key: traceIndex, trace: trace, - guid: Math.floor(1e12 * (1 + Math.random())), + guid: Lib.randstr(), horizontal: horizontal, width: width, height: height, @@ -379,7 +379,7 @@ function nodeModel(d, n) { var key = 'node_' + n.pointNumber; // If it's a group, it's mutable and should be unique if(n.group) { - key = 'group_' + Math.floor(1e12 * (1 + Math.random())); + key = Lib.randstr(); } // for event data From b90fda19ab6b19bf7f1bb704c3e3e40b9f0730f5 Mon Sep 17 00:00:00 2001 From: Antoine Roy-Gobeil Date: Tue, 19 Feb 2019 17:25:49 -0500 Subject: [PATCH 04/13] remove opacity transition if _context.staticPlot --- src/snapshot/helpers.js | 1 - src/traces/sankey/render.js | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/snapshot/helpers.js b/src/snapshot/helpers.js index 93af480d623..b6f6ae21423 100644 --- a/src/snapshot/helpers.js +++ b/src/snapshot/helpers.js @@ -15,7 +15,6 @@ exports.getDelay = function(fullLayout) { return ( fullLayout._has('gl3d') || fullLayout._has('gl2d') || - fullLayout._has('sankey') || fullLayout._has('mapbox') ) ? 500 : 0; }; diff --git a/src/traces/sankey/render.js b/src/traces/sankey/render.js index 871fcd27956..3cd3899dde9 100644 --- a/src/traces/sankey/render.js +++ b/src/traces/sankey/render.js @@ -736,7 +736,7 @@ module.exports = function(gd, svg, calcData, layout, callbacks) { .attr('d', linkPath()); sankeyLink - .style('opacity', 0) + .style('opacity', function() { return gd._context.staticPlot ? 1 : 0;}) .transition() .ease(c.ease).duration(c.duration) .style('opacity', 1); @@ -775,7 +775,7 @@ module.exports = function(gd, svg, calcData, layout, callbacks) { .append('g') .classed(c.cn.sankeyNode, true) .call(updateNodePositions) - .style('opacity', 0); + .style('opacity', function(n) { return (!gd._context.staticPlot && n.partOfGroup) ? 0 : 1;}); sankeyNode .call(attachPointerEvents, sankey, callbacks.nodeEvents) From b4d7ff32790d07f0406ba36e86b0c383ece0e999 Mon Sep 17 00:00:00 2001 From: Antoine Roy-Gobeil Date: Tue, 19 Feb 2019 17:37:47 -0500 Subject: [PATCH 05/13] fix opacity logic --- src/traces/sankey/render.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/traces/sankey/render.js b/src/traces/sankey/render.js index 3cd3899dde9..ec1b0ffe173 100644 --- a/src/traces/sankey/render.js +++ b/src/traces/sankey/render.js @@ -775,7 +775,7 @@ module.exports = function(gd, svg, calcData, layout, callbacks) { .append('g') .classed(c.cn.sankeyNode, true) .call(updateNodePositions) - .style('opacity', function(n) { return (!gd._context.staticPlot && n.partOfGroup) ? 0 : 1;}); + .style('opacity', function(n) { return (gd._context.staticPlot && !n.partOfGroup) ? 1 : 0;}); sankeyNode .call(attachPointerEvents, sankey, callbacks.nodeEvents) From ab1a0fb5e3f0578fe901278dd0dc52f70faa35cd Mon Sep 17 00:00:00 2001 From: Antoine Roy-Gobeil Date: Tue, 19 Feb 2019 17:52:51 -0500 Subject: [PATCH 06/13] update position of phantom children nodes when a group is dragged --- src/traces/sankey/calc.js | 1 + src/traces/sankey/render.js | 11 +++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/traces/sankey/calc.js b/src/traces/sankey/calc.js index cebea266e37..7af7ec179d2 100644 --- a/src/traces/sankey/calc.js +++ b/src/traces/sankey/calc.js @@ -111,6 +111,7 @@ function convertToD3Sankey(trace) { nodes.push({ group: (i > nodeCount - 1), + children: [], pointNumber: i, label: l, color: hasNodeColorArray ? nodeSpec.color[i] : nodeSpec.color diff --git a/src/traces/sankey/render.js b/src/traces/sankey/render.js index ec1b0ffe173..3258725fb80 100644 --- a/src/traces/sankey/render.js +++ b/src/traces/sankey/render.js @@ -79,7 +79,7 @@ function sankeyModel(layout, d, traceIndex) { } } - graph.nodes.unshift({ + var child = { pointNumber: parseInt(nodePointNumber), x0: groupingNode.x0, x1: groupingNode.x1, @@ -88,7 +88,10 @@ function sankeyModel(layout, d, traceIndex) { partOfGroup: true, sourceLinks: [], targetLinks: [] - }); + }; + + graph.nodes.unshift(child); + groupingNode.children.unshift(child); } function computeLinkConcentrations() { @@ -559,6 +562,10 @@ function attachDragHandler(sankeyNode, sankeyLink, callbacks) { .on('dragend', function(d) { d.interactionState.dragInProgress = false; + for(var i = 0; i < d.node.children.length; i++) { + d.node.children[i].x = d.node.x; + d.node.children[i].y = d.node.y; + } }); sankeyNode From 63d819088679cad926ad8521ec5e141613a60fb1 Mon Sep 17 00:00:00 2001 From: Antoine Roy-Gobeil Date: Tue, 19 Feb 2019 18:03:42 -0500 Subject: [PATCH 07/13] replace children by childrenNodes --- src/traces/sankey/calc.js | 2 +- src/traces/sankey/render.js | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/traces/sankey/calc.js b/src/traces/sankey/calc.js index 7af7ec179d2..87619f2549b 100644 --- a/src/traces/sankey/calc.js +++ b/src/traces/sankey/calc.js @@ -111,7 +111,7 @@ function convertToD3Sankey(trace) { nodes.push({ group: (i > nodeCount - 1), - children: [], + childrenNodes: [], pointNumber: i, label: l, color: hasNodeColorArray ? nodeSpec.color[i] : nodeSpec.color diff --git a/src/traces/sankey/render.js b/src/traces/sankey/render.js index 3258725fb80..b8ec3f4ee5f 100644 --- a/src/traces/sankey/render.js +++ b/src/traces/sankey/render.js @@ -91,7 +91,7 @@ function sankeyModel(layout, d, traceIndex) { }; graph.nodes.unshift(child); - groupingNode.children.unshift(child); + groupingNode.childrenNodes.unshift(child); } function computeLinkConcentrations() { @@ -562,9 +562,9 @@ function attachDragHandler(sankeyNode, sankeyLink, callbacks) { .on('dragend', function(d) { d.interactionState.dragInProgress = false; - for(var i = 0; i < d.node.children.length; i++) { - d.node.children[i].x = d.node.x; - d.node.children[i].y = d.node.y; + for(var i = 0; i < d.node.childrenNodes.length; i++) { + d.node.childrenNodes[i].x = d.node.x; + d.node.childrenNodes[i].y = d.node.y; } }); From 6175fb59363ee1eb00e138a6f4353865dc1bef80 Mon Sep 17 00:00:00 2001 From: Antoine Roy-Gobeil Date: Thu, 21 Feb 2019 17:51:36 -0500 Subject: [PATCH 08/13] sankey: add warning if node is in multiple groups, change transition settings --- src/traces/sankey/calc.js | 6 +++++- src/traces/sankey/constants.js | 4 ++-- test/image/mocks/sankey_groups.json | 4 ++-- test/jasmine/tests/sankey_test.js | 24 ++++++++++++++++++++++++ 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/traces/sankey/calc.js b/src/traces/sankey/calc.js index 87619f2549b..c3bbe4ba246 100644 --- a/src/traces/sankey/calc.js +++ b/src/traces/sankey/calc.js @@ -51,7 +51,11 @@ function convertToD3Sankey(trace) { for(j = 0; j < group.length; j++) { var nodeIndex = group[j]; var groupIndex = nodeCount + i; - groupLookup[nodeIndex] = groupIndex; + if(groupLookup.hasOwnProperty(nodeIndex)) { + Lib.warn('Node ' + nodeIndex + ' is already part of a group.'); + } else { + groupLookup[nodeIndex] = groupIndex; + } } } diff --git a/src/traces/sankey/constants.js b/src/traces/sankey/constants.js index bffd9294237..534b05ad4e7 100644 --- a/src/traces/sankey/constants.js +++ b/src/traces/sankey/constants.js @@ -15,8 +15,8 @@ module.exports = { sankeyIterations: 50, forceIterations: 5, forceTicksPerFrame: 10, - duration: 350, - ease: 'quart-in-out', + duration: 500, + ease: 'linear', cn: { sankey: 'sankey', sankeyLinks: 'sankey-links', diff --git a/test/image/mocks/sankey_groups.json b/test/image/mocks/sankey_groups.json index a58b8857d3a..90afbde12bb 100644 --- a/test/image/mocks/sankey_groups.json +++ b/test/image/mocks/sankey_groups.json @@ -8,8 +8,8 @@ "color": "white", "width": 2 }, - "color": ["black", "black", "black", "black", "black", "orange" ], - "label": ["process0", "process1", "process2", "process3", "process4", "Group A"], + "color": ["black", "black", "black", "black", "black", "orange", "orange" ], + "label": ["process0", "process1", "process2", "process3", "process4", "Group A", "Group B"], "groups": [[2, 3, 4]] }, "link": { diff --git a/test/jasmine/tests/sankey_test.js b/test/jasmine/tests/sankey_test.js index 238344b3a38..58e818ddc2a 100644 --- a/test/jasmine/tests/sankey_test.js +++ b/test/jasmine/tests/sankey_test.js @@ -303,6 +303,30 @@ describe('sankey tests', function() { expect(groups.length).toBe(2); expect(calcData[0].circular).toBe(false); }); + + it('emits a warning if a node is part of more than one group', function() { + var warnings = []; + spyOn(Lib, 'warn').and.callFake(function(msg) { + warnings.push(msg); + }); + + var calcData = _calc(Lib.extendDeep({}, base, { + node: { + label: ['a', 'b', 'c', 'd', 'e'], + groups: [[0, 1], [1, 2, 3]] + }, + link: { + value: [1, 1, 1, 1], + source: [0, 1, 2, 3], + target: [1, 2, 4, 4] + } + })); + + expect(warnings.length).toBe(1); + + // Expect node '1' to be in the first group + expect(calcData[0]._groupLookup[1]).toBe(5); + }); }); describe('lifecycle methods', function() { From 31990f074c5f63dea26b107114055e2f5417ef65 Mon Sep 17 00:00:00 2001 From: Antoine Roy-Gobeil Date: Mon, 25 Feb 2019 15:46:02 -0500 Subject: [PATCH 09/13] sankey: do not animate on first render --- src/traces/sankey/render.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/traces/sankey/render.js b/src/traces/sankey/render.js index b8ec3f4ee5f..6ea0049fc55 100644 --- a/src/traces/sankey/render.js +++ b/src/traces/sankey/render.js @@ -678,6 +678,11 @@ function switchToSankeyFormat(nodes) { // scene graph module.exports = function(gd, svg, calcData, layout, callbacks) { + // To prevent animation on first render + var firstRender = false; + Lib.ensureSingle(gd._fullLayout._infolayer, 'g', 'first-render', function() { + firstRender = true; + }); var styledData = calcData .filter(function(d) {return unwrap(d).trace.visible;}) @@ -743,7 +748,7 @@ module.exports = function(gd, svg, calcData, layout, callbacks) { .attr('d', linkPath()); sankeyLink - .style('opacity', function() { return gd._context.staticPlot ? 1 : 0;}) + .style('opacity', function() { return (gd._context.staticPlot || firstRender) ? 1 : 0;}) .transition() .ease(c.ease).duration(c.duration) .style('opacity', 1); @@ -782,7 +787,7 @@ module.exports = function(gd, svg, calcData, layout, callbacks) { .append('g') .classed(c.cn.sankeyNode, true) .call(updateNodePositions) - .style('opacity', function(n) { return (gd._context.staticPlot && !n.partOfGroup) ? 1 : 0;}); + .style('opacity', function(n) { return ((gd._context.staticPlot || firstRender) && !n.partOfGroup) ? 1 : 0;}); sankeyNode .call(attachPointerEvents, sankey, callbacks.nodeEvents) From fec37399e786b136d206088beb805dacb7d36567 Mon Sep 17 00:00:00 2001 From: Antoine Roy-Gobeil Date: Mon, 25 Feb 2019 16:23:16 -0500 Subject: [PATCH 10/13] sankey: fix attribute's role for `node.groups` --- src/traces/sankey/attributes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/traces/sankey/attributes.js b/src/traces/sankey/attributes.js index ccd14455e81..9e0929100c7 100644 --- a/src/traces/sankey/attributes.js +++ b/src/traces/sankey/attributes.js @@ -95,7 +95,7 @@ var attrs = module.exports = overrideAll({ freeLength: true, dflt: [], items: {valType: 'number', editType: 'calc'}, - role: 'calc', + role: 'info', description: [ 'Groups of nodes.', 'Each group is defined by an array with the indices of the nodes it contains.', From ffbafa72b494d64d99b714a8c3b09fed12181b47 Mon Sep 17 00:00:00 2001 From: Antoine Roy-Gobeil Date: Mon, 25 Feb 2019 20:29:28 -0500 Subject: [PATCH 11/13] sankey: test that groups have appropriate DOM elements --- src/traces/sankey/render.js | 4 +++ test/jasmine/tests/sankey_test.js | 50 ++++++++++++++++++++++--------- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/src/traces/sankey/render.js b/src/traces/sankey/render.js index 6ea0049fc55..7c2ca3489ce 100644 --- a/src/traces/sankey/render.js +++ b/src/traces/sankey/render.js @@ -71,6 +71,7 @@ function sankeyModel(layout, d, traceIndex) { for(var nodePointNumber in calcData._groupLookup) { var groupIndex = parseInt(calcData._groupLookup[nodePointNumber]); + // Find node representing groupIndex var groupingNode; for(var i = 0; i < graph.nodes.length; i++) { if(graph.nodes[i].pointNumber === groupIndex) { @@ -78,6 +79,8 @@ function sankeyModel(layout, d, traceIndex) { break; } } + // If groupinNode is undefined, no links are targeting this group + if(!groupingNode) continue; var child = { pointNumber: parseInt(nodePointNumber), @@ -211,6 +214,7 @@ function linkModel(d, l, i) { link: l, tinyColorHue: Color.tinyRGB(tc), tinyColorAlpha: tc.getAlpha(), + linkPath: linkPath, linkLineColor: d.linkLineColor, linkLineWidth: d.linkLineWidth, valueFormat: d.valueFormat, diff --git a/test/jasmine/tests/sankey_test.js b/test/jasmine/tests/sankey_test.js index 58e818ddc2a..53cfd4abecc 100644 --- a/test/jasmine/tests/sankey_test.js +++ b/test/jasmine/tests/sankey_test.js @@ -330,11 +330,14 @@ describe('sankey tests', function() { }); describe('lifecycle methods', function() { + var gd; + beforeEach(function() { + gd = createGraphDiv(); + }); afterEach(destroyGraphDiv); it('Plotly.deleteTraces with two traces removes the deleted plot', function(done) { - var gd = createGraphDiv(); var mockCopy = Lib.extendDeep({}, mock); var mockCopy2 = Lib.extendDeep({}, mockDark); @@ -363,7 +366,6 @@ describe('sankey tests', function() { it('Plotly.plot does not show Sankey if \'visible\' is false', function(done) { - var gd = createGraphDiv(); var mockCopy = Lib.extendDeep({}, mock); Plotly.plot(gd, mockCopy) @@ -386,7 +388,6 @@ describe('sankey tests', function() { it('\'node\' remains visible even if \'value\' is very low', function(done) { - var gd = createGraphDiv(); var minimock = [{ type: 'sankey', node: { @@ -408,7 +409,6 @@ describe('sankey tests', function() { }); it('switch from normal to circular Sankey on react', function(done) { - var gd = createGraphDiv(); var mockCopy = Lib.extendDeep({}, mock); var mockCircularCopy = Lib.extendDeep({}, mockCircular); @@ -424,7 +424,6 @@ describe('sankey tests', function() { }); it('switch from circular to normal Sankey on react', function(done) { - var gd = createGraphDiv(); var mockCircularCopy = Lib.extendDeep({}, mockCircular); Plotly.plot(gd, mockCircularCopy) @@ -448,20 +447,43 @@ describe('sankey tests', function() { }); }); - it('can create groups', function(done) { - var gd = createGraphDiv(); + it('can create groups, restyle groups and properly update DOM', function(done) { var mockCircularCopy = Lib.extendDeep({}, mockCircular); - mockCircularCopy.data[0].node.groups = [[2, 3], [0, 1]]; + var firstGroup = [[2, 3], [0, 1]]; + var newGroup = [[2, 3]]; + mockCircularCopy.data[0].node.groups = firstGroup; + Plotly.plot(gd, mockCircularCopy) .then(function() { - expect(gd._fullData[0].node.groups).toEqual([[2, 3], [0, 1]]); - return Plotly.restyle(gd, {'node.groups': [[[3, 4]]]}); + expect(gd._fullData[0].node.groups).toEqual(firstGroup); + return Plotly.restyle(gd, {'node.groups': [newGroup]}); }) .then(function() { - expect(gd._fullData[0].node.groups).toEqual([[3, 4]]); - destroyGraphDiv(); - done(); - }); + expect(gd._fullData[0].node.groups).toEqual(newGroup); + + // Check that all links have updated their links + d3.selectAll('.sankey .sankey-link').each(function(d, i) { + var path = this.getAttribute('d'); + expect(path).toBe(d.linkPath()(d), 'link ' + i + ' has wrong `d` attribute'); + }); + + // Check that ghost nodes used for animations: + // 1) are drawn first so they apear behind + var seeRealNode = false; + var sankeyNodes = d3.selectAll('.sankey .sankey-node'); + sankeyNodes.each(function(d, i) { + if(d.partOfGroup) { + if(seeRealNode) fail('node ' + i + ' is a ghost node and should be behind'); + } else { + seeRealNode = true; + } + }); + // 2) have an element for each grouped node + var L = sankeyNodes.filter(function(d) { return d.partOfGroup;}).size(); + expect(L).toBe(newGroup.flat().length, 'does not have the right number of ghost nodes'); + }) + .catch(failTest) + .then(done); }); }); From 7094887584b7bb9a3e2ca17fef03deba138858f6 Mon Sep 17 00:00:00 2001 From: Antoine Roy-Gobeil Date: Tue, 26 Feb 2019 11:04:20 -0500 Subject: [PATCH 12/13] sankey: check for circularity after grouping --- src/traces/sankey/calc.js | 25 ++++++++++++++++--------- test/jasmine/tests/sankey_test.js | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/traces/sankey/calc.js b/src/traces/sankey/calc.js index c3bbe4ba246..5b31199f1f9 100644 --- a/src/traces/sankey/calc.js +++ b/src/traces/sankey/calc.js @@ -60,6 +60,10 @@ function convertToD3Sankey(trace) { } // Process links + var groupedLinks = { + source: [], + target: [] + }; for(i = 0; i < linkSpec.value.length; i++) { var val = linkSpec.value[i]; // remove negative values, but keep zeros with special treatment @@ -103,6 +107,9 @@ function convertToD3Sankey(trace) { target: target, value: +val }); + + groupedLinks.source.push(source); + groupedLinks.target.push(target); } // Process nodes @@ -122,7 +129,14 @@ function convertToD3Sankey(trace) { }); } + // Check if we have circularity on the resulting graph + var circular = false; + if(circularityPresent(totalCount, groupedLinks.source, groupedLinks.target)) { + circular = true; + } + return { + circular: circular, links: links, nodes: nodes, @@ -132,9 +146,7 @@ function convertToD3Sankey(trace) { }; } -function circularityPresent(nodeList, sources, targets) { - - var nodeLen = nodeList.length; +function circularityPresent(nodeLen, sources, targets) { var nodes = Lib.init2dArray(nodeLen, 0); for(var i = 0; i < Math.min(sources.length, targets.length); i++) { @@ -156,15 +168,10 @@ function circularityPresent(nodeList, sources, targets) { } module.exports = function calc(gd, trace) { - var circular = false; - if(circularityPresent(trace.node.label, trace.link.source, trace.link.target)) { - circular = true; - } - var result = convertToD3Sankey(trace); return wrap({ - circular: circular, + circular: result.circular, _nodes: result.nodes, _links: result.links, diff --git a/test/jasmine/tests/sankey_test.js b/test/jasmine/tests/sankey_test.js index 53cfd4abecc..c39017fb5f5 100644 --- a/test/jasmine/tests/sankey_test.js +++ b/test/jasmine/tests/sankey_test.js @@ -485,6 +485,28 @@ describe('sankey tests', function() { .catch(failTest) .then(done); }); + + it('switches from normal to circular Sankey on grouping', function(done) { + var mockCopy = Lib.extendDeep({}, mock); + + Plotly.plot(gd, mockCopy) + .then(function() { + expect(gd.calcdata[0][0].circular).toBe(false); + + // Group two nodes to create circularity + return Plotly.restyle(gd, 'node.groups', [[[1, 3]]]); + }) + .then(function() { + expect(gd.calcdata[0][0].circular).toBe(true); + // Group two nodes to that do not create circularity + return Plotly.restyle(gd, 'node.groups', [[[1, 4]]]); + }) + .then(function() { + expect(gd.calcdata[0][0].circular).toBe(false); + done(); + }); + }); + }); describe('Test hover/click interactions:', function() { From fb9479a5ca6b81ad83c930c600bf3be62b47c871 Mon Sep 17 00:00:00 2001 From: Antoine Roy-Gobeil Date: Tue, 26 Feb 2019 11:12:34 -0500 Subject: [PATCH 13/13] fix typo --- src/traces/sankey/calc.js | 2 +- test/jasmine/tests/sankey_test.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/traces/sankey/calc.js b/src/traces/sankey/calc.js index 5b31199f1f9..b46715a155e 100644 --- a/src/traces/sankey/calc.js +++ b/src/traces/sankey/calc.js @@ -62,7 +62,7 @@ function convertToD3Sankey(trace) { // Process links var groupedLinks = { source: [], - target: [] + target: [] }; for(i = 0; i < linkSpec.value.length; i++) { var val = linkSpec.value[i]; diff --git a/test/jasmine/tests/sankey_test.js b/test/jasmine/tests/sankey_test.js index c39017fb5f5..69d985a5041 100644 --- a/test/jasmine/tests/sankey_test.js +++ b/test/jasmine/tests/sankey_test.js @@ -493,12 +493,12 @@ describe('sankey tests', function() { .then(function() { expect(gd.calcdata[0][0].circular).toBe(false); - // Group two nodes to create circularity + // Group two nodes that creates a circularity return Plotly.restyle(gd, 'node.groups', [[[1, 3]]]); }) .then(function() { expect(gd.calcdata[0][0].circular).toBe(true); - // Group two nodes to that do not create circularity + // Group two nodes that do not create a circularity return Plotly.restyle(gd, 'node.groups', [[[1, 4]]]); }) .then(function() {