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); + }); + }); + }); +});