From 348aa235b3db14023ca743fc51faaf79d7264209 Mon Sep 17 00:00:00 2001 From: wfr Date: Fri, 26 Jun 2026 07:25:10 +0200 Subject: [PATCH 1/4] Add bottom-up and right-left orientations to Sankey \Extend the Sankey 'orientation' attribute with four direction-namedvalues while keeping the legacy ones as synonyms: - left-right (synonym of h): sources left, flow rightward - right-left: sources right, flow leftward (new) - top-down (synonym of v): sources top, flow downward - bottom-up: sources bottom, flow upward (new)right-left and bottom-up are mirrors of the existing horizontal andvertical layouts, each expressed as a single group-level matrix plustranslate in sankeyTransform(). right-left counts as horizontal forlayout sizing, dragging and hover-axis mapping; only the group ismirrored. Node labels get an updated counter-transform so glyphs stayupright, and right-left flips the outer-side text-anchor.plot.js: replace the '=== v' link-hover check with a proper verticaltest so left-right/right-left are not transposed, and mirror the flowaxis for bottom-up (y) and right-left (x).Default stays h; existing h/v figures render identically.Tests: orientation coercion for all values plus invalid fallback, anda group-transform assertion per orientation. --- src/traces/sankey/attributes.js | 11 +++++-- src/traces/sankey/plot.js | 9 +++++- src/traces/sankey/render.js | 37 +++++++++++++++++++----- test/jasmine/tests/sankey_test.js | 48 +++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 10 deletions(-) diff --git a/src/traces/sankey/attributes.js b/src/traces/sankey/attributes.js index a2ee49f0083..9f486e76db6 100644 --- a/src/traces/sankey/attributes.js +++ b/src/traces/sankey/attributes.js @@ -31,9 +31,16 @@ var attrs = (module.exports = overrideAll( orientation: { valType: 'enumerated', - values: ['v', 'h'], + values: ['v', 'h', 'left-right', 'right-left', 'top-down', 'bottom-up'], dflt: 'h', - description: 'Sets the orientation of the Sankey diagram.' + description: [ + 'Sets the orientation of the Sankey diagram.', + '`left-right` (synonym of the legacy value `h`) places sources on the left', + 'with the flow running rightward; `right-left` places sources on the right', + 'with the flow running leftward; `top-down` (synonym of the legacy value `v`)', + 'places sources at the top with the flow running downward; `bottom-up` places', + 'sources at the bottom with the flow running upward.' + ].join(' ') }, valueformat: { diff --git a/src/traces/sankey/plot.js b/src/traces/sankey/plot.js index 2ac7ec7206e..b53cd0dfdbf 100644 --- a/src/traces/sankey/plot.js +++ b/src/traces/sankey/plot.js @@ -193,8 +193,15 @@ module.exports = function plot(gd, calcData) { hoverCenterX = (link.source.x1 + link.target.x0) / 2; hoverCenterY = (link.y0 + link.y1) / 2; } + var orientation = link.trace.orientation; var center = [hoverCenterX, hoverCenterY]; - if(link.trace.orientation === 'v') center.reverse(); + // Vertical orientations transpose x/y to match the group transform. + if(orientation === 'v' || orientation === 'top-down' || orientation === 'bottom-up') { + center.reverse(); + } + // bottom-up / right-left additionally mirror the flow axis (matching the translate). + if(orientation === 'bottom-up') center[1] = d.parent.height - center[1]; + if(orientation === 'right-left') center[0] = d.parent.width - center[0]; center[0] += d.parent.translateX; center[1] += d.parent.translateY; return center; diff --git a/src/traces/sankey/render.js b/src/traces/sankey/render.js index a07d18a9c9e..b12c5945606 100644 --- a/src/traces/sankey/render.js +++ b/src/traces/sankey/render.js @@ -32,7 +32,11 @@ function sankeyModel(layout, d, traceIndex) { var calcData = unwrap(d); var trace = calcData.trace; var domain = trace.domain; - var horizontal = trace.orientation === 'h'; + var horizontal = trace.orientation === 'h' || + trace.orientation === 'left-right' || + trace.orientation === 'right-left'; + var rightLeft = trace.orientation === 'right-left'; + var bottomUp = trace.orientation === 'bottom-up'; var nodePad = trace.node.pad; var nodeThickness = trace.node.thickness; var nodeAlign = { @@ -271,6 +275,8 @@ function sankeyModel(layout, d, traceIndex) { trace: trace, guid: Lib.randstr(), horizontal: horizontal, + rightLeft: rightLeft, + bottomUp: bottomUp, width: width, height: height, nodePad: trace.node.pad, @@ -577,6 +583,8 @@ function nodeModel(d, n) { sizeAcross: d.width, forceLayouts: d.forceLayouts, horizontal: d.horizontal, + rightLeft: d.rightLeft, + bottomUp: d.bottomUp, darkBackground: tc.getBrightness() <= 128, tinyColorHue: Color.tinyRGB(tc), tinyColorAlpha: tc.getAlpha(), @@ -618,8 +626,21 @@ function sizeNode(rect) { function salientEnough(d) {return (d.link.width > 1 || d.linkLineWidth > 0);} function sankeyTransform(d) { - var offset = strTranslate(d.translateX, d.translateY); - return offset + (d.horizontal ? 'matrix(1 0 0 1 0 0)' : 'matrix(0 1 1 0 0 0)'); + if(d.horizontal) { + if(d.rightLeft) { + // right-left: sources on the right, flow leftward; horizontal mirror of left-right. + return strTranslate(d.translateX + d.width, d.translateY) + 'matrix(-1 0 0 1 0 0)'; + } + // h / left-right: sources on the left, flow rightward. + return strTranslate(d.translateX, d.translateY) + 'matrix(1 0 0 1 0 0)'; + } + if(d.bottomUp) { + // bottom-up: sources at the bottom, flow upward; a vertical mirror of top-down. + // Pure 90deg rotation (det +1) keeps the cross axis intact. + return strTranslate(d.translateX, d.translateY + d.height) + 'matrix(0 -1 1 0 0 0)'; + } + // top-down (also 'v'): reflection about y=x, sources at the top, flow downward. + return strTranslate(d.translateX, d.translateY) + 'matrix(0 1 1 0 0 0)'; } // event handling @@ -1048,7 +1069,8 @@ module.exports = function(gd, svg, calcData, layout, callbacks) { svgTextUtils.convertToTspans(e, gd); }) .attr('text-anchor', function(d) { - return (d.horizontal && d.left) ? 'end' : 'start'; + // right-left mirrors the layout horizontally, so the outer side (and anchor) flips. + return (d.horizontal && (d.left !== d.rightLeft)) ? 'end' : 'start'; }) .attr('transform', function(d) { var e = d3.select(this); @@ -1068,9 +1090,10 @@ module.exports = function(gd, svg, calcData, layout, callbacks) { } } - var flipText = d.horizontal ? '' : ( - 'scale(-1,1)' + strRotate(90) - ); + var flipText = d.horizontal ? + (d.rightLeft ? 'scale(-1,1)' : '') : ( + d.bottomUp ? strRotate(90) : ('scale(-1,1)' + strRotate(90)) + ); return strTranslate( d.horizontal ? posX : posY, diff --git a/test/jasmine/tests/sankey_test.js b/test/jasmine/tests/sankey_test.js index 69120330795..c7689ec5126 100644 --- a/test/jasmine/tests/sankey_test.js +++ b/test/jasmine/tests/sankey_test.js @@ -152,6 +152,15 @@ describe('sankey tests', function() { .toEqual(attributes.domain.y.dflt, 'y domain by default'); }); + it('coerces the vertical orientation values', function() { + ['h', 'v', 'left-right', 'right-left', 'top-down', 'bottom-up'].forEach(function(o) { + expect(_supply({orientation: o}).orientation) + .toBe(o, o + ' is a valid orientation'); + }); + expect(_supply({orientation: 'sideways'}).orientation) + .toBe(attributes.orientation.dflt, 'invalid orientation falls back to default'); + }); + it('\'Sankey\' layout dependent specification should have proper types', function() { var fullTrace = _supplyWithLayout({}, {font: { @@ -372,6 +381,45 @@ describe('sankey tests', function() { }); afterEach(destroyGraphDiv); + it('applies the correct group transform per orientation', function(done) { + function groupTransform() { + return d3Select('.sankey').attr('transform'); + } + function plotWith(orientation) { + var fig = Lib.extendDeep({}, mock); + fig.data[0].orientation = orientation; + // newPlot re-enters the trace, so the transform is set synchronously + // (no mid-transition interpolation to race against). + return Plotly.newPlot(gd, fig); + } + + plotWith('h') + .then(function() { + expect(groupTransform()).toContain('matrix(1 0 0 1 0 0)'); + return plotWith('left-right'); // legacy synonym of h + }) + .then(function() { + expect(groupTransform()).toContain('matrix(1 0 0 1 0 0)'); + return plotWith('right-left'); + }) + .then(function() { + expect(groupTransform()).toContain('matrix(-1 0 0 1 0 0)'); + return plotWith('top-down'); + }) + .then(function() { + expect(groupTransform()).toContain('matrix(0 1 1 0 0 0)'); + return plotWith('v'); // legacy synonym of top-down + }) + .then(function() { + expect(groupTransform()).toContain('matrix(0 1 1 0 0 0)'); + return plotWith('bottom-up'); + }) + .then(function() { + expect(groupTransform()).toContain('matrix(0 -1 1 0 0 0)'); + }) + .then(done, done.fail); + }); + it('Plotly.deleteTraces with two traces removes the deleted plot', function(done) { var mockCopy = Lib.extendDeep({}, mock); var mockCopy2 = Lib.extendDeep({}, mockDark); From a6dde4cbbfff950c9c873a96273d424c80cc80d7 Mon Sep 17 00:00:00 2001 From: wfr Date: Fri, 26 Jun 2026 09:15:36 +0200 Subject: [PATCH 2/4] Add bottom-up and right-left orientations to Sankey Adjust node label alignment for vertical case (labels below still need work) --- src/traces/sankey/render.js | 42 ++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/src/traces/sankey/render.js b/src/traces/sankey/render.js index b12c5945606..c821f832313 100644 --- a/src/traces/sankey/render.js +++ b/src/traces/sankey/render.js @@ -1069,8 +1069,10 @@ module.exports = function(gd, svg, calcData, layout, callbacks) { svgTextUtils.convertToTspans(e, gd); }) .attr('text-anchor', function(d) { - // right-left mirrors the layout horizontally, so the outer side (and anchor) flips. - return (d.horizontal && (d.left !== d.rightLeft)) ? 'end' : 'start'; + // vertical: labels are centered over the node. horizontal: aligned to the outer + // edge (right-left mirrors the layout, so the outer side and anchor flip). + if(!d.horizontal) return 'middle'; + return (d.left !== d.rightLeft) ? 'end' : 'start'; }) .attr('transform', function(d) { var e = d3.select(this); @@ -1080,25 +1082,27 @@ module.exports = function(gd, svg, calcData, layout, callbacks) { (nLines - 1) * LINE_SPACING - CAP_SHIFT ); - var posX = d.nodeLineWidth / 2 + TEXTPAD; - var posY = ((d.horizontal ? d.visibleHeight : d.visibleWidth) - blockHeight) / 2; - if(d.horizontal) { - if(d.left) { - posX = -posX; - } else { - posX += d.visibleWidth; - } - } + var pad = d.nodeLineWidth / 2 + TEXTPAD; - var flipText = d.horizontal ? - (d.rightLeft ? 'scale(-1,1)' : '') : ( - d.bottomUp ? strRotate(90) : ('scale(-1,1)' + strRotate(90)) - ); + if(!d.horizontal) { + var across = d.visibleHeight / 2; + var gap = pad + CAP_SHIFT * d.textFont.size; + // letzte Spalte (originalLayer === 1): Label nach innen, damit es nicht ueber den + // aeusseren Plot-Rand laeuft - analog zum horizontalen d.left-Fall. + var outside = d.left ? -gap : (d.visibleWidth + gap); + var flipV = d.bottomUp ? strRotate(90) : ('scale(-1,1)' + strRotate(90)); + return strTranslate(outside, across) + flipV; + } - return strTranslate( - d.horizontal ? posX : posY, - d.horizontal ? posY : posX - ) + flipText; + // horizontal: center along the node length, place just past the thickness edge. + var posX = pad; + var posY = (d.visibleHeight - blockHeight) / 2; + if(d.left) { + posX = -posX; + } else { + posX += d.visibleWidth; + } + return strTranslate(posX, posY) + (d.rightLeft ? 'scale(-1,1)' : ''); }); nodeLabel From 871a92b427777cb105f37dfef2465fb8d5003598 Mon Sep 17 00:00:00 2001 From: wfr Date: Fri, 26 Jun 2026 09:38:55 +0200 Subject: [PATCH 3/4] Add bottom-up and right-left orientations to Sankey Finalize node label alignment for vertical cases --- src/traces/sankey/render.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/traces/sankey/render.js b/src/traces/sankey/render.js index c821f832313..809a2b5c8e9 100644 --- a/src/traces/sankey/render.js +++ b/src/traces/sankey/render.js @@ -1085,13 +1085,12 @@ module.exports = function(gd, svg, calcData, layout, callbacks) { var pad = d.nodeLineWidth / 2 + TEXTPAD; if(!d.horizontal) { - var across = d.visibleHeight / 2; - var gap = pad + CAP_SHIFT * d.textFont.size; - // letzte Spalte (originalLayer === 1): Label nach innen, damit es nicht ueber den - // aeusseren Plot-Rand laeuft - analog zum horizontalen d.left-Fall. - var outside = d.left ? -gap : (d.visibleWidth + gap); + var posY = d.visibleHeight / 2; + // last Column (originalLayer === 1): put label towards center. + var posX = d.bottomUp ? + (d.left ? -(pad + CAP_SHIFT * d.textFont.size) : (d.visibleWidth + pad)) : (d.left ? -pad : (d.visibleWidth + pad + CAP_SHIFT * d.textFont.size)); var flipV = d.bottomUp ? strRotate(90) : ('scale(-1,1)' + strRotate(90)); - return strTranslate(outside, across) + flipV; + return strTranslate(posX, posY) + flipV; } // horizontal: center along the node length, place just past the thickness edge. From a98cf6e4ffffbbefe19de14605dd39a509157753 Mon Sep 17 00:00:00 2001 From: wfr Date: Fri, 26 Jun 2026 10:01:41 +0200 Subject: [PATCH 4/4] Add draft log --- draftlogs/7870_add.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 draftlogs/7870_add.md diff --git a/draftlogs/7870_add.md b/draftlogs/7870_add.md new file mode 100644 index 00000000000..40753530003 --- /dev/null +++ b/draftlogs/7870_add.md @@ -0,0 +1 @@ +- Add `right-left` and `bottom-up` values to Sankey `orientation`, with `left-right` and `top-down` as aliases for `h` and `v` [[#7870](https://github.com/plotly/plotly.js/pull/7870)] \ No newline at end of file