Skip to content
Open
1 change: 1 addition & 0 deletions draftlogs/7870_add.md
Original file line number Diff line number Diff line change
@@ -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)]
18 changes: 17 additions & 1 deletion src/traces/sankey/attributes.js
Comment thread
wf-r marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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(' ')
Comment thread
wf-r marked this conversation as resolved.
},

valueformat: {
Expand Down
1 change: 1 addition & 0 deletions src/traces/sankey/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout
handleDomainDefaults(traceOut, layout, coerce);

coerce('orientation');
coerce('direction');
coerce('valueformat');
coerce('valuesuffix');

Expand Down
8 changes: 7 additions & 1 deletion src/traces/sankey/plot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
69 changes: 49 additions & 20 deletions src/traces/sankey/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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';
// horizontal: aligned to the outer edge (reverse flips which side is outer).
// vertical: inside-node placement, anchored at 'start'.
if(!d.horizontal) return 'start';
return (d.left !== d.reverse) ? 'end' : 'start';
})
.attr('transform', function(d) {
var e = d3.select(this);
Expand All @@ -1059,27 +1080,35 @@ 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;

if(!d.horizontal) {
// vertical: label sits inside the node, centered along its length.
// forward's local flip (scale(-1,1) + rotate(90)) is a reflection,
// reverse's (rotate(90) alone) a pure rotation; the group
// matrix has the same opposing handedness. That flips the sign with which
// the CAP_SHIFT baseline correction (baked into blockHeight) lands on
// screen, so it must be applied with the opposite sign for reverse -
// otherwise the label renders about half a line height too high.
var posY = d.reverse
? (d.visibleWidth + blockHeight) / 2
: (d.visibleWidth - blockHeight) / 2;
var flipV = d.reverse ? strRotate(90) : ('scale(-1,1)' + strRotate(90));
return strTranslate(posY, pad) + flipV;
}

var flipText = d.horizontal ? '' : (
'scale(-1,1)' + strRotate(90)
);

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);
};
};
23 changes: 18 additions & 5 deletions src/traces/sankey/select.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,30 @@ module.exports = function selectPoints(searchInfo, selectionTester) {
var selection = [];
var fullData = cd[0].trace;

var nodes = fullData._sankey.graph.nodes;
var model = fullData._sankey;
var nodes = model.graph.nodes;
var vertical = fullData.orientation === 'v';
var reverse = fullData.direction === 'reverse';

for(var i = 0; i < nodes.length; i++) {
var node = nodes[i];
if(node.partOfGroup) continue; // Those are invisible

// Position of node's centroid
var pos = [(node.x0 + node.x1) / 2, (node.y0 + node.y1) / 2];
// Position of node's centroid in the layout frame
var cx = (node.x0 + node.x1) / 2;
var cy = (node.y0 + node.y1) / 2;

// Swap x and y if trace is vertical
if(fullData.orientation === 'v') pos.reverse();
// Mirror/swap to match the group transform applied in render.js (sankeyTransform):
// h + forward: (cx, cy)
// h + reverse: (width - cx, cy) -> matrix(-1 0 0 1) + translate(width, 0)
// v + forward: (cy, cx) -> matrix( 0 1 1 0) (swap x/y)
// v + reverse: (cy, height - cx) -> matrix( 0 -1 1 0) + translate(0, height)
var pos;
if(vertical) {
pos = [cy, reverse ? model.height - cx : cx];
} else {
pos = [reverse ? model.width - cx : cx, cy];
}

if(selectionTester && selectionTester.contains(pos, false, i, searchInfo)) {
selection.push({
Expand Down
7 changes: 6 additions & 1 deletion src/types/generated/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6901,6 +6901,11 @@ export interface SankeyData {
arrangement?: 'snap' | 'perpendicular' | 'freeform' | 'fixed';
/** Assigns extra data each datum. This may be useful when listening to hover, click and selection events. Note that, *scatter* traces also appends customdata items in the markers DOM elements */
customdata?: Datum[] | Datum[][] | TypedArray;
/**
* 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).
* @default 'forward'
*/
direction?: 'forward' | 'reverse';
domain?: Domain;
/**
* Determines what trace information appears on hover. If `none` or `skip` are set, no information is displayed upon hovering. But, if `none` is set, click and hover events are still fired. Note that this attribute is superseded by `node.hoverinfo` and `node.hoverinfo` for nodes and links respectively.
Expand Down Expand Up @@ -7073,7 +7078,7 @@ export interface SankeyData {
y?: Datum[] | Datum[][] | TypedArray;
};
/**
* Sets the orientation of the Sankey diagram.
* 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.
* @default 'h'
*/
orientation?: 'v' | 'h';
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/image/baselines/sankey_circular_reverse.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/image/baselines/sankey_circular_vertical.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 22 additions & 0 deletions test/image/mocks/sankey_circular_reverse.json
Original file line number Diff line number Diff line change
@@ -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
}
}
22 changes: 22 additions & 0 deletions test/image/mocks/sankey_circular_vertical.json
Original file line number Diff line number Diff line change
@@ -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
}
}
23 changes: 23 additions & 0 deletions test/image/mocks/sankey_circular_vertical_reverse.json
Original file line number Diff line number Diff line change
@@ -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
}
}
23 changes: 23 additions & 0 deletions test/image/mocks/sankey_circular_with_arrows_reverse.json
Original file line number Diff line number Diff line change
@@ -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
}
}
24 changes: 24 additions & 0 deletions test/image/mocks/sankey_circular_with_arrows_vertical_reverse.json
Original file line number Diff line number Diff line change
@@ -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
}
}
32 changes: 32 additions & 0 deletions test/image/mocks/sankey_input_link_sort-reverse.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"data": [
{
"type": "sankey",
"direction": "reverse",
"node": {
"label": ["0", "1", "2", "3", "4", "5"],
"align": "center",
"color": "gray"
},
"link": {
"source": [0, 0, 0, 0, 1, 4],
"target": [1, 3, 2, 4, 5, 5],
"value": [1, 1, 2, 1, 1, 1],
"color": [
"rgba(230, 25, 75, 0.6)",
"rgba(255, 165, 0, 0.6)",
"rgba(60, 180, 75, 0.6)",
"rgba(0, 130, 200, 0.6)",
"rgba(145, 30, 180, 0.6)",
"rgba(128, 128, 128, 0.6)"
],
"sort": "input"
}
}
],
"layout": {
"title": { "text": "Sankey with link ordering matching the input array" },
"width": 800,
"height": 800
}
}
Loading