Skip to content

Sankey: add attributes node.(x|y) #3583

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Mar 21, 2019
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/plot_api/plot_api.js
Original file line number Diff line number Diff line change
Expand Up @@ -2524,6 +2524,7 @@ var traceUIControlPatterns = [
// "visible" includes trace.transforms[i].styles[j].value.visible
{pattern: /(^|value\.)visible$/, attr: 'legend.uirevision'},
{pattern: /^dimensions\[\d+\]\.constraintrange/},
{pattern: /^node\.(x|y)/}, // for Sankey nodes

// below this you must be in editable: true mode
// TODO: I still put name and title with `trace.uirevision`
Expand Down
13 changes: 13 additions & 0 deletions src/traces/sankey/attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ var attrs = module.exports = overrideAll({
},
groups: {
valType: 'info_array',
impliedEdits: {'x': [], 'y': []},
dimensions: 2,
freeLength: true,
dflt: [],
Expand All @@ -102,6 +103,18 @@ var attrs = module.exports = overrideAll({
'Multiple groups can be specified.'
].join(' ')
},
x: {
valType: 'data_array',
dflt: [],
role: 'info',
description: 'The normalized horizontal position of the node.'
},
y: {
valType: 'data_array',
dflt: [],
role: 'info',
description: 'The normalized vertical position of the node.'
},
color: {
valType: 'color',
role: 'style',
Expand Down
2 changes: 2 additions & 0 deletions src/traces/sankey/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout
}
coerceNode('label');
coerceNode('groups');
coerceNode('x');
coerceNode('y');
coerceNode('pad');
coerceNode('thickness');
coerceNode('line.color');
Expand Down
160 changes: 144 additions & 16 deletions src/traces/sankey/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ var repeat = gup.repeat;
var unwrap = gup.unwrap;
var interpolateNumber = require('d3-interpolate').interpolateNumber;

var Plotly = require('../../plot_api/plot_api');

// view models

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

// Counters for nested loops
var i, j, k;

// 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++) {

for(i = 0; i < graph.nodes.length; i++) {
if(graph.nodes[i].pointNumber === groupIndex) {
groupingNode = graph.nodes[i];
break;
Expand All @@ -98,7 +104,6 @@ function sankeyModel(layout, d, traceIndex) {
}

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

// Push any overlapping nodes down.
function resolveCollisionsTopToBottom(columns) {
columns.forEach(function(nodes) {
var node;
var dy;
var y = 0;
var n = nodes.length;
var i;
nodes.sort(function(a, b) {
return a.y0 - b.y0;
});
for(i = 0; i < n; ++i) {
node = nodes[i];
if(node.y0 >= y) {
// No overlap
} else {
dy = (y - node.y0);
if(dy > 1e-6) node.y0 += dy, node.y1 += dy;
}
y = node.y1 + nodePad;
}
});
}

// Group nodes into columns based on their x position
function snapToColumns(nodes) {
// Sort nodes by x position
var orderedNodes = nodes.map(function(n, i) {
return {
x0: n.x0,
index: i
};
})
.sort(function(a, b) {
return a.x0 - b.x0;
});

var columns = [];
var colNumber = -1;
var colX; // Position of column
var lastX = -Infinity; // Position of last node
var dx;
for(i = 0; i < orderedNodes.length; i++) {
var node = nodes[orderedNodes[i].index];
// If the node does not overlap with the last one
if(node.x0 > lastX + nodeThickness) {
// Start a new column
colNumber += 1;
colX = node.x0;
}
lastX = node.x0;

// Add node to its associated column
if(!columns[colNumber]) columns[colNumber] = [];
columns[colNumber].push(node);

// Change node's x position to align it with its column
dx = colX - node.x0;
node.x0 += dx, node.x1 += dx;

}
return columns;
}

// Force node position
if(trace.node.x.length !== 0 && trace.node.y.length !== 0) {
for(i = 0; i < Math.min(trace.node.x.length, trace.node.y.length, graph.nodes.length); i++) {
if(trace.node.x[i] && trace.node.y[i]) {
var pos = [trace.node.x[i] * width, trace.node.y[i] * height];
graph.nodes[i].x0 = pos[0] - nodeThickness / 2;
graph.nodes[i].x1 = pos[0] + nodeThickness / 2;

var nodeHeight = graph.nodes[i].y1 - graph.nodes[i].y0;
graph.nodes[i].y0 = pos[1] - nodeHeight / 2;
graph.nodes[i].y1 = pos[1] + nodeHeight / 2;
}
}
if(trace.arrangement === 'snap') {
nodes = graph.nodes;
var columns = snapToColumns(nodes);
resolveCollisionsTopToBottom(columns);
}
// Update links
sankey.update(graph);
}


return {
circular: circular,
key: traceIndex,
Expand Down Expand Up @@ -399,6 +491,7 @@ function nodeModel(d, n) {
partOfGroup: n.partOfGroup || false,
group: n.group,
traceId: d.key,
trace: d.trace,
node: n,
nodePad: d.nodePad,
nodeLineColor: d.nodeLineColor,
Expand All @@ -425,7 +518,8 @@ function nodeModel(d, n) {
graph: d.graph,
arrangement: d.arrangement,
uniqueNodeLabelPathId: [d.guid, d.key, key].join('_'),
interactionState: d.interactionState
interactionState: d.interactionState,
figure: d
};
}

Expand Down Expand Up @@ -509,7 +603,7 @@ function attachPointerEvents(selection, sankey, eventSet) {
});
}

function attachDragHandler(sankeyNode, sankeyLink, callbacks) {
function attachDragHandler(sankeyNode, sankeyLink, callbacks, gd) {
var dragBehavior = d3.behavior.drag()
.origin(function(d) {
return {
Expand All @@ -520,6 +614,9 @@ function attachDragHandler(sankeyNode, sankeyLink, callbacks) {

.on('dragstart', function(d) {
if(d.arrangement === 'fixed') return;
Lib.ensureSingle(gd._fullLayout._infolayer, 'g', 'dragcover', function(s) {
gd._fullLayout._dragCover = s;
});
Lib.raiseToTop(this);
d.interactionState.dragInProgress = d.node;

Expand All @@ -533,9 +630,9 @@ function attachDragHandler(sankeyNode, sankeyLink, callbacks) {
if(d.forceLayouts[forceKey]) {
d.forceLayouts[forceKey].alpha(1);
} else { // make a forceLayout if needed
attachForce(sankeyNode, forceKey, d);
attachForce(sankeyNode, forceKey, d, gd);
}
startForce(sankeyNode, sankeyLink, d, forceKey);
startForce(sankeyNode, sankeyLink, d, forceKey, gd);
}
})

Expand All @@ -553,8 +650,9 @@ function attachDragHandler(sankeyNode, sankeyLink, callbacks) {
d.node.x0 = x - d.visibleWidth / 2;
d.node.x1 = x + d.visibleWidth / 2;
}
d.node.y0 = Math.max(0, Math.min(d.size - d.visibleHeight, y));
d.node.y1 = d.node.y0 + d.visibleHeight;
y = Math.max(0, Math.min(d.size - d.visibleHeight / 2, y));
d.node.y0 = y - d.visibleHeight / 2;
d.node.y1 = y + d.visibleHeight / 2;
}

saveCurrentDragPosition(d.node);
Expand All @@ -570,14 +668,15 @@ function attachDragHandler(sankeyNode, sankeyLink, callbacks) {
d.node.childrenNodes[i].x = d.node.x;
d.node.childrenNodes[i].y = d.node.y;
}
if(d.arrangement !== 'snap') persistFinalNodePositions(d, gd);
});

sankeyNode
.on('.drag', null) // remove possible previous handlers
.call(dragBehavior);
}

function attachForce(sankeyNode, forceKey, d) {
function attachForce(sankeyNode, forceKey, d, gd) {
// Attach force to nodes in the same column (same x coordinate)
switchToForceFormat(d.graph.nodes);
var nodes = d.graph.nodes
Expand All @@ -590,11 +689,11 @@ function attachForce(sankeyNode, forceKey, d) {
.radius(function(n) {return n.dy / 2 + d.nodePad / 2;})
.strength(1)
.iterations(c.forceIterations))
.force('constrain', snappingForce(sankeyNode, forceKey, nodes, d))
.force('constrain', snappingForce(sankeyNode, forceKey, nodes, d, gd))
.stop();
}

function startForce(sankeyNode, sankeyLink, d, forceKey) {
function startForce(sankeyNode, sankeyLink, d, forceKey, gd) {
window.requestAnimationFrame(function faster() {
var i;
for(i = 0; i < c.forceTicksPerFrame; i++) {
Expand All @@ -609,6 +708,14 @@ function startForce(sankeyNode, sankeyLink, d, forceKey) {

if(d.forceLayouts[forceKey].alpha() > 0) {
window.requestAnimationFrame(faster);
} else {
// Make sure the final x position is equal to its original value
// because the force simulation will have numerical error
var x = d.node.originalX;
d.node.x0 = x - d.visibleWidth / 2;
d.node.x1 = x + d.visibleWidth / 2;

persistFinalNodePositions(d, gd);
}
});
}
Expand All @@ -628,13 +735,31 @@ function snappingForce(sankeyNode, forceKey, nodes, d) {
maxVelocity = Math.max(maxVelocity, Math.abs(n.vx), Math.abs(n.vy));
}
if(!d.interactionState.dragInProgress && maxVelocity < 0.1 && d.forceLayouts[forceKey].alpha() > 0) {
d.forceLayouts[forceKey].alpha(0);
d.forceLayouts[forceKey].alpha(0); // This will stop the animation loop
}
};
}

// basic data utilities

function persistFinalNodePositions(d, gd) {
var x = [];
var y = [];
for(var i = 0; i < d.graph.nodes.length; i++) {
var nodeX = (d.graph.nodes[i].x0 + d.graph.nodes[i].x1) / 2;
var nodeY = (d.graph.nodes[i].y0 + d.graph.nodes[i].y1) / 2;
x.push(nodeX / d.figure.width);
y.push(nodeY / d.figure.height);
}
Plotly._guiRestyle(gd, {
'node.x': [x],
'node.y': [y]
}, d.trace.index)
.then(function() {
if(gd._fullLayout._dragCover) gd._fullLayout._dragCover.remove();
});
}

function persistOriginalPlace(nodes) {
var distinctLayerPositions = [];
var i;
Expand Down Expand Up @@ -664,8 +789,8 @@ function sameLayer(d) {
function switchToForceFormat(nodes) {
// force uses x, y as centers
for(var i = 0; i < nodes.length; i++) {
nodes[i].y = nodes[i].y0 + nodes[i].dy / 2;
nodes[i].x = nodes[i].x0 + nodes[i].dx / 2;
nodes[i].y = (nodes[i].y0 + nodes[i].y1) / 2;
nodes[i].x = (nodes[i].x0 + nodes[i].x1) / 2;
}
}

Expand All @@ -688,6 +813,9 @@ module.exports = function(gd, svg, calcData, layout, callbacks) {
firstRender = true;
});

// To prevent animation on dragging
var dragcover = gd.querySelector('.dragcover');

var styledData = calcData
.filter(function(d) {return unwrap(d).trace.visible;})
.map(sankeyModel.bind(null, layout));
Expand Down Expand Up @@ -752,7 +880,7 @@ module.exports = function(gd, svg, calcData, layout, callbacks) {
.attr('d', linkPath());

sankeyLink
.style('opacity', function() { return (gd._context.staticPlot || firstRender) ? 1 : 0;})
.style('opacity', function() { return (gd._context.staticPlot || firstRender || dragcover) ? 1 : 0;})
.transition()
.ease(c.ease).duration(c.duration)
.style('opacity', 1);
Expand Down Expand Up @@ -795,7 +923,7 @@ module.exports = function(gd, svg, calcData, layout, callbacks) {

sankeyNode
.call(attachPointerEvents, sankey, callbacks.nodeEvents)
.call(attachDragHandler, sankeyLink, callbacks); // has to be here as it binds sankeyLink
.call(attachDragHandler, sankeyLink, callbacks, gd); // has to be here as it binds sankeyLink

sankeyNode.transition()
.ease(c.ease).duration(c.duration)
Expand Down
Binary file added test/image/baselines/sankey_x_y.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 28 additions & 0 deletions test/image/mocks/sankey_x_y.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"data": [
{
"type": "sankey",
"arrangement": "freeform",
Copy link
Contributor

@etpinard etpinard Mar 20, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do all the possible values of arrangement have an effect of the node layout when node.x and node.y are set?

Copy link
Contributor Author

@antoinerg antoinerg Mar 20, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do all the possible values of arrangement have an effect of the node layout when node.x and node.y are set?

Short answer: only snap can override node.(x|y)

Long answer:
When node.(x|y) is set, the nodes are forced to sit at the specified locations except in the snap arrangement. When drag and dropping, snap is the only arrangement in which we forbid nodes from overlapping so I made sure we keep honoring this promise.

The goal of this PR was to make it possible to replicate any state accessible via drag and drop via node.(x|y) and hence allowing to export images.

Copy link
Contributor

@etpinard etpinard Mar 20, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, thanks for the info!

Could we then make arrangement default to 'freeform' whenever node.(x|y) are set? That way, a user that wants to programmatically tweak the node position via node.(x|y) can do so w/o having to worry about changing the arrangement value.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 65c7201

"node": {
"label": ["0", "1", "2", "3", "4", "5"],
"x": [0.128, 0.128, 0.559, 0.785, 0.352, 0.559],
"y": [0.738, 0.165, 0.205, 0.390, 0.165, 0.256]
},
"link": {
"source": [
0, 0, 1, 2, 5, 4, 3, 5
],
"target": [
5, 3, 4, 3, 0, 2, 2, 3
],
"value": [
1, 2, 1, 1, 1, 1, 1, 2
]
}
}],
"layout": {
"title": "Sankey with manually positioned node",
"width": 800,
"height": 800
}
}
Loading