diff --git a/lib/index.js b/lib/index.js
index 150bece3355..6cdd813a807 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -21,6 +21,7 @@ Plotly.register([
require('./pie'),
require('./contour'),
require('./scatterternary'),
+ require('./sankey'),
require('./scatter3d'),
require('./surface'),
diff --git a/lib/sankey.js b/lib/sankey.js
new file mode 100644
index 00000000000..ce73021f47a
--- /dev/null
+++ b/lib/sankey.js
@@ -0,0 +1,11 @@
+/**
+* Copyright 2012-2017, Plotly, Inc.
+* All rights reserved.
+*
+* This source code is licensed under the MIT license found in the
+* LICENSE file in the root directory of this source tree.
+*/
+
+'use strict';
+
+module.exports = require('../src/traces/sankey');
diff --git a/package.json b/package.json
index f5b0f7157a1..bf06599bb11 100644
--- a/package.json
+++ b/package.json
@@ -54,12 +54,14 @@
},
"dependencies": {
"3d-view": "^2.0.0",
+ "@plotly/d3-sankey": "^0.5.0",
"alpha-shape": "^1.0.0",
"arraytools": "^1.0.0",
"color-rgba": "^1.0.4",
"convex-hull": "^1.0.3",
"country-regex": "^1.1.0",
"d3": "^3.5.12",
+ "d3-force": "^1.0.6",
"delaunay-triangulate": "^1.1.6",
"es6-promise": "^3.0.2",
"fast-isnumeric": "^1.1.1",
@@ -93,6 +95,7 @@
"right-now": "^1.0.0",
"robust-orientation": "^1.1.3",
"sane-topojson": "^2.0.0",
+ "strongly-connected-components": "^1.0.1",
"superscript-text": "^1.0.0",
"tinycolor2": "^1.3.0",
"topojson-client": "^2.1.0",
diff --git a/src/plots/cartesian/graph_interact.js b/src/plots/cartesian/graph_interact.js
index d1307a8cbb5..f5113b4f6ec 100644
--- a/src/plots/cartesian/graph_interact.js
+++ b/src/plots/cartesian/graph_interact.js
@@ -300,7 +300,7 @@ fx.hover = function(gd, evt, subplot) {
// The actual implementation is here:
function hover(gd, evt, subplot) {
- if(subplot === 'pie') {
+ if(['pie', 'sankey'].indexOf(subplot) !== -1) {
gd.emit('plotly_hover', {
event: evt.originalEvent,
points: [evt]
diff --git a/src/traces/sankey/attributes.js b/src/traces/sankey/attributes.js
new file mode 100644
index 00000000000..ccd49ccba70
--- /dev/null
+++ b/src/traces/sankey/attributes.js
@@ -0,0 +1,207 @@
+/**
+* Copyright 2012-2017, Plotly, Inc.
+* All rights reserved.
+*
+* This source code is licensed under the MIT license found in the
+* LICENSE file in the root directory of this source tree.
+*/
+
+'use strict';
+
+var shapeAttrs = require('../../components/shapes/attributes');
+var fontAttrs = require('../../plots/font_attributes');
+var plotAttrs = require('../../plots/attributes');
+var colorAttrs = require('../../components/color/attributes');
+
+var extendFlat = require('../../lib/extend').extendFlat;
+
+module.exports = {
+ hoverinfo: extendFlat({}, plotAttrs.hoverinfo, {
+ flags: ['label', 'text', 'value', 'percent', 'name']
+ }),
+ domain: {
+ x: {
+ valType: 'info_array',
+ role: 'info',
+ items: [
+ {valType: 'number', min: 0, max: 1},
+ {valType: 'number', min: 0, max: 1}
+ ],
+ dflt: [0, 1],
+ description: [
+ 'Sets the horizontal domain of this `sankey` trace',
+ '(in plot fraction).'
+ ].join(' ')
+ },
+ y: {
+ valType: 'info_array',
+ role: 'info',
+ items: [
+ {valType: 'number', min: 0, max: 1},
+ {valType: 'number', min: 0, max: 1}
+ ],
+ dflt: [0, 1],
+ description: [
+ 'Sets the vertical domain of this `sankey` trace',
+ '(in plot fraction).'
+ ].join(' ')
+ }
+ },
+
+ orientation: {
+ valType: 'enumerated',
+ values: ['v', 'h'],
+ dflt: 'h',
+ role: 'style',
+ description: 'Sets the orientation of the Sankey diagram.'
+ },
+
+ valueformat: {
+ valType: 'string',
+ dflt: '.3s',
+ role: 'style',
+ description: [
+ 'Sets the value formatting rule using d3 formatting mini-language',
+ 'which is similar to those of Python. See',
+ 'https://github.com/d3/d3-format/blob/master/README.md#locale_format'
+ ].join(' ')
+ },
+
+ valuesuffix: {
+ valType: 'string',
+ dflt: '',
+ role: 'style',
+ description: [
+ 'Adds a unit to follow the value in the hover tooltip. Add a space if a separation',
+ 'is necessary from the value.'
+ ].join(' ')
+ },
+
+ arrangement: {
+ valType: 'enumerated',
+ values: ['snap', 'perpendicular', 'freeform', 'fixed'],
+ dflt: 'snap',
+ role: 'style',
+ description: [
+ 'If value is `snap` (the default), the node arrangement is assisted by automatic snapping of elements to',
+ 'preserve space between nodes specified via `nodepad`.',
+ 'If value is `perpendicular`, the nodes can only move along a line perpendicular to the flow.',
+ 'If value is `freeform`, the nodes can freely move on the plane.',
+ 'If value is `fixed`, the nodes are stationary.'
+ ].join(' ')
+ },
+
+ textfont: fontAttrs,
+
+ node: {
+ label: {
+ valType: 'data_array',
+ dflt: [],
+ role: 'info',
+ description: 'The shown name of the node.'
+ },
+ color: extendFlat({}, shapeAttrs.fillcolor, {
+ arrayOk: true,
+ description: [
+ 'Sets the `node` color. It can be a single value, or an array for specifying color for each `node`.',
+ 'If `node.color` is omitted, then the default `Plotly` color palette will be cycled through',
+ 'to have a variety of colors. These defaults are not fully opaque, to allow some visibility of',
+ 'what is beneath the node.'
+ ].join(' ')
+ }),
+ line: {
+ color: {
+ valType: 'color',
+ role: 'style',
+ dflt: colorAttrs.defaultLine,
+ arrayOk: true,
+ description: [
+ 'Sets the color of the `line` around each `node`.'
+ ].join(' ')
+ },
+ width: {
+ valType: 'number',
+ role: 'style',
+ min: 0,
+ dflt: 0.5,
+ arrayOk: true,
+ description: [
+ 'Sets the width (in px) of the `line` around each `node`.'
+ ].join(' ')
+ }
+ },
+ pad: {
+ valType: 'number',
+ arrayOk: false,
+ min: 0,
+ dflt: 20,
+ role: 'style',
+ description: 'Sets the padding (in px) between the `nodes`.'
+ },
+ thickness: {
+ valType: 'number',
+ arrayOk: false,
+ min: 1,
+ dflt: 20,
+ role: 'style',
+ description: 'Sets the thickness (in px) of the `nodes`.'
+ },
+ description: 'The nodes of the Sankey plot.'
+ },
+
+ link: {
+ label: {
+ valType: 'data_array',
+ dflt: [],
+ role: 'info',
+ description: 'The shown name of the link.'
+ },
+ color: extendFlat({}, shapeAttrs.fillcolor, {
+ 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.'
+ ].join(' ')
+ }),
+ line: {
+ color: {
+ valType: 'color',
+ role: 'style',
+ dflt: colorAttrs.defaultLine,
+ arrayOk: true,
+ description: [
+ 'Sets the color of the `line` around each `link`.'
+ ].join(' ')
+ },
+ width: {
+ valType: 'number',
+ role: 'style',
+ min: 0,
+ dflt: 0,
+ arrayOk: true,
+ description: [
+ 'Sets the width (in px) of the `line` around each `link`.'
+ ].join(' ')
+ }
+ },
+ source: {
+ valType: 'data_array',
+ role: 'info',
+ dflt: [],
+ description: 'An integer number `[0..nodes.length - 1]` that represents the source node.'
+ },
+ target: {
+ valType: 'data_array',
+ role: 'info',
+ dflt: [],
+ description: 'An integer number `[0..nodes.length - 1]` that represents the target node.'
+ },
+ value: {
+ valType: 'data_array',
+ dflt: [],
+ role: 'info',
+ description: 'A numeric value representing the flow volume value.'
+ },
+ description: 'The links of the Sankey plot.'
+ }
+};
diff --git a/src/traces/sankey/base_plot.js b/src/traces/sankey/base_plot.js
new file mode 100644
index 00000000000..479ead95f0d
--- /dev/null
+++ b/src/traces/sankey/base_plot.js
@@ -0,0 +1,30 @@
+/**
+* Copyright 2012-2017, Plotly, Inc.
+* All rights reserved.
+*
+* This source code is licensed under the MIT license found in the
+* LICENSE file in the root directory of this source tree.
+*/
+
+'use strict';
+
+var Plots = require('../../plots/plots');
+var plot = require('./plot');
+
+exports.name = 'sankey';
+
+exports.attr = 'type';
+
+exports.plot = function(gd) {
+ var calcData = Plots.getSubplotCalcData(gd.calcdata, 'sankey', 'sankey');
+ if(calcData.length) plot(gd, calcData);
+};
+
+exports.clean = function(newFullData, newFullLayout, oldFullData, oldFullLayout) {
+ var hadPlot = (oldFullLayout._has && oldFullLayout._has('sankey'));
+ var hasPlot = (newFullLayout._has && newFullLayout._has('sankey'));
+
+ if(hadPlot && !hasPlot) {
+ oldFullLayout._paperdiv.selectAll('.sankey').remove();
+ }
+};
diff --git a/src/traces/sankey/calc.js b/src/traces/sankey/calc.js
new file mode 100644
index 00000000000..4d877c5ad7e
--- /dev/null
+++ b/src/traces/sankey/calc.js
@@ -0,0 +1,51 @@
+/**
+* Copyright 2012-2017, Plotly, Inc.
+* All rights reserved.
+*
+* This source code is licensed under the MIT license found in the
+* LICENSE file in the root directory of this source tree.
+*/
+
+'use strict';
+
+var tarjan = require('strongly-connected-components');
+var Lib = require('../../lib');
+
+function circularityPresent(nodeList, sources, targets) {
+
+ var nodes = nodeList.map(function() {return [];});
+
+ for(var i = 0; i < Math.min(sources.length, targets.length); i++) {
+ if(sources[i] === targets[i]) {
+ return true; // self-link which is also a scc of one
+ }
+ nodes[sources[i]].push(targets[i]);
+ }
+
+ var scc = tarjan(nodes);
+
+ // Tarján's strongly connected components algorithm coded by Mikola Lysenko
+ // returns at least one non-singular component if there's circularity in the graph
+ return scc.components.some(function(c) {
+ return c.length > 1;
+ });
+}
+
+module.exports = function calc(gd, trace) {
+
+ if(circularityPresent(trace.node.label, trace.link.source, trace.link.target)) {
+ Lib.error('Circularity is present in the Sankey data. Removing all nodes and links.');
+ trace.link.label = [];
+ trace.link.source = [];
+ trace.link.target = [];
+ trace.link.value = [];
+ trace.link.color = [];
+ trace.node.label = [];
+ trace.node.color = [];
+ }
+
+ return [{
+ link: trace.link,
+ node: trace.node
+ }];
+};
diff --git a/src/traces/sankey/constants.js b/src/traces/sankey/constants.js
new file mode 100644
index 00000000000..d841cf1a164
--- /dev/null
+++ b/src/traces/sankey/constants.js
@@ -0,0 +1,20 @@
+/**
+* Copyright 2012-2017, Plotly, Inc.
+* All rights reserved.
+*
+* This source code is licensed under the MIT license found in the
+* LICENSE file in the root directory of this source tree.
+*/
+
+'use strict';
+
+module.exports = {
+ nodeTextOffsetHorizontal: 4,
+ nodeTextOffsetVertical: 3,
+ nodePadAcross: 10,
+ sankeyIterations: 50,
+ forceIterations: 5,
+ forceTicksPerFrame: 10,
+ duration: 500,
+ ease: 'linear'
+};
diff --git a/src/traces/sankey/defaults.js b/src/traces/sankey/defaults.js
new file mode 100644
index 00000000000..adfcf5db1ae
--- /dev/null
+++ b/src/traces/sankey/defaults.js
@@ -0,0 +1,67 @@
+/**
+* Copyright 2012-2017, Plotly, Inc.
+* All rights reserved.
+*
+* This source code is licensed under the MIT license found in the
+* LICENSE file in the root directory of this source tree.
+*/
+
+'use strict';
+
+var Lib = require('../../lib');
+var attributes = require('./attributes');
+var colors = require('../../components/color/attributes').defaults;
+var Color = require('../../components/color');
+var tinycolor = require('tinycolor2');
+
+module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout) {
+
+ function coerce(attr, dflt) {
+ return Lib.coerce(traceIn, traceOut, attributes, attr, dflt);
+ }
+
+ coerce('node.label');
+ coerce('node.pad');
+ coerce('node.thickness');
+ coerce('node.line.color');
+ coerce('node.line.width');
+
+ var defaultNodePalette = function(i) {return colors[i % colors.length];};
+
+ coerce('node.color', traceOut.node.label.map(function(d, i) {
+ return Color.addOpacity(defaultNodePalette(i), 0.8);
+ }));
+
+ coerce('link.label');
+ coerce('link.source');
+ coerce('link.target');
+ coerce('link.value');
+ coerce('link.line.color');
+ coerce('link.line.width');
+
+ coerce('link.color', traceOut.link.value.map(function() {
+ return tinycolor(layout.paper_bgcolor).getLuminance() < 0.333 ?
+ 'rgba(255, 255, 255, 0.6)' :
+ 'rgba(0, 0, 0, 0.2)';
+ }));
+
+ coerce('hoverinfo', layout._dataLength === 1 ? 'label+text+value+percent' : undefined);
+
+ coerce('domain.x');
+ coerce('domain.y');
+ coerce('orientation');
+ coerce('valueformat');
+ coerce('valuesuffix');
+ coerce('arrangement');
+
+ Lib.coerceFont(coerce, 'textfont', Lib.extendFlat({}, layout.font));
+
+ var missing = function(n, i) {
+ return traceOut.link.source.indexOf(i) === -1 &&
+ traceOut.link.target.indexOf(i) === -1;
+ };
+
+ if(traceOut.node.label.some(missing)) {
+ Lib.warn('Some of the nodes are neither sources nor targets, they will not be displayed.');
+ }
+};
diff --git a/src/traces/sankey/index.js b/src/traces/sankey/index.js
new file mode 100644
index 00000000000..4ca291bf70c
--- /dev/null
+++ b/src/traces/sankey/index.js
@@ -0,0 +1,30 @@
+/**
+* Copyright 2012-2017, Plotly, Inc.
+* All rights reserved.
+*
+* This source code is licensed under the MIT license found in the
+* LICENSE file in the root directory of this source tree.
+*/
+
+'use strict';
+
+var Plot = {};
+
+Plot.attributes = require('./attributes');
+Plot.supplyDefaults = require('./defaults');
+Plot.calc = require('./calc');
+Plot.plot = require('./plot');
+
+Plot.moduleType = 'trace';
+Plot.name = 'sankey';
+Plot.basePlotModule = require('./base_plot');
+Plot.categories = ['noOpacity'];
+Plot.meta = {
+ description: [
+ 'Sankey plots for network flow data analysis.',
+ 'The nodes are specified in `nodes` and the links between sources and targets in `links`.',
+ 'The colors are set in `nodes[i].color` and `links[i].color`; otherwise defaults are used.'
+ ].join(' ')
+};
+
+module.exports = Plot;
diff --git a/src/traces/sankey/plot.js b/src/traces/sankey/plot.js
new file mode 100644
index 00000000000..1810603a8ab
--- /dev/null
+++ b/src/traces/sankey/plot.js
@@ -0,0 +1,240 @@
+/**
+* Copyright 2012-2017, Plotly, Inc.
+* All rights reserved.
+*
+* This source code is licensed under the MIT license found in the
+* LICENSE file in the root directory of this source tree.
+*/
+
+'use strict';
+
+var render = require('./render');
+var Fx = require('../../plots/cartesian/graph_interact');
+var d3 = require('d3');
+var Color = require('../../components/color');
+
+function renderableValuePresent(d) {return d !== '';}
+
+function ownTrace(selection, d) {
+ return selection.filter(function(s) {return s.key === d.traceId;});
+}
+
+function makeTranslucent(element, alpha) {
+ d3.select(element)
+ .select('path')
+ .style('fill-opacity', alpha);
+ d3.select(element)
+ .select('rect')
+ .style('fill-opacity', alpha);
+}
+
+function makeTextContrasty(element) {
+ d3.select(element)
+ .select('text.name')
+ .style('fill', 'black');
+}
+
+function relatedLinks(d) {
+ return function(l) {
+ return d.node.sourceLinks.indexOf(l.link) !== -1 || d.node.targetLinks.indexOf(l.link) !== -1;
+ };
+}
+
+function relatedNodes(l) {
+ return function(d) {
+ return d.node.sourceLinks.indexOf(l.link) !== -1 || d.node.targetLinks.indexOf(l.link) !== -1;
+ };
+}
+
+function nodeHoveredStyle(sankeyNode, d, sankey) {
+ if(d && sankey) {
+ ownTrace(sankey, d)
+ .selectAll('.sankeyLink')
+ .filter(relatedLinks(d))
+ .call(linkHoveredStyle.bind(0, d, sankey, false));
+ }
+}
+
+function nodeNonHoveredStyle(sankeyNode, d, sankey) {
+ if(d && sankey) {
+ ownTrace(sankey, d)
+ .selectAll('.sankeyLink')
+ .filter(relatedLinks(d))
+ .call(linkNonHoveredStyle.bind(0, d, sankey, false));
+ }
+}
+
+function linkHoveredStyle(d, sankey, visitNodes, sankeyLink) {
+
+ var label = sankeyLink.datum().link.label;
+
+ sankeyLink.style('fill-opacity', 0.4);
+
+ if(label) {
+ ownTrace(sankey, d)
+ .selectAll('.sankeyLink')
+ .filter(function(l) {return l.link.label === label;})
+ .style('fill-opacity', 0.4);
+ }
+
+ if(visitNodes) {
+ ownTrace(sankey, d)
+ .selectAll('.sankeyNode')
+ .filter(relatedNodes(d))
+ .call(nodeHoveredStyle);
+ }
+}
+
+function linkNonHoveredStyle(d, sankey, visitNodes, sankeyLink) {
+
+ var label = sankeyLink.datum().link.label;
+
+ sankeyLink.style('fill-opacity', function(d) {return d.tinyColorAlpha;});
+
+ if(label) {
+ ownTrace(sankey, d)
+ .selectAll('.sankeyLink')
+ .filter(function(l) {return l.link.label === label;})
+ .style('fill-opacity', function(d) {return d.tinyColorAlpha;});
+ }
+
+ if(visitNodes) {
+ ownTrace(sankey, d)
+ .selectAll('.sankeyNode')
+ .filter(relatedNodes(d))
+ .call(nodeNonHoveredStyle);
+ }
+}
+
+module.exports = function plot(gd, calcData) {
+
+ var fullLayout = gd._fullLayout;
+ var svg = fullLayout._paper;
+ var size = fullLayout._size;
+
+ var linkSelect = function(element, d) {
+ gd._hoverdata = [d.link];
+ gd._hoverdata.trace = calcData.trace;
+ Fx.click(gd, { target: true });
+ };
+
+ var linkHover = function(element, d, sankey) {
+ d3.select(element).call(linkHoveredStyle.bind(0, d, sankey, true));
+ Fx.hover(gd, d.link, 'sankey');
+ };
+
+ var linkHoverFollow = function(element, d) {
+
+ var boundingBox = element.getBoundingClientRect();
+ var hoverCenterX = boundingBox.left + boundingBox.width / 2;
+ var hoverCenterY = boundingBox.top + boundingBox.height / 2;
+
+ var tooltip = Fx.loneHover({
+ x: hoverCenterX + window.scrollX,
+ y: hoverCenterY + window.scrollY,
+ name: d3.format(d.valueFormat)(d.link.value) + d.valueSuffix,
+ text: [
+ d.link.label,
+ ['Source:', d.link.source.label].join(' '),
+ ['Target:', d.link.target.label].join(' ')
+ ].filter(renderableValuePresent).join('
'),
+ color: Color.addOpacity(d.tinyColorHue, 1),
+ idealAlign: d3.event.x < hoverCenterX ? 'right' : 'left'
+ }, {
+ container: fullLayout._hoverlayer.node(),
+ outerContainer: fullLayout._paper.node()
+ });
+
+ makeTranslucent(tooltip, 0.65);
+ makeTextContrasty(tooltip);
+ };
+
+ var linkUnhover = function(element, d, sankey) {
+ d3.select(element).call(linkNonHoveredStyle.bind(0, d, sankey, true));
+ gd.emit('plotly_unhover', {
+ points: [d.link]
+ });
+
+ Fx.loneUnhover(fullLayout._hoverlayer.node());
+ };
+
+ var nodeSelect = function(element, d, sankey) {
+ gd._hoverdata = [d.node];
+ gd._hoverdata.trace = calcData.trace;
+ d3.select(element).call(nodeNonHoveredStyle, d, sankey);
+ Fx.click(gd, { target: true });
+ };
+
+ var nodeHover = function(element, d, sankey) {
+ d3.select(element).call(nodeHoveredStyle, d, sankey);
+ Fx.hover(gd, d.node, 'sankey');
+ };
+
+ var nodeHoverFollow = function(element, d) {
+
+ var nodeRect = d3.select(element).select('.nodeRect');
+ var boundingBox = nodeRect.node().getBoundingClientRect();
+ var hoverCenterX0 = boundingBox.left - 2;
+ var hoverCenterX1 = boundingBox.right + 2;
+ var hoverCenterY = boundingBox.top + boundingBox.height / 4;
+
+ var tooltip = Fx.loneHover({
+ x0: hoverCenterX0 + window.scrollX,
+ x1: hoverCenterX1 + window.scrollX,
+ y: hoverCenterY + window.scrollY,
+ name: d3.format(d.valueFormat)(d.node.value) + d.valueSuffix,
+ text: [
+ d.node.label,
+ ['Incoming flow count:', d.node.targetLinks.length].join(' '),
+ ['Outgoing flow count:', d.node.sourceLinks.length].join(' ')
+ ].filter(renderableValuePresent).join('
'),
+ color: d.tinyColorHue,
+ idealAlign: 'left'
+ }, {
+ container: fullLayout._hoverlayer.node(),
+ outerContainer: fullLayout._paper.node()
+ });
+
+ makeTranslucent(tooltip, 0.85);
+ makeTextContrasty(tooltip);
+ };
+
+ var nodeUnhover = function(element, d, sankey) {
+
+ d3.select(element).call(nodeNonHoveredStyle, d, sankey);
+ gd.emit('plotly_unhover', {
+ points: [d.node]
+ });
+
+ Fx.loneUnhover(fullLayout._hoverlayer.node());
+ };
+
+ render(
+ svg,
+ calcData,
+ {
+ width: size.w,
+ height: size.h,
+ margin: {
+ t: size.t,
+ r: size.r,
+ b: size.b,
+ l: size.l
+ }
+ },
+ {
+ linkEvents: {
+ hover: linkHover,
+ follow: linkHoverFollow,
+ unhover: linkUnhover,
+ select: linkSelect
+ },
+ nodeEvents: {
+ hover: nodeHover,
+ follow: nodeHoverFollow,
+ unhover: nodeUnhover,
+ select: nodeSelect
+ }
+ }
+ );
+};
diff --git a/src/traces/sankey/render.js b/src/traces/sankey/render.js
new file mode 100644
index 00000000000..c12713cfe92
--- /dev/null
+++ b/src/traces/sankey/render.js
@@ -0,0 +1,609 @@
+/**
+* Copyright 2012-2017, Plotly, Inc.
+* All rights reserved.
+*
+* This source code is licensed under the MIT license found in the
+* LICENSE file in the root directory of this source tree.
+*/
+
+'use strict';
+
+var c = require('./constants');
+var d3 = require('d3');
+var tinycolor = require('tinycolor2');
+var Color = require('../../components/color');
+var Drawing = require('../../components/drawing');
+var d3sankey = require('@plotly/d3-sankey').sankey;
+var d3Force = require('d3-force');
+var Lib = require('../../lib');
+
+// basic data utilities
+
+function keyFun(d) {return d.key;}
+function repeat(d) {return [d];} // d3 data binding convention
+function unwrap(d) {return d[0];} // plotly data structure convention
+
+function persistOriginalPlace(nodes) {
+ var i, distinctLayerPositions = [];
+ for(i = 0; i < nodes.length; i++) {
+ nodes[i].originalX = nodes[i].x;
+ nodes[i].originalY = nodes[i].y;
+ if(distinctLayerPositions.indexOf(nodes[i].x) === -1) {
+ distinctLayerPositions.push(nodes[i].x);
+ }
+ }
+ distinctLayerPositions.sort(function(a, b) {return a - b;});
+ for(i = 0; i < nodes.length; i++) {
+ nodes[i].originalLayerIndex = distinctLayerPositions.indexOf(nodes[i].originalX);
+ nodes[i].originalLayer = nodes[i].originalLayerIndex / (distinctLayerPositions.length - 1);
+ }
+}
+
+function saveCurrentDragPosition(d) {
+ d.lastDraggedX = d.x;
+ d.lastDraggedY = d.y;
+}
+
+function sameLayer(d) {
+ return function(n) {return n.node.originalX === d.node.originalX;};
+}
+
+function switchToForceFormat(nodes) {
+ // force uses x, y as centers
+ for(var i = 0; i < nodes.length; i++) {
+ nodes[i].y = nodes[i].y + nodes[i].dy / 2;
+ }
+}
+
+function switchToSankeyFormat(nodes) {
+ // sankey uses x, y as top left
+ for(var i = 0; i < nodes.length; i++) {
+ nodes[i].y = nodes[i].y - nodes[i].dy / 2;
+ }
+}
+
+// view models
+
+function sankeyModel(layout, d, i) {
+
+ var trace = unwrap(d).trace,
+ domain = trace.domain,
+ nodeSpec = trace.node,
+ linkSpec = trace.link,
+ arrangement = trace.arrangement,
+ horizontal = trace.orientation === 'h',
+ nodePad = trace.node.pad,
+ nodeThickness = trace.node.thickness,
+ nodeLineColor = trace.node.line.color,
+ nodeLineWidth = trace.node.line.width,
+ linkLineColor = trace.link.line.color,
+ linkLineWidth = trace.link.line.width,
+ valueFormat = trace.valueformat,
+ valueSuffix = trace.valuesuffix,
+ textFont = trace.textfont;
+
+ var width = layout.width * (domain.x[1] - domain.x[0]),
+ height = layout.height * (domain.y[1] - domain.y[0]);
+
+ var nodes = nodeSpec.label.map(function(l, i) {
+ return {
+ label: l,
+ color: Lib.isArray(nodeSpec.color) ? nodeSpec.color[i] : nodeSpec.color
+ };
+ });
+
+ var links = linkSpec.value.map(function(d, i) {
+ return {
+ label: linkSpec.label[i],
+ color: Lib.isArray(linkSpec.color) ? linkSpec.color[i] : linkSpec.color,
+ source: linkSpec.source[i],
+ target: linkSpec.target[i],
+ value: d
+ };
+ });
+
+ var sankey = d3sankey()
+ .size(horizontal ? [width, height] : [height, width])
+ .nodeWidth(nodeThickness)
+ .nodePadding(nodePad)
+ .nodes(nodes)
+ .links(links)
+ .layout(c.sankeyIterations);
+
+ var node, sankeyNodes = sankey.nodes();
+ for(var n = 0; n < sankeyNodes.length; n++) {
+ node = sankeyNodes[n];
+ node.width = width;
+ node.height = height;
+ if(node.parallel) node.x = (horizontal ? width : height) * node.parallel;
+ if(node.perpendicular) node.y = (horizontal ? height : width) * node.perpendicular;
+ }
+
+ switchToForceFormat(nodes);
+
+ return {
+ key: i,
+ guid: Math.floor(1e12 * (1 + Math.random())),
+ horizontal: horizontal,
+ width: width,
+ height: height,
+ nodePad: nodePad,
+ nodeLineColor: nodeLineColor,
+ nodeLineWidth: nodeLineWidth,
+ linkLineColor: linkLineColor,
+ linkLineWidth: linkLineWidth,
+ valueFormat: valueFormat,
+ valueSuffix: valueSuffix,
+ textFont: textFont,
+ translateX: domain.x[0] * width + layout.margin.l,
+ translateY: layout.height - domain.y[1] * layout.height + layout.margin.t,
+ dragParallel: horizontal ? height : width,
+ dragPerpendicular: horizontal ? width : height,
+ nodes: nodes,
+ links: links,
+ arrangement: arrangement,
+ sankey: sankey,
+ forceLayouts: {},
+ interactionState: {
+ dragInProgress: false,
+ hovered: false
+ }
+ };
+}
+
+function linkModel(uniqueKeys, d, l) {
+
+ var tc = tinycolor(l.color);
+ var basicKey = l.source.label + '|' + l.target.label;
+ var foundKey = uniqueKeys[basicKey];
+ uniqueKeys[basicKey] = (foundKey === void(0) ? foundKey : 0) + 1;
+ var key = basicKey + (foundKey === void(0) ? '' : '__' + foundKey);
+
+ return {
+ key: key,
+ traceId: d.key,
+ link: l,
+ tinyColorHue: Color.tinyRGB(tc),
+ tinyColorAlpha: tc.getAlpha(),
+ linkLineColor: d.linkLineColor,
+ linkLineWidth: d.linkLineWidth,
+ valueFormat: d.valueFormat,
+ valueSuffix: d.valueSuffix,
+ sankey: d.sankey,
+ interactionState: d.interactionState
+ };
+}
+
+function nodeModel(uniqueKeys, d, n) {
+
+ var tc = tinycolor(n.color),
+ zoneThicknessPad = c.nodePadAcross,
+ zoneLengthPad = d.nodePad / 2,
+ visibleThickness = n.dx + 0.5,
+ visibleLength = n.dy - 0.5,
+ zoneThickness = visibleThickness + 2 * zoneThicknessPad,
+ zoneLength = visibleLength + 2 * zoneLengthPad;
+
+ var basicKey = n.label;
+ var foundKey = uniqueKeys[basicKey];
+ uniqueKeys[basicKey] = (foundKey === void(0) ? foundKey : 0) + 1;
+ var key = basicKey + (foundKey === void(0) ? '' : '__' + foundKey);
+
+ return {
+ key: key,
+ traceId: d.key,
+ node: n,
+ nodePad: d.nodePad,
+ nodeLineColor: d.nodeLineColor,
+ nodeLineWidth: d.nodeLineWidth,
+ textFont: d.textFont,
+ size: d.horizontal ? d.height : d.width,
+ visibleWidth: Math.ceil(d.horizontal ? visibleThickness : visibleLength),
+ visibleHeight: Math.ceil(d.horizontal ? visibleLength : visibleThickness),
+ zoneX: d.horizontal ? -zoneThicknessPad : -zoneLengthPad,
+ zoneY: d.horizontal ? -zoneLengthPad : -zoneThicknessPad,
+ zoneWidth: d.horizontal ? zoneThickness : zoneLength,
+ zoneHeight: d.horizontal ? zoneLength : zoneThickness,
+ labelY: d.horizontal ? n.dy / 2 + 1 : n.dx / 2 + 1,
+ left: n.originalLayer === 1,
+ sizeAcross: d.horizontal ? d.width : d.height,
+ forceLayouts: d.forceLayouts,
+ horizontal: d.horizontal,
+ darkBackground: tc.getBrightness() <= 128,
+ tinyColorHue: Color.tinyRGB(tc),
+ tinyColorAlpha: tc.getAlpha(),
+ valueFormat: d.valueFormat,
+ valueSuffix: d.valueSuffix,
+ sankey: d.sankey,
+ arrangement: d.arrangement,
+ uniqueNodeLabelPathId: [d.guid, d.key, key].join(' '),
+ interactionState: d.interactionState
+ };
+}
+
+// rendering snippets
+
+function crispLinesOnEnd(sankeyNode) {
+ d3.select(sankeyNode.node().parentElement).style('shape-rendering', 'crispEdges');
+}
+
+function updateNodePositions(sankeyNode) {
+ sankeyNode
+ .attr('transform', function(d) {
+ return d.horizontal ?
+ 'translate(' + (d.node.x - 0.5) + ', ' + (d.node.y - d.node.dy / 2 + 0.5) + ')' :
+ 'translate(' + (d.node.y - d.node.dy / 2 - 0.5) + ', ' + (d.node.x + 0.5) + ')';
+ });
+}
+
+function linkPath(d) {
+ var nodes = d.sankey.nodes();
+ switchToSankeyFormat(nodes);
+ var result = d.sankey.link()(d.link);
+ switchToForceFormat(nodes);
+ return result;
+}
+
+function updateNodeShapes(sankeyNode) {
+ d3.select(sankeyNode.node().parentElement).style('shape-rendering', 'optimizeSpeed');
+ sankeyNode.call(updateNodePositions);
+}
+
+function updateShapes(sankeyNode, sankeyLink) {
+ sankeyNode.call(updateNodeShapes);
+ sankeyLink.attr('d', linkPath);
+}
+
+function sizeNode(rect) {
+ rect.attr('width', function(d) {return d.visibleWidth;})
+ .attr('height', function(d) {return d.visibleHeight;});
+}
+
+function salientEnough(d) {
+ return d.link.dy > 1 || d.linkLineWidth > 0;
+}
+
+function linksTransform(d) {
+ return d.horizontal ? 'matrix(1,0,0,1,0,0)' : 'matrix(0,1,1,0,0,0)';
+}
+
+function textGuidePath(d) {
+ return d3.svg.line()([
+ [d.horizontal ? (d.left ? -d.sizeAcross : d.visibleWidth + c.nodeTextOffsetHorizontal) : c.nodeTextOffsetHorizontal, d.labelY],
+ [d.horizontal ? (d.left ? - c.nodeTextOffsetHorizontal : d.sizeAcross) : d.visibleWidth - c.nodeTextOffsetHorizontal, d.labelY]
+ ]);}
+
+// event handling
+
+function attachPointerEvents(selection, sankey, eventSet) {
+ selection
+ .on('.basic', null) // remove any preexisting handlers
+ .on('mouseover.basic', function(d) {
+ if(!d.interactionState.dragInProgress) {
+ eventSet.hover(this, d, sankey);
+ d.interactionState.hovered = [this, d];
+ }
+ })
+ .on('mousemove.basic', function(d) {
+ if(!d.interactionState.dragInProgress) {
+ eventSet.follow(this, d);
+ d.interactionState.hovered = [this, d];
+ }
+ })
+ .on('mouseout.basic', function(d) {
+ if(!d.interactionState.dragInProgress) {
+ eventSet.unhover(this, d, sankey);
+ d.interactionState.hovered = false;
+ }
+ })
+ .on('click.basic', function(d) {
+ if(d.interactionState.hovered) {
+ eventSet.unhover(this, d, sankey);
+ d.interactionState.hovered = false;
+ }
+ if(!d.interactionState.dragInProgress) {
+ eventSet.select(this, d, sankey);
+ }
+ });
+}
+
+function attachDragHandler(sankeyNode, sankeyLink, callbacks) {
+
+ var dragBehavior = d3.behavior.drag()
+
+ .origin(function(d) {return d.horizontal ? d.node : {x: d.node.y, y: d.node.x};})
+
+ .on('dragstart', function(d) {
+ if(d.arrangement === 'fixed') return;
+ this.parentNode.appendChild(this); // bring element to top (painter's algo)
+ d.interactionState.dragInProgress = d.node;
+ saveCurrentDragPosition(d.node);
+ if(d.interactionState.hovered) {
+ callbacks.nodeEvents.unhover.apply(0, d.interactionState.hovered);
+ d.interactionState.hovered = false;
+ }
+ if(d.arrangement === 'snap') {
+ var forceKey = d.traceId + '|' + Math.floor(d.node.originalX);
+ if(d.forceLayouts[forceKey]) {
+ d.forceLayouts[forceKey].alpha(1);
+ } else { // make a forceLayout iff needed
+ attachForce(sankeyNode, forceKey, d);
+ }
+ startForce(sankeyNode, sankeyLink, d, forceKey);
+ }
+ })
+
+ .on('drag', function(d) {
+ if(d.arrangement === 'fixed') return;
+ var x = d.horizontal ? d3.event.x : d3.event.y;
+ var y = d.horizontal ? d3.event.y : d3.event.x;
+ if(d.arrangement === 'snap') {
+ d.node.x = x;
+ d.node.y = y;
+ } else {
+ if(d.arrangement === 'freeform') {
+ d.node.x = x;
+ }
+ d.node.y = Math.max(d.node.dy / 2, Math.min(d.size - d.node.dy / 2, y));
+ }
+ saveCurrentDragPosition(d.node);
+ if(d.arrangement !== 'snap') {
+ d.sankey.relayout();
+ updateShapes(sankeyNode.filter(sameLayer(d)), sankeyLink);
+ sankeyNode.call(crispLinesOnEnd);
+ }
+ })
+
+ .on('dragend', function(d) {
+ d.interactionState.dragInProgress = false;
+ });
+
+ sankeyNode
+ .on('.drag', null) // remove possible previous handlers
+ .call(dragBehavior);
+}
+
+function attachForce(sankeyNode, forceKey, d) {
+ var nodes = d.sankey.nodes().filter(function(n) {return n.originalX === d.node.originalX;});
+ d.forceLayouts[forceKey] = d3Force.forceSimulation(nodes)
+ .alphaDecay(0)
+ .force('collide', d3Force.forceCollide()
+ .radius(function(n) {return n.dy / 2 + d.nodePad / 2;})
+ .strength(1)
+ .iterations(c.forceIterations))
+ .force('constrain', snappingForce(sankeyNode, forceKey, nodes, d))
+ .stop();
+}
+
+function startForce(sankeyNode, sankeyLink, d, forceKey) {
+ window.requestAnimationFrame(function faster() {
+ for(var i = 0; i < c.forceTicksPerFrame; i++) {
+ d.forceLayouts[forceKey].tick();
+ }
+ d.sankey.relayout();
+ updateShapes(sankeyNode.filter(sameLayer(d)), sankeyLink);
+ if(d.forceLayouts[forceKey].alpha() > 0) {
+ window.requestAnimationFrame(faster);
+ }
+ });
+}
+
+function snappingForce(sankeyNode, forceKey, nodes, d) {
+ return function _snappingForce() {
+ var maxVelocity = 0;
+ for(var i = 0; i < nodes.length; i++) {
+ var n = nodes[i];
+ if(n === d.interactionState.dragInProgress) { // constrain node position to the dragging pointer
+ n.x = n.lastDraggedX;
+ n.y = n.lastDraggedY;
+ } else {
+ n.vx = (n.originalX - n.x) / c.forceTicksPerFrame; // snap to layer
+ n.y = Math.min(d.size - n.dy / 2, Math.max(n.dy / 2, n.y)); // constrain to extent
+ }
+ maxVelocity = Math.max(maxVelocity, Math.abs(n.vx), Math.abs(n.vy));
+ }
+ if(!d.interactionState.dragInProgress && maxVelocity < 0.1 && d.forceLayouts[forceKey].alpha() > 0) {
+ d.forceLayouts[forceKey].alpha(0);
+ window.setTimeout(function() {
+ sankeyNode.call(crispLinesOnEnd);
+ }, 30); // geome on move, crisp when static
+ }
+ };
+}
+
+// scene graph
+
+module.exports = function(svg, styledData, layout, callbacks) {
+
+ var sankey = svg.selectAll('.sankey')
+ .data(styledData
+ .filter(function(d) {return unwrap(d).trace.visible;})
+ .map(sankeyModel.bind(null, layout)),
+ keyFun);
+
+ sankey.exit()
+ .remove();
+
+ sankey.enter()
+ .append('g')
+ .classed('sankey', true)
+ .style('box-sizing', 'content-box')
+ .style('position', 'absolute')
+ .style('left', 0)
+ .style('shape-rendering', 'geometricPrecision')
+ .style('pointer-events', 'auto')
+ .style('box-sizing', 'content-box');
+
+ sankey
+ .attr('transform', function(d) {return 'translate(' + d.translateX + ',' + d.translateY + ')';});
+
+ var sankeyLinks = sankey.selectAll('.sankeyLinks')
+ .data(repeat, keyFun);
+
+ sankeyLinks.enter()
+ .append('g')
+ .classed('sankeyLinks', true)
+ .style('fill', 'none')
+ .style('transform', linksTransform);
+
+ sankeyLinks.transition()
+ .ease(c.ease).duration(c.duration)
+ .style('transform', linksTransform);
+
+ var sankeyLink = sankeyLinks.selectAll('.sankeyLink')
+ .data(function(d) {
+ var uniqueKeys = {};
+ return d.sankey.links()
+ .filter(function(l) {return l.value;})
+ .map(linkModel.bind(null, uniqueKeys, d));
+ }, keyFun);
+
+ sankeyLink.enter()
+ .append('path')
+ .classed('sankeyLink', true)
+ .attr('d', linkPath)
+ .call(attachPointerEvents, sankey, callbacks.linkEvents);
+
+ sankeyLink
+ .style('stroke', function(d) {
+ return salientEnough(d) ? Color.tinyRGB(tinycolor(d.linkLineColor)) : d.tinyColorHue;
+ })
+ .style('stroke-opacity', function(d) {
+ return salientEnough(d) ? Color.opacity(d.linkLineColor) : d.tinyColorAlpha;
+ })
+ .style('stroke-width', function(d) {return salientEnough(d) ? d.linkLineWidth : 1;})
+ .style('fill', function(d) {return d.tinyColorHue;})
+ .style('fill-opacity', function(d) {return d.tinyColorAlpha;});
+
+ sankeyLink.transition()
+ .ease(c.ease).duration(c.duration)
+ .attr('d', linkPath);
+
+ sankeyLink.exit().transition()
+ .ease(c.ease).duration(c.duration)
+ .style('opacity', 0)
+ .remove();
+
+ var sankeyNodeSet = sankey.selectAll('.sankeyNodeSet')
+ .data(repeat, keyFun);
+
+ sankeyNodeSet.enter()
+ .append('g')
+ .style('shape-rendering', 'geometricPrecision')
+ .classed('sankeyNodeSet', true);
+
+ sankeyNodeSet
+ .style('cursor', function(d) {
+ switch(d.arrangement) {
+ case 'fixed': return 'default';
+ case 'perpendicular': return 'ns-resize';
+ default: return 'move';
+ }
+ });
+
+ var sankeyNode = sankeyNodeSet.selectAll('.sankeyNode')
+ .data(function(d) {
+ var nodes = d.sankey.nodes();
+ var uniqueKeys = {};
+ persistOriginalPlace(nodes);
+ return nodes
+ .filter(function(n) {return n.value;})
+ .map(nodeModel.bind(null, uniqueKeys, d));
+ }, keyFun);
+
+ sankeyNode.enter()
+ .append('g')
+ .classed('sankeyNode', true)
+ .call(updateNodePositions)
+ .call(attachPointerEvents, sankey, callbacks.nodeEvents);
+
+ sankeyNode
+ .call(attachDragHandler, sankeyLink, callbacks); // has to be here as it binds sankeyLink
+
+ sankeyNode.transition()
+ .ease(c.ease).duration(c.duration)
+ .call(updateNodePositions);
+
+ sankeyNode.exit().transition()
+ .ease(c.ease).duration(c.duration)
+ .style('opacity', 0)
+ .remove();
+
+ var nodeRect = sankeyNode.selectAll('.nodeRect')
+ .data(repeat);
+
+ nodeRect.enter()
+ .append('rect')
+ .classed('nodeRect', true)
+ .call(sizeNode);
+
+ nodeRect
+ .style('stroke-width', function(d) {return d.nodeLineWidth;})
+ .style('stroke', function(d) {return Color.tinyRGB(tinycolor(d.nodeLineColor));})
+ .style('stroke-opacity', function(d) {return Color.opacity(d.nodeLineColor);})
+ .style('fill', function(d) {return d.tinyColorHue;})
+ .style('fill-opacity', function(d) {return d.tinyColorAlpha;});
+
+ nodeRect.transition()
+ .ease(c.ease).duration(c.duration)
+ .call(sizeNode);
+
+ var nodeCapture = sankeyNode.selectAll('.nodeCapture')
+ .data(repeat);
+
+ nodeCapture.enter()
+ .append('rect')
+ .classed('nodeCapture', true)
+ .style('fill-opacity', 0);
+
+ nodeCapture
+ .attr('x', function(d) {return d.zoneX;})
+ .attr('y', function(d) {return d.zoneY;})
+ .attr('width', function(d) {return d.zoneWidth;})
+ .attr('height', function(d) {return d.zoneHeight;});
+
+ var nodeLabelGuide = sankeyNode.selectAll('.nodeLabelGuide')
+ .data(repeat);
+
+ nodeLabelGuide.enter()
+ .append('path')
+ .classed('nodeLabelGuide', true)
+ .attr('id', function(d) {return d.uniqueNodeLabelPathId;})
+ .attr('d', textGuidePath);
+
+ nodeLabelGuide
+ .transition()
+ .ease(c.ease).duration(c.duration)
+ .attr('d', textGuidePath);
+
+ var nodeLabel = sankeyNode.selectAll('.nodeLabel')
+ .data(repeat);
+
+ nodeLabel.enter()
+ .append('text')
+ .classed('nodeLabel', true)
+ .style('user-select', 'none')
+ .style('cursor', 'default')
+ .style('fill', 'black');
+
+ nodeLabel
+ .style('text-shadow', function(d) {
+ return d.horizontal ? '-1px 1px 1px #fff, 1px 1px 1px #fff, 1px -1px 1px #fff, -1px -1px 1px #fff' : 'none';
+ })
+ .each(function(d) {Drawing.font(nodeLabel, d.textFont);});
+
+ var nodeLabelTextPath = nodeLabel.selectAll('.nodeLabelTextPath')
+ .data(repeat);
+
+ nodeLabelTextPath.enter()
+ .append('textPath')
+ .classed('nodeLabelTextPath', true)
+ .attr('alignment-baseline', 'middle')
+ .attr('xlink:href', function(d) {return '#' + d.uniqueNodeLabelPathId;});
+
+ nodeLabelTextPath
+ .text(function(d) {return d.horizontal || d.node.dy > 5 ? d.node.label : '';})
+ .attr('startOffset', function(d) {return d.horizontal && d.left ? '100%' : '0%';})
+ .style('text-anchor', function(d) {return d.horizontal && d.left ? 'end' : 'start';})
+ .style('fill', function(d) {return d.darkBackground && !d.horizontal ? 'white' : 'black';});
+};
diff --git a/test/image/baselines/sankey_energy.png b/test/image/baselines/sankey_energy.png
new file mode 100644
index 00000000000..d057a2b49a3
Binary files /dev/null and b/test/image/baselines/sankey_energy.png differ
diff --git a/test/image/baselines/sankey_energy_dark.png b/test/image/baselines/sankey_energy_dark.png
new file mode 100644
index 00000000000..323c46f8f14
Binary files /dev/null and b/test/image/baselines/sankey_energy_dark.png differ
diff --git a/test/image/mocks/sankey_energy.json b/test/image/mocks/sankey_energy.json
new file mode 100644
index 00000000000..b70e8eb450e
--- /dev/null
+++ b/test/image/mocks/sankey_energy.json
@@ -0,0 +1,578 @@
+{
+ "data": [
+ {
+ "type": "sankey",
+ "domain": {
+ "x": [
+ 0,
+ 1
+ ],
+ "y": [
+ 0,
+ 1
+ ]
+ },
+ "orientation": "h",
+ "valueformat": ".0f",
+ "valuesuffix": "TWh",
+ "node": {
+ "pad": 15,
+ "thickness": 15,
+ "line": {
+ "color": "black",
+ "width": 0.5
+ },
+ "label": [
+ "Agricultural 'waste'",
+ "Bio-conversion",
+ "Liquid",
+ "Losses",
+ "Solid",
+ "Gas",
+ "Biofuel imports",
+ "Biomass imports",
+ "Coal imports",
+ "Coal",
+ "Coal reserves",
+ "District heating",
+ "Industry",
+ "Heating and cooling - commercial",
+ "Heating and cooling - homes",
+ "Electricity grid",
+ "Over generation / exports",
+ "H2 conversion",
+ "Road transport",
+ "Agriculture",
+ "Rail transport",
+ "Lighting & appliances - commercial",
+ "Lighting & appliances - homes",
+ "Gas imports",
+ "Ngas",
+ "Gas reserves",
+ "Thermal generation",
+ "Geothermal",
+ "H2",
+ "Hydro",
+ "International shipping",
+ "Domestic aviation",
+ "International aviation",
+ "National navigation",
+ "Marine algae",
+ "Nuclear",
+ "Oil imports",
+ "Oil",
+ "Oil reserves",
+ "Other waste",
+ "Pumped heat",
+ "Solar PV",
+ "Solar Thermal",
+ "Solar",
+ "Tidal",
+ "UK land based bioenergy",
+ "Wave",
+ "Wind"
+ ],
+ "color": [
+ "rgba(31, 119, 180, 0.8)",
+ "rgba(255, 127, 14, 0.8)",
+ "rgba(44, 160, 44, 0.8)",
+ "rgba(214, 39, 40, 0.8)",
+ "rgba(148, 103, 189, 0.8)",
+ "rgba(140, 86, 75, 0.8)",
+ "rgba(227, 119, 194, 0.8)",
+ "rgba(127, 127, 127, 0.8)",
+ "rgba(188, 189, 34, 0.8)",
+ "rgba(23, 190, 207, 0.8)",
+ "rgba(31, 119, 180, 0.8)",
+ "rgba(255, 127, 14, 0.8)",
+ "rgba(44, 160, 44, 0.8)",
+ "rgba(214, 39, 40, 0.8)",
+ "rgba(148, 103, 189, 0.8)",
+ "rgba(140, 86, 75, 0.8)",
+ "rgba(227, 119, 194, 0.8)",
+ "rgba(127, 127, 127, 0.8)",
+ "rgba(188, 189, 34, 0.8)",
+ "rgba(23, 190, 207, 0.8)",
+ "rgba(31, 119, 180, 0.8)",
+ "rgba(255, 127, 14, 0.8)",
+ "rgba(44, 160, 44, 0.8)",
+ "rgba(214, 39, 40, 0.8)",
+ "rgba(148, 103, 189, 0.8)",
+ "rgba(140, 86, 75, 0.8)",
+ "rgba(227, 119, 194, 0.8)",
+ "rgba(127, 127, 127, 0.8)",
+ "rgba(188, 189, 34, 0.8)",
+ "rgba(23, 190, 207, 0.8)",
+ "rgba(31, 119, 180, 0.8)",
+ "rgba(255, 127, 14, 0.8)",
+ "rgba(44, 160, 44, 0.8)",
+ "rgba(214, 39, 40, 0.8)",
+ "rgba(148, 103, 189, 0.8)",
+ "magenta",
+ "rgba(227, 119, 194, 0.8)",
+ "rgba(127, 127, 127, 0.8)",
+ "rgba(188, 189, 34, 0.8)",
+ "rgba(23, 190, 207, 0.8)",
+ "rgba(31, 119, 180, 0.8)",
+ "rgba(255, 127, 14, 0.8)",
+ "rgba(44, 160, 44, 0.8)",
+ "rgba(214, 39, 40, 0.8)",
+ "rgba(148, 103, 189, 0.8)",
+ "rgba(140, 86, 75, 0.8)",
+ "rgba(227, 119, 194, 0.8)",
+ "rgba(127, 127, 127, 0.8)"]
+ },
+ "link": {
+ "source": [
+ 0,
+ 1,
+ 1,
+ 1,
+ 1,
+ 6,
+ 7,
+ 8,
+ 10,
+ 9,
+ 11,
+ 11,
+ 11,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 23,
+ 25,
+ 5,
+ 5,
+ 5,
+ 5,
+ 5,
+ 27,
+ 17,
+ 17,
+ 28,
+ 29,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 34,
+ 24,
+ 35,
+ 35,
+ 36,
+ 38,
+ 37,
+ 39,
+ 39,
+ 40,
+ 40,
+ 41,
+ 42,
+ 43,
+ 43,
+ 4,
+ 4,
+ 4,
+ 26,
+ 26,
+ 26,
+ 44,
+ 45,
+ 46,
+ 47
+ ],
+ "target": [
+ 1,
+ 2,
+ 3,
+ 4,
+ 5,
+ 2,
+ 4,
+ 9,
+ 9,
+ 4,
+ 12,
+ 13,
+ 14,
+ 16,
+ 14,
+ 17,
+ 12,
+ 18,
+ 19,
+ 13,
+ 3,
+ 20,
+ 21,
+ 22,
+ 24,
+ 24,
+ 13,
+ 3,
+ 26,
+ 19,
+ 12,
+ 15,
+ 28,
+ 3,
+ 18,
+ 15,
+ 12,
+ 30,
+ 18,
+ 31,
+ 32,
+ 19,
+ 33,
+ 20,
+ 1,
+ 5,
+ 26,
+ 26,
+ 37,
+ 37,
+ 2,
+ 4,
+ 1,
+ 14,
+ 13,
+ 15,
+ 14,
+ 42,
+ 41,
+ 19,
+ 26,
+ 12,
+ 15,
+ 3,
+ 11,
+ 15,
+ 1,
+ 15,
+ 15
+ ],
+ "value": [
+ 124.729,
+ 0.597,
+ 26.862,
+ 280.322,
+ 81.144,
+ 35,
+ 35,
+ 11.606,
+ 63.965,
+ 75.571,
+ 10.639,
+ 22.505,
+ 46.184,
+ 104.453,
+ 113.726,
+ 27.14,
+ 342.165,
+ 37.797,
+ 4.412,
+ 40.858,
+ 56.691,
+ 7.863,
+ 90.008,
+ 93.494,
+ 40.719,
+ 82.233,
+ 0.129,
+ 1.401,
+ 151.891,
+ 2.096,
+ 48.58,
+ 7.013,
+ 20.897,
+ 6.242,
+ 20.897,
+ 6.995,
+ 121.066,
+ 128.69,
+ 135.835,
+ 14.458,
+ 206.267,
+ 3.64,
+ 33.218,
+ 4.413,
+ 14.375,
+ 122.952,
+ 500,
+ 339.978,
+ 504.287,
+ 107.703,
+ 611.99,
+ 56.587,
+ 77.81,
+ 193.026,
+ 70.672,
+ 59.901,
+ 19.263,
+ 19.263,
+ 59.901,
+ 0.882,
+ 400.12,
+ 46.477,
+ 525.531,
+ 787.129,
+ 79.329,
+ 9.452,
+ 182.01,
+ 19.013,
+ 289.366
+ ],
+ "color": [
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(33,102,172,0.35)",
+ "rgba(178,24,43,0.35)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)",
+ "rgba(0,0,96,0.2)"
+ ],
+ "label": [
+ "stream 1",
+ "",
+ "",
+ "",
+ "stream 1",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "stream 1",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "Old generation plant (made-up)",
+ "New generation plant (made-up)",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ ""
+ ]
+ }
+ }],
+ "layout": {
+ "title": "Energy forecast for 2050, UK — Department of Energy & Climate Change
Imperfect copy of Mike Bostock's example
with numerous Plotly features",
+ "width": 1118,
+ "height": 772,
+ "font": {
+ "size": 10
+ },
+ "updatemenus": [
+ {
+ "y": 1,
+ "buttons": [
+ {
+ "label": "Light",
+ "method": "relayout",
+ "args": [ "paper_bgcolor", "white" ]
+ },
+ {
+ "label": "Dark",
+ "method": "relayout",
+ "args": [ "paper_bgcolor", "black"]
+ }
+ ]
+ },
+ {
+ "y": 0.9,
+ "buttons": [
+ {
+ "label": "Thick",
+ "method": "restyle",
+ "args": [ "node.thickness", 15 ]
+ },
+ {
+ "label": "Thin",
+ "method": "restyle",
+ "args": [ "node.thickness", 8]
+ }
+ ]
+ },
+ {
+ "y": 0.8,
+ "buttons": [
+ {
+ "label": "Small gap",
+ "method": "restyle",
+ "args": [ "node.pad", 15 ]
+ },
+ {
+ "label": "Large gap",
+ "method": "restyle",
+ "args": [ "node.pad", 20]
+ }
+ ]
+ },
+ {
+ "y": 0.7,
+ "buttons": [
+ {
+ "label": "Snap",
+ "method": "restyle",
+ "args": [ "arrangement", "snap" ]
+ },
+ {
+ "label": "Perpendicular",
+ "method": "restyle",
+ "args": [ "arrangement", "perpendicular"]
+ },
+ {
+ "label": "Freeform",
+ "method": "restyle",
+ "args": [ "arrangement", "freeform"]
+ },
+ {
+ "label": "Fixed",
+ "method": "restyle",
+ "args": [ "arrangement", "fixed"]
+ }
+ ]
+ },
+ {
+ "y": 0.6,
+ "buttons": [
+ {
+ "label": "Horizontal",
+ "method": "restyle",
+ "args": [ "orientation", "h" ]
+ },
+ {
+ "label": "Vertical",
+ "method": "restyle",
+ "args": [ "orientation", "v"]
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/test/image/mocks/sankey_energy_dark.json b/test/image/mocks/sankey_energy_dark.json
new file mode 100644
index 00000000000..1952da02079
--- /dev/null
+++ b/test/image/mocks/sankey_energy_dark.json
@@ -0,0 +1,508 @@
+{
+ "data": [
+ {
+ "type": "sankey",
+ "domain": {
+ "x": [
+ 0,
+ 1
+ ],
+ "y": [
+ 0,
+ 1
+ ]
+ },
+ "orientation": "v",
+ "valueformat": ".0f",
+ "valuesuffix": "TWh",
+ "node": {
+ "pad": 15,
+ "thickness": 15,
+ "line": {
+ "color": "black",
+ "width": 0.5
+ },
+ "label": [
+ "Agricultural 'waste'",
+ "Bio-conversion",
+ "Liquid",
+ "Losses",
+ "Solid",
+ "Gas",
+ "Biofuel imports",
+ "Biomass imports",
+ "Coal imports",
+ "Coal",
+ "Coal reserves",
+ "District heating",
+ "Industry",
+ "Heating and cooling - commercial",
+ "Heating and cooling - homes",
+ "Electricity grid",
+ "Over generation / exports",
+ "H2 conversion",
+ "Road transport",
+ "Agriculture",
+ "Rail transport",
+ "Lighting & appliances - commercial",
+ "Lighting & appliances - homes",
+ "Gas imports",
+ "Ngas",
+ "Gas reserves",
+ "Thermal generation",
+ "Geothermal",
+ "H2",
+ "Hydro",
+ "International shipping",
+ "Domestic aviation",
+ "International aviation",
+ "National navigation",
+ "Marine algae",
+ "Nuclear",
+ "Oil imports",
+ "Oil",
+ "Oil reserves",
+ "Other waste",
+ "Pumped heat",
+ "Solar PV",
+ "Solar Thermal",
+ "Solar",
+ "Tidal",
+ "UK land based bioenergy",
+ "Wave",
+ "Wind"
+ ],
+ "color": [
+ "rgba(31, 119, 180, 0.8)",
+ "rgba(255, 127, 14, 0.8)",
+ "rgba(44, 160, 44, 0.8)",
+ "rgba(214, 39, 40, 0.8)",
+ "rgba(148, 103, 189, 0.8)",
+ "rgba(140, 86, 75, 0.8)",
+ "rgba(227, 119, 194, 0.8)",
+ "rgba(127, 127, 127, 0.8)",
+ "rgba(188, 189, 34, 0.8)",
+ "rgba(23, 190, 207, 0.8)",
+ "rgba(31, 119, 180, 0.8)",
+ "rgba(255, 127, 14, 0.8)",
+ "rgba(44, 160, 44, 0.8)",
+ "rgba(214, 39, 40, 0.8)",
+ "rgba(148, 103, 189, 0.8)",
+ "rgba(140, 86, 75, 0.8)",
+ "rgba(227, 119, 194, 0.8)",
+ "rgba(127, 127, 127, 0.8)",
+ "rgba(188, 189, 34, 0.8)",
+ "rgba(23, 190, 207, 0.8)",
+ "rgba(31, 119, 180, 0.8)",
+ "rgba(255, 127, 14, 0.8)",
+ "rgba(44, 160, 44, 0.8)",
+ "rgba(214, 39, 40, 0.8)",
+ "rgba(148, 103, 189, 0.8)",
+ "rgba(140, 86, 75, 0.8)",
+ "rgba(227, 119, 194, 0.8)",
+ "rgba(127, 127, 127, 0.8)",
+ "rgba(188, 189, 34, 0.8)",
+ "rgba(23, 190, 207, 0.8)",
+ "rgba(31, 119, 180, 0.8)",
+ "rgba(255, 127, 14, 0.8)",
+ "rgba(44, 160, 44, 0.8)",
+ "rgba(214, 39, 40, 0.8)",
+ "rgba(148, 103, 189, 0.8)",
+ "magenta",
+ "rgba(227, 119, 194, 0.8)",
+ "rgba(127, 127, 127, 0.8)",
+ "rgba(188, 189, 34, 0.8)",
+ "rgba(23, 190, 207, 0.8)",
+ "rgba(31, 119, 180, 0.8)",
+ "rgba(255, 127, 14, 0.8)",
+ "rgba(44, 160, 44, 0.8)",
+ "rgba(214, 39, 40, 0.8)",
+ "rgba(148, 103, 189, 0.8)",
+ "rgba(140, 86, 75, 0.8)",
+ "rgba(227, 119, 194, 0.8)",
+ "rgba(127, 127, 127, 0.8)"]
+ },
+ "link": {
+ "source": [
+ 0,
+ 1,
+ 1,
+ 1,
+ 1,
+ 6,
+ 7,
+ 8,
+ 10,
+ 9,
+ 11,
+ 11,
+ 11,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 23,
+ 25,
+ 5,
+ 5,
+ 5,
+ 5,
+ 5,
+ 27,
+ 17,
+ 17,
+ 28,
+ 29,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 34,
+ 24,
+ 35,
+ 35,
+ 36,
+ 38,
+ 37,
+ 39,
+ 39,
+ 40,
+ 40,
+ 41,
+ 42,
+ 43,
+ 43,
+ 4,
+ 4,
+ 4,
+ 26,
+ 26,
+ 26,
+ 44,
+ 45,
+ 46,
+ 47
+ ],
+ "target": [
+ 1,
+ 2,
+ 3,
+ 4,
+ 5,
+ 2,
+ 4,
+ 9,
+ 9,
+ 4,
+ 12,
+ 13,
+ 14,
+ 16,
+ 14,
+ 17,
+ 12,
+ 18,
+ 19,
+ 13,
+ 3,
+ 20,
+ 21,
+ 22,
+ 24,
+ 24,
+ 13,
+ 3,
+ 26,
+ 19,
+ 12,
+ 15,
+ 28,
+ 3,
+ 18,
+ 15,
+ 12,
+ 30,
+ 18,
+ 31,
+ 32,
+ 19,
+ 33,
+ 20,
+ 1,
+ 5,
+ 26,
+ 26,
+ 37,
+ 37,
+ 2,
+ 4,
+ 1,
+ 14,
+ 13,
+ 15,
+ 14,
+ 42,
+ 41,
+ 19,
+ 26,
+ 12,
+ 15,
+ 3,
+ 11,
+ 15,
+ 1,
+ 15,
+ 15
+ ],
+ "value": [
+ 124.729,
+ 0.597,
+ 26.862,
+ 280.322,
+ 81.144,
+ 35,
+ 35,
+ 11.606,
+ 63.965,
+ 75.571,
+ 10.639,
+ 22.505,
+ 46.184,
+ 104.453,
+ 113.726,
+ 27.14,
+ 342.165,
+ 37.797,
+ 4.412,
+ 40.858,
+ 56.691,
+ 7.863,
+ 90.008,
+ 93.494,
+ 40.719,
+ 82.233,
+ 0.129,
+ 1.401,
+ 151.891,
+ 2.096,
+ 48.58,
+ 7.013,
+ 20.897,
+ 6.242,
+ 20.897,
+ 6.995,
+ 121.066,
+ 128.69,
+ 135.835,
+ 14.458,
+ 206.267,
+ 3.64,
+ 33.218,
+ 4.413,
+ 14.375,
+ 122.952,
+ 500,
+ 339.978,
+ 504.287,
+ 107.703,
+ 611.99,
+ 56.587,
+ 77.81,
+ 193.026,
+ 70.672,
+ 59.901,
+ 19.263,
+ 19.263,
+ 59.901,
+ 0.882,
+ 400.12,
+ 46.477,
+ 525.531,
+ 787.129,
+ 79.329,
+ 9.452,
+ 182.01,
+ 19.013,
+ 289.366
+ ],
+ "label": [
+ "stream 1",
+ "",
+ "",
+ "",
+ "stream 1",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "stream 1",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "Old generation plant (made-up)",
+ "New generation plant (made-up)",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ ""
+ ]
+ }
+ }],
+ "layout": {
+ "title": "Energy forecast for 2050, UK — Department of Energy & Climate Change
Imperfect copy of Mike Bostock's example
with numerous Plotly features",
+ "width": 1200,
+ "height": 1000,
+ "paper_bgcolor": "rgba(0,0,0,1)",
+ "font": {
+ "size": 10
+ },
+ "updatemenus": [
+ {
+ "y": 1,
+ "buttons": [
+ {
+ "label": "Light",
+ "method": "relayout",
+ "args": [ "paper_bgcolor", "white" ]
+ },
+ {
+ "label": "Dark",
+ "method": "relayout",
+ "args": [ "paper_bgcolor", "black"]
+ }
+ ]
+ },
+ {
+ "y": 0.9,
+ "buttons": [
+ {
+ "label": "Thick",
+ "method": "restyle",
+ "args": [ "node.thickness", 15 ]
+ },
+ {
+ "label": "Thin",
+ "method": "restyle",
+ "args": [ "node.thickness", 8]
+ }
+ ]
+ },
+ {
+ "y": 0.8,
+ "buttons": [
+ {
+ "label": "Small gap",
+ "method": "restyle",
+ "args": [ "node.pad", 15 ]
+ },
+ {
+ "label": "Large gap",
+ "method": "restyle",
+ "args": [ "node.pad", 20]
+ }
+ ]
+ },
+ {
+ "y": 0.7,
+ "buttons": [
+ {
+ "label": "Snap",
+ "method": "restyle",
+ "args": [ "arrangement", "snap" ]
+ },
+ {
+ "label": "Perpendicular",
+ "method": "restyle",
+ "args": [ "arrangement", "perpendicular"]
+ },
+ {
+ "label": "Freeform",
+ "method": "restyle",
+ "args": [ "arrangement", "freeform"]
+ },
+ {
+ "label": "Fixed",
+ "method": "restyle",
+ "args": [ "arrangement", "fixed"]
+ }
+ ]
+ },
+ {
+ "y": 0.6,
+ "buttons": [
+ {
+ "label": "Horizontal",
+ "method": "restyle",
+ "args": [ "orientation", "h" ]
+ },
+ {
+ "label": "Vertical",
+ "method": "restyle",
+ "args": [ "orientation", "v"]
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/test/jasmine/tests/sankey_test.js b/test/jasmine/tests/sankey_test.js
new file mode 100644
index 00000000000..a03da2c9c59
--- /dev/null
+++ b/test/jasmine/tests/sankey_test.js
@@ -0,0 +1,342 @@
+var Plotly = require('@lib/index');
+var attributes = require('@src/traces/sankey/attributes');
+var Lib = require('@src/lib');
+var d3 = require('d3');
+var mock = require('@mocks/sankey_energy.json');
+var mockDark = require('@mocks/sankey_energy_dark.json');
+var Plots = require('@src/plots/plots');
+var Sankey = require('@src/traces/sankey');
+
+var createGraphDiv = require('../assets/create_graph_div');
+var destroyGraphDiv = require('../assets/destroy_graph_div');
+var mouseEvent = require('../assets/mouse_event');
+
+describe('sankey tests', function() {
+
+ 'use strict';
+
+ function _supply(traceIn) {
+ var traceOut = { visible: true },
+ defaultColor = '#444',
+ layout = { };
+
+ Sankey.supplyDefaults(traceIn, traceOut, defaultColor, layout);
+
+ return traceOut;
+ }
+
+ function _supplyWithLayout(traceIn, layout) {
+ var traceOut = { visible: true },
+ defaultColor = '#444';
+
+ Sankey.supplyDefaults(traceIn, traceOut, defaultColor, layout);
+
+ return traceOut;
+ }
+
+ describe('don\'t remove nodes if encountering no circularity', function() {
+
+ it('removing a single self-pointing node', function() {
+ var fullTrace = _supply({
+ node: {
+ label: ['a', 'b']
+ },
+ link: {
+ value: [1],
+ source: [1],
+ target: [0]
+ }
+ });
+
+ expect(fullTrace.node.label).toEqual(['a', 'b'], 'node labels retained');
+ expect(fullTrace.link.value).toEqual([1], 'link value(s) retained');
+ expect(fullTrace.link.source).toEqual([1], 'link source(s) retained');
+ expect(fullTrace.link.target).toEqual([0], 'link target(s) retained');
+ });
+ });
+
+ describe('log warning if issue is encountered with graph structure',
+ function() {
+
+ it('some nodes are not linked', function() {
+
+ var warnings = [];
+ spyOn(Lib, 'warn').and.callFake(function(msg) {
+ warnings.push(msg);
+ });
+
+ _supply({
+ node: {
+ label: ['a', 'b', 'c']
+ },
+ link: {
+ value: [1],
+ source: [0],
+ target: [1]
+ }
+ });
+
+ expect(warnings.length).toEqual(1);
+ });
+ });
+
+ describe('sankey global defaults', function() {
+
+ it('should not coerce trace opacity', function() {
+ var gd = Lib.extendDeep({}, mock);
+
+ Plots.supplyDefaults(gd);
+
+ expect(gd._fullData[0].opacity).toBeUndefined();
+ });
+
+ });
+
+ describe('sankey defaults', function() {
+
+ it('\'Sankey\' specification should have proper arrays where mandatory',
+ function() {
+
+ var fullTrace = _supply({});
+
+ expect(fullTrace.node.label)
+ .toEqual([], 'presence of node label array is guaranteed');
+
+ expect(fullTrace.link.value)
+ .toEqual([], 'presence of link value array is guaranteed');
+
+ expect(fullTrace.link.source)
+ .toEqual([], 'presence of link source array is guaranteed');
+
+ expect(fullTrace.link.target)
+ .toEqual([], 'presence of link target array is guaranteed');
+
+ });
+
+ it('\'Sankey\' specification should have proper types',
+ function() {
+
+ var fullTrace = _supply({});
+
+ expect(fullTrace.orientation)
+ .toEqual(attributes.orientation.dflt, 'use orientation by default');
+
+ expect(fullTrace.valueformat)
+ .toEqual(attributes.valueformat.dflt, 'valueformat by default');
+
+ expect(fullTrace.valuesuffix)
+ .toEqual(attributes.valuesuffix.dflt, 'valuesuffix by default');
+
+ expect(fullTrace.arrangement)
+ .toEqual(attributes.arrangement.dflt, 'arrangement by default');
+
+ expect(fullTrace.domain.x)
+ .toEqual(attributes.domain.x.dflt, 'x domain by default');
+
+ expect(fullTrace.domain.y)
+ .toEqual(attributes.domain.y.dflt, 'y domain by default');
+ });
+
+ it('\'Sankey\' layout dependent specification should have proper types',
+ function() {
+
+ var fullTrace = _supplyWithLayout({}, {font: {family: 'Arial'}});
+ expect(fullTrace.textfont)
+ .toEqual({family: 'Arial'}, 'textfont is defined');
+ });
+
+ it('\'line\' specifications should yield the default values',
+ function() {
+
+ var fullTrace = _supply({});
+
+ expect(fullTrace.node.line.color)
+ .toEqual('#444', 'default node line color');
+ expect(fullTrace.node.line.width)
+ .toEqual(0.5, 'default node line thickness');
+
+ expect(fullTrace.link.line.color)
+ .toEqual('#444', 'default link line color');
+ expect(fullTrace.link.line.width)
+ .toEqual(0, 'default link line thickness');
+ });
+
+ it('fills \'node\' colors if not specified', function() {
+
+ var fullTrace = _supply({
+ node: {
+ label: ['a', 'b']
+ },
+ link: {
+ source: [0],
+ target: [1],
+ value: [1]
+ }
+ });
+
+ expect(Lib.isArray(fullTrace.node.color)).toBe(true, 'set up color array');
+
+ });
+
+ });
+
+ describe('sankey calc', function() {
+
+ function _calc(trace) {
+ var gd = { data: [trace] };
+
+ Plots.supplyDefaults(gd);
+ var fullTrace = gd._fullData[0];
+ Sankey.calc(gd, fullTrace);
+ return fullTrace;
+ }
+
+ var base = { type: 'sankey' };
+
+ it('circularity is detected', function() {
+
+ var errors = [];
+ spyOn(Lib, 'error').and.callFake(function(msg) {
+ errors.push(msg);
+ });
+
+ _calc(Lib.extendDeep({}, base, {
+ node: {
+ label: ['a', 'b', 'c']
+ },
+ link: {
+ value: [1, 1, 1],
+ source: [0, 1, 2],
+ target: [1, 2, 0]
+ }
+ }));
+
+ expect(errors.length).toEqual(1);
+ });
+
+ describe('remove nodes if encountering circularity', function() {
+
+ it('removing a single self-pointing node', function() {
+ var fullTrace = _calc(Lib.extendDeep({}, base, {
+ node: {
+ label: ['a']
+ },
+ link: {
+ value: [1],
+ source: [0],
+ target: [0]
+ }
+ }));
+
+ expect(fullTrace.node.label).toEqual([], 'node label(s) removed');
+ expect(fullTrace.link.value).toEqual([], 'link value(s) removed');
+ expect(fullTrace.link.source).toEqual([], 'link source(s) removed');
+ expect(fullTrace.link.target).toEqual([], 'link target(s) removed');
+
+ });
+
+ it('removing everything if detecting a circle', function() {
+ var fullTrace = _calc(Lib.extendDeep({}, base, {
+ node: {
+ label: ['a', 'b', 'c', 'd', 'e']
+ },
+ link: {
+ value: [1, 1, 1, 1, 1, 1, 1, 1],
+ source: [0, 1, 2, 3],
+ target: [1, 2, 0, 4]
+ }
+ }));
+
+ expect(fullTrace.node.label).toEqual([], 'node label(s) removed');
+ expect(fullTrace.link.value).toEqual([], 'link value(s) removed');
+ expect(fullTrace.link.source).toEqual([], 'link source(s) removed');
+ expect(fullTrace.link.target).toEqual([], 'link target(s) removed');
+
+ });
+ });
+ });
+
+ describe('lifecycle methods', function() {
+
+ afterEach(destroyGraphDiv);
+
+ it('Plotly.deleteTraces with two traces removes the deleted plot', function(done) {
+
+ var gd = createGraphDiv();
+ var mockCopy = Lib.extendDeep({}, mock);
+ var mockCopy2 = Lib.extendDeep({}, mockDark);
+
+ Plotly.plot(gd, mockCopy)
+ .then(function() {
+ expect(gd.data.length).toEqual(1);
+ expect(d3.selectAll('.sankey').size()).toEqual(1);
+ return Plotly.addTraces(gd, mockCopy2.data[0]);
+ })
+ .then(function() {
+ expect(gd.data.length).toEqual(2);
+ expect(d3.selectAll('.sankey').size()).toEqual(2);
+ return Plotly.deleteTraces(gd, [0]);
+ })
+ .then(function() {
+ expect(gd.data.length).toEqual(1);
+ expect(d3.selectAll('.sankey').size()).toEqual(1);
+ return Plotly.deleteTraces(gd, 0);
+ })
+ .then(function() {
+ expect(gd.data.length).toEqual(0);
+ expect(d3.selectAll('.sankey').size()).toEqual(0);
+ done();
+ });
+ });
+
+ it('Plotly.plot does not show Sankey if \'visible\' is false', function(done) {
+
+ var gd = createGraphDiv();
+ var mockCopy = Lib.extendDeep({}, mock);
+
+ Plotly.plot(gd, mockCopy)
+ .then(function() {
+ expect(gd.data.length).toEqual(1);
+ expect(d3.selectAll('.sankey').size()).toEqual(1);
+ return Plotly.restyle(gd, 'visible', false);
+ })
+ .then(function() {
+ expect(gd.data.length).toEqual(1);
+ expect(d3.selectAll('.sankey').size()).toEqual(0);
+ return Plotly.restyle(gd, 'visible', true);
+ })
+ .then(function() {
+ expect(gd.data.length).toEqual(1);
+ expect(d3.selectAll('.sankey').size()).toEqual(1);
+ done();
+ });
+ });
+
+ it('Plotly.plot shows and removes tooltip on node, link', function(done) {
+
+ var gd = createGraphDiv();
+ var mockCopy = Lib.extendDeep({}, mock);
+
+ Plotly.plot(gd, mockCopy)
+ .then(function() {
+
+ mouseEvent('mousemove', 400, 300);
+ mouseEvent('mouseover', 400, 300);
+
+ window.setTimeout(function() {
+ expect(d3.select('.hoverlayer>.hovertext>text').node().innerHTML)
+ .toEqual('447TWh', 'tooltip present');
+
+ mouseEvent('mousemove', 450, 300);
+ mouseEvent('mouseover', 450, 300);
+
+ window.setTimeout(function() {
+ expect(d3.select('.hoverlayer>.hovertext>text').node().innerHTML)
+ .toEqual('46TWh', 'tooltip jumped to link');
+ done();
+ }, 60);
+ }, 60);
+ });
+ });
+ });
+});