diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index c8e440b32fc..3671d433689 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2524,6 +2524,7 @@ var traceUIControlPatterns = [ // "visible" includes trace.transforms[i].styles[j].value.visible {pattern: /(^|value\.)visible$/, attr: 'legend.uirevision'}, {pattern: /^dimensions\[\d+\]\.constraintrange/}, + {pattern: /^node\.(x|y)/}, // for Sankey nodes // below this you must be in editable: true mode // TODO: I still put name and title with `trace.uirevision` diff --git a/src/traces/sankey/attributes.js b/src/traces/sankey/attributes.js index 9e0929100c7..57cd56072da 100644 --- a/src/traces/sankey/attributes.js +++ b/src/traces/sankey/attributes.js @@ -91,6 +91,7 @@ var attrs = module.exports = overrideAll({ }, groups: { valType: 'info_array', + impliedEdits: {'x': [], 'y': []}, dimensions: 2, freeLength: true, dflt: [], @@ -102,6 +103,18 @@ var attrs = module.exports = overrideAll({ 'Multiple groups can be specified.' ].join(' ') }, + x: { + valType: 'data_array', + dflt: [], + role: 'info', + description: 'The normalized horizontal position of the node.' + }, + y: { + valType: 'data_array', + dflt: [], + role: 'info', + description: 'The normalized vertical position of the node.' + }, color: { valType: 'color', role: 'style', diff --git a/src/traces/sankey/defaults.js b/src/traces/sankey/defaults.js index 531dfffcf50..a82706c3cb4 100644 --- a/src/traces/sankey/defaults.js +++ b/src/traces/sankey/defaults.js @@ -33,6 +33,8 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout } coerceNode('label'); coerceNode('groups'); + coerceNode('x'); + coerceNode('y'); coerceNode('pad'); coerceNode('thickness'); coerceNode('line.color'); @@ -82,7 +84,12 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout coerce('orientation'); coerce('valueformat'); coerce('valuesuffix'); - coerce('arrangement'); + + var dfltArrangement; + if(nodeOut.x.length && nodeOut.y.length) { + dfltArrangement = 'freeform'; + } + coerce('arrangement', dfltArrangement); Lib.coerceFont(coerce, 'textfont', Lib.extendFlat({}, layout.font)); diff --git a/src/traces/sankey/render.js b/src/traces/sankey/render.js index ae392c101cf..2c98e761ccf 100644 --- a/src/traces/sankey/render.js +++ b/src/traces/sankey/render.js @@ -23,6 +23,8 @@ var repeat = gup.repeat; var unwrap = gup.unwrap; var interpolateNumber = require('d3-interpolate').interpolateNumber; +var Registry = require('../../registry'); + // view models function sankeyModel(layout, d, traceIndex) { @@ -67,13 +69,17 @@ function sankeyModel(layout, d, traceIndex) { Lib.warn('node.pad was reduced to ', sankey.nodePadding(), ' to fit within the figure.'); } + // Counters for nested loops + var i, j, k; + // Create transient nodes for animations for(var nodePointNumber in calcData._groupLookup) { var groupIndex = parseInt(calcData._groupLookup[nodePointNumber]); // Find node representing groupIndex var groupingNode; - for(var i = 0; i < graph.nodes.length; i++) { + + for(i = 0; i < graph.nodes.length; i++) { if(graph.nodes[i].pointNumber === groupIndex) { groupingNode = graph.nodes[i]; break; @@ -98,7 +104,6 @@ function sankeyModel(layout, d, traceIndex) { } function computeLinkConcentrations() { - var i, j, k; for(i = 0; i < graph.nodes.length; i++) { var node = graph.nodes[i]; // Links connecting the same two nodes are part of a flow @@ -163,6 +168,93 @@ function sankeyModel(layout, d, traceIndex) { } computeLinkConcentrations(); + // Push any overlapping nodes down. + function resolveCollisionsTopToBottom(columns) { + columns.forEach(function(nodes) { + var node; + var dy; + var y = 0; + var n = nodes.length; + var i; + nodes.sort(function(a, b) { + return a.y0 - b.y0; + }); + for(i = 0; i < n; ++i) { + node = nodes[i]; + if(node.y0 >= y) { + // No overlap + } else { + dy = (y - node.y0); + if(dy > 1e-6) node.y0 += dy, node.y1 += dy; + } + y = node.y1 + nodePad; + } + }); + } + + // Group nodes into columns based on their x position + function snapToColumns(nodes) { + // Sort nodes by x position + var orderedNodes = nodes.map(function(n, i) { + return { + x0: n.x0, + index: i + }; + }) + .sort(function(a, b) { + return a.x0 - b.x0; + }); + + var columns = []; + var colNumber = -1; + var colX; // Position of column + var lastX = -Infinity; // Position of last node + var dx; + for(i = 0; i < orderedNodes.length; i++) { + var node = nodes[orderedNodes[i].index]; + // If the node does not overlap with the last one + if(node.x0 > lastX + nodeThickness) { + // Start a new column + colNumber += 1; + colX = node.x0; + } + lastX = node.x0; + + // Add node to its associated column + if(!columns[colNumber]) columns[colNumber] = []; + columns[colNumber].push(node); + + // Change node's x position to align it with its column + dx = colX - node.x0; + node.x0 += dx, node.x1 += dx; + + } + return columns; + } + + // Force node position + if(trace.node.x.length && trace.node.y.length) { + for(i = 0; i < Math.min(trace.node.x.length, trace.node.y.length, graph.nodes.length); i++) { + if(trace.node.x[i] && trace.node.y[i]) { + var pos = [trace.node.x[i] * width, trace.node.y[i] * height]; + graph.nodes[i].x0 = pos[0] - nodeThickness / 2; + graph.nodes[i].x1 = pos[0] + nodeThickness / 2; + + var nodeHeight = graph.nodes[i].y1 - graph.nodes[i].y0; + graph.nodes[i].y0 = pos[1] - nodeHeight / 2; + graph.nodes[i].y1 = pos[1] + nodeHeight / 2; + } + } + if(trace.arrangement === 'snap') { + nodes = graph.nodes; + var columns = snapToColumns(nodes); + resolveCollisionsTopToBottom(columns); + } + // Update links + sankey.update(graph); + } + + return { circular: circular, key: traceIndex, @@ -399,6 +491,7 @@ function nodeModel(d, n) { partOfGroup: n.partOfGroup || false, group: n.group, traceId: d.key, + trace: d.trace, node: n, nodePad: d.nodePad, nodeLineColor: d.nodeLineColor, @@ -425,7 +518,8 @@ function nodeModel(d, n) { graph: d.graph, arrangement: d.arrangement, uniqueNodeLabelPathId: [d.guid, d.key, key].join('_'), - interactionState: d.interactionState + interactionState: d.interactionState, + figure: d }; } @@ -509,7 +603,7 @@ function attachPointerEvents(selection, sankey, eventSet) { }); } -function attachDragHandler(sankeyNode, sankeyLink, callbacks) { +function attachDragHandler(sankeyNode, sankeyLink, callbacks, gd) { var dragBehavior = d3.behavior.drag() .origin(function(d) { return { @@ -520,6 +614,9 @@ function attachDragHandler(sankeyNode, sankeyLink, callbacks) { .on('dragstart', function(d) { if(d.arrangement === 'fixed') return; + Lib.ensureSingle(gd._fullLayout._infolayer, 'g', 'dragcover', function(s) { + gd._fullLayout._dragCover = s; + }); Lib.raiseToTop(this); d.interactionState.dragInProgress = d.node; @@ -533,9 +630,9 @@ function attachDragHandler(sankeyNode, sankeyLink, callbacks) { if(d.forceLayouts[forceKey]) { d.forceLayouts[forceKey].alpha(1); } else { // make a forceLayout if needed - attachForce(sankeyNode, forceKey, d); + attachForce(sankeyNode, forceKey, d, gd); } - startForce(sankeyNode, sankeyLink, d, forceKey); + startForce(sankeyNode, sankeyLink, d, forceKey, gd); } }) @@ -553,8 +650,9 @@ function attachDragHandler(sankeyNode, sankeyLink, callbacks) { d.node.x0 = x - d.visibleWidth / 2; d.node.x1 = x + d.visibleWidth / 2; } - d.node.y0 = Math.max(0, Math.min(d.size - d.visibleHeight, y)); - d.node.y1 = d.node.y0 + d.visibleHeight; + y = Math.max(0, Math.min(d.size - d.visibleHeight / 2, y)); + d.node.y0 = y - d.visibleHeight / 2; + d.node.y1 = y + d.visibleHeight / 2; } saveCurrentDragPosition(d.node); @@ -565,11 +663,13 @@ function attachDragHandler(sankeyNode, sankeyLink, callbacks) { }) .on('dragend', function(d) { + if(d.arrangement === 'fixed') return; d.interactionState.dragInProgress = false; for(var i = 0; i < d.node.childrenNodes.length; i++) { d.node.childrenNodes[i].x = d.node.x; d.node.childrenNodes[i].y = d.node.y; } + if(d.arrangement !== 'snap') persistFinalNodePositions(d, gd); }); sankeyNode @@ -577,7 +677,7 @@ function attachDragHandler(sankeyNode, sankeyLink, callbacks) { .call(dragBehavior); } -function attachForce(sankeyNode, forceKey, d) { +function attachForce(sankeyNode, forceKey, d, gd) { // Attach force to nodes in the same column (same x coordinate) switchToForceFormat(d.graph.nodes); var nodes = d.graph.nodes @@ -590,11 +690,11 @@ function attachForce(sankeyNode, forceKey, d) { .radius(function(n) {return n.dy / 2 + d.nodePad / 2;}) .strength(1) .iterations(c.forceIterations)) - .force('constrain', snappingForce(sankeyNode, forceKey, nodes, d)) + .force('constrain', snappingForce(sankeyNode, forceKey, nodes, d, gd)) .stop(); } -function startForce(sankeyNode, sankeyLink, d, forceKey) { +function startForce(sankeyNode, sankeyLink, d, forceKey, gd) { window.requestAnimationFrame(function faster() { var i; for(i = 0; i < c.forceTicksPerFrame; i++) { @@ -609,6 +709,14 @@ function startForce(sankeyNode, sankeyLink, d, forceKey) { if(d.forceLayouts[forceKey].alpha() > 0) { window.requestAnimationFrame(faster); + } else { + // Make sure the final x position is equal to its original value + // because the force simulation will have numerical error + var x = d.node.originalX; + d.node.x0 = x - d.visibleWidth / 2; + d.node.x1 = x + d.visibleWidth / 2; + + persistFinalNodePositions(d, gd); } }); } @@ -628,13 +736,31 @@ function snappingForce(sankeyNode, forceKey, nodes, d) { 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); + d.forceLayouts[forceKey].alpha(0); // This will stop the animation loop } }; } // basic data utilities +function persistFinalNodePositions(d, gd) { + var x = []; + var y = []; + for(var i = 0; i < d.graph.nodes.length; i++) { + var nodeX = (d.graph.nodes[i].x0 + d.graph.nodes[i].x1) / 2; + var nodeY = (d.graph.nodes[i].y0 + d.graph.nodes[i].y1) / 2; + x.push(nodeX / d.figure.width); + y.push(nodeY / d.figure.height); + } + Registry.call('_guiRestyle', gd, { + 'node.x': [x], + 'node.y': [y] + }, d.trace.index) + .then(function() { + if(gd._fullLayout._dragCover) gd._fullLayout._dragCover.remove(); + }); +} + function persistOriginalPlace(nodes) { var distinctLayerPositions = []; var i; @@ -664,8 +790,8 @@ function sameLayer(d) { function switchToForceFormat(nodes) { // force uses x, y as centers for(var i = 0; i < nodes.length; i++) { - nodes[i].y = nodes[i].y0 + nodes[i].dy / 2; - nodes[i].x = nodes[i].x0 + nodes[i].dx / 2; + nodes[i].y = (nodes[i].y0 + nodes[i].y1) / 2; + nodes[i].x = (nodes[i].x0 + nodes[i].x1) / 2; } } @@ -688,6 +814,9 @@ module.exports = function(gd, svg, calcData, layout, callbacks) { firstRender = true; }); + // To prevent animation on dragging + var dragcover = gd._fullLayout._dragCover; + var styledData = calcData .filter(function(d) {return unwrap(d).trace.visible;}) .map(sankeyModel.bind(null, layout)); @@ -752,7 +881,7 @@ module.exports = function(gd, svg, calcData, layout, callbacks) { .attr('d', linkPath()); sankeyLink - .style('opacity', function() { return (gd._context.staticPlot || firstRender) ? 1 : 0;}) + .style('opacity', function() { return (gd._context.staticPlot || firstRender || dragcover) ? 1 : 0;}) .transition() .ease(c.ease).duration(c.duration) .style('opacity', 1); @@ -795,7 +924,7 @@ module.exports = function(gd, svg, calcData, layout, callbacks) { sankeyNode .call(attachPointerEvents, sankey, callbacks.nodeEvents) - .call(attachDragHandler, sankeyLink, callbacks); // has to be here as it binds sankeyLink + .call(attachDragHandler, sankeyLink, callbacks, gd); // has to be here as it binds sankeyLink sankeyNode.transition() .ease(c.ease).duration(c.duration) diff --git a/test/image/baselines/sankey_x_y.png b/test/image/baselines/sankey_x_y.png new file mode 100644 index 00000000000..f769d35ab57 Binary files /dev/null and b/test/image/baselines/sankey_x_y.png differ diff --git a/test/image/mocks/sankey_x_y.json b/test/image/mocks/sankey_x_y.json new file mode 100644 index 00000000000..f9d06522495 --- /dev/null +++ b/test/image/mocks/sankey_x_y.json @@ -0,0 +1,28 @@ +{ + "data": [ + { + "type": "sankey", + "arrangement": "freeform", + "node": { + "label": ["0", "1", "2", "3", "4", "5"], + "x": [0.128, 0.128, 0.559, 0.785, 0.352, 0.559], + "y": [0.738, 0.165, 0.205, 0.390, 0.165, 0.256] + }, + "link": { + "source": [ + 0, 0, 1, 2, 5, 4, 3, 5 + ], + "target": [ + 5, 3, 4, 3, 0, 2, 2, 3 + ], + "value": [ + 1, 2, 1, 1, 1, 1, 1, 2 + ] + } + }], + "layout": { + "title": "Sankey with manually positioned node", + "width": 800, + "height": 800 + } +} diff --git a/test/jasmine/assets/check_overlap.js b/test/jasmine/assets/check_overlap.js new file mode 100644 index 00000000000..023ab70ae45 --- /dev/null +++ b/test/jasmine/assets/check_overlap.js @@ -0,0 +1,38 @@ +'use strict'; + +function compare(baseRects, compareRects) { + return baseRects.left < compareRects.right && + baseRects.right > compareRects.left && + baseRects.top < compareRects.bottom && + baseRects.bottom > compareRects.top; +} + +module.exports = function checkOverlap(base, elements) { + var baseRects = base.getBoundingClientRect(); + + // handle array as second argument + if(Array.isArray(elements)) { + return elements.map(function(el) { + if(!el) return false; + + var compareRects = el.getBoundingClientRect(); + return compare(baseRects, compareRects); + }); + } + + // handle HTMLCollection or NodeList as second argument + if(elements instanceof NodeList || elements instanceof HTMLCollection) { + var collection = Array.prototype.slice.call(elements); + + return collection.map(function(el) { + // check for holly or null values + if(!el) return false; + + var compareRects = el.getBoundingClientRect(); + return compare(baseRects, compareRects); + }); + } + + // assume element is node + return compare(baseRects, elements.getBoundingClientRect()); +}; diff --git a/test/jasmine/assets/drag.js b/test/jasmine/assets/drag.js index 6777ceb2204..23643c79b8e 100644 --- a/test/jasmine/assets/drag.js +++ b/test/jasmine/assets/drag.js @@ -1,6 +1,7 @@ var isNumeric = require('fast-isnumeric'); var mouseEvent = require('./mouse_event'); var getNodeCoords = require('./get_node_coords'); +var delay = require('./delay'); function makeFns(node, dx, dy, opts) { opts = opts || {}; @@ -49,7 +50,8 @@ function makeFns(node, dx, dy, opts) { * optionally specify an edge ('n', 'se', 'w' etc) * to grab it by an edge or corner (otherwise the middle is used) */ -function drag(node, dx, dy, edge, x0, y0, nsteps, noCover) { +function drag(node, dx, dy, edge, x0, y0, nsteps, noCover, timeDelay) { + if(!timeDelay) timeDelay = 0; var fns = makeFns(node, dx, dy, { edge: edge, x0: x0, @@ -58,7 +60,7 @@ function drag(node, dx, dy, edge, x0, y0, nsteps, noCover) { noCover: noCover }); - return fns.start().then(fns.end); + return fns.start().then(delay(timeDelay)).then(fns.end); } function waitForDragCover() { diff --git a/test/jasmine/tests/sankey_test.js b/test/jasmine/tests/sankey_test.js index 69ec5616e73..9fd99be2789 100644 --- a/test/jasmine/tests/sankey_test.js +++ b/test/jasmine/tests/sankey_test.js @@ -8,6 +8,7 @@ var mock = require('@mocks/sankey_energy.json'); var mockDark = require('@mocks/sankey_energy_dark.json'); var mockCircular = require('@mocks/sankey_circular.json'); var mockCircularLarge = require('@mocks/sankey_circular_large.json'); +var mockXY = require('@mocks/sankey_x_y.json'); var Sankey = require('@src/traces/sankey'); var createGraphDiv = require('../assets/create_graph_div'); @@ -19,6 +20,10 @@ var assertHoverLabelStyle = require('../assets/custom_assertions').assertHoverLa var supplyAllDefaults = require('../assets/supply_defaults'); var defaultColors = require('@src/components/color/attributes').defaults; +var drag = require('../assets/drag'); +var checkOverlap = require('../assets/check_overlap'); +var delay = require('../assets/delay'); + describe('sankey tests', function() { 'use strict'; @@ -245,6 +250,32 @@ describe('sankey tests', function() { expect(Array.isArray(fullTrace.link.label)).toBe(true, 'must be an array'); expect(fullTrace.link.label).toEqual(['a', 'b'], 'an array of the supplied values'); }); + + it('defaults to `snap` arrangement', function() { + var fullTrace = _supply({ + link: { + source: [0], + target: [1], + value: [1] + } + }); + expect(fullTrace.arrangement).toBe('snap'); + }); + + it('defaults to `freeform` arrangement if node.(x|y) is specified', function() { + var fullTrace = _supply({ + node: { + x: [0, 0.5], + y: [0, 0.5] + }, + link: { + source: [0], + target: [1], + value: [1] + } + }); + expect(fullTrace.arrangement).toBe('freeform'); + }); }); describe('sankey calc', function() { @@ -508,6 +539,32 @@ describe('sankey tests', function() { }); }); + it('prevents nodes from overlapping in snap arrangement', function(done) { + function checkElementOverlap(i, j) { + var base = document.querySelector('.sankey-node:nth-of-type(' + i + ')'); + base = base.querySelector('.node-rect'); + var compare = document.querySelector('.sankey-node:nth-of-type(' + j + ')'); + compare = compare.querySelector('.node-rect'); + return checkOverlap(base, compare); + } + + var mockCopy = Lib.extendDeep({}, mockXY); + + Plotly.plot(gd, mockCopy) + .then(function() { + // Nodes overlap + expect(checkElementOverlap(3, 6)).toBeTruthy('nodes do not overlap'); + + mockCopy.data[0].arrangement = 'snap'; + return Plotly.newPlot(gd, mockCopy); + }) + .then(function() { + // Nodes do not overlap in snap + expect(checkElementOverlap(3, 6)).not.toBeTruthy('nodes overlap'); + }) + .catch(failTest) + .then(done); + }); }); describe('Test hover/click interactions:', function() { @@ -1009,57 +1066,193 @@ describe('sankey tests', function() { }); }); - describe('Test drag interactions:', function() { - var gd; + describe('Test drag interactions', function() { + ['freeform', 'perpendicular', 'snap'].forEach(function(arrangement) { + describe('for arrangement ' + arrangement + ':', function() { + var gd; + var mockCopy; + var nodeId = 4; // Selecting node with label 'Solid' - beforeEach(function() { - gd = createGraphDiv(); - }); + beforeEach(function() { + gd = createGraphDiv(); + mockCopy = Lib.extendDeep({}, mock); + }); - afterEach(destroyGraphDiv); + afterEach(function() { + Plotly.purge(gd); + destroyGraphDiv(); + }); - function _drag(fromX, fromY, dX, dY, delay) { - var toX = fromX + dX; - var toY = fromY + dY; + function testDragNode(move) { + return function() { + var position; + var nodes; + var node; + + return Promise.resolve() + .then(function() { + nodes = document.getElementsByClassName('sankey-node'); + node = nodes.item(nodeId); + position = getNodeCoords(node); + var timeDelay = (arrangement === 'snap') ? 2000 : 0; // Wait for force simulation to finish + return drag(node, move[0], move[1], false, false, false, 10, false, timeDelay); + }) + .then(function() { + nodes = document.getElementsByClassName('sankey-node'); + node = nodes.item(nodes.length - 1); // Dragged node is now the last one + var newPosition = getNodeCoords(node); + if(arrangement === 'freeform') expect(newPosition.x).toBeCloseTo(position.x + move[0], 2, 'final x position is off'); + expect(newPosition.y).toBeCloseTo(position.y + move[1], 2, 'final y position is off'); + return Promise.resolve(true); + }); + }; + } + + it('should change the position of a node on drag', function(done) { + mockCopy.data[0].arrangement = arrangement; + var move = [50, -150]; + + Plotly.newPlot(gd, mockCopy) + .then(testDragNode(move)) + .catch(failTest) + .then(done); + }); - return new Promise(function(resolve) { - mouseEvent('mousemove', fromX, fromY); - mouseEvent('mousedown', fromX, fromY); - mouseEvent('mousemove', toX, toY); + it('should not change the position of a node if the mouse does not move', function(done) { + mockCopy.data[0].arrangement = arrangement; + var move = [0, 0]; - setTimeout(function() { - mouseEvent('mouseup', toX, toY); - resolve(); - }, delay); + Plotly.newPlot(gd, mockCopy) + .then(testDragNode(move)) + .catch(failTest) + .then(done); + }); + + it('should persist the position of every nodes after drag in attributes nodes.(x|y)', function(done) { + mockCopy.data[0].arrangement = arrangement; + var move = [50, -50]; + var nodes; + var node; + var x, x1; + var y, y1; + var precision = 3; + + Plotly.newPlot(gd, mockCopy) + .then(function() { + x = gd._fullData[0].node.x.slice(); + y = gd._fullData[0].node.y.slice(); + expect(x.length).toBe(0); + expect(y.length).toBe(0); + + nodes = document.getElementsByClassName('sankey-node'); + node = nodes.item(nodeId); + return drag(node, move[0], move[1]); + }) + .then(function() { + x = gd._fullData[0].node.x.slice(); + y = gd._fullData[0].node.y.slice(); + expect(x.length).toBe(mockCopy.data[0].node.label.length); + expect(y.length).toBe(mockCopy.data[0].node.label.length); + + nodes = document.getElementsByClassName('sankey-node'); + node = nodes.item(nodes.length - 1); // Dragged node is now the last one + return drag(node, move[0], move[1]); + }) + .then(function() { + x1 = gd._fullData[0].node.x.slice(); + y1 = gd._fullData[0].node.y.slice(); + if(arrangement === 'freeform') expect(x1[nodeId]).not.toBeCloseTo(x[nodeId], 2, 'node ' + nodeId + ' has not changed x position'); + expect(y1[nodeId]).not.toBeCloseTo(y[nodeId], precision, 'node ' + nodeId + ' has not changed y position'); + + // All nodes should have same x, y values after drag + for(var i = 0; i < x.length; i++) { + if(i === nodeId) continue; // except the one was just dragged + if(arrangement === 'freeform') expect(x1[i]).toBeCloseTo(x[i], 3, 'node ' + i + ' has changed x position'); + expect(y1[i]).toBeCloseTo(y[i], precision, 'node ' + i + ' has changed y position'); + } + return true; + }) + .catch(failTest) + .then(done); + }); }); - } + }); - it('should change the position of a node', function(done) { - var fig = Lib.extendDeep({}, mock); - var nodes; - var node; - var position; - var nodePos = [404, 302]; - var move = [0, -100]; + describe('in relation to uirevision', function() { + var gd; - Plotly.plot(gd, fig) - .then(function() { - nodes = document.getElementsByClassName('sankey-node'); - node = nodes.item(4); // Selecting node with label 'Solid' - position = getNodeCoords(node); - return _drag(nodePos[0], nodePos[1], move[0], move[1], 500); - }) - .then(function() { - nodes = document.getElementsByClassName('sankey-node'); - node = nodes.item(nodes.length - 1); // Dragged node is now the last one - var newPosition = getNodeCoords(node); - expect(newPosition.x).toBeCloseTo(position.x + move[0]); - expect(newPosition.y).toBeCloseTo(position.y + move[1]); - }) - .catch(failTest) - .then(done); + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(function() { + Plotly.purge(gd); + destroyGraphDiv(); + }); + + ['0', '1'].forEach(function(finalUIRevision) { + it('on Plotly.react, it preserves the position of nodes depending on layout.uirevision', function(done) { + var nodes, node, positionBeforeDrag, positionAfterDrag; + var move = [-50, 100]; + var uirevisions = ['0', finalUIRevision]; + + // Use a freeform arrangement + var mockCircularFreeform = Lib.extendDeep({}, mockCircular); + mockCircularFreeform.data[0].arrangement = 'freeform'; + + var mockCopy = Lib.extendDeep({}, mockCircularFreeform); + mockCopy.layout.uirevision = uirevisions[0]; + + Plotly.plot(gd, mockCopy) + .then(function() { + // move a node around + nodes = document.getElementsByClassName('sankey-node'); + node = Array.prototype.slice.call(nodes).find(function(n) { return n.textContent === '0';}); + positionBeforeDrag = getNodeCoords(node); + positionBeforeDrag = [positionBeforeDrag.x, positionBeforeDrag.y]; + positionAfterDrag = [positionBeforeDrag[0] + move[0], positionBeforeDrag[1] + move[1]]; + return drag(node, move[0], move[1], false, false, false, 10, false, 1000); + }) + .then(function() { + // Check that the node was really moved + nodes = document.getElementsByClassName('sankey-node'); + node = Array.prototype.slice.call(nodes).find(function(n) { return n.textContent === '0';}); + var newPosition = getNodeCoords(node); + expect(newPosition.x).toBeCloseTo(positionAfterDrag[0], 2, 'final x position is off'); + expect(newPosition.y).toBeCloseTo(positionAfterDrag[1], 2, 'final y position is off'); + + // Change color of nodes + var mockCopy = Lib.extendDeep({}, mockCircularFreeform); + mockCopy.data[0].node.color = 'orange'; + mockCopy.layout.uirevision = uirevisions[1]; + return Plotly.react(gd, mockCopy); + }) + .then(delay(1000)) + .then(function() { + nodes = document.getElementsByClassName('sankey-node'); + node = Array.prototype.slice.call(nodes).find(function(n) { return n.textContent === '0';}); + var newPosition = getNodeCoords(node); + + var pos, msg; + if(uirevisions[0] === uirevisions[1]) { + // If uirevision is the same, the node should stay where it is + pos = positionAfterDrag; + msg = 'should stay the same because uirevision did not change'; + } else { + // If uirevision changed, the node should go back to its default position + pos = positionBeforeDrag; + msg = 'should go back to its default because uirevision changed'; + } + expect(newPosition.x).toBeCloseTo(pos[0], 2, 'x position ' + msg); + expect(newPosition.y).toBeCloseTo(pos[1], 2, 'y position ' + msg); + }) + .catch(failTest) + .then(done); + }); + }); }); }); + it('emits a warning if node.pad is too large', function(done) { var gd = createGraphDiv(); var mockCopy = Lib.extendDeep({}, mock); @@ -1153,53 +1346,14 @@ describe('sankey layout generators', function() { } describe('d3-sankey', function() { - var data = { - 'nodes': [{ - 'node': 0, - 'name': 'node0' - }, { - 'node': 1, - 'name': 'node1' - }, { - 'node': 2, - 'name': 'node2' - }, { - 'node': 3, - 'name': 'node3' - }, { - 'node': 4, - 'name': 'node4' - }], - 'links': [{ - 'source': 0, - 'target': 2, - 'value': 2 - }, { - 'source': 1, - 'target': 2, - 'value': 2 - }, { - 'source': 1, - 'target': 3, - 'value': 2 - }, { - 'source': 0, - 'target': 4, - 'value': 2 - }, { - 'source': 2, - 'target': 3, - 'value': 2 - }, { - 'source': 2, - 'target': 4, - 'value': 2 - }, { - 'source': 3, - 'target': 4, - 'value': 4 - }] - }; + function _calc(trace) { + var gd = { data: [trace] }; + + supplyAllDefaults(gd); + var fullTrace = gd._fullData[0]; + return Sankey.calc(gd, fullTrace); + } + var data; var sankey; var graph; var margin = { @@ -1212,6 +1366,23 @@ describe('sankey layout generators', function() { var height = 740 - margin.top - margin.bottom; beforeEach(function() { + data = _calc({ + type: 'sankey', + node: { + label: ['node0', 'node1', 'node2', 'node3', 'node4'], + x: [0, 20, 40, 60, 80, 100, 120, 140], + y: [0, 20, 40, 60, 80, 100, 120, 140] + }, + link: { + source: [0, 1, 1, 0, 2, 2, 3], + target: [2, 2, 3, 4, 3, 4, 4], + value: [2, 2, 2, 2, 2, 2, 4] + } + }); + data = { + nodes: data[0]._nodes, + links: data[0]._links + }; sankey = d3sankey .sankey() .nodeWidth(36) @@ -1237,7 +1408,7 @@ describe('sankey layout generators', function() { }); it('keep a list of nodes', function() { - checkArray(graph.nodes, 'name', ['node0', 'node1', 'node2', 'node3', 'node4']); + checkArray(graph.nodes, 'label', ['node0', 'node1', 'node2', 'node3', 'node4']); }); it('keep a list of nodes with x and y values', function() { @@ -1272,7 +1443,7 @@ describe('sankey layout generators', function() { }); }); - describe('d3-sankey-ciruclar', function() { + describe('d3-sankey-circular', function() { var data, sankey, graph; describe('implements d3-sankey compatible API', function() { @@ -1324,16 +1495,15 @@ describe('sankey layout generators', function() { }); it('supports column reordering', function() { - var reorder = [ 2, 2, 1, 1, 0, 0 ]; - checkArray(graph.nodes, 'column', [0, 0, 2, 3, 1, 1]); var a = graph.nodes[0].x0; + var reorder = [ 2, 2, 1, 1, 0, 0 ]; sankey.nodeAlign(function(node) { return reorder[node.pointNumber]; }); graph = sankey(); - checkArray(graph.nodes, 'column', [2, 2, 1, 1, 0, 0]); + checkArray(graph.nodes, 'column', reorder); checkArray(graph.nodes, 'height', [1, 3, 1, 0, 2, 0]); var b = graph.nodes[0].x0; expect(a).not.toEqual(b);