Skip to content

Commit db54ced

Browse files
authoredFeb 26, 2019
Merge pull request #3556 from plotly/sankey2-grouping
Sankey: group nodes
2 parents 3e15f17 + fb9479a commit db54ced

File tree

8 files changed

+424
-63
lines changed

8 files changed

+424
-63
lines changed
 

‎src/traces/sankey/attributes.js

+13
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,19 @@ var attrs = module.exports = overrideAll({
8989
role: 'info',
9090
description: 'The shown name of the node.'
9191
},
92+
groups: {
93+
valType: 'info_array',
94+
dimensions: 2,
95+
freeLength: true,
96+
dflt: [],
97+
items: {valType: 'number', editType: 'calc'},
98+
role: 'info',
99+
description: [
100+
'Groups of nodes.',
101+
'Each group is defined by an array with the indices of the nodes it contains.',
102+
'Multiple groups can be specified.'
103+
].join(' ')
104+
},
92105
color: {
93106
valType: 'color',
94107
role: 'style',

‎src/traces/sankey/calc.js

+78-31
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,36 @@ function convertToD3Sankey(trace) {
3434
components[cscale.label] = scale;
3535
}
3636

37-
var nodeCount = nodeSpec.label.length;
37+
var maxNodeId = 0;
38+
for(i = 0; i < linkSpec.value.length; i++) {
39+
if(linkSpec.source[i] > maxNodeId) maxNodeId = linkSpec.source[i];
40+
if(linkSpec.target[i] > maxNodeId) maxNodeId = linkSpec.target[i];
41+
}
42+
var nodeCount = maxNodeId + 1;
43+
44+
// Group nodes
45+
var j;
46+
var groups = trace.node.groups;
47+
var groupLookup = {};
48+
for(i = 0; i < groups.length; i++) {
49+
var group = groups[i];
50+
// Build a lookup table to quickly find in which group a node is
51+
for(j = 0; j < group.length; j++) {
52+
var nodeIndex = group[j];
53+
var groupIndex = nodeCount + i;
54+
if(groupLookup.hasOwnProperty(nodeIndex)) {
55+
Lib.warn('Node ' + nodeIndex + ' is already part of a group.');
56+
} else {
57+
groupLookup[nodeIndex] = groupIndex;
58+
}
59+
}
60+
}
61+
62+
// Process links
63+
var groupedLinks = {
64+
source: [],
65+
target: []
66+
};
3867
for(i = 0; i < linkSpec.value.length; i++) {
3968
var val = linkSpec.value[i];
4069
// remove negative values, but keep zeros with special treatment
@@ -44,6 +73,21 @@ function convertToD3Sankey(trace) {
4473
continue;
4574
}
4675

76+
// Remove links that are within the same group
77+
if(groupLookup.hasOwnProperty(source) && groupLookup.hasOwnProperty(target) && groupLookup[source] === groupLookup[target]) {
78+
continue;
79+
}
80+
81+
// if link targets a node in the group, relink target to that group
82+
if(groupLookup.hasOwnProperty(target)) {
83+
target = groupLookup[target];
84+
}
85+
86+
// if link originates from a node in a group, relink source to that group
87+
if(groupLookup.hasOwnProperty(source)) {
88+
source = groupLookup[source];
89+
}
90+
4791
source = +source;
4892
target = +target;
4993
linkedNodes[source] = linkedNodes[target] = true;
@@ -63,42 +107,46 @@ function convertToD3Sankey(trace) {
63107
target: target,
64108
value: +val
65109
});
110+
111+
groupedLinks.source.push(source);
112+
groupedLinks.target.push(target);
66113
}
67114

115+
// Process nodes
116+
var totalCount = nodeCount + groups.length;
68117
var hasNodeColorArray = isArrayOrTypedArray(nodeSpec.color);
69118
var nodes = [];
70-
var removedNodes = false;
71-
var nodeIndices = {};
72-
73-
for(i = 0; i < nodeCount; i++) {
74-
if(linkedNodes[i]) {
75-
var l = nodeSpec.label[i];
76-
nodeIndices[i] = nodes.length;
77-
nodes.push({
78-
pointNumber: i,
79-
label: l,
80-
color: hasNodeColorArray ? nodeSpec.color[i] : nodeSpec.color
81-
});
82-
} else removedNodes = true;
119+
for(i = 0; i < totalCount; i++) {
120+
if(!linkedNodes[i]) continue;
121+
var l = nodeSpec.label[i];
122+
123+
nodes.push({
124+
group: (i > nodeCount - 1),
125+
childrenNodes: [],
126+
pointNumber: i,
127+
label: l,
128+
color: hasNodeColorArray ? nodeSpec.color[i] : nodeSpec.color
129+
});
83130
}
84131

85-
// need to re-index links now, since we didn't put all the nodes in
86-
if(removedNodes) {
87-
for(i = 0; i < links.length; i++) {
88-
links[i].source = nodeIndices[links[i].source];
89-
links[i].target = nodeIndices[links[i].target];
90-
}
132+
// Check if we have circularity on the resulting graph
133+
var circular = false;
134+
if(circularityPresent(totalCount, groupedLinks.source, groupedLinks.target)) {
135+
circular = true;
91136
}
92137

93138
return {
139+
circular: circular,
94140
links: links,
95-
nodes: nodes
141+
nodes: nodes,
142+
143+
// Data structure for groups
144+
groups: groups,
145+
groupLookup: groupLookup
96146
};
97147
}
98148

99-
function circularityPresent(nodeList, sources, targets) {
100-
101-
var nodeLen = nodeList.length;
149+
function circularityPresent(nodeLen, sources, targets) {
102150
var nodes = Lib.init2dArray(nodeLen, 0);
103151

104152
for(var i = 0; i < Math.min(sources.length, targets.length); i++) {
@@ -120,16 +168,15 @@ function circularityPresent(nodeList, sources, targets) {
120168
}
121169

122170
module.exports = function calc(gd, trace) {
123-
var circular = false;
124-
if(circularityPresent(trace.node.label, trace.link.source, trace.link.target)) {
125-
circular = true;
126-
}
127-
128171
var result = convertToD3Sankey(trace);
129172

130173
return wrap({
131-
circular: circular,
174+
circular: result.circular,
132175
_nodes: result.nodes,
133-
_links: result.links
176+
_links: result.links,
177+
178+
// Data structure for grouping
179+
_groups: result.groups,
180+
_groupLookup: result.groupLookup,
134181
});
135182
};

‎src/traces/sankey/constants.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ module.exports = {
1616
forceIterations: 5,
1717
forceTicksPerFrame: 10,
1818
duration: 500,
19-
ease: 'cubic-in-out',
19+
ease: 'linear',
2020
cn: {
2121
sankey: 'sankey',
2222
sankeyLinks: 'sankey-links',

‎src/traces/sankey/defaults.js

+1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout
3232
return Lib.coerce(nodeIn, nodeOut, attributes.node, attr, dflt);
3333
}
3434
coerceNode('label');
35+
coerceNode('groups');
3536
coerceNode('pad');
3637
coerceNode('thickness');
3738
coerceNode('line.color');

‎src/traces/sankey/render.js

+77-24
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,7 @@ function sankeyModel(layout, d, traceIndex) {
4545
if(circular) {
4646
sankey = d3SankeyCircular
4747
.sankeyCircular()
48-
.circularLinkGap(0)
49-
.nodeId(function(d) {
50-
return d.pointNumber;
51-
});
48+
.circularLinkGap(0);
5249
} else {
5350
sankey = d3Sankey.sankey();
5451
}
@@ -58,6 +55,9 @@ function sankeyModel(layout, d, traceIndex) {
5855
.size(horizontal ? [width, height] : [height, width])
5956
.nodeWidth(nodeThickness)
6057
.nodePadding(nodePad)
58+
.nodeId(function(d) {
59+
return d.pointNumber;
60+
})
6161
.nodes(nodes)
6262
.links(links);
6363

@@ -67,6 +67,36 @@ function sankeyModel(layout, d, traceIndex) {
6767
Lib.warn('node.pad was reduced to ', sankey.nodePadding(), ' to fit within the figure.');
6868
}
6969

70+
// Create transient nodes for animations
71+
for(var nodePointNumber in calcData._groupLookup) {
72+
var groupIndex = parseInt(calcData._groupLookup[nodePointNumber]);
73+
74+
// Find node representing groupIndex
75+
var groupingNode;
76+
for(var i = 0; i < graph.nodes.length; i++) {
77+
if(graph.nodes[i].pointNumber === groupIndex) {
78+
groupingNode = graph.nodes[i];
79+
break;
80+
}
81+
}
82+
// If groupinNode is undefined, no links are targeting this group
83+
if(!groupingNode) continue;
84+
85+
var child = {
86+
pointNumber: parseInt(nodePointNumber),
87+
x0: groupingNode.x0,
88+
x1: groupingNode.x1,
89+
y0: groupingNode.y0,
90+
y1: groupingNode.y1,
91+
partOfGroup: true,
92+
sourceLinks: [],
93+
targetLinks: []
94+
};
95+
96+
graph.nodes.unshift(child);
97+
groupingNode.childrenNodes.unshift(child);
98+
}
99+
70100
function computeLinkConcentrations() {
71101
var i, j, k;
72102
for(i = 0; i < graph.nodes.length; i++) {
@@ -137,7 +167,7 @@ function sankeyModel(layout, d, traceIndex) {
137167
circular: circular,
138168
key: traceIndex,
139169
trace: trace,
140-
guid: Math.floor(1e12 * (1 + Math.random())),
170+
guid: Lib.randstr(),
141171
horizontal: horizontal,
142172
width: width,
143173
height: height,
@@ -184,6 +214,7 @@ function linkModel(d, l, i) {
184214
link: l,
185215
tinyColorHue: Color.tinyRGB(tc),
186216
tinyColorAlpha: tc.getAlpha(),
217+
linkPath: linkPath,
187218
linkLineColor: d.linkLineColor,
188219
linkLineWidth: d.linkLineWidth,
189220
valueFormat: d.valueFormat,
@@ -343,7 +374,7 @@ function linkPath() {
343374
return path;
344375
}
345376

346-
function nodeModel(d, n, i) {
377+
function nodeModel(d, n) {
347378
var tc = tinycolor(n.color);
348379
var zoneThicknessPad = c.nodePadAcross;
349380
var zoneLengthPad = d.nodePad / 2;
@@ -352,8 +383,11 @@ function nodeModel(d, n, i) {
352383
var visibleThickness = n.dx;
353384
var visibleLength = Math.max(0.5, n.dy);
354385

355-
var basicKey = n.label;
356-
var key = basicKey + '__' + i;
386+
var key = 'node_' + n.pointNumber;
387+
// If it's a group, it's mutable and should be unique
388+
if(n.group) {
389+
key = Lib.randstr();
390+
}
357391

358392
// for event data
359393
n.trace = d.trace;
@@ -362,6 +396,8 @@ function nodeModel(d, n, i) {
362396
return {
363397
index: n.pointNumber,
364398
key: key,
399+
partOfGroup: n.partOfGroup || false,
400+
group: n.group,
365401
traceId: d.key,
366402
node: n,
367403
nodePad: d.nodePad,
@@ -445,19 +481,19 @@ function attachPointerEvents(selection, sankey, eventSet) {
445481
selection
446482
.on('.basic', null) // remove any preexisting handlers
447483
.on('mouseover.basic', function(d) {
448-
if(!d.interactionState.dragInProgress) {
484+
if(!d.interactionState.dragInProgress && !d.partOfGroup) {
449485
eventSet.hover(this, d, sankey);
450486
d.interactionState.hovered = [this, d];
451487
}
452488
})
453489
.on('mousemove.basic', function(d) {
454-
if(!d.interactionState.dragInProgress) {
490+
if(!d.interactionState.dragInProgress && !d.partOfGroup) {
455491
eventSet.follow(this, d);
456492
d.interactionState.hovered = [this, d];
457493
}
458494
})
459495
.on('mouseout.basic', function(d) {
460-
if(!d.interactionState.dragInProgress) {
496+
if(!d.interactionState.dragInProgress && !d.partOfGroup) {
461497
eventSet.unhover(this, d, sankey);
462498
d.interactionState.hovered = false;
463499
}
@@ -467,7 +503,7 @@ function attachPointerEvents(selection, sankey, eventSet) {
467503
eventSet.unhover(this, d, sankey);
468504
d.interactionState.hovered = false;
469505
}
470-
if(!d.interactionState.dragInProgress) {
506+
if(!d.interactionState.dragInProgress && !d.partOfGroup) {
471507
eventSet.select(this, d, sankey);
472508
}
473509
});
@@ -530,6 +566,10 @@ function attachDragHandler(sankeyNode, sankeyLink, callbacks) {
530566

531567
.on('dragend', function(d) {
532568
d.interactionState.dragInProgress = false;
569+
for(var i = 0; i < d.node.childrenNodes.length; i++) {
570+
d.node.childrenNodes[i].x = d.node.x;
571+
d.node.childrenNodes[i].y = d.node.y;
572+
}
533573
});
534574

535575
sankeyNode
@@ -540,7 +580,10 @@ function attachDragHandler(sankeyNode, sankeyLink, callbacks) {
540580
function attachForce(sankeyNode, forceKey, d) {
541581
// Attach force to nodes in the same column (same x coordinate)
542582
switchToForceFormat(d.graph.nodes);
543-
var nodes = d.graph.nodes.filter(function(n) {return n.originalX === d.node.originalX;});
583+
var nodes = d.graph.nodes
584+
.filter(function(n) {return n.originalX === d.node.originalX;})
585+
// Filter out children
586+
.filter(function(n) {return !n.partOfGroup;});
544587
d.forceLayouts[forceKey] = d3Force.forceSimulation(nodes)
545588
.alphaDecay(0)
546589
.force('collide', d3Force.forceCollide()
@@ -639,6 +682,11 @@ function switchToSankeyFormat(nodes) {
639682

640683
// scene graph
641684
module.exports = function(gd, svg, calcData, layout, callbacks) {
685+
// To prevent animation on first render
686+
var firstRender = false;
687+
Lib.ensureSingle(gd._fullLayout._infolayer, 'g', 'first-render', function() {
688+
firstRender = true;
689+
});
642690

643691
var styledData = calcData
644692
.filter(function(d) {return unwrap(d).trace.visible;})
@@ -683,7 +731,6 @@ module.exports = function(gd, svg, calcData, layout, callbacks) {
683731
sankeyLink
684732
.enter().append('path')
685733
.classed(c.cn.sankeyLink, true)
686-
.attr('d', linkPath())
687734
.call(attachPointerEvents, sankey, callbacks.linkEvents);
688735

689736
sankeyLink
@@ -701,13 +748,17 @@ module.exports = function(gd, svg, calcData, layout, callbacks) {
701748
})
702749
.style('stroke-width', function(d) {
703750
return salientEnough(d) ? d.linkLineWidth : 1;
704-
});
751+
})
752+
.attr('d', linkPath());
705753

706-
sankeyLink.transition()
707-
.ease(c.ease).duration(c.duration)
708-
.attr('d', linkPath());
754+
sankeyLink
755+
.style('opacity', function() { return (gd._context.staticPlot || firstRender) ? 1 : 0;})
756+
.transition()
757+
.ease(c.ease).duration(c.duration)
758+
.style('opacity', 1);
709759

710-
sankeyLink.exit().transition()
760+
sankeyLink.exit()
761+
.transition()
711762
.ease(c.ease).duration(c.duration)
712763
.style('opacity', 0)
713764
.remove();
@@ -733,24 +784,26 @@ module.exports = function(gd, svg, calcData, layout, callbacks) {
733784
var nodes = d.graph.nodes;
734785
persistOriginalPlace(nodes);
735786
return nodes
736-
.filter(function(n) {return n.value;})
737-
.map(nodeModel.bind(null, d));
787+
.map(nodeModel.bind(null, d));
738788
}, keyFun);
739789

740790
sankeyNode.enter()
741791
.append('g')
742792
.classed(c.cn.sankeyNode, true)
743793
.call(updateNodePositions)
744-
.call(attachPointerEvents, sankey, callbacks.nodeEvents);
794+
.style('opacity', function(n) { return ((gd._context.staticPlot || firstRender) && !n.partOfGroup) ? 1 : 0;});
745795

746796
sankeyNode
797+
.call(attachPointerEvents, sankey, callbacks.nodeEvents)
747798
.call(attachDragHandler, sankeyLink, callbacks); // has to be here as it binds sankeyLink
748799

749800
sankeyNode.transition()
750801
.ease(c.ease).duration(c.duration)
751-
.call(updateNodePositions);
802+
.call(updateNodePositions)
803+
.style('opacity', function(n) { return n.partOfGroup ? 0 : 1;});
752804

753-
sankeyNode.exit().transition()
805+
sankeyNode.exit()
806+
.transition()
754807
.ease(c.ease).duration(c.duration)
755808
.style('opacity', 0)
756809
.remove();
44.9 KB
Loading

‎test/image/mocks/sankey_groups.json

+144
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
{
2+
"data": [
3+
{
4+
"type": "sankey",
5+
"node": {
6+
"pad": 25,
7+
"line": {
8+
"color": "white",
9+
"width": 2
10+
},
11+
"color": ["black", "black", "black", "black", "black", "orange", "orange" ],
12+
"label": ["process0", "process1", "process2", "process3", "process4", "Group A", "Group B"],
13+
"groups": [[2, 3, 4]]
14+
},
15+
"link": {
16+
"source": [
17+
0, 0, 0, 0,
18+
1, 1, 1, 1,
19+
1, 1, 1, 1,
20+
1, 1,
21+
2
22+
],
23+
"target": [
24+
1, 1, 1, 1,
25+
2, 2, 2, 2,
26+
3, 3, 3, 3,
27+
4, 4,
28+
0
29+
],
30+
"value": [
31+
10, 20, 40, 30,
32+
10, 5, 10, 20,
33+
0, 10, 10, 10,
34+
15, 5,
35+
20
36+
37+
],
38+
"label": [
39+
"elementA", "elementB", "elementC", "elementD",
40+
"elementA", "elementB", "elementC", "elementD",
41+
"elementA", "elementB", "elementC", "elementD",
42+
"elementC", "elementC",
43+
"elementA"
44+
],
45+
"line": {
46+
"color": "white",
47+
"width": 2
48+
},
49+
"colorscales": [
50+
{
51+
"label": "elementA",
52+
"colorscale": [[0, "white"], [1, "blue"]]
53+
},
54+
{
55+
"label": "elementB",
56+
"colorscale": [[0, "white"], [1, "red"]]
57+
},
58+
{
59+
"label": "elementC",
60+
"colorscale": [[0, "white"], [1, "green"]]
61+
},
62+
{
63+
"label": "elementD"
64+
}
65+
],
66+
67+
"hovertemplate": "%{label}<br><b>flow.labelConcentration</b>: %{flow.labelConcentration:%0.2f}<br><b>flow.concentration</b>: %{flow.concentration:%0.2f}<br><b>flow.value</b>: %{flow.value}"
68+
}
69+
70+
}],
71+
"layout": {
72+
"title": "Sankey diagram with links colored based on their concentration within a flow",
73+
"width": 800,
74+
"height": 800,
75+
"updatemenus": [{
76+
"y": 1,
77+
"x": 0,
78+
"active": 1,
79+
"buttons": [{
80+
"label": "Ungroup [[]]",
81+
"method": "restyle",
82+
"args": ["node.groups", [
83+
[]
84+
]]
85+
},
86+
{
87+
"label": "Group [[2, 3, 4]]",
88+
"method": "restyle",
89+
"args": ["node.groups", [
90+
[
91+
[2, 3, 4]
92+
]
93+
]]
94+
},
95+
{
96+
"label": "Group [[3, 4]]",
97+
"method": "restyle",
98+
"args": ["node.groups", [
99+
[
100+
[3, 4]
101+
]
102+
]]
103+
},
104+
{
105+
"label": "Group [[1, 2]]",
106+
"method": "restyle",
107+
"args": ["node.groups", [
108+
[
109+
[1, 2]
110+
]
111+
]]
112+
},
113+
{
114+
"label": "Group [[2, 4]]]",
115+
"method": "restyle",
116+
"args": ["node.groups", [
117+
[
118+
[2, 4]
119+
]
120+
]]
121+
},
122+
{
123+
"label": "Group [[0, 2]]]",
124+
"method": "restyle",
125+
"args": ["node.groups", [
126+
[
127+
[0, 2]
128+
]
129+
]]
130+
},
131+
{
132+
"label": "Group [[1, 2], [3, 4]]",
133+
"method": "restyle",
134+
"args": ["node.groups", [
135+
[
136+
[1, 2],
137+
[3, 4]
138+
]
139+
]]
140+
}
141+
]
142+
}]
143+
}
144+
}

‎test/jasmine/tests/sankey_test.js

+110-7
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ describe('sankey tests', function() {
264264
label: ['a', 'b', 'c', 'd', 'e']
265265
},
266266
link: {
267-
value: [1, 1, 1, 1, 1, 1, 1, 1],
267+
value: [1, 1, 1, 1],
268268
source: [0, 1, 2, 3],
269269
target: [1, 2, 0, 4]
270270
}
@@ -278,21 +278,67 @@ describe('sankey tests', function() {
278278
label: ['a', 'b', 'c', 'd', 'e']
279279
},
280280
link: {
281-
value: [1, 1, 1, 1, 1, 1, 1, 1],
281+
value: [1, 1, 1, 1],
282282
source: [0, 1, 2, 3],
283283
target: [1, 2, 4, 4]
284284
}
285285
}));
286286
expect(calcData[0].circular).toBe(false);
287287
});
288+
289+
it('keep an index of groups', function() {
290+
var calcData = _calc(Lib.extendDeep({}, base, {
291+
node: {
292+
label: ['a', 'b', 'c', 'd', 'e'],
293+
groups: [[0, 1], [2, 3]]
294+
},
295+
link: {
296+
value: [1, 1, 1, 1],
297+
source: [0, 1, 2, 3],
298+
target: [1, 2, 4, 4]
299+
}
300+
}));
301+
var groups = calcData[0]._nodes.filter(function(node) {
302+
return node.group;
303+
});
304+
expect(groups.length).toBe(2);
305+
expect(calcData[0].circular).toBe(false);
306+
});
307+
308+
it('emits a warning if a node is part of more than one group', function() {
309+
var warnings = [];
310+
spyOn(Lib, 'warn').and.callFake(function(msg) {
311+
warnings.push(msg);
312+
});
313+
314+
var calcData = _calc(Lib.extendDeep({}, base, {
315+
node: {
316+
label: ['a', 'b', 'c', 'd', 'e'],
317+
groups: [[0, 1], [1, 2, 3]]
318+
},
319+
link: {
320+
value: [1, 1, 1, 1],
321+
source: [0, 1, 2, 3],
322+
target: [1, 2, 4, 4]
323+
}
324+
}));
325+
326+
expect(warnings.length).toBe(1);
327+
328+
// Expect node '1' to be in the first group
329+
expect(calcData[0]._groupLookup[1]).toBe(5);
330+
});
288331
});
289332

290333
describe('lifecycle methods', function() {
334+
var gd;
335+
beforeEach(function() {
336+
gd = createGraphDiv();
337+
});
291338
afterEach(destroyGraphDiv);
292339

293340
it('Plotly.deleteTraces with two traces removes the deleted plot', function(done) {
294341

295-
var gd = createGraphDiv();
296342
var mockCopy = Lib.extendDeep({}, mock);
297343
var mockCopy2 = Lib.extendDeep({}, mockDark);
298344

@@ -321,7 +367,6 @@ describe('sankey tests', function() {
321367

322368
it('Plotly.plot does not show Sankey if \'visible\' is false', function(done) {
323369

324-
var gd = createGraphDiv();
325370
var mockCopy = Lib.extendDeep({}, mock);
326371

327372
Plotly.plot(gd, mockCopy)
@@ -344,7 +389,6 @@ describe('sankey tests', function() {
344389

345390
it('\'node\' remains visible even if \'value\' is very low', function(done) {
346391

347-
var gd = createGraphDiv();
348392
var minimock = [{
349393
type: 'sankey',
350394
node: {
@@ -366,7 +410,6 @@ describe('sankey tests', function() {
366410
});
367411

368412
it('switch from normal to circular Sankey on react', function(done) {
369-
var gd = createGraphDiv();
370413
var mockCopy = Lib.extendDeep({}, mock);
371414
var mockCircularCopy = Lib.extendDeep({}, mockCircular);
372415

@@ -382,7 +425,6 @@ describe('sankey tests', function() {
382425
});
383426

384427
it('switch from circular to normal Sankey on react', function(done) {
385-
var gd = createGraphDiv();
386428
var mockCircularCopy = Lib.extendDeep({}, mockCircular);
387429

388430
Plotly.plot(gd, mockCircularCopy)
@@ -405,6 +447,67 @@ describe('sankey tests', function() {
405447
done();
406448
});
407449
});
450+
451+
it('can create groups, restyle groups and properly update DOM', function(done) {
452+
var mockCircularCopy = Lib.extendDeep({}, mockCircular);
453+
var firstGroup = [[2, 3], [0, 1]];
454+
var newGroup = [[2, 3]];
455+
mockCircularCopy.data[0].node.groups = firstGroup;
456+
457+
Plotly.plot(gd, mockCircularCopy)
458+
.then(function() {
459+
expect(gd._fullData[0].node.groups).toEqual(firstGroup);
460+
return Plotly.restyle(gd, {'node.groups': [newGroup]});
461+
})
462+
.then(function() {
463+
expect(gd._fullData[0].node.groups).toEqual(newGroup);
464+
465+
// Check that all links have updated their links
466+
d3.selectAll('.sankey .sankey-link').each(function(d, i) {
467+
var path = this.getAttribute('d');
468+
expect(path).toBe(d.linkPath()(d), 'link ' + i + ' has wrong `d` attribute');
469+
});
470+
471+
// Check that ghost nodes used for animations:
472+
// 1) are drawn first so they apear behind
473+
var seeRealNode = false;
474+
var sankeyNodes = d3.selectAll('.sankey .sankey-node');
475+
sankeyNodes.each(function(d, i) {
476+
if(d.partOfGroup) {
477+
if(seeRealNode) fail('node ' + i + ' is a ghost node and should be behind');
478+
} else {
479+
seeRealNode = true;
480+
}
481+
});
482+
// 2) have an element for each grouped node
483+
var L = sankeyNodes.filter(function(d) { return d.partOfGroup;}).size();
484+
expect(L).toBe(newGroup.flat().length, 'does not have the right number of ghost nodes');
485+
})
486+
.catch(failTest)
487+
.then(done);
488+
});
489+
490+
it('switches from normal to circular Sankey on grouping', function(done) {
491+
var mockCopy = Lib.extendDeep({}, mock);
492+
493+
Plotly.plot(gd, mockCopy)
494+
.then(function() {
495+
expect(gd.calcdata[0][0].circular).toBe(false);
496+
497+
// Group two nodes that creates a circularity
498+
return Plotly.restyle(gd, 'node.groups', [[[1, 3]]]);
499+
})
500+
.then(function() {
501+
expect(gd.calcdata[0][0].circular).toBe(true);
502+
// Group two nodes that do not create a circularity
503+
return Plotly.restyle(gd, 'node.groups', [[[1, 4]]]);
504+
})
505+
.then(function() {
506+
expect(gd.calcdata[0][0].circular).toBe(false);
507+
done();
508+
});
509+
});
510+
408511
});
409512

410513
describe('Test hover/click interactions:', function() {

0 commit comments

Comments
 (0)
Please sign in to comment.