Skip to content

Commit f752b3e

Browse files
authored
Merge pull request #7826 from kyo5uke/sankey-fixed-node-positions
Respect explicit node x/y positions in fixed-arrangement Sankey
2 parents 50084b2 + e6ea65d commit f752b3e

3 files changed

Lines changed: 121 additions & 17 deletions

File tree

‎draftlogs/7826_fix.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Respect explicit `node.x`/`node.y` positions in Sankey traces that contain unlinked or grouped nodes [[#7826](https://github.com/plotly/plotly.js/pull/7826)], with thanks to @kyo5uke for the contribution!

‎src/traces/sankey/render.js‎

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -261,15 +261,23 @@ function sankeyModel(layout, d, traceIndex) {
261261

262262
// Force node position
263263
if(trace.node.x.length && trace.node.y.length) {
264-
for(i = 0; i < Math.min(trace.node.x.length, trace.node.y.length, graph.nodes.length); i++) {
265-
if(trace.node.x[i] && trace.node.y[i]) {
266-
var pos = [trace.node.x[i] * width, trace.node.y[i] * height];
267-
graph.nodes[i].x0 = pos[0] - nodeThickness / 2;
268-
graph.nodes[i].x1 = pos[0] + nodeThickness / 2;
269-
270-
var nodeHeight = graph.nodes[i].y1 - graph.nodes[i].y0;
271-
graph.nodes[i].y0 = pos[1] - nodeHeight / 2;
272-
graph.nodes[i].y1 = pos[1] + nodeHeight / 2;
264+
// graph.nodes omits unlinked nodes and may have transient group
265+
// children unshifted to its front, so its order does not match
266+
// trace.node.x/y. Index the explicit positions by each node's
267+
// original pointNumber rather than by array position.
268+
for(i = 0; i < graph.nodes.length; i++) {
269+
var forcedNode = graph.nodes[i];
270+
if(forcedNode.partOfGroup) continue;
271+
272+
var p = forcedNode.pointNumber;
273+
if (trace.node.x[p] && trace.node.y[p]) {
274+
var pos = [trace.node.x[p] * width, trace.node.y[p] * height];
275+
forcedNode.x0 = pos[0] - nodeThickness / 2;
276+
forcedNode.x1 = pos[0] + nodeThickness / 2;
277+
278+
var nodeHeight = forcedNode.y1 - forcedNode.y0;
279+
forcedNode.y0 = pos[1] - nodeHeight / 2;
280+
forcedNode.y1 = pos[1] + nodeHeight / 2;
273281
}
274282
}
275283
if(trace.arrangement === 'snap') {
@@ -825,13 +833,13 @@ function snappingForce(sankeyNode, forceKey, nodes, d) {
825833
// basic data utilities
826834

827835
function persistFinalNodePositions(d, gd) {
828-
var x = [];
829-
var y = [];
830-
for(var i = 0; i < d.graph.nodes.length; i++) {
831-
var nodeX = (d.graph.nodes[i].x0 + d.graph.nodes[i].x1) / 2;
832-
var nodeY = (d.graph.nodes[i].y0 + d.graph.nodes[i].y1) / 2;
833-
x.push(nodeX / d.figure.width);
834-
y.push(nodeY / d.figure.height);
836+
// Write by pointNumber because graph.nodes omits unlinked nodes
837+
const x = [...d.trace.node.x];
838+
const y = [...d.trace.node.y];
839+
for (const node of d.graph.nodes) {
840+
if (node.partOfGroup) continue;
841+
x[node.pointNumber] = (node.x0 + node.x1) / 2 / d.figure.width;
842+
y[node.pointNumber] = (node.y0 + node.y1) / 2 / d.figure.height;
835843
}
836844
Registry.call('_guiRestyle', gd, {
837845
'node.x': [x],
@@ -1110,4 +1118,4 @@ module.exports = function(gd, svg, calcData, layout, callbacks) {
11101118
nodeLabel
11111119
.transition()
11121120
.ease(c.ease).duration(c.duration);
1113-
};
1121+
};

‎test/jasmine/tests/sankey_test.js‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,101 @@ describe('sankey tests', function () {
696696
.then(done, done.fail);
697697
});
698698

699+
describe('explicit node.(x|y) positions', () => {
700+
// Node 1 has no links, so it is absent from the rendered graph
701+
const makeFixedMock = () => ({
702+
data: [
703+
{
704+
type: 'sankey',
705+
arrangement: 'fixed',
706+
node: {
707+
label: ['a', 'unlinked', 'b', 'c', 'd'],
708+
x: [0.1, 0.5, 0.5, 0.9, 0.9],
709+
y: [0.2, 0.5, 0.3, 0.8, 0.1],
710+
pad: 5
711+
},
712+
link: {
713+
source: [0, 0, 2],
714+
target: [2, 3, 4],
715+
value: [1, 1, 1]
716+
}
717+
}
718+
],
719+
layout: { width: 600, height: 400 }
720+
});
721+
722+
const getCentersByIndex = () => {
723+
const centers = {};
724+
// d3 v3 binds the element to `this`, so this callback stays a function
725+
d3SelectAll('.sankey-node').each(function (d) {
726+
// measure the rect because the label shifts the group bounding box
727+
centers[d.index] = getNodeCoords(this.querySelector('.node-rect'));
728+
});
729+
return centers;
730+
};
731+
732+
const assertPositions = (trace, centers, indices, msg) => {
733+
const size = gd._fullLayout._size;
734+
const ref = indices[0];
735+
indices.forEach((i) => {
736+
const dx = (trace.node.x[i] - trace.node.x[ref]) * size.w;
737+
const dy = (trace.node.y[i] - trace.node.y[ref]) * size.h;
738+
expect(centers[i].x - centers[ref].x).toBeCloseTo(dx, 0, msg + ': node ' + i + ' x');
739+
expect(centers[i].y - centers[ref].y).toBeCloseTo(dy, 0, msg + ': node ' + i + ' y');
740+
});
741+
};
742+
743+
it('applies positions to the right nodes when a node is unlinked', (done) => {
744+
const fig = makeFixedMock();
745+
746+
Plotly.newPlot(gd, fig)
747+
.then(() => {
748+
assertPositions(fig.data[0], getCentersByIndex(), [0, 2, 3, 4], 'fixed');
749+
})
750+
.then(done, done.fail);
751+
});
752+
753+
it('applies positions to the right nodes when nodes are grouped', (done) => {
754+
const fig = makeFixedMock();
755+
// Grouping nodes 3 and 4 leaves them unlinked and adds group node 5
756+
fig.data[0].node.groups = [[3, 4]];
757+
758+
Plotly.newPlot(gd, fig)
759+
.then(() => {
760+
assertPositions(fig.data[0], getCentersByIndex(), [0, 2], 'grouped');
761+
})
762+
.then(done, done.fail);
763+
});
764+
765+
it('keeps the positions of the other nodes after a drag when a node is unlinked', (done) => {
766+
const fig = makeFixedMock();
767+
fig.data[0].arrangement = 'freeform';
768+
let before;
769+
770+
Plotly.newPlot(gd, fig)
771+
.then(() => {
772+
before = getCentersByIndex();
773+
const node = d3SelectAll('.sankey-node')
774+
.filter((d) => d.index === 2)
775+
.node();
776+
return drag({ node: node, dpos: [0, 30] });
777+
})
778+
.then(() => {
779+
const { x } = gd._fullData[0].node;
780+
expect(x.length).toBe(5, 'x covers the unlinked node');
781+
expect(x[1]).toBeCloseTo(0.5, 5, 'unlinked node x is kept');
782+
783+
const after = getCentersByIndex();
784+
[0, 3, 4].forEach((i) => {
785+
expect(after[i].x).toBeCloseTo(before[i].x, 0, 'node ' + i + ' x');
786+
expect(after[i].y).toBeCloseTo(before[i].y, 0, 'node ' + i + ' y');
787+
});
788+
expect(after[2].y).toBeCloseTo(before[2].y + 30, 0, 'dragged node y');
789+
})
790+
.then(done, done.fail);
791+
});
792+
});
793+
699794
it('resets each subplot to its initial view (ie. x, y groups) via modebar button', function (done) {
700795
var mockCopy = Lib.extendDeep({}, require('../../image/mocks/sankey_subplots_circular'));
701796

0 commit comments

Comments
 (0)