diff --git a/src/traces/sankey/attributes.js b/src/traces/sankey/attributes.js index b9144ad0e4c..9e0929100c7 100644 --- a/src/traces/sankey/attributes.js +++ b/src/traces/sankey/attributes.js @@ -89,6 +89,19 @@ var attrs = module.exports = overrideAll({ role: 'info', description: 'The shown name of the node.' }, + groups: { + valType: 'info_array', + dimensions: 2, + freeLength: true, + dflt: [], + items: {valType: 'number', editType: 'calc'}, + role: 'info', + 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..b46715a155e 100644 --- a/src/traces/sankey/calc.js +++ b/src/traces/sankey/calc.js @@ -34,7 +34,36 @@ 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 + for(j = 0; j < group.length; j++) { + var nodeIndex = group[j]; + var groupIndex = nodeCount + i; + if(groupLookup.hasOwnProperty(nodeIndex)) { + Lib.warn('Node ' + nodeIndex + ' is already part of a group.'); + } else { + groupLookup[nodeIndex] = groupIndex; + } + } + } + + // 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 @@ -44,6 +73,21 @@ 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(groupLookup.hasOwnProperty(source)) { + source = groupLookup[source]; + } + source = +source; target = +target; linkedNodes[source] = linkedNodes[target] = true; @@ -63,42 +107,46 @@ function convertToD3Sankey(trace) { target: target, value: +val }); + + groupedLinks.source.push(source); + groupedLinks.target.push(target); } + // 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]; + + nodes.push({ + group: (i > nodeCount - 1), + childrenNodes: [], + pointNumber: i, + label: l, + color: hasNodeColorArray ? nodeSpec.color[i] : nodeSpec.color + }); } - // 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]; - } + // 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 + nodes: nodes, + + // Data structure for groups + groups: groups, + groupLookup: groupLookup }; } -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++) { @@ -120,16 +168,15 @@ 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 + _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..534b05ad4e7 100644 --- a/src/traces/sankey/constants.js +++ b/src/traces/sankey/constants.js @@ -16,7 +16,7 @@ module.exports = { forceIterations: 5, forceTicksPerFrame: 10, duration: 500, - ease: 'cubic-in-out', + ease: 'linear', 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..7c2ca3489ce 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,36 @@ function sankeyModel(layout, d, traceIndex) { Lib.warn('node.pad was reduced to ', sankey.nodePadding(), ' to fit within the figure.'); } + // Create transient nodes for animations + 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) { + groupingNode = graph.nodes[i]; + break; + } + } + // If groupinNode is undefined, no links are targeting this group + if(!groupingNode) continue; + + var child = { + pointNumber: parseInt(nodePointNumber), + x0: groupingNode.x0, + x1: groupingNode.x1, + y0: groupingNode.y0, + y1: groupingNode.y1, + partOfGroup: true, + sourceLinks: [], + targetLinks: [] + }; + + graph.nodes.unshift(child); + groupingNode.childrenNodes.unshift(child); + } + function computeLinkConcentrations() { var i, j, k; for(i = 0; i < graph.nodes.length; i++) { @@ -137,7 +167,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, @@ -184,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, @@ -343,7 +374,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 +383,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 = Lib.randstr(); + } // for event data n.trace = d.trace; @@ -362,6 +396,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, @@ -445,19 +481,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; } @@ -467,7 +503,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); } }); @@ -530,6 +566,10 @@ function attachDragHandler(sankeyNode, sankeyLink, callbacks) { .on('dragend', function(d) { d.interactionState.dragInProgress = false; + 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; + } }); sankeyNode @@ -540,7 +580,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() @@ -639,6 +682,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;}) @@ -683,7 +731,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 +748,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', function() { return (gd._context.staticPlot || firstRender) ? 1 : 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 +784,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', function(n) { return ((gd._context.staticPlot || firstRender) && !n.partOfGroup) ? 1 : 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 00000000000..ee05b7d512e Binary files /dev/null and b/test/image/baselines/sankey_groups.png differ diff --git a/test/image/mocks/sankey_groups.json b/test/image/mocks/sankey_groups.json new file mode 100644 index 00000000000..90afbde12bb --- /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", "orange" ], + "label": ["process0", "process1", "process2", "process3", "process4", "Group A", "Group B"], + "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..69d985a5041 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,21 +277,67 @@ 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); + }); + + 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() { + 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); @@ -320,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) @@ -343,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: { @@ -365,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); @@ -381,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) @@ -404,6 +446,67 @@ describe('sankey tests', function() { done(); }); }); + + it('can create groups, restyle groups and properly update DOM', function(done) { + var mockCircularCopy = Lib.extendDeep({}, mockCircular); + 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(firstGroup); + return Plotly.restyle(gd, {'node.groups': [newGroup]}); + }) + .then(function() { + 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); + }); + + 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 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 that do not create a 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() {