Skip to content

Commit e017bd4

Browse files
committed
fix(sankey): keep explicitly-positioned and snapped nodes inside the plot area (plotly.js #7946)
Explicit node positions centered on y=0/y=1 (and the snap collision cascade) could push node rects past the top/bottom edge, clipping them outside the plot area (plotly.js #7946). - Clamp explicitly-positioned nodes to [0, height] in the Force-node-position block so a node centered on an edge stays fully inside. - Add resolveCollisionsBottomToTop, a bottom-bounded upward pass run after the existing top-to-bottom pass for arrangement:snap, so an overlapping column is absorbed upward instead of being walked off the bottom edge. Adds a jasmine regression test covering both repro cases.
1 parent 7a8dae8 commit e017bd4

2 files changed

Lines changed: 126 additions & 2 deletions

File tree

src/traces/sankey/render.js

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,34 @@ function sankeyModel(layout, d, traceIndex) {
225225
});
226226
}
227227

228+
// Push any nodes that overflow the bottom edge back up so the whole
229+
// column stays inside the plot area. Counterpart to
230+
// resolveCollisionsTopToBottom: with `arrangement: "snap"` a downward
231+
// cascade can walk the last node(s) straight past `height`, even when
232+
// there is empty space above to absorb the correction.
233+
function resolveCollisionsBottomToTop(columns) {
234+
columns.forEach(function(nodes) {
235+
var node;
236+
var dy;
237+
var y = height;
238+
var n = nodes.length;
239+
var i;
240+
nodes.sort(function(a, b) {
241+
return b.y0 - a.y0;
242+
});
243+
for(i = 0; i < n; ++i) {
244+
node = nodes[i];
245+
if(node.y1 <= y) {
246+
// No overflow at the bottom edge
247+
} else {
248+
dy = (node.y1 - y);
249+
if(dy > 1e-6) node.y0 -= dy, node.y1 -= dy;
250+
}
251+
y = node.y0 - nodePad;
252+
}
253+
});
254+
}
255+
228256
// Group nodes into columns based on their x position
229257
function snapToColumns(nodes) {
230258
// Sort nodes by x position
@@ -273,14 +301,27 @@ function sankeyModel(layout, d, traceIndex) {
273301
graph.nodes[i].x1 = pos[0] + nodeThickness / 2;
274302

275303
var nodeHeight = graph.nodes[i].y1 - graph.nodes[i].y0;
276-
graph.nodes[i].y0 = pos[1] - nodeHeight / 2;
277-
graph.nodes[i].y1 = pos[1] + nodeHeight / 2;
304+
var y0 = pos[1] - nodeHeight / 2;
305+
var y1 = pos[1] + nodeHeight / 2;
306+
// Keep the node fully inside the plot area: a node centered
307+
// exactly on the top/bottom edge (y = 0 / y = 1) would
308+
// otherwise render half outside it.
309+
if(y0 < 0) {
310+
y0 = 0;
311+
y1 = nodeHeight;
312+
} else if(y1 > height) {
313+
y1 = height;
314+
y0 = height - nodeHeight;
315+
}
316+
graph.nodes[i].y0 = y0;
317+
graph.nodes[i].y1 = y1;
278318
}
279319
}
280320
if(trace.arrangement === 'snap') {
281321
nodes = graph.nodes;
282322
var columns = snapToColumns(nodes);
283323
resolveCollisionsTopToBottom(columns);
324+
resolveCollisionsBottomToTop(columns);
284325
}
285326
// Update links
286327
sankey.update(graph);

test/jasmine/tests/sankey_test.js

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1907,5 +1907,88 @@ describe('sankey layout generators', function() {
19071907
expect(circularLinks.length).toEqual(89, 'right number of circular links');
19081908
});
19091909
});
1910+
1911+
describe('keeps explicitly-positioned / snapped nodes inside the plot area (plotly.js #7946)', function() {
1912+
function plotArea(gd) {
1913+
var fl = gd._fullLayout;
1914+
var rect = gd.getBoundingClientRect();
1915+
return {
1916+
left: rect.left + fl.margin.l,
1917+
top: rect.top + fl.margin.t,
1918+
right: rect.left + fl.width - fl.margin.r,
1919+
bottom: rect.top + fl.height - fl.margin.b
1920+
};
1921+
}
1922+
1923+
function assertNodesInside(gd, msg) {
1924+
var pa = plotArea(gd);
1925+
var eps = 1.5;
1926+
d3SelectAll('.sankey .node-rect').each(function() {
1927+
var r = this.getBoundingClientRect();
1928+
expect(r.top).toBeGreaterThan(pa.top - eps);
1929+
expect(r.bottom).toBeLessThan(pa.bottom + eps);
1930+
expect(r.left).toBeGreaterThan(pa.left - eps);
1931+
expect(r.right).toBeLessThan(pa.right + eps);
1932+
});
1933+
}
1934+
1935+
it('does not clip an explicitly-positioned node near the bottom edge', function(done) {
1936+
var gd = createGraphDiv();
1937+
var fig = {
1938+
data: [{
1939+
type: 'sankey',
1940+
arrangement: 'fixed',
1941+
node: {
1942+
label: ['A', 'B at y=0.98', 'C'],
1943+
x: [0.1, 0.1, 0.9],
1944+
y: [0.3, 0.98, 0.5],
1945+
pad: 10
1946+
},
1947+
link: {
1948+
source: [0, 1],
1949+
target: [2, 2],
1950+
value: [10, 10]
1951+
}
1952+
}],
1953+
layout: {
1954+
width: 600,
1955+
height: 300,
1956+
margin: {l: 10, r: 10, t: 10, b: 10}
1957+
}
1958+
};
1959+
Plotly.newPlot(gd, fig)
1960+
.then(function() { assertNodesInside(gd, 'repro1'); })
1961+
.then(done, done.fail);
1962+
});
1963+
1964+
it('does not cascade snapped nodes past the bottom edge', function(done) {
1965+
var gd = createGraphDiv();
1966+
var fig = {
1967+
data: [{
1968+
type: 'sankey',
1969+
arrangement: 'snap',
1970+
node: {
1971+
label: ['A', 'B', 'C', 'D', 'E'],
1972+
x: [0.1, 0.5, 0.5, 0.5, 0.9],
1973+
y: [0.5, 0.80, 0.86, 0.92, 0.5],
1974+
pad: 10
1975+
},
1976+
link: {
1977+
source: [0, 0, 0, 1, 2, 3],
1978+
target: [1, 2, 3, 4, 4, 4],
1979+
value: [8, 8, 8, 8, 8, 8]
1980+
}
1981+
}],
1982+
layout: {
1983+
width: 600,
1984+
height: 400,
1985+
margin: {l: 10, r: 10, t: 10, b: 10}
1986+
}
1987+
};
1988+
Plotly.newPlot(gd, fig)
1989+
.then(function() { assertNodesInside(gd, 'repro2'); })
1990+
.then(done, done.fail);
1991+
});
1992+
});
19101993
});
19111994
});

0 commit comments

Comments
 (0)