From 5887787506f1c2c9f1c8f7fae75691fe31ce07be Mon Sep 17 00:00:00 2001 From: wfr Date: Fri, 26 Jun 2026 07:25:10 +0200 Subject: [PATCH 1/7] Add bottom-up and right-left orientations to Sankey Rebase to v4. --- 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 60b7cb3e419..abfbb0fbdb0 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 8b179c767cb..96e209d56be 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 5f31b5d1d20..8513679972d 100644 --- a/src/traces/sankey/render.js +++ b/src/traces/sankey/render.js @@ -31,7 +31,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 = { @@ -285,6 +289,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, @@ -588,6 +594,8 @@ function nodeModel(d, n) { sizeAcross: d.width, forceLayouts: d.forceLayouts, horizontal: d.horizontal, + rightLeft: d.rightLeft, + bottomUp: d.bottomUp, darkBackground: Color.color(n.color).isDark(), rgb: Color.rgb(n.color), alpha: Color.color(n.color).alpha(), @@ -629,8 +637,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 @@ -1049,7 +1070,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); @@ -1069,9 +1091,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 1d326af1399..db7f98aa16d 100644 --- a/test/jasmine/tests/sankey_test.js +++ b/test/jasmine/tests/sankey_test.js @@ -138,6 +138,15 @@ describe('sankey tests', function () { expect(fullTrace.domain.y).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( {}, @@ -376,6 +385,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 0758cdb1e3742a5054775520cfe8ce449f899d93 Mon Sep 17 00:00:00 2001 From: wfr Date: Fri, 26 Jun 2026 09:15:36 +0200 Subject: [PATCH 2/7] 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 8513679972d..7ae183e5c8e 100644 --- a/src/traces/sankey/render.js +++ b/src/traces/sankey/render.js @@ -1070,8 +1070,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); @@ -1081,25 +1083,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 bdadff886e6772fa9c19694a65e6e72d15154d3c Mon Sep 17 00:00:00 2001 From: wfr Date: Fri, 26 Jun 2026 09:38:55 +0200 Subject: [PATCH 3/7] 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 7ae183e5c8e..838ff6c7e03 100644 --- a/src/traces/sankey/render.js +++ b/src/traces/sankey/render.js @@ -1086,13 +1086,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 5ea67d977c79f2c199e0060a605b5961642fb705 Mon Sep 17 00:00:00 2001 From: wfr Date: Fri, 26 Jun 2026 10:01:41 +0200 Subject: [PATCH 4/7] 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 From 542c55e4e0809614a882319f6ed38fb3f2364c8e Mon Sep 17 00:00:00 2001 From: wfr Date: Wed, 22 Jul 2026 11:39:29 +0200 Subject: [PATCH 5/7] Introduce `direction` attribute instead of more parameters for `orientation` --- draftlogs/7870_add.md | 2 +- src/traces/sankey/attributes.js | 21 ++++++++++++----- src/traces/sankey/defaults.js | 1 + src/traces/sankey/plot.js | 15 ++++++------ src/traces/sankey/render.js | 39 ++++++++++++++----------------- test/jasmine/tests/sankey_test.js | 29 ++++++++++++++++------- 6 files changed, 62 insertions(+), 45 deletions(-) diff --git a/draftlogs/7870_add.md b/draftlogs/7870_add.md index 40753530003..49624d6af38 100644 --- a/draftlogs/7870_add.md +++ b/draftlogs/7870_add.md @@ -1 +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 +- Add `direction` attribute (`forward` / `reverse`) to the Sankey trace, controlling the flow direction along the `orientation` axis: `forward` keeps sources on the left (horizontal) or top (vertical), `reverse` moves them to the right or bottom [[#7870](https://github.com/plotly/plotly.js/pull/7870)] diff --git a/src/traces/sankey/attributes.js b/src/traces/sankey/attributes.js index abfbb0fbdb0..22edd7c4e5b 100644 --- a/src/traces/sankey/attributes.js +++ b/src/traces/sankey/attributes.js @@ -31,15 +31,24 @@ var attrs = (module.exports = overrideAll( orientation: { valType: 'enumerated', - values: ['v', 'h', 'left-right', 'right-left', 'top-down', 'bottom-up'], + values: ['v', 'h'], dflt: 'h', 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.' + 'With `h` (the default), the flow runs horizontally.', + 'With `v`, the flow runs vertically.', + 'Use `direction` to control which side the sources are placed on.' + ].join(' ') + }, + + direction: { + valType: 'enumerated', + values: ['forward', 'reverse'], + dflt: 'forward', + description: [ + 'Sets the direction of the flow along the `orientation` axis.', + 'With `forward` (the default), sources are on the left (horizontal) or top (vertical).', + 'With `reverse`, sources are on the right (horizontal) or bottom (vertical).', ].join(' ') }, diff --git a/src/traces/sankey/defaults.js b/src/traces/sankey/defaults.js index 118b58db30f..98a8e1a018e 100644 --- a/src/traces/sankey/defaults.js +++ b/src/traces/sankey/defaults.js @@ -101,6 +101,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout handleDomainDefaults(traceOut, layout, coerce); coerce('orientation'); + coerce('direction'); coerce('valueformat'); coerce('valuesuffix'); diff --git a/src/traces/sankey/plot.js b/src/traces/sankey/plot.js index 96e209d56be..f8b2bdf4f17 100644 --- a/src/traces/sankey/plot.js +++ b/src/traces/sankey/plot.js @@ -193,15 +193,14 @@ 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 vertical = link.trace.orientation === 'v'; + var reverse = link.trace.direction === 'reverse'; var center = [hoverCenterX, hoverCenterY]; - // 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]; + // Vertical orientation transposes x/y to match the group transform. + if(vertical) center.reverse(); + // reverse direction additionally mirrors the flow axis (matching the translate). + if(vertical && reverse) center[1] = d.parent.height - center[1]; + if(!vertical && reverse) 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 838ff6c7e03..4c6a960414b 100644 --- a/src/traces/sankey/render.js +++ b/src/traces/sankey/render.js @@ -31,11 +31,10 @@ function sankeyModel(layout, d, traceIndex) { var calcData = unwrap(d); var trace = calcData.trace; var domain = trace.domain; - 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 horizontal = trace.orientation === 'h'; + // reverse flips the source side along the flow axis: horizontal -> sources on the + // right (flow leftward), vertical -> sources at the bottom (flow upward). + var reverse = trace.direction === 'reverse'; var nodePad = trace.node.pad; var nodeThickness = trace.node.thickness; var nodeAlign = { @@ -289,8 +288,7 @@ function sankeyModel(layout, d, traceIndex) { trace: trace, guid: Lib.randstr(), horizontal: horizontal, - rightLeft: rightLeft, - bottomUp: bottomUp, + reverse: reverse, width: width, height: height, nodePad: trace.node.pad, @@ -594,8 +592,7 @@ function nodeModel(d, n) { sizeAcross: d.width, forceLayouts: d.forceLayouts, horizontal: d.horizontal, - rightLeft: d.rightLeft, - bottomUp: d.bottomUp, + reverse: d.reverse, darkBackground: Color.color(n.color).isDark(), rgb: Color.rgb(n.color), alpha: Color.color(n.color).alpha(), @@ -638,19 +635,19 @@ function salientEnough(d) {return (d.link.width > 1 || d.linkLineWidth > 0);} function sankeyTransform(d) { if(d.horizontal) { - if(d.rightLeft) { - // right-left: sources on the right, flow leftward; horizontal mirror of left-right. + if(d.reverse) { + // horizontal + reverse: sources on the right, flow leftward; a mirror of forward. return strTranslate(d.translateX + d.width, d.translateY) + 'matrix(-1 0 0 1 0 0)'; } - // h / left-right: sources on the left, flow rightward. + // horizontal + forward: 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. + if(d.reverse) { + // vertical + reverse: sources at the bottom, flow upward; a mirror of forward. // 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. + // vertical + forward: reflection about y=x, sources at the top, flow downward. return strTranslate(d.translateX, d.translateY) + 'matrix(0 1 1 0 0 0)'; } @@ -1071,9 +1068,9 @@ module.exports = function(gd, svg, calcData, layout, callbacks) { }) .attr('text-anchor', function(d) { // 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). + // edge (reverse mirrors the layout, so the outer side and anchor flip). if(!d.horizontal) return 'middle'; - return (d.left !== d.rightLeft) ? 'end' : 'start'; + return (d.left !== d.reverse) ? 'end' : 'start'; }) .attr('transform', function(d) { var e = d3.select(this); @@ -1088,9 +1085,9 @@ module.exports = function(gd, svg, calcData, layout, callbacks) { if(!d.horizontal) { var posY = d.visibleHeight / 2; // last Column (originalLayer === 1): put label towards center. - var posX = d.bottomUp ? + var posX = d.reverse ? (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)); + var flipV = d.reverse ? strRotate(90) : ('scale(-1,1)' + strRotate(90)); return strTranslate(posX, posY) + flipV; } @@ -1102,10 +1099,10 @@ module.exports = function(gd, svg, calcData, layout, callbacks) { } else { posX += d.visibleWidth; } - return strTranslate(posX, posY) + (d.rightLeft ? 'scale(-1,1)' : ''); + return strTranslate(posX, posY) + (d.reverse ? 'scale(-1,1)' : ''); }); 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 db7f98aa16d..435e122127c 100644 --- a/test/jasmine/tests/sankey_test.js +++ b/test/jasmine/tests/sankey_test.js @@ -138,8 +138,8 @@ describe('sankey tests', function () { expect(fullTrace.domain.y).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) { + it('coerces the orientation values', function() { + ['h', 'v'].forEach(function(o) { expect(_supply({orientation: o}).orientation) .toBe(o, o + ' is a valid orientation'); }); @@ -147,6 +147,15 @@ describe('sankey tests', function () { .toBe(attributes.orientation.dflt, 'invalid orientation falls back to default'); }); + it('coerces the direction values', function() { + ['forward', 'reverse'].forEach(function(dir) { + expect(_supply({direction: dir}).direction) + .toBe(dir, dir + ' is a valid direction'); + }); + expect(_supply({direction: 'backward'}).direction) + .toBe(attributes.direction.dflt, 'invalid direction falls back to default'); + }); + it("'Sankey' layout dependent specification should have proper types", function () { var fullTrace = _supplyWithLayout( {}, @@ -385,13 +394,14 @@ describe('sankey tests', function () { }); afterEach(destroyGraphDiv); - it('applies the correct group transform per orientation', function(done) { + it('applies the correct group transform per orientation and direction', function(done) { function groupTransform() { return d3Select('.sankey').attr('transform'); } - function plotWith(orientation) { + function plotWith(orientation, direction) { var fig = Lib.extendDeep({}, mock); fig.data[0].orientation = orientation; + if(direction !== undefined) fig.data[0].direction = direction; // newPlot re-enters the trace, so the transform is set synchronously // (no mid-transition interpolation to race against). return Plotly.newPlot(gd, fig); @@ -399,24 +409,25 @@ describe('sankey tests', function () { plotWith('h') .then(function() { + // forward is the default direction expect(groupTransform()).toContain('matrix(1 0 0 1 0 0)'); - return plotWith('left-right'); // legacy synonym of h + return plotWith('h', 'forward'); }) .then(function() { expect(groupTransform()).toContain('matrix(1 0 0 1 0 0)'); - return plotWith('right-left'); + return plotWith('h', 'reverse'); }) .then(function() { expect(groupTransform()).toContain('matrix(-1 0 0 1 0 0)'); - return plotWith('top-down'); + return plotWith('v', 'forward'); }) .then(function() { expect(groupTransform()).toContain('matrix(0 1 1 0 0 0)'); - return plotWith('v'); // legacy synonym of top-down + return plotWith('v'); // forward is the default direction }) .then(function() { expect(groupTransform()).toContain('matrix(0 1 1 0 0 0)'); - return plotWith('bottom-up'); + return plotWith('v', 'reverse'); }) .then(function() { expect(groupTransform()).toContain('matrix(0 -1 1 0 0 0)'); From 61a556d0cffd09e94e04e14ba709f81ea8bc4097 Mon Sep 17 00:00:00 2001 From: wfr Date: Wed, 22 Jul 2026 13:37:07 +0200 Subject: [PATCH 6/7] Add tests and mocks for direction argument. --- test/image/mocks/sankey_circular_reverse.json | 22 ++++ .../image/mocks/sankey_circular_vertical.json | 22 ++++ .../sankey_circular_vertical_reverse.json | 23 ++++ .../sankey_circular_with_arrows_reverse.json | 23 ++++ ...circular_with_arrows_vertical_reverse.json | 24 ++++ test/jasmine/tests/sankey_test.js | 116 ++++++++++++++++++ 6 files changed, 230 insertions(+) create mode 100644 test/image/mocks/sankey_circular_reverse.json create mode 100644 test/image/mocks/sankey_circular_vertical.json create mode 100644 test/image/mocks/sankey_circular_vertical_reverse.json create mode 100644 test/image/mocks/sankey_circular_with_arrows_reverse.json create mode 100644 test/image/mocks/sankey_circular_with_arrows_vertical_reverse.json diff --git a/test/image/mocks/sankey_circular_reverse.json b/test/image/mocks/sankey_circular_reverse.json new file mode 100644 index 00000000000..34028be7b75 --- /dev/null +++ b/test/image/mocks/sankey_circular_reverse.json @@ -0,0 +1,22 @@ +{ + "data": [ + { + "type": "sankey", + "direction": "reverse", + "node": { + "pad": 5, + "label": ["0", "1", "2", "3", "4", "5", "6"] + }, + "link": { + "source": [0, 0, 1, 2, 5, 4, 3], + "target": [5, 3, 4, 3, 0, 2, 2], + "value": [1, 2, 1, 1, 1, 1, 1] + } + } + ], + "layout": { + "title": { "text": "Sankey with circular data" }, + "width": 800, + "height": 800 + } +} diff --git a/test/image/mocks/sankey_circular_vertical.json b/test/image/mocks/sankey_circular_vertical.json new file mode 100644 index 00000000000..ad7992c4e3b --- /dev/null +++ b/test/image/mocks/sankey_circular_vertical.json @@ -0,0 +1,22 @@ +{ + "data": [ + { + "type": "sankey", + "orientation": "v", + "node": { + "pad": 5, + "label": ["0", "1", "2", "3", "4", "5", "6"] + }, + "link": { + "source": [0, 0, 1, 2, 5, 4, 3], + "target": [5, 3, 4, 3, 0, 2, 2], + "value": [1, 2, 1, 1, 1, 1, 1] + } + } + ], + "layout": { + "title": { "text": "Sankey with circular data" }, + "width": 800, + "height": 800 + } +} diff --git a/test/image/mocks/sankey_circular_vertical_reverse.json b/test/image/mocks/sankey_circular_vertical_reverse.json new file mode 100644 index 00000000000..4e2b6ccb535 --- /dev/null +++ b/test/image/mocks/sankey_circular_vertical_reverse.json @@ -0,0 +1,23 @@ +{ + "data": [ + { + "type": "sankey", + "orientation": "v", + "direction": "reverse", + "node": { + "pad": 5, + "label": ["0", "1", "2", "3", "4", "5", "6"] + }, + "link": { + "source": [0, 0, 1, 2, 5, 4, 3], + "target": [5, 3, 4, 3, 0, 2, 2], + "value": [1, 2, 1, 1, 1, 1, 1] + } + } + ], + "layout": { + "title": { "text": "Sankey with circular data" }, + "width": 800, + "height": 800 + } +} diff --git a/test/image/mocks/sankey_circular_with_arrows_reverse.json b/test/image/mocks/sankey_circular_with_arrows_reverse.json new file mode 100644 index 00000000000..82ac9ddd165 --- /dev/null +++ b/test/image/mocks/sankey_circular_with_arrows_reverse.json @@ -0,0 +1,23 @@ +{ + "data": [ + { + "type": "sankey", + "direction": "reverse", + "node": { + "pad": 5, + "label": ["0", "1", "2", "3", "4", "5", "6"] + }, + "link": { + "arrowlen": 20, + "source": [0, 0, 1, 2, 5, 4, 3], + "target": [5, 3, 4, 3, 0, 2, 2], + "value": [1, 2, 1, 1, 1, 1, 1] + } + } + ], + "layout": { + "title": { "text": "Sankey with circular data and arrows" }, + "width": 800, + "height": 800 + } +} diff --git a/test/image/mocks/sankey_circular_with_arrows_vertical_reverse.json b/test/image/mocks/sankey_circular_with_arrows_vertical_reverse.json new file mode 100644 index 00000000000..6bc8094af34 --- /dev/null +++ b/test/image/mocks/sankey_circular_with_arrows_vertical_reverse.json @@ -0,0 +1,24 @@ +{ + "data": [ + { + "type": "sankey", + "orientation": "v", + "direction": "reverse", + "node": { + "pad": 5, + "label": ["0", "1", "2", "3", "4", "5", "6"] + }, + "link": { + "arrowlen": 20, + "source": [0, 0, 1, 2, 5, 4, 3], + "target": [5, 3, 4, 3, 0, 2, 2], + "value": [1, 2, 1, 1, 1, 1, 1] + } + } + ], + "layout": { + "title": { "text": "Sankey with circular data and arrows" }, + "width": 800, + "height": 800 + } +} diff --git a/test/jasmine/tests/sankey_test.js b/test/jasmine/tests/sankey_test.js index 435e122127c..37fe193a96a 100644 --- a/test/jasmine/tests/sankey_test.js +++ b/test/jasmine/tests/sankey_test.js @@ -127,6 +127,8 @@ describe('sankey tests', function () { expect(fullTrace.orientation).toEqual(attributes.orientation.dflt, 'use orientation by default'); + expect(fullTrace.direction).toEqual(attributes.direction.dflt, 'use direction by default'); + expect(fullTrace.valueformat).toEqual(attributes.valueformat.dflt, 'valueformat by default'); expect(fullTrace.valuesuffix).toEqual(attributes.valuesuffix.dflt, 'valuesuffix by default'); @@ -978,6 +980,66 @@ describe('sankey tests', function () { .then(done, done.fail); }); + it('@noCI should position hover labels correctly - horizontal, reverse', function (done) { + var gd = createGraphDiv(); + var mockCopy = Lib.extendDeep({}, mock); + mockCopy.data[0].direction = 'reverse'; + + Plotly.newPlot(gd, mockCopy) + .then(function () { + hoverLink('Thermal generation', 'Losses'); + + assertLabel( + ['source: Thermal generation', 'target: Losses', '787TWh'], + ['rgb(0, 0, 96)', 'rgb(255, 255, 255)', 13, 'Arial', 'rgb(255, 255, 255)'] + ); + + // With direction:'reverse' the link is drawn mirrored on screen + // (see 'applies the correct group transform per orientation and + // direction'). The hover label must track that mirrored link, + // not the 'forward' position - which is exactly what a + // hoverCenterPosition unaware of `direction` would still produce. + var linkRect = rectForLink('Thermal generation', 'Losses'); + var g = d3Select('.hovertext'); + var pos = g.node().getBoundingClientRect(); + expect(pos.x).toBeCloseTo( + linkRect.left + linkRect.width / 2, + -1.5, + 'label tracks the mirrored link horizontally' + ); + expect(pos.x).not.toBeCloseTo(782, -1.5, 'label must not sit at the un-mirrored (forward) position'); + }) + .then(done, done.fail); + }); + + it('@noCI should position hover labels correctly - vertical, reverse', function (done) { + var gd = createGraphDiv(); + var mockCopy = Lib.extendDeep({}, mock); + mockCopy.data[0].orientation = 'v'; + mockCopy.data[0].direction = 'reverse'; + + Plotly.newPlot(gd, mockCopy) + .then(function () { + hoverLink('Thermal generation', 'Losses'); + + assertLabel( + ['source: Thermal generation', 'target: Losses', '787TWh'], + ['rgb(0, 0, 96)', 'rgb(255, 255, 255)', 13, 'Arial', 'rgb(255, 255, 255)'] + ); + + var linkRect = rectForLink('Thermal generation', 'Losses'); + var g = d3Select('.hovertext'); + var pos = g.node().getBoundingClientRect(); + expect(pos.y).toBeCloseTo( + linkRect.top + linkRect.height / 2, + -1.5, + 'label tracks the mirrored link vertically' + ); + expect(pos.y).not.toBeCloseTo(500, -1.5, 'label must not sit at the un-mirrored (forward) position'); + }) + .then(done, done.fail); + }); + it('should show the correct hover labels when hovertemplate is specified', function (done) { var gd = createGraphDiv(); var mockCopy = Lib.extendDeep({}, mock); @@ -1679,6 +1741,60 @@ describe('sankey tests', function () { }); }); + describe('for mirrored direction:', function () { + var gd; + var mockCopy; + var nodeId = 4; // Selecting node with label 'Solid' + + beforeEach(function () { + gd = createGraphDiv(); + mockCopy = Lib.extendDeep({}, mock); + mockCopy.data[0].arrangement = 'freeform'; + }); + + afterEach(function () { + Plotly.purge(gd); + destroyGraphDiv(); + }); + + // Same helper as 'for arrangement freeform:' above: getNodeCoords + // reads the node's actual on-screen position, so a correct drag + // must land at position + move regardless of any mirroring applied + // by the orientation/direction group transform. + const testDragNode = (move) => async () => { + let nodes = document.getElementsByClassName('sankey-node'); + const node = nodes.item(nodeId); + const position = getNodeCoords(node); + await drag({ node: node, dpos: move, nsteps: 10, timeDelay: 0 }); + + nodes = document.getElementsByClassName('sankey-node'); + const draggedNode = nodes.item(nodes.length - 1); + if (!draggedNode) return; + const newPosition = getNodeCoords(draggedNode); + expect(newPosition.x).toBeCloseTo(position.x + move[0], 0, 'final x position is off'); + expect(newPosition.y).toBeCloseTo(position.y + move[1], 0, 'final y position is off'); + }; + + [ + { orientation: 'h', direction: 'reverse' }, + { orientation: 'v', direction: 'reverse' } + ].forEach(function (combo) { + it( + 'should change the position of a node on drag - orientation ' + + combo.orientation + + ', direction ' + + combo.direction, + function (done) { + mockCopy.data[0].orientation = combo.orientation; + mockCopy.data[0].direction = combo.direction; + var move = [50, -50]; + + Plotly.newPlot(gd, mockCopy).then(testDragNode(move)).then(done, done.fail); + } + ); + }); + }); + describe('in relation to uirevision', function () { var gd; From 3b8c5864856679bf4981385de1d6f2561e4e1d0b Mon Sep 17 00:00:00 2001 From: wfr Date: Wed, 22 Jul 2026 13:52:55 +0200 Subject: [PATCH 7/7] Adjust test. --- test/jasmine/tests/sankey_test.js | 80 ++++++++++++++++++++++--------- 1 file changed, 57 insertions(+), 23 deletions(-) diff --git a/test/jasmine/tests/sankey_test.js b/test/jasmine/tests/sankey_test.js index 37fe193a96a..e09e4612ae9 100644 --- a/test/jasmine/tests/sankey_test.js +++ b/test/jasmine/tests/sankey_test.js @@ -982,10 +982,28 @@ describe('sankey tests', function () { it('@noCI should position hover labels correctly - horizontal, reverse', function (done) { var gd = createGraphDiv(); - var mockCopy = Lib.extendDeep({}, mock); - mockCopy.data[0].direction = 'reverse'; + var forwardOffsetX; - Plotly.newPlot(gd, mockCopy) + function plotWith(direction) { + var fig = Lib.extendDeep({}, mock); + if (direction !== undefined) fig.data[0].direction = direction; + return Plotly.newPlot(gd, fig); + } + + plotWith() + .then(function () { + // Baseline: the label's offset from its own link's center, + // under the default 'forward' direction (absolute pixel + // positions are font/browser sensitive - see the plain + // 'horizontal'/'vertical' tests above - so we anchor to the + // link itself rather than a hard-coded literal). + hoverLink('Thermal generation', 'Losses'); + var linkRect = rectForLink('Thermal generation', 'Losses'); + var pos = d3Select('.hovertext').node().getBoundingClientRect(); + forwardOffsetX = pos.x - (linkRect.left + linkRect.width / 2); + + return plotWith('reverse'); + }) .then(function () { hoverLink('Thermal generation', 'Losses'); @@ -994,31 +1012,47 @@ describe('sankey tests', function () { ['rgb(0, 0, 96)', 'rgb(255, 255, 255)', 13, 'Arial', 'rgb(255, 255, 255)'] ); - // With direction:'reverse' the link is drawn mirrored on screen + // The link itself is drawn mirrored under direction:'reverse' // (see 'applies the correct group transform per orientation and - // direction'). The hover label must track that mirrored link, - // not the 'forward' position - which is exactly what a - // hoverCenterPosition unaware of `direction` would still produce. + // direction' above). hoverCenterPosition places the label at a + // fixed offset from its anchor regardless of direction, so that + // offset - not the absolute position - is what should stay + // constant here. If hoverCenterPosition ignored `direction`, the + // link would still be mirrored but the label would stay glued to + // the old, un-mirrored anchor, breaking this invariant. var linkRect = rectForLink('Thermal generation', 'Losses'); - var g = d3Select('.hovertext'); - var pos = g.node().getBoundingClientRect(); - expect(pos.x).toBeCloseTo( - linkRect.left + linkRect.width / 2, + var pos = d3Select('.hovertext').node().getBoundingClientRect(); + var reverseOffsetX = pos.x - (linkRect.left + linkRect.width / 2); + + expect(reverseOffsetX).toBeCloseTo( + forwardOffsetX, -1.5, - 'label tracks the mirrored link horizontally' + 'label offset from its link is direction-independent' ); - expect(pos.x).not.toBeCloseTo(782, -1.5, 'label must not sit at the un-mirrored (forward) position'); }) .then(done, done.fail); }); it('@noCI should position hover labels correctly - vertical, reverse', function (done) { var gd = createGraphDiv(); - var mockCopy = Lib.extendDeep({}, mock); - mockCopy.data[0].orientation = 'v'; - mockCopy.data[0].direction = 'reverse'; + var forwardOffsetY; - Plotly.newPlot(gd, mockCopy) + function plotWith(direction) { + var fig = Lib.extendDeep({}, mock); + fig.data[0].orientation = 'v'; + if (direction !== undefined) fig.data[0].direction = direction; + return Plotly.newPlot(gd, fig); + } + + plotWith() + .then(function () { + hoverLink('Thermal generation', 'Losses'); + var linkRect = rectForLink('Thermal generation', 'Losses'); + var pos = d3Select('.hovertext').node().getBoundingClientRect(); + forwardOffsetY = pos.y - (linkRect.top + linkRect.height / 2); + + return plotWith('reverse'); + }) .then(function () { hoverLink('Thermal generation', 'Losses'); @@ -1028,14 +1062,14 @@ describe('sankey tests', function () { ); var linkRect = rectForLink('Thermal generation', 'Losses'); - var g = d3Select('.hovertext'); - var pos = g.node().getBoundingClientRect(); - expect(pos.y).toBeCloseTo( - linkRect.top + linkRect.height / 2, + var pos = d3Select('.hovertext').node().getBoundingClientRect(); + var reverseOffsetY = pos.y - (linkRect.top + linkRect.height / 2); + + expect(reverseOffsetY).toBeCloseTo( + forwardOffsetY, -1.5, - 'label tracks the mirrored link vertically' + 'label offset from its link is direction-independent' ); - expect(pos.y).not.toBeCloseTo(500, -1.5, 'label must not sit at the un-mirrored (forward) position'); }) .then(done, done.fail); });