You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When sankey node positions are supplied explicitly via node.x / node.y, nodes placed near the bottom of the plot area (y close to 1) are drawn partly outside the plot area and get visually clipped. The label and the node rect are both cut off.
There are two independent code paths that produce this, and they can be triggered separately:
1. Explicit positioning centers the node on y * height without bounds checking.
In src/traces/sankey/render.js ("Force node position", ~L246-257), the node is centered on the requested position:
y = 1 means "center of the node at the bottom edge", so half the node always renders below the plot area. The overflow is nodeHeight / 2 at y = 1, and grows with the node's value. Nothing clamps y1 to height, and this happens for every arrangement value, including fixed.
2. resolveCollisionsTopToBottom cascades nodes past the bottom edge.
With arrangement: "snap" (the default), overlapping nodes in a column are pushed downward only (~L183-204). The pass never checks whether the last node ended up below height, so a column whose nodes are clustered near the bottom gets walked straight off the plot area — even when there was plenty of free space above it to absorb the correction. The bundled dependency already handles this: @plotly/d3-sankey@0.7.2 (src/sankey.js:292-299) ends its resolveCollisions with a bottom-bounded upward pass followed by a top-bounded downward pass —
resolveCollisionsBottomToTop(nodes,y1,nodes.length-1,alpha);// bottom boundresolveCollisionsTopToBottom(nodes,y0,0,alpha);// top bound
— whereas render.js declares a local function with the same name as the dep's downward helper, implements only that half, and never calls a counterpart.
Screenshots/Video
Measured on plotly.js v3.7.0 (dist/plotly.min.js from master). The dashed red rectangle is the plot area computed from the layout margins; the readout under each plot measures every .node-rect against it.
Repro 1 — node B requested at y = 0.98 overflows the 280px plot area by 61.9px (0.98 × 280 + 135/2 − 280). At y = 1.0 the overflow would be the full nodeHeight / 2 = 67.5px. The lower part of the rect and its label are cut off:
Repro 2 — arrangement: "snap". Nodes C and D are pushed 114px and 244px past the bottom of a 380px plot area. D is invisible; only the top 6px of C renders as a green sliver at the edge. Note the top 244px of the plot area is empty — there was room to absorb the entire cascade:
Control — identical trace to Repro 1 with y = 0.60 instead of 0.98: zero overflow, node renders in full. So this is a bounds bug at the edges, not a node-sizing bug.
Note node B: its center sits near the bottom edge and the lower half of the rect (plus its label) is clipped. Measured: y0 = 206.9, y1 = 341.9 in a 280px plot area — 61.9px of overflow.
Repro 2 — arrangement: "snap", collision cascade (isolates path 2; every y here is < 1, so path 1 alone would not clip):
Nodes B, C, D share a column and overlap, so they are pushed down in sequence: measured y0 of 244, 374, 504 in a 380px plot area.
C overflows by 114px and D by 244px — D never appears at all. The top 244px of the plot area is empty, so the cascade could have been absorbed upward in full.
Measuring this yourself: compare node rects against the plot area derived from the layout (gd.getBoundingClientRect().top + margin.t … + layout.height - margin.b). Do not compare them against the .sankey layer's getBoundingClientRect() — that box is the union of its children, so it stretches to contain the overflow and reports no clipping. In Repro 2 the layer box measures 614px against a 380px plot area.
Expected behaviour
Nodes stay inside the plot area. Specifically:
An explicitly positioned node is nudged so y0 >= 0 and y1 <= height, rather than being centered on an out-of-bounds point.
Collision resolution that overflows the bottom edge pulls the column back up into the available space above it.
Two cases genuinely cannot fit and should degrade predictably rather than silently hanging off the bottom:
a single node taller than the plot area — pin it to the top edge (y0 = 0);
a column whose stacked height exceeds the plot area — respect the bottom edge and let the excess run off the top, which is what the bundled d3-sankey does.
Actual behaviour
y0 / y1 are written past the plot area bounds and the node is clipped by the plot's clip path. No warning is emitted.
Related but distinct, both touching the same explicit-position block in render.js:
Explicit node coordinates (x/y positions) not fully respected in Sankey diagram #7758 — explicit x/y not respected. Different root cause (index correspondence between trace.node.x/y and graph.nodes breaks when isolated or phantom nodes are present), but it is the same for loop, so a fix for either should be written with the other in mind.
Adjacent and possibly a separate issue: the guard on the explicit-position branch is if(trace.node.x[i] && trace.node.y[i]), which is falsy for 0. A node requested at exactly x = 0 or y = 0 is silently skipped and keeps its computed position. Happy to split that out if you'd rather keep this issue to the bottom-edge case.
Description
When sankey node positions are supplied explicitly via
node.x/node.y, nodes placed near the bottom of the plot area (yclose to1) are drawn partly outside the plot area and get visually clipped. The label and the node rect are both cut off.There are two independent code paths that produce this, and they can be triggered separately:
1. Explicit positioning centers the node on
y * heightwithout bounds checking.In
src/traces/sankey/render.js("Force node position", ~L246-257), the node is centered on the requested position:y = 1means "center of the node at the bottom edge", so half the node always renders below the plot area. The overflow isnodeHeight / 2aty = 1, and grows with the node's value. Nothing clampsy1toheight, and this happens for everyarrangementvalue, includingfixed.2.
resolveCollisionsTopToBottomcascades nodes past the bottom edge.With
arrangement: "snap"(the default), overlapping nodes in a column are pushed downward only (~L183-204). The pass never checks whether the last node ended up belowheight, so a column whose nodes are clustered near the bottom gets walked straight off the plot area — even when there was plenty of free space above it to absorb the correction. The bundled dependency already handles this:@plotly/d3-sankey@0.7.2(src/sankey.js:292-299) ends itsresolveCollisionswith a bottom-bounded upward pass followed by a top-bounded downward pass —— whereas
render.jsdeclares a local function with the same name as the dep's downward helper, implements only that half, and never calls a counterpart.Screenshots/Video
Measured on plotly.js v3.7.0 (
dist/plotly.min.jsfrommaster). The dashed red rectangle is the plot area computed from the layout margins; the readout under each plot measures every.node-rectagainst it.Repro 1 — node
Brequested aty = 0.98overflows the 280px plot area by 61.9px (0.98 × 280 + 135/2 − 280). Aty = 1.0the overflow would be the fullnodeHeight / 2 = 67.5px. The lower part of the rect and its label are cut off:Repro 2 —
arrangement: "snap". NodesCandDare pushed 114px and 244px past the bottom of a 380px plot area.Dis invisible; only the top 6px ofCrenders as a green sliver at the edge. Note the top 244px of the plot area is empty — there was room to absorb the entire cascade:Control — identical trace to Repro 1 with
y = 0.60instead of0.98: zero overflow, node renders in full. So this is a bounds bug at the edges, not a node-sizing bug.Steps to reproduce
Both snippets use plotly.js latest un-minified from https://github.com/plotly/plotly.js/releases, in a
<div id="graph">. Margins are pinned so the numbers are deterministic.Repro 1 — explicit position, no collision resolution involved (
arrangement: "fixed"isolates path 1):y0 = 206.9,y1 = 341.9in a 280px plot area — 61.9px of overflow.Repro 2 —
arrangement: "snap", collision cascade (isolates path 2; everyyhere is < 1, so path 1 alone would not clip):y0of244,374,504in a 380px plot area.Dnever appears at all. The top 244px of the plot area is empty, so the cascade could have been absorbed upward in full.Measuring this yourself: compare node rects against the plot area derived from the layout (
gd.getBoundingClientRect().top + margin.t…+ layout.height - margin.b). Do not compare them against the.sankeylayer'sgetBoundingClientRect()— that box is the union of its children, so it stretches to contain the overflow and reports no clipping. In Repro 2 the layer box measures 614px against a 380px plot area.Expected behaviour
Nodes stay inside the plot area. Specifically:
y0 >= 0andy1 <= height, rather than being centered on an out-of-bounds point.Two cases genuinely cannot fit and should degrade predictably rather than silently hanging off the bottom:
y0 = 0);Actual behaviour
y0/y1are written past the plot area bounds and the node is clipped by the plot's clip path. No warning is emitted.Notes
render.js:x/ynot respected. Different root cause (index correspondence betweentrace.node.x/yandgraph.nodesbreaks when isolated or phantom nodes are present), but it is the sameforloop, so a fix for either should be written with the other in mind.if(trace.node.x[i] && trace.node.y[i]), which is falsy for0. A node requested at exactlyx = 0ory = 0is silently skipped and keeps its computed position. Happy to split that out if you'd rather keep this issue to the bottom-edge case.