diff --git a/src/traces/sankey/attributes.js b/src/traces/sankey/attributes.js index 905901c7bd3..b9144ad0e4c 100644 --- a/src/traces/sankey/attributes.js +++ b/src/traces/sankey/attributes.js @@ -14,6 +14,8 @@ var colorAttrs = require('../../components/color/attributes'); var fxAttrs = require('../../components/fx/attributes'); var domainAttrs = require('../../plots/domain').attributes; var hovertemplateAttrs = require('../../components/fx/hovertemplate_attributes'); +var colorAttributes = require('../../components/colorscale/attributes'); +var templatedArray = require('../../plot_api/plot_template').templatedArray; var extendFlat = require('../../lib/extend').extendFlat; var overrideAll = require('../../plot_api/edit_types').overrideAll; @@ -225,7 +227,37 @@ var attrs = module.exports = overrideAll({ description: 'Variables `source` and `target` are node objects.', keys: ['value', 'label'] }), - description: 'The links of the Sankey plot.' + colorscales: templatedArray('concentrationscales', { + editType: 'calc', + label: { + valType: 'string', + role: 'info', + editType: 'calc', + description: 'The label of the links to color based on their concentration within a flow.', + dflt: '' + }, + cmax: { + valType: 'number', + role: 'info', + editType: 'calc', + dflt: 1, + description: [ + 'Sets the upper bound of the color domain.' + ].join('') + }, + cmin: { + valType: 'number', + role: 'info', + editType: 'calc', + dflt: 0, + description: [ + 'Sets the lower bound of the color domain.' + ].join('') + }, + colorscale: extendFlat(colorAttributes().colorscale, {dflt: [[0, 'white'], [1, 'black']]}) + }), + description: 'The links of the Sankey plot.', + role: 'info' } }, 'calc', 'nested'); attrs.transforms = undefined; diff --git a/src/traces/sankey/calc.js b/src/traces/sankey/calc.js index 8e9a5756517..861ce4f0397 100644 --- a/src/traces/sankey/calc.js +++ b/src/traces/sankey/calc.js @@ -14,7 +14,7 @@ var wrap = require('../../lib/gup').wrap; var isArrayOrTypedArray = Lib.isArrayOrTypedArray; var isIndex = Lib.isIndex; - +var Colorscale = require('../../components/colorscale'); function convertToD3Sankey(trace) { var nodeSpec = trace.node; @@ -24,8 +24,17 @@ function convertToD3Sankey(trace) { var hasLinkColorArray = isArrayOrTypedArray(linkSpec.color); var linkedNodes = {}; - var nodeCount = nodeSpec.label.length; + var components = {}; + var componentCount = linkSpec.colorscales.length; var i; + for(i = 0; i < componentCount; i++) { + var cscale = linkSpec.colorscales[i]; + var specs = Colorscale.extractScale(cscale, {cLetter: 'c'}); + var scale = Colorscale.makeColorScaleFunc(specs); + components[cscale.label] = scale; + } + + var nodeCount = nodeSpec.label.length; for(i = 0; i < linkSpec.value.length; i++) { var val = linkSpec.value[i]; // remove negative values, but keep zeros with special treatment @@ -42,10 +51,14 @@ function convertToD3Sankey(trace) { var label = ''; if(linkSpec.label && linkSpec.label[i]) label = linkSpec.label[i]; + var concentrationscale = null; + if(label && components.hasOwnProperty(label)) concentrationscale = components[label]; + links.push({ pointNumber: i, label: label, color: hasLinkColorArray ? linkSpec.color[i] : linkSpec.color, + concentrationscale: concentrationscale, source: source, target: target, value: +val diff --git a/src/traces/sankey/defaults.js b/src/traces/sankey/defaults.js index a135eecbdee..c5283910ade 100644 --- a/src/traces/sankey/defaults.js +++ b/src/traces/sankey/defaults.js @@ -15,6 +15,7 @@ var tinycolor = require('tinycolor2'); var handleDomainDefaults = require('../../plots/domain').defaults; var handleHoverLabelDefaults = require('../../components/fx/hoverlabel_defaults'); var Template = require('../../plot_api/plot_template'); +var handleArrayContainerDefaults = require('../../plots/array_container_defaults'); module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout) { function coerce(attr, dflt) { @@ -48,7 +49,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout })); // link attributes - var linkIn = traceIn.link; + var linkIn = traceIn.link || {}; var linkOut = Template.newContainer(traceOut, 'link'); function coerceLink(attr, dflt) { @@ -70,6 +71,11 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout coerceLink('color', Lib.repeat(defaultLinkColor, linkOut.value.length)); + handleArrayContainerDefaults(linkIn, linkOut, { + name: 'colorscales', + handleItemDefaults: concentrationscalesDefaults + }); + handleDomainDefaults(traceOut, layout, coerce); coerce('orientation'); @@ -83,3 +89,14 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout // don't match, between nodes and links traceOut._length = null; }; + +function concentrationscalesDefaults(In, Out) { + function coerce(attr, dflt) { + return Lib.coerce(In, Out, attributes.link.colorscales, attr, dflt); + } + + coerce('label'); + coerce('cmin'); + coerce('cmax'); + coerce('colorscale'); +} diff --git a/src/traces/sankey/plot.js b/src/traces/sankey/plot.js index cc04eb441ed..6de168a3ed3 100644 --- a/src/traces/sankey/plot.js +++ b/src/traces/sankey/plot.js @@ -72,13 +72,21 @@ function linkHoveredStyle(d, sankey, visitNodes, sankeyLink) { var label = sankeyLink.datum().link.label; - sankeyLink.style('fill-opacity', 0.4); + sankeyLink.style('fill-opacity', function(l) { + if(!l.link.concentrationscale) { + return 0.4; + } + }); if(label) { ownTrace(sankey, d) .selectAll('.' + cn.sankeyLink) .filter(function(l) {return l.link.label === label;}) - .style('fill-opacity', 0.4); + .style('fill-opacity', function(l) { + if(!l.link.concentrationscale) { + return 0.4; + } + }); } if(visitNodes) { @@ -143,6 +151,7 @@ module.exports = function plot(gd, calcData) { var sourceLabel = _(gd, 'source:') + ' '; var targetLabel = _(gd, 'target:') + ' '; + var concentrationLabel = _(gd, 'concentration:') + ' '; var incomingLabel = _(gd, 'incoming flow count:') + ' '; var outgoingLabel = _(gd, 'outgoing flow count:') + ' '; @@ -172,7 +181,8 @@ module.exports = function plot(gd, calcData) { text: [ d.link.label || '', sourceLabel + d.link.source.label, - targetLabel + d.link.target.label + targetLabel + d.link.target.label, + d.link.concentrationscale ? concentrationLabel + d3.format('%0.2f')(d.link.flow.labelConcentration) : '' ].filter(renderableValuePresent).join('
'), color: castHoverOption(obj, 'bgcolor') || Color.addOpacity(d.tinyColorHue, 1), borderColor: castHoverOption(obj, 'bordercolor'), @@ -190,7 +200,9 @@ module.exports = function plot(gd, calcData) { gd: gd }); - makeTranslucent(tooltip, 0.65); + if(!d.link.concentrationscale) { + makeTranslucent(tooltip, 0.65); + } makeTextContrasty(tooltip); }; @@ -288,6 +300,7 @@ module.exports = function plot(gd, calcData) { }; render( + gd, svg, calcData, { diff --git a/src/traces/sankey/render.js b/src/traces/sankey/render.js index 249ebab281d..88b27dbdafc 100644 --- a/src/traces/sankey/render.js +++ b/src/traces/sankey/render.js @@ -67,6 +67,72 @@ function sankeyModel(layout, d, traceIndex) { Lib.warn('node.pad was reduced to ', sankey.nodePadding(), ' to fit within the figure.'); } + function computeLinkConcentrations() { + var i, j, k; + for(i = 0; i < graph.nodes.length; i++) { + var node = graph.nodes[i]; + // Links connecting the same two nodes are part of a flow + var flows = {}; + var flowKey; + var link; + for(j = 0; j < node.targetLinks.length; j++) { + link = node.targetLinks[j]; + flowKey = link.source.pointNumber + ':' + link.target.pointNumber; + if(!flows.hasOwnProperty(flowKey)) flows[flowKey] = []; + flows[flowKey].push(link); + } + + // Compute statistics for each flow + var keys = Object.keys(flows); + for(j = 0; j < keys.length; j++) { + flowKey = keys[j]; + var flowLinks = flows[flowKey]; + + // Find the total size of the flow and total size per label + var total = 0; + var totalPerLabel = {}; + for(k = 0; k < flowLinks.length; k++) { + link = flowLinks[k]; + if(!totalPerLabel[link.label]) totalPerLabel[link.label] = 0; + totalPerLabel[link.label] += link.value; + total += link.value; + } + + // Find the ratio of the link's value and the size of the flow + for(k = 0; k < flowLinks.length; k++) { + link = flowLinks[k]; + link.flow = { + value: total, + labelConcentration: totalPerLabel[link.label] / total, + concentration: link.value / total, + links: flowLinks + }; + } + } + + // Gather statistics of all links at current node + var totalOutflow = 0; + for(j = 0; j < node.sourceLinks.length; j++) { + totalOutflow += node.sourceLinks[j].value; + } + for(j = 0; j < node.sourceLinks.length; j++) { + link = node.sourceLinks[j]; + link.concentrationOut = link.value / totalOutflow; + } + + var totalInflow = 0; + for(j = 0; j < node.targetLinks.length; j++) { + totalInflow += node.targetLinks[j].value; + } + + for(j = 0; j < node.targetLinks.length; j++) { + link = node.targetLinks[j]; + link.concenrationIn = link.value / totalInflow; + } + } + } + computeLinkConcentrations(); + return { circular: circular, key: traceIndex, @@ -100,6 +166,9 @@ function sankeyModel(layout, d, traceIndex) { function linkModel(d, l, i) { var tc = tinycolor(l.color); + if(l.concentrationscale) { + tc = tinycolor(l.concentrationscale(l.flow.labelConcentration)); + } var basicKey = l.source.label + '|' + l.target.label; var key = basicKey + '__' + i; @@ -121,7 +190,8 @@ function linkModel(d, l, i) { valueSuffix: d.valueSuffix, sankey: d.sankey, parent: d, - interactionState: d.interactionState + interactionState: d.interactionState, + flow: l.flow }; } @@ -568,7 +638,7 @@ function switchToSankeyFormat(nodes) { } // scene graph -module.exports = function(svg, calcData, layout, callbacks) { +module.exports = function(gd, svg, calcData, layout, callbacks) { var styledData = calcData .filter(function(d) {return unwrap(d).trace.visible;}) diff --git a/test/image/baselines/sankey_link_concentration.png b/test/image/baselines/sankey_link_concentration.png new file mode 100644 index 00000000000..9b512b8fac7 Binary files /dev/null and b/test/image/baselines/sankey_link_concentration.png differ diff --git a/test/image/mocks/sankey_link_concentration.json b/test/image/mocks/sankey_link_concentration.json new file mode 100644 index 00000000000..789380539d0 --- /dev/null +++ b/test/image/mocks/sankey_link_concentration.json @@ -0,0 +1,75 @@ +{ + "data": [ + { + "type": "sankey", + "node": { + "pad": 25, + "line": { + "color": "white", + "width": 2 + }, + "color": "black", + "label": ["process0", "process1", "process2", "process3", "process4"] + }, + "link": { + "source": [ + 0, 0, 0, 0, + 1, 1, 1, 1, + 1, 1, 1, 1, + 1, 1, + 2 + ], + "target": [ + 1, 1, 1, 1, + 2, 2, 2, 2, + 3, 3, 3, 3, + 4, 4, + 0 + ], + "value": [ + 10, 20, 40, 30, + 10, 5, 10, 20, + 0, 10, 10, 10, + 15, 5, + 20 + + ], + "label": [ + "elementA", "elementB", "elementC", "elementD", + "elementA", "elementB", "elementC", "elementD", + "elementA", "elementB", "elementC", "elementD", + "elementC", "elementC", + "elementA" + ], + "line": { + "color": "white", + "width": 2 + }, + "colorscales": [ + { + "label": "elementA", + "colorscale": [[0, "white"], [1, "blue"]] + }, + { + "label": "elementB", + "colorscale": [[0, "white"], [1, "red"]] + }, + { + "label": "elementC", + "colorscale": [[0, "white"], [1, "green"]] + }, + { + "label": "elementD" + } + ], + + "hovertemplate": "%{label}
flow.labelConcentration: %{flow.labelConcentration:%0.2f}
flow.concentration: %{flow.concentration:%0.2f}
flow.value: %{flow.value}" + } + + }], + "layout": { + "title": "Sankey diagram with links colored based on their concentration within a flow", + "width": 800, + "height": 800 + } +} diff --git a/test/jasmine/tests/sankey_test.js b/test/jasmine/tests/sankey_test.js index d2a7391d8ed..6471906d6e1 100644 --- a/test/jasmine/tests/sankey_test.js +++ b/test/jasmine/tests/sankey_test.js @@ -121,6 +121,9 @@ describe('sankey tests', function() { expect(fullTrace.link.label) .toEqual([], 'presence of link target array is guaranteed'); + + expect(fullTrace.link.colorscales) + .toEqual([], 'presence of link colorscales array is guaranteed'); }); it('\'Sankey\' specification should have proper types', @@ -826,6 +829,12 @@ describe('sankey tests', function() { var pt = d.points[0]; expect(pt.hasOwnProperty('source')).toBeTruthy(); expect(pt.hasOwnProperty('target')).toBeTruthy(); + expect(pt.hasOwnProperty('flow')).toBeTruthy(); + + expect(pt.flow.hasOwnProperty('concentration')).toBeTruthy(); + expect(pt.flow.hasOwnProperty('labelConcentration')).toBeTruthy(); + expect(pt.flow.hasOwnProperty('value')).toBeTruthy(); + expect(pt.flow.hasOwnProperty('links')).toBeTruthy(); }) .then(function() { return _unhover('node'); }) .then(function(d) {