Skip to content

Commit e76536b

Browse files
authored
Merge pull request #3583 from plotly/draft-sankey2-x-y
Sankey: add attributes `node.(x|y)`
2 parents c73e84b + 65c7201 commit e76536b

File tree

9 files changed

+500
-112
lines changed

9 files changed

+500
-112
lines changed

src/plot_api/plot_api.js

+1
Original file line numberDiff line numberDiff line change
@@ -2527,6 +2527,7 @@ var traceUIControlPatterns = [
25272527
// "visible" includes trace.transforms[i].styles[j].value.visible
25282528
{pattern: /(^|value\.)visible$/, attr: 'legend.uirevision'},
25292529
{pattern: /^dimensions\[\d+\]\.constraintrange/},
2530+
{pattern: /^node\.(x|y)/}, // for Sankey nodes
25302531

25312532
// below this you must be in editable: true mode
25322533
// TODO: I still put name and title with `trace.uirevision`

src/traces/sankey/attributes.js

+13
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ var attrs = module.exports = overrideAll({
9191
},
9292
groups: {
9393
valType: 'info_array',
94+
impliedEdits: {'x': [], 'y': []},
9495
dimensions: 2,
9596
freeLength: true,
9697
dflt: [],
@@ -102,6 +103,18 @@ var attrs = module.exports = overrideAll({
102103
'Multiple groups can be specified.'
103104
].join(' ')
104105
},
106+
x: {
107+
valType: 'data_array',
108+
dflt: [],
109+
role: 'info',
110+
description: 'The normalized horizontal position of the node.'
111+
},
112+
y: {
113+
valType: 'data_array',
114+
dflt: [],
115+
role: 'info',
116+
description: 'The normalized vertical position of the node.'
117+
},
105118
color: {
106119
valType: 'color',
107120
role: 'style',

src/traces/sankey/defaults.js

+8-1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout
3333
}
3434
coerceNode('label');
3535
coerceNode('groups');
36+
coerceNode('x');
37+
coerceNode('y');
3638
coerceNode('pad');
3739
coerceNode('thickness');
3840
coerceNode('line.color');
@@ -82,7 +84,12 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout
8284
coerce('orientation');
8385
coerce('valueformat');
8486
coerce('valuesuffix');
85-
coerce('arrangement');
87+
88+
var dfltArrangement;
89+
if(nodeOut.x.length && nodeOut.y.length) {
90+
dfltArrangement = 'freeform';
91+
}
92+
coerce('arrangement', dfltArrangement);
8693

8794
Lib.coerceFont(coerce, 'textfont', Lib.extendFlat({}, layout.font));
8895

src/traces/sankey/render.js

+145-16
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ var repeat = gup.repeat;
2323
var unwrap = gup.unwrap;
2424
var interpolateNumber = require('d3-interpolate').interpolateNumber;
2525

26+
var Registry = require('../../registry');
27+
2628
// view models
2729

2830
function sankeyModel(layout, d, traceIndex) {
@@ -67,13 +69,17 @@ function sankeyModel(layout, d, traceIndex) {
6769
Lib.warn('node.pad was reduced to ', sankey.nodePadding(), ' to fit within the figure.');
6870
}
6971

72+
// Counters for nested loops
73+
var i, j, k;
74+
7075
// Create transient nodes for animations
7176
for(var nodePointNumber in calcData._groupLookup) {
7277
var groupIndex = parseInt(calcData._groupLookup[nodePointNumber]);
7378

7479
// Find node representing groupIndex
7580
var groupingNode;
76-
for(var i = 0; i < graph.nodes.length; i++) {
81+
82+
for(i = 0; i < graph.nodes.length; i++) {
7783
if(graph.nodes[i].pointNumber === groupIndex) {
7884
groupingNode = graph.nodes[i];
7985
break;
@@ -98,7 +104,6 @@ function sankeyModel(layout, d, traceIndex) {
98104
}
99105

100106
function computeLinkConcentrations() {
101-
var i, j, k;
102107
for(i = 0; i < graph.nodes.length; i++) {
103108
var node = graph.nodes[i];
104109
// Links connecting the same two nodes are part of a flow
@@ -163,6 +168,93 @@ function sankeyModel(layout, d, traceIndex) {
163168
}
164169
computeLinkConcentrations();
165170

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 = -Infinity; // 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+
235+
// Force node position
236+
if(trace.node.x.length && trace.node.y.length) {
237+
for(i = 0; i < Math.min(trace.node.x.length, trace.node.y.length, graph.nodes.length); i++) {
238+
if(trace.node.x[i] && trace.node.y[i]) {
239+
var pos = [trace.node.x[i] * width, trace.node.y[i] * height];
240+
graph.nodes[i].x0 = pos[0] - nodeThickness / 2;
241+
graph.nodes[i].x1 = pos[0] + nodeThickness / 2;
242+
243+
var nodeHeight = graph.nodes[i].y1 - graph.nodes[i].y0;
244+
graph.nodes[i].y0 = pos[1] - nodeHeight / 2;
245+
graph.nodes[i].y1 = pos[1] + nodeHeight / 2;
246+
}
247+
}
248+
if(trace.arrangement === 'snap') {
249+
nodes = graph.nodes;
250+
var columns = snapToColumns(nodes);
251+
resolveCollisionsTopToBottom(columns);
252+
}
253+
// Update links
254+
sankey.update(graph);
255+
}
256+
257+
166258
return {
167259
circular: circular,
168260
key: traceIndex,
@@ -399,6 +491,7 @@ function nodeModel(d, n) {
399491
partOfGroup: n.partOfGroup || false,
400492
group: n.group,
401493
traceId: d.key,
494+
trace: d.trace,
402495
node: n,
403496
nodePad: d.nodePad,
404497
nodeLineColor: d.nodeLineColor,
@@ -425,7 +518,8 @@ function nodeModel(d, n) {
425518
graph: d.graph,
426519
arrangement: d.arrangement,
427520
uniqueNodeLabelPathId: [d.guid, d.key, key].join('_'),
428-
interactionState: d.interactionState
521+
interactionState: d.interactionState,
522+
figure: d
429523
};
430524
}
431525

@@ -509,7 +603,7 @@ function attachPointerEvents(selection, sankey, eventSet) {
509603
});
510604
}
511605

512-
function attachDragHandler(sankeyNode, sankeyLink, callbacks) {
606+
function attachDragHandler(sankeyNode, sankeyLink, callbacks, gd) {
513607
var dragBehavior = d3.behavior.drag()
514608
.origin(function(d) {
515609
return {
@@ -520,6 +614,9 @@ function attachDragHandler(sankeyNode, sankeyLink, callbacks) {
520614

521615
.on('dragstart', function(d) {
522616
if(d.arrangement === 'fixed') return;
617+
Lib.ensureSingle(gd._fullLayout._infolayer, 'g', 'dragcover', function(s) {
618+
gd._fullLayout._dragCover = s;
619+
});
523620
Lib.raiseToTop(this);
524621
d.interactionState.dragInProgress = d.node;
525622

@@ -533,9 +630,9 @@ function attachDragHandler(sankeyNode, sankeyLink, callbacks) {
533630
if(d.forceLayouts[forceKey]) {
534631
d.forceLayouts[forceKey].alpha(1);
535632
} else { // make a forceLayout if needed
536-
attachForce(sankeyNode, forceKey, d);
633+
attachForce(sankeyNode, forceKey, d, gd);
537634
}
538-
startForce(sankeyNode, sankeyLink, d, forceKey);
635+
startForce(sankeyNode, sankeyLink, d, forceKey, gd);
539636
}
540637
})
541638

@@ -553,8 +650,9 @@ function attachDragHandler(sankeyNode, sankeyLink, callbacks) {
553650
d.node.x0 = x - d.visibleWidth / 2;
554651
d.node.x1 = x + d.visibleWidth / 2;
555652
}
556-
d.node.y0 = Math.max(0, Math.min(d.size - d.visibleHeight, y));
557-
d.node.y1 = d.node.y0 + d.visibleHeight;
653+
y = Math.max(0, Math.min(d.size - d.visibleHeight / 2, y));
654+
d.node.y0 = y - d.visibleHeight / 2;
655+
d.node.y1 = y + d.visibleHeight / 2;
558656
}
559657

560658
saveCurrentDragPosition(d.node);
@@ -565,19 +663,21 @@ function attachDragHandler(sankeyNode, sankeyLink, callbacks) {
565663
})
566664

567665
.on('dragend', function(d) {
666+
if(d.arrangement === 'fixed') return;
568667
d.interactionState.dragInProgress = false;
569668
for(var i = 0; i < d.node.childrenNodes.length; i++) {
570669
d.node.childrenNodes[i].x = d.node.x;
571670
d.node.childrenNodes[i].y = d.node.y;
572671
}
672+
if(d.arrangement !== 'snap') persistFinalNodePositions(d, gd);
573673
});
574674

575675
sankeyNode
576676
.on('.drag', null) // remove possible previous handlers
577677
.call(dragBehavior);
578678
}
579679

580-
function attachForce(sankeyNode, forceKey, d) {
680+
function attachForce(sankeyNode, forceKey, d, gd) {
581681
// Attach force to nodes in the same column (same x coordinate)
582682
switchToForceFormat(d.graph.nodes);
583683
var nodes = d.graph.nodes
@@ -590,11 +690,11 @@ function attachForce(sankeyNode, forceKey, d) {
590690
.radius(function(n) {return n.dy / 2 + d.nodePad / 2;})
591691
.strength(1)
592692
.iterations(c.forceIterations))
593-
.force('constrain', snappingForce(sankeyNode, forceKey, nodes, d))
693+
.force('constrain', snappingForce(sankeyNode, forceKey, nodes, d, gd))
594694
.stop();
595695
}
596696

597-
function startForce(sankeyNode, sankeyLink, d, forceKey) {
697+
function startForce(sankeyNode, sankeyLink, d, forceKey, gd) {
598698
window.requestAnimationFrame(function faster() {
599699
var i;
600700
for(i = 0; i < c.forceTicksPerFrame; i++) {
@@ -609,6 +709,14 @@ function startForce(sankeyNode, sankeyLink, d, forceKey) {
609709

610710
if(d.forceLayouts[forceKey].alpha() > 0) {
611711
window.requestAnimationFrame(faster);
712+
} else {
713+
// Make sure the final x position is equal to its original value
714+
// because the force simulation will have numerical error
715+
var x = d.node.originalX;
716+
d.node.x0 = x - d.visibleWidth / 2;
717+
d.node.x1 = x + d.visibleWidth / 2;
718+
719+
persistFinalNodePositions(d, gd);
612720
}
613721
});
614722
}
@@ -628,13 +736,31 @@ function snappingForce(sankeyNode, forceKey, nodes, d) {
628736
maxVelocity = Math.max(maxVelocity, Math.abs(n.vx), Math.abs(n.vy));
629737
}
630738
if(!d.interactionState.dragInProgress && maxVelocity < 0.1 && d.forceLayouts[forceKey].alpha() > 0) {
631-
d.forceLayouts[forceKey].alpha(0);
739+
d.forceLayouts[forceKey].alpha(0); // This will stop the animation loop
632740
}
633741
};
634742
}
635743

636744
// basic data utilities
637745

746+
function persistFinalNodePositions(d, gd) {
747+
var x = [];
748+
var y = [];
749+
for(var i = 0; i < d.graph.nodes.length; i++) {
750+
var nodeX = (d.graph.nodes[i].x0 + d.graph.nodes[i].x1) / 2;
751+
var nodeY = (d.graph.nodes[i].y0 + d.graph.nodes[i].y1) / 2;
752+
x.push(nodeX / d.figure.width);
753+
y.push(nodeY / d.figure.height);
754+
}
755+
Registry.call('_guiRestyle', gd, {
756+
'node.x': [x],
757+
'node.y': [y]
758+
}, d.trace.index)
759+
.then(function() {
760+
if(gd._fullLayout._dragCover) gd._fullLayout._dragCover.remove();
761+
});
762+
}
763+
638764
function persistOriginalPlace(nodes) {
639765
var distinctLayerPositions = [];
640766
var i;
@@ -664,8 +790,8 @@ function sameLayer(d) {
664790
function switchToForceFormat(nodes) {
665791
// force uses x, y as centers
666792
for(var i = 0; i < nodes.length; i++) {
667-
nodes[i].y = nodes[i].y0 + nodes[i].dy / 2;
668-
nodes[i].x = nodes[i].x0 + nodes[i].dx / 2;
793+
nodes[i].y = (nodes[i].y0 + nodes[i].y1) / 2;
794+
nodes[i].x = (nodes[i].x0 + nodes[i].x1) / 2;
669795
}
670796
}
671797

@@ -688,6 +814,9 @@ module.exports = function(gd, svg, calcData, layout, callbacks) {
688814
firstRender = true;
689815
});
690816

817+
// To prevent animation on dragging
818+
var dragcover = gd._fullLayout._dragCover;
819+
691820
var styledData = calcData
692821
.filter(function(d) {return unwrap(d).trace.visible;})
693822
.map(sankeyModel.bind(null, layout));
@@ -752,7 +881,7 @@ module.exports = function(gd, svg, calcData, layout, callbacks) {
752881
.attr('d', linkPath());
753882

754883
sankeyLink
755-
.style('opacity', function() { return (gd._context.staticPlot || firstRender) ? 1 : 0;})
884+
.style('opacity', function() { return (gd._context.staticPlot || firstRender || dragcover) ? 1 : 0;})
756885
.transition()
757886
.ease(c.ease).duration(c.duration)
758887
.style('opacity', 1);
@@ -795,7 +924,7 @@ module.exports = function(gd, svg, calcData, layout, callbacks) {
795924

796925
sankeyNode
797926
.call(attachPointerEvents, sankey, callbacks.nodeEvents)
798-
.call(attachDragHandler, sankeyLink, callbacks); // has to be here as it binds sankeyLink
927+
.call(attachDragHandler, sankeyLink, callbacks, gd); // has to be here as it binds sankeyLink
799928

800929
sankeyNode.transition()
801930
.ease(c.ease).duration(c.duration)

test/image/baselines/sankey_x_y.png

36.8 KB
Loading

0 commit comments

Comments
 (0)