diff --git a/draftlogs/7978_fix.md b/draftlogs/7978_fix.md new file mode 100644 index 00000000000..f707c91a066 --- /dev/null +++ b/draftlogs/7978_fix.md @@ -0,0 +1 @@ + - Fix `sankey` nodes positioned near or below the bottom of the plot area being clipped: explicitly-positioned node rectangles are now clamped to the plot bounds, and the `snap` collision pass gained a bottom-bounded upward sweep so columns can no longer walk past the plot edge [[#7978](https://github.com/plotly/plotly.js/pull/7978)] diff --git a/src/traces/sankey/render.js b/src/traces/sankey/render.js index 83f8bbb2f98..b1c4f28cb09 100644 --- a/src/traces/sankey/render.js +++ b/src/traces/sankey/render.js @@ -220,6 +220,34 @@ function sankeyModel(layout, d, traceIndex) { }); } + // Push any nodes that overflow the bottom edge back up so the whole + // column stays inside the plot area. Counterpart to + // resolveCollisionsTopToBottom: with `arrangement: "snap"` a downward + // cascade can walk the last node(s) straight past `height`, even when + // there is empty space above to absorb the correction. + function resolveCollisionsBottomToTop(columns) { + columns.forEach(function(nodes) { + var node; + var dy; + var y = height; + var n = nodes.length; + var i; + nodes.sort(function(a, b) { + return b.y0 - a.y0; + }); + for(i = 0; i < n; ++i) { + node = nodes[i]; + if(node.y1 <= y) { + // No overflow at the bottom edge + } else { + dy = (node.y1 - y); + if(dy > 1e-6) node.y0 -= dy, node.y1 -= dy; + } + y = node.y0 - nodePad; + } + }); + } + // Group nodes into columns based on their x position function snapToColumns(nodes) { // Sort nodes by x position @@ -268,14 +296,27 @@ function sankeyModel(layout, d, traceIndex) { 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; + var y0 = pos[1] - nodeHeight / 2; + var y1 = pos[1] + nodeHeight / 2; + // Keep the node fully inside the plot area: a node centered + // exactly on the top/bottom edge (y = 0 / y = 1) would + // otherwise render half outside it. + if(y0 < 0) { + y0 = 0; + y1 = nodeHeight; + } else if(y1 > height) { + y1 = height; + y0 = height - nodeHeight; + } + graph.nodes[i].y0 = y0; + graph.nodes[i].y1 = y1; } } if(trace.arrangement === 'snap') { nodes = graph.nodes; var columns = snapToColumns(nodes); resolveCollisionsTopToBottom(columns); + resolveCollisionsBottomToTop(columns); } // Update links sankey.update(graph); @@ -1110,4 +1151,4 @@ module.exports = function(gd, svg, calcData, layout, callbacks) { nodeLabel .transition() .ease(c.ease).duration(c.duration); -}; \ No newline at end of file +}; diff --git a/test/jasmine/tests/sankey_test.js b/test/jasmine/tests/sankey_test.js index d82ecdf9296..307f86a4136 100644 --- a/test/jasmine/tests/sankey_test.js +++ b/test/jasmine/tests/sankey_test.js @@ -95,6 +95,7 @@ describe('sankey tests', function () { }); }); + describe('sankey global defaults', function () { it('should not coerce trace opacity', function () { var gd = Lib.extendDeep({}, mock); @@ -2279,5 +2280,88 @@ describe('sankey layout generators', function () { expect(circularLinks.length).toEqual(89, 'right number of circular links'); }); }); + + describe('keeps explicitly-positioned / snapped nodes inside the plot area (plotly.js #7946)', function() { + function plotArea(gd) { + var fl = gd._fullLayout; + var rect = gd.getBoundingClientRect(); + return { + left: rect.left + fl.margin.l, + top: rect.top + fl.margin.t, + right: rect.left + fl.width - fl.margin.r, + bottom: rect.top + fl.height - fl.margin.b + }; + } + + function assertNodesInside(gd, msg) { + var pa = plotArea(gd); + var eps = 1.5; + d3SelectAll('.sankey .node-rect').each(function() { + var r = this.getBoundingClientRect(); + expect(r.top).toBeGreaterThan(pa.top - eps); + expect(r.bottom).toBeLessThan(pa.bottom + eps); + expect(r.left).toBeGreaterThan(pa.left - eps); + expect(r.right).toBeLessThan(pa.right + eps); + }); + } + + it('does not clip an explicitly-positioned node near the bottom edge', function(done) { + var gd = createGraphDiv(); + var fig = { + data: [{ + type: 'sankey', + arrangement: 'fixed', + node: { + label: ['A', 'B at y=0.98', 'C'], + x: [0.1, 0.1, 0.9], + y: [0.3, 0.98, 0.5], + pad: 10 + }, + link: { + source: [0, 1], + target: [2, 2], + value: [10, 10] + } + }], + layout: { + width: 600, + height: 300, + margin: {l: 10, r: 10, t: 10, b: 10} + } + }; + Plotly.newPlot(gd, fig) + .then(function() { assertNodesInside(gd, 'repro1'); }) + .then(done, done.fail); + }); + + it('does not cascade snapped nodes past the bottom edge', function(done) { + var gd = createGraphDiv(); + var fig = { + data: [{ + type: 'sankey', + arrangement: 'snap', + node: { + label: ['A', 'B', 'C', 'D', 'E'], + x: [0.1, 0.5, 0.5, 0.5, 0.9], + y: [0.5, 0.80, 0.86, 0.92, 0.5], + pad: 10 + }, + link: { + source: [0, 0, 0, 1, 2, 3], + target: [1, 2, 3, 4, 4, 4], + value: [8, 8, 8, 8, 8, 8] + } + }], + layout: { + width: 600, + height: 400, + margin: {l: 10, r: 10, t: 10, b: 10} + } + }; + Plotly.newPlot(gd, fig) + .then(function() { assertNodesInside(gd, 'repro2'); }) + .then(done, done.fail); + }); + }); }); });