Skip to content

Commit 9d61443

Browse files
authored
Merge pull request #2629 from plotly/sankey-overlap-fix
More robust Sankey
2 parents a132b85 + cdcd620 commit 9d61443

File tree

8 files changed

+165
-79
lines changed

8 files changed

+165
-79
lines changed

src/lib/index.js

+14-5
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,19 @@ lib.ensureNumber = function num(v) {
134134
return isNumeric(v) ? Number(v) : BADNUM;
135135
};
136136

137+
/**
138+
* Is v a valid array index? Accepts numeric strings as well as numbers.
139+
*
140+
* @param {any} v: the value to test
141+
* @param {Optional[integer]} len: the array length we are indexing
142+
*
143+
* @return {bool}: v is a valid array index
144+
*/
145+
lib.isIndex = function(v, len) {
146+
if(len !== undefined && v >= len) return false;
147+
return isNumeric(v) && (v >= 0) && (v % 1 === 0);
148+
};
149+
137150
lib.noop = require('./noop');
138151
lib.identity = require('./identity');
139152

@@ -494,18 +507,14 @@ lib.tagSelected = function(calcTrace, trace, ptNumber2cdIndex) {
494507
}
495508
}
496509

497-
function isPtIndexValid(v) {
498-
return isNumeric(v) && v >= 0 && v % 1 === 0;
499-
}
500-
501510
function isCdIndexValid(v) {
502511
return v !== undefined && v < calcTrace.length;
503512
}
504513

505514
for(var i = 0; i < selectedpoints.length; i++) {
506515
var ptIndex = selectedpoints[i];
507516

508-
if(isPtIndexValid(ptIndex)) {
517+
if(lib.isIndex(ptIndex)) {
509518
var ptNumber = ptIndex2ptNumber ? ptIndex2ptNumber[ptIndex] : ptIndex;
510519
var cdIndex = ptNumber2cdIndex ? ptNumber2cdIndex[ptNumber] : ptNumber;
511520

src/plot_api/plot_schema.js

+2
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,8 @@ function recurseIntoValObject(valObject, parts, i) {
437437
return valObject;
438438
}
439439

440+
// note: this is different from Lib.isIndex, this one doesn't accept numeric
441+
// strings, only actual numbers.
440442
function isIndex(val) {
441443
return val === Math.round(val) && val >= 0;
442444
}

src/traces/sankey/calc.js

+7-4
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,16 @@ var wrap = require('../../lib/gup').wrap;
1414

1515
function circularityPresent(nodeList, sources, targets) {
1616

17-
var nodes = nodeList.map(function() {return [];});
17+
var nodeLen = nodeList.length;
18+
var nodes = Lib.init2dArray(nodeLen, 0);
1819

1920
for(var i = 0; i < Math.min(sources.length, targets.length); i++) {
20-
if(sources[i] === targets[i]) {
21-
return true; // self-link which is also a scc of one
21+
if(Lib.isIndex(sources[i], nodeLen) && Lib.isIndex(targets[i], nodeLen)) {
22+
if(sources[i] === targets[i]) {
23+
return true; // self-link which is also a scc of one
24+
}
25+
nodes[sources[i]].push(targets[i]);
2226
}
23-
nodes[sources[i]].push(targets[i]);
2427
}
2528

2629
var scc = tarjan(nodes);

src/traces/sankey/defaults.js

-9
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,6 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout
5555

5656
Lib.coerceFont(coerce, 'textfont', Lib.extendFlat({}, layout.font));
5757

58-
var missing = function(n, i) {
59-
return traceOut.link.source.indexOf(i) === -1 &&
60-
traceOut.link.target.indexOf(i) === -1;
61-
};
62-
63-
if(traceOut.node.label.some(missing)) {
64-
Lib.warn('Some of the nodes are neither sources nor targets, they will not be displayed.');
65-
}
66-
6758
// disable 1D transforms - arrays here are 1D but their lengths/meanings
6859
// don't match, between nodes and links
6960
traceOut._length = null;

src/traces/sankey/render.js

+77-40
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@ var Drawing = require('../../components/drawing');
1616
var d3sankey = require('@plotly/d3-sankey').sankey;
1717
var d3Force = require('d3-force');
1818
var Lib = require('../../lib');
19-
var keyFun = require('../../lib/gup').keyFun;
20-
var repeat = require('../../lib/gup').repeat;
21-
var unwrap = require('../../lib/gup').unwrap;
19+
var isArrayOrTypedArray = Lib.isArrayOrTypedArray;
20+
var isIndex = Lib.isIndex;
21+
var gup = require('../../lib/gup');
22+
var keyFun = gup.keyFun;
23+
var repeat = gup.repeat;
24+
var unwrap = gup.unwrap;
2225

2326
// basic data utilities
2427

@@ -63,44 +66,79 @@ function switchToSankeyFormat(nodes) {
6366

6467
// view models
6568

66-
function sankeyModel(layout, d, i) {
67-
var trace = unwrap(d).trace,
68-
domain = trace.domain,
69-
nodeSpec = trace.node,
70-
linkSpec = trace.link,
71-
arrangement = trace.arrangement,
72-
horizontal = trace.orientation === 'h',
73-
nodePad = trace.node.pad,
74-
nodeThickness = trace.node.thickness,
75-
nodeLineColor = trace.node.line.color,
76-
nodeLineWidth = trace.node.line.width,
77-
linkLineColor = trace.link.line.color,
78-
linkLineWidth = trace.link.line.width,
79-
valueFormat = trace.valueformat,
80-
valueSuffix = trace.valuesuffix,
81-
textFont = trace.textfont;
82-
83-
var width = layout.width * (domain.x[1] - domain.x[0]),
84-
height = layout.height * (domain.y[1] - domain.y[0]);
85-
86-
var nodes = nodeSpec.label.map(function(l, i) {
87-
return {
88-
pointNumber: i,
89-
label: l,
90-
color: Lib.isArrayOrTypedArray(nodeSpec.color) ? nodeSpec.color[i] : nodeSpec.color
91-
};
92-
});
69+
function sankeyModel(layout, d, traceIndex) {
70+
var trace = unwrap(d).trace;
71+
var domain = trace.domain;
72+
var nodeSpec = trace.node;
73+
var linkSpec = trace.link;
74+
var arrangement = trace.arrangement;
75+
var horizontal = trace.orientation === 'h';
76+
var nodePad = trace.node.pad;
77+
var nodeThickness = trace.node.thickness;
78+
var nodeLineColor = trace.node.line.color;
79+
var nodeLineWidth = trace.node.line.width;
80+
var linkLineColor = trace.link.line.color;
81+
var linkLineWidth = trace.link.line.width;
82+
var valueFormat = trace.valueformat;
83+
var valueSuffix = trace.valuesuffix;
84+
var textFont = trace.textfont;
85+
86+
var width = layout.width * (domain.x[1] - domain.x[0]);
87+
var height = layout.height * (domain.y[1] - domain.y[0]);
88+
89+
var links = [];
90+
var hasLinkColorArray = isArrayOrTypedArray(linkSpec.color);
91+
var linkedNodes = {};
92+
93+
var nodeCount = nodeSpec.label.length;
94+
var i;
95+
for(i = 0; i < linkSpec.value.length; i++) {
96+
var val = linkSpec.value[i];
97+
// remove negative values, but keep zeros with special treatment
98+
var source = linkSpec.source[i];
99+
var target = linkSpec.target[i];
100+
if(!(val > 0 && isIndex(source, nodeCount) && isIndex(target, nodeCount))) {
101+
continue;
102+
}
103+
104+
source = +source;
105+
target = +target;
106+
linkedNodes[source] = linkedNodes[target] = true;
93107

94-
var links = linkSpec.value.map(function(d, i) {
95-
return {
108+
links.push({
96109
pointNumber: i,
97110
label: linkSpec.label[i],
98-
color: Lib.isArrayOrTypedArray(linkSpec.color) ? linkSpec.color[i] : linkSpec.color,
99-
source: linkSpec.source[i],
100-
target: linkSpec.target[i],
101-
value: d
102-
};
103-
});
111+
color: hasLinkColorArray ? linkSpec.color[i] : linkSpec.color,
112+
source: source,
113+
target: target,
114+
value: +val
115+
});
116+
}
117+
118+
var hasNodeColorArray = isArrayOrTypedArray(nodeSpec.color);
119+
var nodes = [];
120+
var removedNodes = false;
121+
var nodeIndices = {};
122+
for(i = 0; i < nodeCount; i++) {
123+
if(linkedNodes[i]) {
124+
var l = nodeSpec.label[i];
125+
nodeIndices[i] = nodes.length;
126+
nodes.push({
127+
pointNumber: i,
128+
label: l,
129+
color: hasNodeColorArray ? nodeSpec.color[i] : nodeSpec.color
130+
});
131+
}
132+
else removedNodes = true;
133+
}
134+
135+
// need to re-index links now, since we didn't put all the nodes in
136+
if(removedNodes) {
137+
for(i = 0; i < links.length; i++) {
138+
links[i].source = nodeIndices[links[i].source];
139+
links[i].target = nodeIndices[links[i].target];
140+
}
141+
}
104142

105143
var sankey = d3sankey()
106144
.size(horizontal ? [width, height] : [height, width])
@@ -120,7 +158,7 @@ function sankeyModel(layout, d, i) {
120158
switchToForceFormat(nodes);
121159

122160
return {
123-
key: i,
161+
key: traceIndex,
124162
trace: trace,
125163
guid: Math.floor(1e12 * (1 + Math.random())),
126164
horizontal: horizontal,
@@ -430,7 +468,6 @@ module.exports = function(svg, styledData, layout, callbacks) {
430468
.style('left', 0)
431469
.style('shape-rendering', 'geometricPrecision')
432470
.style('pointer-events', 'auto')
433-
.style('box-sizing', 'content-box')
434471
.attr('transform', sankeyTransform);
435472

436473
sankey.transition()

test/image/baselines/sankey_messy.png

80.8 KB
Loading

test/image/mocks/sankey_messy.json

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"data": [
3+
{
4+
"type": "sankey",
5+
"node": {
6+
"pad": 5,
7+
"label": [
8+
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j",
9+
"10", 11, "12", 13, 14, 15, 16, 17, 18, 19,
10+
20, "21", 22, "23", 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42,
11+
"k", "l", "m", "n", "o", "p", "q", "r", "s", "t"
12+
]
13+
},
14+
"link": {
15+
"source": [
16+
"", "x", 20, 21, 20, 21,
17+
20, 21, 22, 23, 24, 25, 20, 26, 22, 23, 27, 28,
18+
22, 29, 26, 28, 23, 30, 31, 32, 29, 26, 33, 34,
19+
24, 35, 36, 37, 38, 26, 22, 39, 36, 27, 26, 23,
20+
"40", "22", "41", "35", 42, 27, 40, 20, 28
21+
],
22+
"target": [
23+
10, 10, "", "q", 10, 10,
24+
10, 10, 10, 10, 10, 11, 11, 11, 11, 11, 12, 12,
25+
12, 13, 13, 13, 13, 14, 14, 14, 14, 14, 15, 15,
26+
15, 15, 16, 16, 16, 16, 16, 16, 17, 17, 17, 17,
27+
18, 18, "18", "18", 19, 19, "19", "19", 19
28+
],
29+
"value": [
30+
100, 100, 100, 100, -15, "nope",
31+
48, 40, 44, 4, 14, 0, 40, 25, 50, 137, 149, 100,
32+
100, 10, 200, 149, 237, 11, 130, 40, 10, 200, 50, 100,
33+
13, 100, 20, 100, 20, 100, 100, 7, 150, 40, 50, 11,
34+
"50", 80, "2", 40, "50", 50, "90", 125, 50
35+
]
36+
}
37+
}],
38+
"layout": {
39+
"title": "Sankey with messy data",
40+
"width": 600,
41+
"height": 800
42+
}
43+
}

test/jasmine/tests/sankey_test.js

+22-21
Original file line numberDiff line numberDiff line change
@@ -58,30 +58,31 @@ describe('sankey tests', function() {
5858
});
5959
});
6060

61-
describe('log warning if issue is encountered with graph structure',
62-
function() {
63-
64-
it('some nodes are not linked', function() {
65-
66-
var warnings = [];
67-
spyOn(Lib, 'warn').and.callFake(function(msg) {
68-
warnings.push(msg);
69-
});
70-
71-
_supply({
72-
node: {
73-
label: ['a', 'b', 'c']
74-
},
75-
link: {
76-
value: [1],
77-
source: [0],
78-
target: [1]
79-
}
80-
});
61+
describe('No warnings for missing nodes', function() {
62+
// we used to warn when some nodes were not used in the links
63+
// not doing that anymore, it's not really consistent with
64+
// the rest of our data processing.
65+
it('some nodes are not linked', function() {
66+
67+
var warnings = [];
68+
spyOn(Lib, 'warn').and.callFake(function(msg) {
69+
warnings.push(msg);
70+
});
8171

82-
expect(warnings.length).toEqual(1);
72+
_supply({
73+
node: {
74+
label: ['a', 'b', 'c']
75+
},
76+
link: {
77+
value: [1],
78+
source: [0],
79+
target: [1]
80+
}
8381
});
82+
83+
expect(warnings.length).toEqual(0);
8484
});
85+
});
8586

8687
describe('sankey global defaults', function() {
8788

0 commit comments

Comments
 (0)