diff --git a/draftlogs/7873_add.md b/draftlogs/7873_add.md new file mode 100644 index 00000000000..d3f15fb475b --- /dev/null +++ b/draftlogs/7873_add.md @@ -0,0 +1 @@ + - Add `sort` option to Sankey links and nodes [[#7873](https://github.com/plotly/plotly.js/pull/7873)], with thanks to @adamreeve for the contribution! diff --git a/src/traces/sankey/attributes.js b/src/traces/sankey/attributes.js index a2ee49f0083..ab1bae7acac 100644 --- a/src/traces/sankey/attributes.js +++ b/src/traces/sankey/attributes.js @@ -170,6 +170,15 @@ var attrs = (module.exports = overrideAll( dflt: 'justify', description: 'Sets the alignment method used to position the nodes along the horizontal axis.' }, + sort: { + valType: 'enumerated', + values: ['auto', 'input'], + dflt: 'auto', + description: [ + 'For `auto` (the default), the vertical order of nodes will be determined automatically by the layout.', + 'For `input`, the vertical order of nodes is kept the same as the order in the input array.' + ].join(' ') + }, description: 'The nodes of the Sankey plot.' }, @@ -283,6 +292,15 @@ var attrs = (module.exports = overrideAll( ] }) }), + sort: { + valType: 'enumerated', + values: ['auto', 'input'], + dflt: 'auto', + description: [ + 'For `auto` (the default), the order of links attached to each node is determined automatically by the layout.', + 'For `input`, the order of links at each node is kept the same as the order in the input array.' + ].join(' ') + }, description: 'The links of the Sankey plot.' } }, diff --git a/src/traces/sankey/defaults.js b/src/traces/sankey/defaults.js index 4f6eb92d1c4..118b58db30f 100644 --- a/src/traces/sankey/defaults.js +++ b/src/traces/sankey/defaults.js @@ -34,6 +34,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout handleHoverLabelDefaults(nodeIn, nodeOut, coerceNode, hoverlabelDefault); coerceNode('hovertemplate'); coerceNode('align'); + coerceNode('sort'); var colors = layout.colorway; @@ -62,6 +63,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout coerceLink('hoverinfo', traceIn.hoverinfo); handleHoverLabelDefaults(linkIn, linkOut, coerceLink, hoverlabelDefault); coerceLink('hovertemplate'); + coerceLink('sort'); var darkBG = Color.color(layout.paper_bgcolor).luminosity() < 0.333; var defaultLinkColor = darkBG ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.2)'; diff --git a/src/traces/sankey/render.js b/src/traces/sankey/render.js index ab00a51223e..5f31b5d1d20 100644 --- a/src/traces/sankey/render.js +++ b/src/traces/sankey/render.js @@ -70,6 +70,21 @@ function sankeyModel(layout, d, traceIndex) { .nodes(nodes) .links(links); + function applyInputSort(type) { + if (trace[type].sort === 'input') { + if (circular) { + Lib.warn( + `Circular Sankey diagrams do not support the "input" ${type}.sort mode; falling back to the default sort.` + ); + } else { + // Passing null maintains the input order + sankey[`${type}Sort`](null); + } + } + } + applyInputSort('link'); + applyInputSort('node'); + var graph = sankey(); if(sankey.nodePadding() < nodePad) { diff --git a/src/types/generated/schema.d.ts b/src/types/generated/schema.d.ts index 15fae6cbd87..438111a4905 100644 --- a/src/types/generated/schema.d.ts +++ b/src/types/generated/schema.d.ts @@ -6775,6 +6775,11 @@ export interface SankeyData { */ label?: Datum[] | Datum[][] | TypedArray; line?: _internal.Line; + /** + * For `auto` (the default), the order of links attached to each node is determined automatically by the layout. For `input`, the order of links at each node is kept the same as the order in the input array. + * @default 'auto' + */ + sort?: 'auto' | 'input'; /** * An integer number `[0..nodes.length - 1]` that represents the source node. * @default [] @@ -6837,6 +6842,11 @@ export interface SankeyData { * Minimum: 0 */ pad?: number; + /** + * For `auto` (the default), the vertical order of nodes will be determined automatically by the layout. For `input`, the vertical order of nodes is kept the same as the order in the input array. + * @default 'auto' + */ + sort?: 'auto' | 'input'; /** * Sets the thickness (in px) of the `nodes`. * @default 20 diff --git a/test/image/baselines/sankey_input_link_sort.png b/test/image/baselines/sankey_input_link_sort.png new file mode 100644 index 00000000000..ccc84ee06e7 Binary files /dev/null and b/test/image/baselines/sankey_input_link_sort.png differ diff --git a/test/image/baselines/sankey_input_node_sort.png b/test/image/baselines/sankey_input_node_sort.png new file mode 100644 index 00000000000..7fe38577814 Binary files /dev/null and b/test/image/baselines/sankey_input_node_sort.png differ diff --git a/test/image/mocks/sankey_input_link_sort.json b/test/image/mocks/sankey_input_link_sort.json new file mode 100644 index 00000000000..ae1682daecb --- /dev/null +++ b/test/image/mocks/sankey_input_link_sort.json @@ -0,0 +1,31 @@ +{ + "data": [ + { + "type": "sankey", + "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 + } +} diff --git a/test/image/mocks/sankey_input_node_sort.json b/test/image/mocks/sankey_input_node_sort.json new file mode 100644 index 00000000000..6d3fd9a198a --- /dev/null +++ b/test/image/mocks/sankey_input_node_sort.json @@ -0,0 +1,31 @@ +{ + "data": [ + { + "type": "sankey", + "node": { + "label": ["0", "1", "2", "3", "4", "5"], + "align": "center", + "color": "gray", + "sort": "input" + }, + "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)" + ] + } + } + ], + "layout": { + "title": { "text": "Sankey with vertical node ordering matching the input array" }, + "width": 800, + "height": 800 + } +} diff --git a/test/jasmine/tests/sankey_test.js b/test/jasmine/tests/sankey_test.js index d9bd928148c..1d326af1399 100644 --- a/test/jasmine/tests/sankey_test.js +++ b/test/jasmine/tests/sankey_test.js @@ -501,6 +501,48 @@ describe('sankey tests', function () { .then(done, done.fail); }); + it('falls back to the default sort for circular Sankey with node.sort set', (done) => { + const warnings = []; + spyOn(Lib, 'warn').and.callFake((msg) => { + warnings.push(msg); + }); + + const mockCircularCopy = Lib.extendDeep({}, mockCircular); + mockCircularCopy.data[0].node.sort = 'input'; + + Plotly.newPlot(gd, mockCircularCopy) + .then(() => { + // The plot renders successfully + expect(gd.calcdata[0][0].circular).toBe(true); + expect(d3SelectAll('.sankey .node-rect').size()).toBeGreaterThan(0); + + // An warning is logged about the fallback + expect(warnings.length).toBe(1); + }) + .then(done, done.fail); + }); + + it('falls back to the default sort for circular Sankey with link.sort set', (done) => { + const warnings = []; + spyOn(Lib, 'warn').and.callFake((msg) => { + warnings.push(msg); + }); + + const mockCircularCopy = Lib.extendDeep({}, mockCircular); + mockCircularCopy.data[0].link.sort = 'input'; + + Plotly.newPlot(gd, mockCircularCopy) + .then(() => { + // The plot renders successfully + expect(gd.calcdata[0][0].circular).toBe(true); + expect(d3SelectAll('.sankey .sankey-link').size()).toBeGreaterThan(0); + + // A warning is logged about the fallback + expect(warnings.length).toBe(1); + }) + .then(done, done.fail); + }); + it('can create groups, restyle groups and properly update DOM', function (done) { var mockCircularCopy = Lib.extendDeep({}, mockCircular); var firstGroup = [ diff --git a/test/plot-schema.json b/test/plot-schema.json index 6156b2ef890..bc9fbf9abe3 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -50476,6 +50476,16 @@ } }, "role": "object", + "sort": { + "description": "For `auto` (the default), the order of links attached to each node is determined automatically by the layout. For `input`, the order of links at each node is kept the same as the order in the input array.", + "dflt": "auto", + "editType": "calc", + "valType": "enumerated", + "values": [ + "auto", + "input" + ] + }, "source": { "description": "An integer number `[0..nodes.length - 1]` that represents the source node.", "dflt": [], @@ -50746,6 +50756,16 @@ "valType": "number" }, "role": "object", + "sort": { + "description": "For `auto` (the default), the vertical order of nodes will be determined automatically by the layout. For `input`, the vertical order of nodes is kept the same as the order in the input array.", + "dflt": "auto", + "editType": "calc", + "valType": "enumerated", + "values": [ + "auto", + "input" + ] + }, "thickness": { "arrayOk": false, "description": "Sets the thickness (in px) of the `nodes`.",