Fix sankey nodes clipped at bottom edge with user-positioned nodes - #7725
Open
jdonaldson wants to merge 3 commits into
Open
Fix sankey nodes clipped at bottom edge with user-positioned nodes#7725jdonaldson wants to merge 3 commits into
jdonaldson wants to merge 3 commits into
Conversation
When nodes are positioned near y=1.0 via trace.node.x/y, they can extend past the bottom of the plot area. Additionally, resolveCollisionsTopToBottom pushes overlapping nodes downward without checking if the last node exceeds the available height. This fix: 1. Clamps yCenter when positioning nodes so they stay within [0, height] 2. Shifts columns upward after collision resolution if the bottom node extends past the plot height Fixes bottom clipping in sankey diagrams with arrangement="snap" and user-specified node positions.
Contributor
|
Thanks for the PR! Could you please open an issue to track the bug and provide steps to reproduce the problem? That will make it easier to review the PR. |
|
@jdonaldson just following up if you are still interested in working on this, could you open an issue to track what this is intended to fix? |
Author
|
Hey thanks for the attention, let me cook up a better example. I just jotted this down in the moment so I could track it. |
Replaces the whole-column shift added earlier in this branch with the missing counterpart to resolveCollisionsTopToBottom. resolveCollisionsTopToBottom only ever moves nodes down, so a column of user-positioned nodes clustered near y=1 gets walked off the bottom of the plot area. The bundled @plotly/d3-sankey resolves this in its own resolveCollisions by following the downward pass with a bottom-bounded upward one (src/sankey.js:292-299); render.js declares a function with the same name as the dep's downward helper but implements only that half. Add resolveCollisionsBottomToTop, bounded by `height`, and call it after the downward pass. Because it only moves a node when node.y1 exceeds the running bound, nodes that already fit stay put and the overflow propagates upward no further than it must -- unlike shifting the whole column by the full overflow, which moves the top node even when it had no need to move and can push a tight column off the top edge instead. Also switch the explicit-position clamp to Lib.constrain. Its swapped-bounds branch pins a node taller than the plot area to the top edge (y0 = 0), whereas the previous Math.max-then-Math.min ordering let the min win in that case and produced y0 = height - nodeHeight, i.e. clipping at the top. Tests: replace the single mock-driven test with two, one per code path (arrangement 'fixed' for the centring clamp, 'snap' for the cascade), and measure node rects against _fullLayout._size rather than the '.sankey' layer's bounding box. Node rects are descendants of that layer and an SVG group's box is the union of its children, so the previous assertions grew to contain any overflow and could not fail. Drops the image mock, which needed a baseline PNG the jasmine test did not use.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #7946
Overview
Sankey nodes positioned via
node.x/node.ynear the bottom of the plot area render outside it and get clipped. Two independent paths insrc/traces/sankey/render.jscause it; this PR fixes both. #7946 has runnable reproductions, screenshots and measured overflow figures.1. "Force node position" centres the node on an unbounded point
trace.node.ypositions the node's centre, soynear1puts up to half the node below the bottom edge, for everyarrangementincludingfixed. Measured: a 135px node aty = 0.98in a 280px plot area overflows by 61.9px.Fix: clamp the centre with
Lib.constrain(pos[1], nodeHeight / 2, height - nodeHeight / 2).Lib.constrain's swapped-bounds branch handles a node taller than the plot area by pinning it to the top edge (y0 = 0), which is better than letting it hang off the top.2.
resolveCollisionsTopToBottomwalks columns off the bottom edgeWith
arrangement: "snap", overlapping nodes are only ever pushed down, and nothing checks the result againstheight. Measured: a three-node column requested aty = 0.80/0.86/0.92in a 380px area ends up 114px and 244px past the bottom, withDnever rendering — while the top 244px of the plot area sits empty.Fix: add the missing counterpart. The bundled dependency already does this —
@plotly/d3-sankey@0.7.2(src/sankey.js:292-299) ends itsresolveCollisionswith a bottom-bounded upward pass and a top-bounded downward one:render.jsdeclares a local function with the same name as the dep's downward helper but implements only that half and never calls a counterpart. This addsresolveCollisionsBottomToTop(columns), bounded byheight, called after the downward pass.It only moves a node when
node.y1exceeds the running bound, so nodes that already fit do not move and the correction propagates upward no further than it must. That is the reason to prefer it over shifting the whole column by the full overflow (what the first commit on this branch did): a whole-column shift moves the top node even when it had no need to move, and pushes a tight column off the top edge instead.Not fixed, and I don't think it should be here: a column whose stacked height genuinely exceeds
heightstill cannot fit, and its top node ends abovey = 0. That is the same outcome the dep produces. ALib.warnthere might be worthwhile as a follow-up — L76-78 already warns on a related fit failure.Behaviour change — worth calling out
This makes an explicitly requested
node.ynon-authoritative near the edges: asking fory = 1now puts the node's bottom at the plot edge rather than its centre. That is the intent, but it is visible to anyone who hand-tunednode.yagainst the old clipped rendering. Alternatives if you'd rather not change the default:Lib.warnwhen a position is clamped, or gate it — though I'd argue drawing outside the plot area isn't behaviour worth preserving behind a flag. Happy to go whichever way you prefer.Tests
Replaces the single test from the first commit with two, one per code path, so neither fix can mask the other:
keeps an explicitly positioned node inside the plot area—arrangement: 'fixed', so no collision resolution runs and it covers the centring clamp alone.keeps a snapped column inside the plot area when collisions cascade— everyyis ≤ 0.92, so the clamp cannot be what saves it; only the new pass can.Both measure node rects against
gd._fullLayout._size, which is whatplot.js:137,371-372passes to the renderer as itsheight.The test in my first commit was a tautology and this replaces it. It compared node rects to
document.querySelector('.sankey').getBoundingClientRect(). Node rects are descendants of that layer, and an SVG group's box is the union of its children, sosankeyRect.bottomis a max over children — necessarily ≥ any child's bottom, and likewise.topis a min. Both assertions held for every possible rendering, patched or not. I found this the hard way: a harness written that way reported "all nodes inside bounds" on a plot that was visibly clipped, with the layer box measuring 336.9px against a 280px plot area.I also dropped
test/image/mocks/sankey_x_y_bottom_clipping.json. It needed a baseline PNG (compare_pixels_test.mjsglobs the mocks directory and fails on a missing baseline) which the jasmine test never used; the fixtures are now inline in the tests.What I have and haven't run