diff --git a/draftlogs/7870_add.md b/draftlogs/7870_add.md new file mode 100644 index 00000000000..49624d6af38 --- /dev/null +++ b/draftlogs/7870_add.md @@ -0,0 +1 @@ +- 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 60b7cb3e419..22edd7c4e5b 100644 --- a/src/traces/sankey/attributes.js +++ b/src/traces/sankey/attributes.js @@ -33,7 +33,23 @@ var attrs = (module.exports = overrideAll( valType: 'enumerated', values: ['v', 'h'], dflt: 'h', - description: 'Sets the orientation of the Sankey diagram.' + description: [ + 'Sets the orientation of the Sankey diagram.', + '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(' ') }, valueformat: { 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 8b179c767cb..f8b2bdf4f17 100644 --- a/src/traces/sankey/plot.js +++ b/src/traces/sankey/plot.js @@ -193,8 +193,14 @@ module.exports = function plot(gd, calcData) { hoverCenterX = (link.source.x1 + link.target.x0) / 2; hoverCenterY = (link.y0 + link.y1) / 2; } + var vertical = link.trace.orientation === 'v'; + var reverse = link.trace.direction === 'reverse'; var center = [hoverCenterX, hoverCenterY]; - if(link.trace.orientation === 'v') center.reverse(); + // 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 5f31b5d1d20..4c6a960414b 100644 --- a/src/traces/sankey/render.js +++ b/src/traces/sankey/render.js @@ -32,6 +32,9 @@ function sankeyModel(layout, d, traceIndex) { var trace = calcData.trace; var domain = trace.domain; 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 = { @@ -285,6 +288,7 @@ function sankeyModel(layout, d, traceIndex) { trace: trace, guid: Lib.randstr(), horizontal: horizontal, + reverse: reverse, width: width, height: height, nodePad: trace.node.pad, @@ -588,6 +592,7 @@ function nodeModel(d, n) { sizeAcross: d.width, forceLayouts: d.forceLayouts, horizontal: d.horizontal, + reverse: d.reverse, darkBackground: Color.color(n.color).isDark(), rgb: Color.rgb(n.color), alpha: Color.color(n.color).alpha(), @@ -629,8 +634,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.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)'; + } + // horizontal + forward: sources on the left, flow rightward. + return strTranslate(d.translateX, d.translateY) + 'matrix(1 0 0 1 0 0)'; + } + 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)'; + } + // 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)'; } // event handling @@ -1049,7 +1067,10 @@ module.exports = function(gd, svg, calcData, layout, callbacks) { svgTextUtils.convertToTspans(e, gd); }) .attr('text-anchor', function(d) { - return (d.horizontal && d.left) ? 'end' : 'start'; + // vertical: labels are centered over the node. horizontal: aligned to the outer + // edge (reverse mirrors the layout, so the outer side and anchor flip). + if(!d.horizontal) return 'middle'; + return (d.left !== d.reverse) ? 'end' : 'start'; }) .attr('transform', function(d) { var e = d3.select(this); @@ -1059,27 +1080,29 @@ 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 ? '' : ( - 'scale(-1,1)' + strRotate(90) - ); + if(!d.horizontal) { + var posY = d.visibleHeight / 2; + // last Column (originalLayer === 1): put label towards center. + 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.reverse ? strRotate(90) : ('scale(-1,1)' + strRotate(90)); + return strTranslate(posX, posY) + 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.reverse ? 'scale(-1,1)' : ''); }); nodeLabel .transition() .ease(c.ease).duration(c.duration); -}; +}; \ No newline at end of file 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 1d326af1399..e09e4612ae9 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'); @@ -138,6 +140,24 @@ describe('sankey tests', function () { expect(fullTrace.domain.y).toEqual(attributes.domain.y.dflt, 'y domain by default'); }); + it('coerces the orientation values', function() { + ['h', 'v'].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('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( {}, @@ -376,6 +396,47 @@ describe('sankey tests', function () { }); afterEach(destroyGraphDiv); + it('applies the correct group transform per orientation and direction', function(done) { + function groupTransform() { + return d3Select('.sankey').attr('transform'); + } + 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); + } + + plotWith('h') + .then(function() { + // forward is the default direction + expect(groupTransform()).toContain('matrix(1 0 0 1 0 0)'); + return plotWith('h', 'forward'); + }) + .then(function() { + expect(groupTransform()).toContain('matrix(1 0 0 1 0 0)'); + return plotWith('h', 'reverse'); + }) + .then(function() { + expect(groupTransform()).toContain('matrix(-1 0 0 1 0 0)'); + return plotWith('v', 'forward'); + }) + .then(function() { + expect(groupTransform()).toContain('matrix(0 1 1 0 0 0)'); + return plotWith('v'); // forward is the default direction + }) + .then(function() { + expect(groupTransform()).toContain('matrix(0 1 1 0 0 0)'); + return plotWith('v', 'reverse'); + }) + .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); @@ -919,6 +980,100 @@ describe('sankey tests', function () { .then(done, done.fail); }); + it('@noCI should position hover labels correctly - horizontal, reverse', function (done) { + var gd = createGraphDiv(); + var forwardOffsetX; + + 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'); + + assertLabel( + ['source: Thermal generation', 'target: Losses', '787TWh'], + ['rgb(0, 0, 96)', 'rgb(255, 255, 255)', 13, 'Arial', 'rgb(255, 255, 255)'] + ); + + // The link itself is drawn mirrored under direction:'reverse' + // (see 'applies the correct group transform per orientation and + // 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 pos = d3Select('.hovertext').node().getBoundingClientRect(); + var reverseOffsetX = pos.x - (linkRect.left + linkRect.width / 2); + + expect(reverseOffsetX).toBeCloseTo( + forwardOffsetX, + -1.5, + 'label offset from its link is direction-independent' + ); + }) + .then(done, done.fail); + }); + + it('@noCI should position hover labels correctly - vertical, reverse', function (done) { + var gd = createGraphDiv(); + var forwardOffsetY; + + 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'); + + 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 pos = d3Select('.hovertext').node().getBoundingClientRect(); + var reverseOffsetY = pos.y - (linkRect.top + linkRect.height / 2); + + expect(reverseOffsetY).toBeCloseTo( + forwardOffsetY, + -1.5, + 'label offset from its link is direction-independent' + ); + }) + .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); @@ -1620,6 +1775,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;