Skip to content

Commit 370250e

Browse files
committed
sankey: in snap, separate overlapping nodes even if node.(x|y) is set
1 parent 1a89a1e commit 370250e

File tree

5 files changed

+146
-11
lines changed

5 files changed

+146
-11
lines changed

src/traces/sankey/render.js

+71-2
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,70 @@ function sankeyModel(layout, d, traceIndex) {
168168
}
169169
computeLinkConcentrations();
170170

171+
// Push any overlapping nodes down.
172+
function resolveCollisionsTopToBottom(columns) {
173+
columns.forEach(function(nodes) {
174+
var node;
175+
var dy;
176+
var y = 0;
177+
var n = nodes.length;
178+
var i;
179+
nodes.sort(function(a, b) {
180+
return a.y0 - b.y0;
181+
});
182+
for(i = 0; i < n; ++i) {
183+
node = nodes[i];
184+
if(node.y0 >= y) {
185+
// No overlap
186+
} else {
187+
dy = (y - node.y0);
188+
if(dy > 1e-6) node.y0 += dy, node.y1 += dy;
189+
}
190+
y = node.y1 + nodePad;
191+
}
192+
});
193+
}
194+
195+
// Group nodes into columns based on their x position
196+
function snapToColumns(nodes) {
197+
// Sort nodes by x position
198+
var orderedNodes = nodes.map(function(n, i) {
199+
return {
200+
x0: n.x0,
201+
index: i
202+
};
203+
})
204+
.sort(function(a, b) {
205+
return a.x0 - b.x0;
206+
});
207+
208+
var columns = [];
209+
var colNumber = -1;
210+
var colX; // Position of column
211+
var lastX = -nodeThickness; // Position of last node
212+
var dx;
213+
for(i = 0; i < orderedNodes.length; i++) {
214+
var node = nodes[orderedNodes[i].index];
215+
// If the node does not overlap with the last one
216+
if(node.x0 > lastX + nodeThickness) {
217+
// Start a new column
218+
colNumber += 1;
219+
colX = node.x0;
220+
}
221+
lastX = node.x0;
222+
223+
// Add node to its associated column
224+
if(!columns[colNumber]) columns[colNumber] = [];
225+
columns[colNumber].push(node);
226+
227+
// Change node's x position to align it with its column
228+
dx = colX - node.x0;
229+
node.x0 += dx, node.x1 += dx;
230+
231+
}
232+
return columns;
233+
}
234+
171235
// Force node position
172236
if(trace.node.x.length !== 0 && trace.node.y.length !== 0) {
173237
for(i = 0; i < Math.min(trace.node.x.length, trace.node.y.length, graph.nodes.length); i++) {
@@ -181,6 +245,11 @@ function sankeyModel(layout, d, traceIndex) {
181245
graph.nodes[i].y1 = pos[1] + nodeHeight / 2;
182246
}
183247
}
248+
if(trace.arrangement === 'snap') {
249+
nodes = graph.nodes;
250+
var columns = snapToColumns(nodes);
251+
resolveCollisionsTopToBottom(columns);
252+
}
184253
// Update links
185254
sankey.update(graph);
186255
}
@@ -720,8 +789,8 @@ function sameLayer(d) {
720789
function switchToForceFormat(nodes) {
721790
// force uses x, y as centers
722791
for(var i = 0; i < nodes.length; i++) {
723-
nodes[i].y = nodes[i].y0 + nodes[i].dy / 2;
724-
nodes[i].x = nodes[i].x0 + nodes[i].dx / 2;
792+
nodes[i].y = (nodes[i].y0 + nodes[i].y1) / 2;
793+
nodes[i].x = (nodes[i].x0 + nodes[i].x1) / 2;
725794
}
726795
}
727796

test/image/baselines/sankey_x_y.png

2.71 KB
Loading

test/image/mocks/sankey_x_y.json

+2-3
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,9 @@
44
"type": "sankey",
55
"arrangement": "freeform",
66
"node": {
7-
"pad": 5,
87
"label": ["0", "1", "2", "3", "4", "5"],
9-
"x": [0.128, 0.128, 0.559, 0.785, 0.352, 0.593],
10-
"y": [0.738, 0.165, 0.205, 0.390, 0.165, 0.733]
8+
"x": [0.128, 0.128, 0.559, 0.785, 0.352, 0.559],
9+
"y": [0.738, 0.165, 0.205, 0.390, 0.165, 0.256]
1110
},
1211
"link": {
1312
"source": [

test/jasmine/assets/check_overlap.js

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
'use strict';
2+
3+
function compare(baseRects, compareRects) {
4+
return baseRects.left < compareRects.right &&
5+
baseRects.right > compareRects.left &&
6+
baseRects.top < compareRects.bottom &&
7+
baseRects.bottom > compareRects.top;
8+
}
9+
10+
module.exports = function checkOverlap(base, elements) {
11+
var baseRects = base.getBoundingClientRect();
12+
13+
// handle array as second argument
14+
if(Array.isArray(elements)) {
15+
return elements.map(function(el) {
16+
if(!el) return false;
17+
18+
var compareRects = el.getBoundingClientRect();
19+
return compare(baseRects, compareRects);
20+
});
21+
}
22+
23+
// handle HTMLCollection or NodeList as second argument
24+
if(elements instanceof NodeList || elements instanceof HTMLCollection) {
25+
var collection = Array.prototype.slice.call(elements);
26+
27+
return collection.map(function(el) {
28+
// check for holly or null values
29+
if(!el) return false;
30+
31+
var compareRects = el.getBoundingClientRect();
32+
return compare(baseRects, compareRects);
33+
});
34+
}
35+
36+
// assume element is node
37+
return compare(baseRects, elements.getBoundingClientRect());
38+
};

test/jasmine/tests/sankey_test.js

+35-6
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ var mock = require('@mocks/sankey_energy.json');
88
var mockDark = require('@mocks/sankey_energy_dark.json');
99
var mockCircular = require('@mocks/sankey_circular.json');
1010
var mockCircularLarge = require('@mocks/sankey_circular_large.json');
11+
var mockXY = require('@mocks/sankey_x_y.json');
1112
var Sankey = require('@src/traces/sankey');
1213

1314
var createGraphDiv = require('../assets/create_graph_div');
@@ -20,6 +21,7 @@ var supplyAllDefaults = require('../assets/supply_defaults');
2021
var defaultColors = require('@src/components/color/attributes').defaults;
2122

2223
var drag = require('../assets/drag');
24+
var checkOverlap = require('../assets/check_overlap');
2325

2426
describe('sankey tests', function() {
2527

@@ -510,6 +512,32 @@ describe('sankey tests', function() {
510512
});
511513
});
512514

515+
it('prevents nodes from overlapping in snap arrangement', function(done) {
516+
function checkElementOverlap(i, j) {
517+
var base = document.querySelector('.sankey-node:nth-of-type(' + i + ')');
518+
base = base.querySelector('.node-rect');
519+
var compare = document.querySelector('.sankey-node:nth-of-type(' + j + ')');
520+
compare = compare.querySelector('.node-rect');
521+
return checkOverlap(base, compare);
522+
}
523+
524+
var mockCopy = Lib.extendDeep({}, mockXY);
525+
526+
Plotly.plot(gd, mockCopy)
527+
.then(function() {
528+
// Nodes overlap
529+
expect(checkElementOverlap(3, 6)).toBeTruthy('nodes do not overlap');
530+
531+
mockCopy.data[0].arrangement = 'snap';
532+
return Plotly.newPlot(gd, mockCopy);
533+
})
534+
.then(function() {
535+
// Nodes do not overlap in snap
536+
expect(checkElementOverlap(3, 6)).not.toBeTruthy('nodes overlap');
537+
})
538+
.catch(failTest)
539+
.then(done);
540+
});
513541
});
514542

515543
describe('Test hover/click interactions:', function() {
@@ -1039,7 +1067,7 @@ describe('sankey tests', function() {
10391067
nodes = document.getElementsByClassName('sankey-node');
10401068
node = nodes.item(nodeId);
10411069
position = getNodeCoords(node);
1042-
var timeDelay = (arrangement === 'snap') ? 1000 : 0; // Wait for force simulation to finish
1070+
var timeDelay = (arrangement === 'snap') ? 2000 : 0; // Wait for force simulation to finish
10431071
return drag(node, move[0], move[1], false, false, false, 10, false, timeDelay);
10441072
})
10451073
.then(function() {
@@ -1075,11 +1103,12 @@ describe('sankey tests', function() {
10751103

10761104
it('should persist the position of every nodes after drag in attributes nodes.(x|y)', function(done) {
10771105
mockCopy.data[0].arrangement = arrangement;
1078-
var move = [50, 50];
1106+
var move = [50, -50];
10791107
var nodes;
10801108
var node;
10811109
var x, x1;
10821110
var y, y1;
1111+
var precision = 3;
10831112

10841113
Plotly.newPlot(gd, mockCopy)
10851114
.then(function() {
@@ -1106,13 +1135,13 @@ describe('sankey tests', function() {
11061135
x1 = gd._fullData[0].node.x.slice();
11071136
y1 = gd._fullData[0].node.y.slice();
11081137
if(arrangement === 'freeform') expect(x1[nodeId]).not.toBeCloseTo(x[nodeId], 2, 'node ' + nodeId + ' has not changed x position');
1109-
expect(y1[nodeId]).not.toBeCloseTo(y[nodeId], 2, 'node ' + nodeId + ' has not changed y position');
1138+
expect(y1[nodeId]).not.toBeCloseTo(y[nodeId], precision, 'node ' + nodeId + ' has not changed y position');
11101139

11111140
// All nodes should have same x, y values after drag
11121141
for(var i = 0; i < x.length; i++) {
11131142
if(i === nodeId) continue; // except the one was just dragged
1114-
if(arrangement === 'freeform') expect(x[i]).toBeCloseTo(x[i], 3, 'node ' + i + ' has changed x position');
1115-
expect(y[i]).toBeCloseTo(y[i], 3, 'node ' + i + ' has changed y position');
1143+
if(arrangement === 'freeform') expect(x1[i]).toBeCloseTo(x[i], 3, 'node ' + i + ' has changed x position');
1144+
expect(y1[i]).toBeCloseTo(y[i], precision, 'node ' + i + ' has changed y position');
11161145
}
11171146
return true;
11181147
})
@@ -1121,8 +1150,8 @@ describe('sankey tests', function() {
11211150
});
11221151
});
11231152
});
1124-
11251153
});
1154+
11261155
it('emits a warning if node.pad is too large', function(done) {
11271156
var gd = createGraphDiv();
11281157
var mockCopy = Lib.extendDeep({}, mock);

0 commit comments

Comments
 (0)