diff --git a/draftlogs/6276_add.md b/draftlogs/6276_add.md new file mode 100644 index 00000000000..fbfaf0c67d3 --- /dev/null +++ b/draftlogs/6276_add.md @@ -0,0 +1,2 @@ + - Add support for sankey links with arrows [[#6276](https://github.com/plotly/plotly.js/pull/6276)], + with thanks to @Andy2003 for the contribution! diff --git a/src/traces/sankey/attributes.js b/src/traces/sankey/attributes.js index c129227014c..3ebd92b0934 100644 --- a/src/traces/sankey/attributes.js +++ b/src/traces/sankey/attributes.js @@ -168,6 +168,14 @@ var attrs = module.exports = overrideAll({ }, link: { + arrowlen: { + valType: 'number', + min: 0, + dflt: 0, + description: [ + 'Sets the length (in px) of the links arrow, if 0 no arrow will be drawn.' + ].join(' ') + }, label: { valType: 'data_array', dflt: [], diff --git a/src/traces/sankey/defaults.js b/src/traces/sankey/defaults.js index 9694df6eb7b..01fc14f6fa4 100644 --- a/src/traces/sankey/defaults.js +++ b/src/traces/sankey/defaults.js @@ -52,6 +52,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout return Lib.coerce(linkIn, linkOut, attributes.link, attr, dflt); } coerceLink('label'); + coerceLink('arrowlen'); coerceLink('source'); coerceLink('target'); coerceLink('value'); diff --git a/src/traces/sankey/render.js b/src/traces/sankey/render.js index ced03a514f5..57bbfafa986 100644 --- a/src/traces/sankey/render.js +++ b/src/traces/sankey/render.js @@ -271,6 +271,7 @@ function sankeyModel(layout, d, traceIndex) { nodeLineWidth: trace.node.line.width, linkLineColor: trace.link.line.color, linkLineWidth: trace.link.line.width, + linkArrowLength: trace.link.arrowlen, valueFormat: trace.valueformat, valueSuffix: trace.valuesuffix, textFont: trace.textfont, @@ -309,6 +310,7 @@ function linkModel(d, l, i) { linkPath: linkPath, linkLineColor: d.linkLineColor, linkLineWidth: d.linkLineWidth, + linkArrowLength: d.linkArrowLength, valueFormat: d.valueFormat, valueSuffix: d.valueSuffix, sankey: d.sankey, @@ -318,7 +320,7 @@ function linkModel(d, l, i) { }; } -function createCircularClosedPathString(link) { +function createCircularClosedPathString(link, arrowLen) { // Using coordinates computed by d3-sankey-circular var pathString = ''; var offset = link.width / 2; @@ -328,17 +330,17 @@ function createCircularClosedPathString(link) { pathString = // start at the left of the target node 'M ' + - coords.targetX + ' ' + (coords.targetY + offset) + ' ' + + (coords.targetX - arrowLen) + ' ' + (coords.targetY + offset) + ' ' + 'L' + - coords.rightInnerExtent + ' ' + (coords.targetY + offset) + + (coords.rightInnerExtent - arrowLen) + ' ' + (coords.targetY + offset) + 'A' + (coords.rightLargeArcRadius + offset) + ' ' + (coords.rightSmallArcRadius + offset) + ' 0 0 1 ' + - (coords.rightFullExtent - offset) + ' ' + (coords.targetY - coords.rightSmallArcRadius) + + (coords.rightFullExtent - offset - arrowLen) + ' ' + (coords.targetY - coords.rightSmallArcRadius) + 'L' + - (coords.rightFullExtent - offset) + ' ' + coords.verticalRightInnerExtent + + (coords.rightFullExtent - offset - arrowLen) + ' ' + coords.verticalRightInnerExtent + 'A' + (coords.rightLargeArcRadius + offset) + ' ' + (coords.rightLargeArcRadius + offset) + ' 0 0 1 ' + - coords.rightInnerExtent + ' ' + (coords.verticalFullExtent - offset) + + (coords.rightInnerExtent - arrowLen) + ' ' + (coords.verticalFullExtent - offset) + 'L' + coords.leftInnerExtent + ' ' + (coords.verticalFullExtent - offset) + 'A' + @@ -366,34 +368,35 @@ function createCircularClosedPathString(link) { (coords.leftLargeArcRadius - offset) + ' ' + (coords.leftLargeArcRadius - offset) + ' 0 0 0 ' + coords.leftInnerExtent + ' ' + (coords.verticalFullExtent + offset) + 'L' + - coords.rightInnerExtent + ' ' + (coords.verticalFullExtent + offset) + + (coords.rightInnerExtent - arrowLen) + ' ' + (coords.verticalFullExtent + offset) + 'A' + (coords.rightLargeArcRadius - offset) + ' ' + (coords.rightLargeArcRadius - offset) + ' 0 0 0 ' + - (coords.rightFullExtent + offset) + ' ' + coords.verticalRightInnerExtent + + (coords.rightFullExtent + offset - arrowLen) + ' ' + coords.verticalRightInnerExtent + 'L' + - (coords.rightFullExtent + offset) + ' ' + (coords.targetY - coords.rightSmallArcRadius) + + (coords.rightFullExtent + offset - arrowLen) + ' ' + (coords.targetY - coords.rightSmallArcRadius) + 'A' + (coords.rightLargeArcRadius - offset) + ' ' + (coords.rightSmallArcRadius - offset) + ' 0 0 0 ' + - coords.rightInnerExtent + ' ' + (coords.targetY - offset) + + (coords.rightInnerExtent - arrowLen) + ' ' + (coords.targetY - offset) + 'L' + - coords.targetX + ' ' + (coords.targetY - offset) + + (coords.targetX - arrowLen) + ' ' + (coords.targetY - offset) + + (arrowLen > 0 ? 'L' + coords.targetX + ' ' + (coords.targetY) : '') + 'Z'; } else { // Bottom path pathString = // start at the left of the target node 'M ' + - coords.targetX + ' ' + (coords.targetY - offset) + ' ' + + (coords.targetX - arrowLen) + ' ' + (coords.targetY - offset) + ' ' + 'L' + - coords.rightInnerExtent + ' ' + (coords.targetY - offset) + + (coords.rightInnerExtent - arrowLen) + ' ' + (coords.targetY - offset) + 'A' + (coords.rightLargeArcRadius + offset) + ' ' + (coords.rightSmallArcRadius + offset) + ' 0 0 0 ' + - (coords.rightFullExtent - offset) + ' ' + (coords.targetY + coords.rightSmallArcRadius) + + (coords.rightFullExtent - offset - arrowLen) + ' ' + (coords.targetY + coords.rightSmallArcRadius) + 'L' + - (coords.rightFullExtent - offset) + ' ' + coords.verticalRightInnerExtent + + (coords.rightFullExtent - offset - arrowLen) + ' ' + coords.verticalRightInnerExtent + 'A' + (coords.rightLargeArcRadius + offset) + ' ' + (coords.rightLargeArcRadius + offset) + ' 0 0 0 ' + - coords.rightInnerExtent + ' ' + (coords.verticalFullExtent + offset) + + (coords.rightInnerExtent - arrowLen) + ' ' + (coords.verticalFullExtent + offset) + 'L' + coords.leftInnerExtent + ' ' + (coords.verticalFullExtent + offset) + 'A' + @@ -421,17 +424,18 @@ function createCircularClosedPathString(link) { (coords.leftLargeArcRadius - offset) + ' ' + (coords.leftLargeArcRadius - offset) + ' 0 0 1 ' + coords.leftInnerExtent + ' ' + (coords.verticalFullExtent - offset) + 'L' + - coords.rightInnerExtent + ' ' + (coords.verticalFullExtent - offset) + + (coords.rightInnerExtent - arrowLen) + ' ' + (coords.verticalFullExtent - offset) + 'A' + (coords.rightLargeArcRadius - offset) + ' ' + (coords.rightLargeArcRadius - offset) + ' 0 0 1 ' + - (coords.rightFullExtent + offset) + ' ' + coords.verticalRightInnerExtent + + (coords.rightFullExtent + offset - arrowLen) + ' ' + coords.verticalRightInnerExtent + 'L' + - (coords.rightFullExtent + offset) + ' ' + (coords.targetY + coords.rightSmallArcRadius) + + (coords.rightFullExtent + offset - arrowLen) + ' ' + (coords.targetY + coords.rightSmallArcRadius) + 'A' + (coords.rightLargeArcRadius - offset) + ' ' + (coords.rightSmallArcRadius - offset) + ' 0 0 1 ' + - coords.rightInnerExtent + ' ' + (coords.targetY + offset) + + (coords.rightInnerExtent - arrowLen) + ' ' + (coords.targetY + offset) + 'L' + - coords.targetX + ' ' + (coords.targetY + offset) + + (coords.targetX - arrowLen) + ' ' + (coords.targetY + offset) + + (arrowLen > 0 ? 'L' + coords.targetX + ' ' + (coords.targetY) : '') + 'Z'; } return pathString; @@ -440,11 +444,16 @@ function createCircularClosedPathString(link) { function linkPath() { var curvature = 0.5; function path(d) { + var arrowLen = d.linkArrowLength; if(d.link.circular) { - return createCircularClosedPathString(d.link); + return createCircularClosedPathString(d.link, arrowLen); } else { + var maxArrowLength = Math.abs((d.link.target.x0 - d.link.source.x1) / 2); + if(arrowLen > maxArrowLength) { + arrowLen = maxArrowLength; + } var x0 = d.link.source.x1; - var x1 = d.link.target.x0; + var x1 = d.link.target.x0 - arrowLen; var xi = interpolateNumber(x0, x1); var x2 = xi(curvature); var x3 = xi(1 - curvature); @@ -452,15 +461,17 @@ function linkPath() { var y0b = d.link.y0 + d.link.width / 2; var y1a = d.link.y1 - d.link.width / 2; var y1b = d.link.y1 + d.link.width / 2; - return 'M' + x0 + ',' + y0a + - 'C' + x2 + ',' + y0a + - ' ' + x3 + ',' + y1a + - ' ' + x1 + ',' + y1a + - 'L' + x1 + ',' + y1b + - 'C' + x3 + ',' + y1b + - ' ' + x2 + ',' + y0b + - ' ' + x0 + ',' + y0b + - 'Z'; + var start = 'M' + x0 + ',' + y0a; + var upperCurve = 'C' + x2 + ',' + y0a + + ' ' + x3 + ',' + y1a + + ' ' + x1 + ',' + y1a; + var lowerCurve = 'C' + x3 + ',' + y1b + + ' ' + x2 + ',' + y0b + + ' ' + x0 + ',' + y0b; + + var rightEnd = arrowLen > 0 ? 'L' + (x1 + arrowLen) + ',' + (y1a + d.link.width / 2) : ''; + rightEnd += 'L' + x1 + ',' + y1b; + return start + upperCurve + rightEnd + lowerCurve + 'Z'; } } return path; diff --git a/test/image/baselines/z-sankey_circular_with_arrows.png b/test/image/baselines/z-sankey_circular_with_arrows.png new file mode 100644 index 00000000000..c5472216a5d Binary files /dev/null and b/test/image/baselines/z-sankey_circular_with_arrows.png differ diff --git a/test/image/baselines/z-sankey_circular_with_arrows_vertical.png b/test/image/baselines/z-sankey_circular_with_arrows_vertical.png new file mode 100644 index 00000000000..b02740c62ed Binary files /dev/null and b/test/image/baselines/z-sankey_circular_with_arrows_vertical.png differ diff --git a/test/image/baselines/z-sankey_x_y_with_arrows.png b/test/image/baselines/z-sankey_x_y_with_arrows.png new file mode 100644 index 00000000000..86a28335878 Binary files /dev/null and b/test/image/baselines/z-sankey_x_y_with_arrows.png differ diff --git a/test/image/mocks/z-sankey_circular_with_arrows.json b/test/image/mocks/z-sankey_circular_with_arrows.json new file mode 100644 index 00000000000..2fb54d02a7c --- /dev/null +++ b/test/image/mocks/z-sankey_circular_with_arrows.json @@ -0,0 +1,27 @@ +{ + "data": [ + { + "type": "sankey", + "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/z-sankey_circular_with_arrows_vertical.json b/test/image/mocks/z-sankey_circular_with_arrows_vertical.json new file mode 100644 index 00000000000..b02189f526b --- /dev/null +++ b/test/image/mocks/z-sankey_circular_with_arrows_vertical.json @@ -0,0 +1,28 @@ +{ + "data": [ + { + "type": "sankey", + "orientation": "v", + "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/z-sankey_x_y_with_arrows.json b/test/image/mocks/z-sankey_x_y_with_arrows.json new file mode 100644 index 00000000000..1a57d8934ce --- /dev/null +++ b/test/image/mocks/z-sankey_x_y_with_arrows.json @@ -0,0 +1,29 @@ +{ + "data": [ + { + "type": "sankey", + "arrangement": "freeform", + "node": { + "label": ["0", "1", "2"], + "x": [1, 2, 3], + "y": [0,0,0] + }, + "link": { + "arrowlen": 40, + "source": [ + 0, 1, 2 + ], + "target": [ + 1, 2, 0 + ], + "value": [ + 10, 9, 1 + ] + } + }], + "layout": { + "title": {"text": "Sankey with little space"}, + "width": 400, + "height": 400 + } +} diff --git a/test/plot-schema.json b/test/plot-schema.json index 8aed6ab4651..c9a667c0640 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -42051,6 +42051,13 @@ "valType": "number" }, "link": { + "arrowlen": { + "description": "Sets the length (in px) of the links arrow, if 0 no arrow will be drawn.", + "dflt": 0, + "editType": "calc", + "min": 0, + "valType": "number" + }, "color": { "arrayOk": true, "description": "Sets the `link` color. It can be a single value, or an array for specifying color for each `link`. If `link.color` is omitted, then by default, a translucent grey link will be used.",