From 57892edf00c497a447b3e9e32847c6e7d08dd5ac Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Mon, 1 Aug 2016 15:23:38 -0400 Subject: [PATCH 01/45] Animate API --- src/components/drawing/index.js | 54 ++- src/components/errorbars/plot.js | 71 +++- src/core.js | 4 + src/lib/index.js | 120 ++++++ src/plot_api/plot_api.js | 330 +++++++++++++++- src/plots/cartesian/index.js | 96 +++-- src/plots/cartesian/transition_axes.js | 295 ++++++++++++++ src/plots/plots.js | 114 +++++- src/traces/scatter/attributes.js | 14 + src/traces/scatter/calc.js | 4 + src/traces/scatter/defaults.js | 2 + src/traces/scatter/index.js | 1 + src/traces/scatter/line_points.js | 5 + src/traces/scatter/link_traces.js | 39 ++ src/traces/scatter/plot.js | 473 ++++++++++++++++------- src/traces/scatter/select.js | 3 +- test/jasmine/assets/fail_test.js | 6 +- test/jasmine/tests/animate_test.js | 145 +++++++ test/jasmine/tests/calcdata_test.js | 14 +- test/jasmine/tests/cartesian_test.js | 98 +++++ test/jasmine/tests/compute_frame_test.js | 236 +++++++++++ test/jasmine/tests/frame_api_test.js | 207 ++++++++++ test/jasmine/tests/lib_test.js | 63 +++ test/jasmine/tests/scatter_test.js | 3 +- test/jasmine/tests/select_test.js | 18 +- test/jasmine/tests/ternary_test.js | 2 + 26 files changed, 2202 insertions(+), 215 deletions(-) create mode 100644 src/plots/cartesian/transition_axes.js create mode 100644 src/traces/scatter/link_traces.js create mode 100644 test/jasmine/tests/animate_test.js create mode 100644 test/jasmine/tests/compute_frame_test.js create mode 100644 test/jasmine/tests/frame_api_test.js diff --git a/src/components/drawing/index.js b/src/components/drawing/index.js index e906f2d792e..ae72f4f308c 100644 --- a/src/components/drawing/index.js +++ b/src/components/drawing/index.js @@ -46,16 +46,62 @@ drawing.setRect = function(s, x, y, w, h) { s.call(drawing.setPosition, x, y).call(drawing.setSize, w, h); }; -drawing.translatePoints = function(s, xa, ya) { - s.each(function(d) { +drawing.translatePoints = function(s, xa, ya, trace, transitionConfig, joinDirection) { + var size; + + var hasTransition = transitionConfig && (transitionConfig || {}).duration > 0; + + if(hasTransition) { + size = s.size(); + } + + s.each(function(d, i) { // put xp and yp into d if pixel scaling is already done var x = d.xp || xa.c2p(d.x), y = d.yp || ya.c2p(d.y), p = d3.select(this); if(isNumeric(x) && isNumeric(y)) { // for multiline text this works better - if(this.nodeName === 'text') p.attr('x', x).attr('y', y); - else p.attr('transform', 'translate(' + x + ',' + y + ')'); + if(this.nodeName === 'text') { + p.attr('x', x).attr('y', y); + } else { + if(hasTransition) { + var trans; + if(!joinDirection) { + trans = p.transition() + .delay(transitionConfig.delay + transitionConfig.cascade / size * i) + .duration(transitionConfig.duration) + .ease(transitionConfig.ease) + .attr('transform', 'translate(' + x + ',' + y + ')'); + + if(trace) { + trans.call(drawing.pointStyle, trace); + } + } else if(joinDirection === -1) { + trans = p.style('opacity', 1) + .transition() + .duration(transitionConfig.duration) + .ease(transitionConfig.ease) + .style('opacity', 0) + .remove(); + } else if(joinDirection === 1) { + trans = p.attr('transform', 'translate(' + x + ',' + y + ')'); + + if(trace) { + trans.call(drawing.pointStyle, trace); + } + + trans.style('opacity', 0) + .transition() + .duration(transitionConfig.duration) + .ease(transitionConfig.ease) + .style('opacity', 1); + } + + } else { + p.attr('transform', 'translate(' + x + ',' + y + ')'); + } + } } else p.remove(); }); diff --git a/src/components/errorbars/plot.js b/src/components/errorbars/plot.js index 3f44ed58df1..5f074b63bc2 100644 --- a/src/components/errorbars/plot.js +++ b/src/components/errorbars/plot.js @@ -12,14 +12,18 @@ var d3 = require('d3'); var isNumeric = require('fast-isnumeric'); -var Lib = require('../../lib'); var subTypes = require('../../traces/scatter/subtypes'); +var styleError = require('./style'); -module.exports = function plot(traces, plotinfo) { +module.exports = function plot(traces, plotinfo, transitionConfig) { + var isNew; var xa = plotinfo.x(), ya = plotinfo.y(); + transitionConfig = transitionConfig || {}; + var hasAnimation = isNumeric(transitionConfig.duration) && transitionConfig.duration > 0; + traces.each(function(d) { var trace = d[0].trace, // || {} is in case the trace (specifically scatterternary) @@ -29,6 +33,12 @@ module.exports = function plot(traces, plotinfo) { xObj = trace.error_x || {}, yObj = trace.error_y || {}; + var keyFunc; + + if(trace.identifier) { + keyFunc = function(d) {return d.identifier;}; + } + var sparse = ( subTypes.hasMarkers(trace) && trace.marker.maxdisplayed > 0 @@ -37,11 +47,21 @@ module.exports = function plot(traces, plotinfo) { if(!yObj.visible && !xObj.visible) return; var errorbars = d3.select(this).selectAll('g.errorbar') - .data(Lib.identity); + .data(d, keyFunc); - errorbars.enter().append('g') + errorbars.exit().remove(); + + errorbars.style('opacity', 1); + + var enter = errorbars.enter().append('g') .classed('errorbar', true); + if(hasAnimation) { + enter.style('opacity', 0).transition() + .duration(transitionConfig.duration) + .style('opacity', 1); + } + errorbars.each(function(d) { var errorbar = d3.select(this); var coords = errorCoords(d, xa, ya); @@ -59,14 +79,28 @@ module.exports = function plot(traces, plotinfo) { coords.yh + 'h' + (2 * yw) + // hat 'm-' + yw + ',0V' + coords.ys; // bar + if(!coords.noYS) path += 'm-' + yw + ',0h' + (2 * yw); // shoe - errorbar.append('path') - .classed('yerror', true) - .attr('d', path); + var yerror = errorbar.select('path.yerror'); + + isNew = !yerror.size(); + + if(isNew) { + yerror = errorbar.append('path') + .classed('yerror', true); + } else if(hasAnimation) { + yerror = yerror + .transition() + .duration(transitionConfig.duration) + .ease(transitionConfig.ease) + .delay(transitionConfig.delay); + } + + yerror.attr('d', path); } - if(xObj.visible && isNumeric(coords.y) && + if(xObj.visible && isNumeric(coords.x) && isNumeric(coords.xh) && isNumeric(coords.xs)) { var xw = (xObj.copy_ystyle ? yObj : xObj).width; @@ -77,11 +111,26 @@ module.exports = function plot(traces, plotinfo) { if(!coords.noXS) path += 'm0,-' + xw + 'v' + (2 * xw); // shoe - errorbar.append('path') - .classed('xerror', true) - .attr('d', path); + var xerror = errorbar.select('path.xerror'); + + isNew = !xerror.size(); + + if(isNew) { + xerror = errorbar.append('path') + .classed('xerror', true); + } else if(hasAnimation) { + xerror = xerror + .transition() + .duration(transitionConfig.duration) + .ease(transitionConfig.ease) + .delay(transitionConfig.delay); + } + + xerror.attr('d', path); } }); + + d3.select(this).call(styleError); }); }; diff --git a/src/core.js b/src/core.js index e264484ad40..05572c773d8 100644 --- a/src/core.js +++ b/src/core.js @@ -43,6 +43,10 @@ exports.register = require('./plot_api/register'); exports.toImage = require('./plot_api/to_image'); exports.downloadImage = require('./snapshot/download'); exports.validate = require('./plot_api/validate'); +exports.addFrames = Plotly.addFrames; +exports.deleteFrames = Plotly.deleteFrames; +exports.transition = Plotly.transition; +exports.animate = Plotly.animate; // scatter is the only trace included by default exports.register(require('./traces/scatter')); diff --git a/src/lib/index.js b/src/lib/index.js index 6f9a3d57c90..b3acba1c613 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -577,6 +577,40 @@ lib.objectFromPath = function(path, value) { return obj; }; +/** + * Iterate through an object in-place, converting dotted properties to objects. + * + * @example + * lib.expandObjectPaths({'nested.test.path': 'value'}); + * // returns { nested: { test: {path: 'value'}}} + */ + +// Store this to avoid recompiling regex on every prop since this may happen many +// many times for animations. +// TODO: Premature optimization? Remove? +var dottedPropertyRegex = /^([^\.]*)\../; + +lib.expandObjectPaths = function(data) { + var match, key, prop, datum; + if(typeof data === 'object' && !Array.isArray(data)) { + for(key in data) { + if(data.hasOwnProperty(key)) { + if((match = key.match(dottedPropertyRegex))) { + datum = data[key]; + prop = match[1]; + + delete data[key]; + + data[prop] = lib.extendDeepNoArrays(data[prop] || {}, lib.objectFromPath(key, lib.expandObjectPaths(datum))[prop]); + } else { + data[key] = lib.expandObjectPaths(data[key]); + } + } + } + } + return data; +}; + /** * Converts value to string separated by the provided separators. * @@ -626,3 +660,89 @@ lib.numSeparate = function(value, separators, separatethousands) { return x1 + x2; }; + +/* + * Compute a keyframe. Merge a keyframe into its base frame(s) and + * expand properties. + * + * @param {object} frameLookup + * An object containing frames keyed by name (i.e. gd._frameData._frameHash) + * @param {string} frame + * The name of the keyframe to be computed + * + * Returns: a new object with the merged content + */ +lib.computeFrame = function(frameLookup, frameName) { + var i, traceIndices, traceIndex, expandedObj, destIndex, copy; + + var framePtr = frameLookup[frameName]; + + // Return false if the name is invalid: + if(!framePtr) { + return false; + } + + var frameStack = [framePtr]; + var frameNameStack = [framePtr.name]; + + // Follow frame pointers: + while((framePtr = frameLookup[framePtr.baseFrame])) { + // Avoid infinite loops: + if(frameNameStack.indexOf(framePtr.name) !== -1) break; + + frameStack.push(framePtr); + frameNameStack.push(framePtr.name); + } + + // A new object for the merged result: + var result = {}; + + // Merge, starting with the last and ending with the desired frame: + while((framePtr = frameStack.pop())) { + if(framePtr.layout) { + copy = lib.extendDeepNoArrays({}, framePtr.layout); + expandedObj = lib.expandObjectPaths(copy); + result.layout = lib.extendDeepNoArrays(result.layout || {}, expandedObj); + } + + if(framePtr.data) { + if(!result.data) { + result.data = []; + } + traceIndices = framePtr.traceIndices; + + if(!traceIndices) { + // If not defined, assume serial order starting at zero + traceIndices = []; + for(i = 0; i < framePtr.data.length; i++) { + traceIndices[i] = i; + } + } + + if(!result.traceIndices) { + result.traceIndices = []; + } + + for(i = 0; i < framePtr.data.length; i++) { + // Loop through this frames data, find out where it should go, + // and merge it! + traceIndex = traceIndices[i]; + if(traceIndex === undefined || traceIndex === null) { + continue; + } + + destIndex = result.traceIndices.indexOf(traceIndex); + if(destIndex === -1) { + destIndex = result.data.length; + result.traceIndices[destIndex] = traceIndex; + } + + copy = lib.extendDeepNoArrays({}, framePtr.data[i]); + expandedObj = lib.expandObjectPaths(copy); + result.data[destIndex] = lib.extendDeepNoArrays(result.data[destIndex] || {}, expandedObj); + } + } + } + + return result; +}; diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 15d51de7604..7c39b9ecda8 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -851,13 +851,17 @@ Plotly.newPlot = function(gd, data, layout, config) { return Plotly.plot(gd, data, layout, config); }; -function doCalcdata(gd) { +function doCalcdata(gd, traces) { var axList = Plotly.Axes.list(gd), fullData = gd._fullData, fullLayout = gd._fullLayout, i; - var calcdata = gd.calcdata = new Array(fullData.length); + // XXX: Is this correct? Needs a closer look so that *some* traces can be recomputed without + // *all* needing doCalcdata: + var calcdata = new Array(fullData.length); + var oldCalcdata = (gd.calcdata || []).slice(0); + gd.calcdata = calcdata; // extra helper variables // firstscatter: fill-to-next on the first trace goes to zero @@ -881,10 +885,24 @@ function doCalcdata(gd) { } for(i = 0; i < fullData.length; i++) { + // If traces were specified and this trace was not included, then transfer it over from + // the old calcdata: + if(Array.isArray(traces) && traces.indexOf(i) === -1) { + calcdata[i] = oldCalcdata[i]; + continue; + } + var trace = fullData[i], _module = trace._module, cd = []; + // If traces were specified and this trace was not included, then transfer it over from + // the old calcdata: + if(Array.isArray(traces) && traces.indexOf(i) === -1) { + calcdata[i] = oldCalcdata[i]; + continue; + } + if(_module && trace.visible === true) { if(_module.calc) cd = _module.calc(gd, trace); } @@ -2506,6 +2524,314 @@ Plotly.relayout = function relayout(gd, astr, val) { }); }; +/** + * Transition to a set of new data and layout properties + * + * @param {string id or DOM element} gd + * the id or DOM element of the graph container div + */ +Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { + gd = getGraphDiv(gd); + + var i, traceIdx; + var fullLayout = gd._fullLayout; + + transitionConfig = Lib.extendFlat({ + ease: 'cubic-in-out', + duration: 500, + delay: 0, + cascade: 0 + }, transitionConfig || {}); + + // Create a single transition to be passed around: + if(transitionConfig.duration > 0) { + gd._currentTransition = d3.transition() + .duration(transitionConfig.duration) + .delay(transitionConfig.delay) + .ease(transitionConfig.ease); + } else { + gd._currentTransition = null; + } + + var dataLength = Array.isArray(data) ? data.length : 0; + + // Select which traces will be updated: + if(isNumeric(traceIndices)) traceIndices = [traceIndices]; + else if(!Array.isArray(traceIndices) || !traceIndices.length) { + traceIndices = gd._fullData.map(function(v, i) {return i;}); + } + + if(traceIndices.length > dataLength) { + traceIndices = traceIndices.slice(0, dataLength); + } + + var transitionedTraces = []; + + function prepareTransitions() { + for(i = 0; i < traceIndices.length; i++) { + var traceIdx = traceIndices[i]; + var trace = gd._fullData[traceIdx]; + var module = trace._module; + + if(!module.animatable) { + continue; + } + + transitionedTraces.push(traceIdx); + + // This is a multi-step process. First clone w/o arrays so that + // we're not modifying the original: + var update = Lib.extendDeepNoArrays({}, data[i]); + + // Then expand object paths since we don't obey object-overwrite + // semantics here: + update = Lib.expandObjectPaths(update); + + // Finally apply the update (without copying arrays, of course): + Lib.extendDeepNoArrays(gd.data[traceIndices[i]], update); + } + + Plots.supplyDefaults(gd); + + // TODO: Add logic that computes transitionedTraces to avoid unnecessary work while + // still handling things like box plots that are interrelated. + // doCalcdata(gd, transitionedTraces); + + doCalcdata(gd); + + ErrorBars.calc(gd); + } + + var restyleList = []; + var completionTimeout = null; + var resolveTransitionCallback = null; + + function executeTransitions() { + var hasTraceTransition = false; + var j; + var basePlotModules = fullLayout._basePlotModules; + for(j = 0; j < basePlotModules.length; j++) { + if(basePlotModules[j].animatable) { + hasTraceTransition = true; + } + basePlotModules[j].plot(gd, transitionedTraces, transitionConfig); + } + + var hasAxisTransition = false; + + if(layout) { + for(j = 0; j < basePlotModules.length; j++) { + if(basePlotModules[j].transitionAxes) { + var newLayout = Lib.expandObjectPaths(layout); + hasAxisTransition = hasAxisTransition || basePlotModules[j].transitionAxes(gd, newLayout, transitionConfig); + } + } + } + + if(!hasAxisTransition && !hasTraceTransition) { + return false; + } + + return new Promise(function(resolve) { + resolveTransitionCallback = resolve; + completionTimeout = setTimeout(resolve, transitionConfig.duration); + }); + } + + function interruptPreviousTransitions() { + clearTimeout(completionTimeout); + + if(resolveTransitionCallback) { + resolveTransitionCallback(); + } + + while(gd._frameData._layoutInterrupts.length) { + (gd._frameData._layoutInterrupts.pop())(); + } + + while(gd._frameData._styleInterrupts.length) { + (gd._frameData._styleInterrupts.pop())(); + } + } + + for(i = 0; i < traceIndices.length; i++) { + traceIdx = traceIndices[i]; + var contFull = gd._fullData[traceIdx]; + var module = contFull._module; + + if(!module.animatable) { + var thisUpdate = {}; + + for(var ai in data[i]) { + thisUpdate[ai] = [data[i][ai]]; + } + + restyleList.push((function(md, data, traces) { + return function() { + return Plotly.restyle(gd, data, traces); + }; + }(module, thisUpdate, [traceIdx]))); + } + } + + var seq = [Plots.previousPromises, interruptPreviousTransitions, prepareTransitions, executeTransitions]; + seq = seq.concat(restyleList); + + var plotDone = Lib.syncOrAsync(seq, gd); + + if(!plotDone || !plotDone.then) plotDone = Promise.resolve(); + + return plotDone.then(function() { + gd.emit('plotly_beginanimate', []); + return gd; + }); +}; + +/** + * Animate to a keyframe + * + * @param {string} name + * name of the keyframe to create + * @param {object} transitionConfig + * configuration for transition + */ +Plotly.animate = function(gd, frameName, transitionConfig) { + gd = getGraphDiv(gd); + + if(!gd._frameData._frameHash[frameName]) { + Lib.warn('animateToFrame failure: keyframe does not exist', frameName); + return Promise.reject(); + } + + var computedFrame = Plots.computeFrame(gd, frameName); + + return Plotly.transition(gd, + computedFrame.data, + computedFrame.layout, + computedFrame.traceIndices, + transitionConfig + ); +}; + +/** + * Create new keyframes + * + * @param {array of objects} frameList + * list of frame definitions, in which each object includes any of: + * - name: {string} name of keyframe to add + * - data: {array of objects} trace data + * - layout {object} layout definition + * - traces {array} trace indices + * - baseFrame {string} name of keyframe from which this keyframe gets defaults + */ +Plotly.addFrames = function(gd, frameList, indices) { + gd = getGraphDiv(gd); + + var i, frame, j, idx; + var _frames = gd._frameData._frames; + var _hash = gd._frameData._frameHash; + + + if(!Array.isArray(frameList)) { + Lib.warn('addFrames failure: frameList must be an Array of frame definitions', frameList); + return Promise.reject(); + } + + // Create a sorted list of insertions since we run into lots of problems if these + // aren't in ascending order of index: + // + // Strictly for sorting. Make sure this is guaranteed to never collide with any + // already-exisisting indices: + var bigIndex = _frames.length + frameList.length * 2; + + var insertions = []; + for(i = frameList.length - 1; i >= 0; i--) { + insertions.push({ + frame: frameList[i], + index: (indices && indices[i] !== undefined && indices[i] !== null) ? indices[i] : bigIndex + i + }); + } + + // Sort this, taking note that undefined insertions end up at the end: + insertions.sort(function(a, b) { + if(a.index > b.index) return -1; + if(a.index < b.index) return 1; + return 0; + }); + + var ops = []; + var revops = []; + var frameCount = _frames.length; + + for(i = insertions.length - 1; i >= 0; i--) { + frame = insertions[i].frame; + + if(!frame.name) { + // Repeatedly assign a default name, incrementing the counter each time until + // we get a name that's not in the hashed lookup table: + while(_hash[(frame.name = 'frame ' + gd._frameData._counter++)]); + } + + if(_hash[frame.name]) { + // If frame is present, overwrite its definition: + for(j = 0; j < _frames.length; j++) { + if(_frames[j].name === frame.name) break; + } + ops.push({type: 'replace', index: j, value: frame}); + revops.unshift({type: 'replace', index: j, value: _frames[j]}); + } else { + // Otherwise insert it at the end of the list: + idx = Math.max(0, Math.min(insertions[i].index, frameCount)); + + ops.push({type: 'insert', index: idx, value: frame}); + revops.unshift({type: 'delete', index: idx}); + frameCount++; + } + } + + var undoFunc = Plots.modifyFrames, + redoFunc = Plots.modifyFrames, + undoArgs = [gd, revops], + redoArgs = [gd, ops]; + + if(Queue) Queue.add(gd, undoFunc, undoArgs, redoFunc, redoArgs); + + return Plots.modifyFrames(gd, ops); +}; + +/** + * Delete keyframes + * + * @param {array of integers} frameList + * list of integer indices of frames to be deleted + */ +Plotly.deleteFrames = function(gd, frameList) { + gd = getGraphDiv(gd); + + var i, idx; + var _frames = gd._frameData._frames; + var ops = []; + var revops = []; + + frameList = frameList.slice(0); + frameList.sort(); + + for(i = frameList.length - 1; i >= 0; i--) { + idx = frameList[i]; + ops.push({type: 'delete', index: idx}); + revops.unshift({type: 'insert', index: idx, value: _frames[idx]}); + } + + var undoFunc = Plots.modifyFrames, + redoFunc = Plots.modifyFrames, + undoArgs = [gd, revops], + redoArgs = [gd, ops]; + + if(Queue) Queue.add(gd, undoFunc, undoArgs, redoFunc, redoArgs); + + return Plots.modifyFrames(gd, ops); +}; + /** * Purge a graph container div back to its initial pre-Plotly.plot state * diff --git a/src/plots/cartesian/index.js b/src/plots/cartesian/index.js index 56f827f0ec6..63aa425bf1a 100644 --- a/src/plots/cartesian/index.js +++ b/src/plots/cartesian/index.js @@ -25,61 +25,89 @@ exports.attrRegex = constants.attrRegex; exports.attributes = require('./attributes'); -exports.plot = function(gd) { +exports.transitionAxes = require('./transition_axes'); + +exports.plot = function(gd, traces, transitionOpts) { + var cdSubplot, cd, trace, i, j, k; + var fullLayout = gd._fullLayout, subplots = Plots.getSubplotIds(fullLayout, 'cartesian'), calcdata = gd.calcdata, modules = fullLayout._modules; - function getCdSubplot(calcdata, subplot) { - var cdSubplot = []; - - for(var i = 0; i < calcdata.length; i++) { - var cd = calcdata[i]; - var trace = cd[0].trace; - - if(trace.xaxis + trace.yaxis === subplot) { - cdSubplot.push(cd); - } + if(!Array.isArray(traces)) { + // If traces is not provided, then it's a complete replot and missing + // traces are removed + traces = []; + for(i = 0; i < calcdata.length; i++) { + traces.push(i); } - - return cdSubplot; } - function getCdModule(cdSubplot, _module) { - var cdModule = []; + for(i = 0; i < subplots.length; i++) { + var subplot = subplots[i], + subplotInfo = fullLayout._plots[subplot]; + + // Get all calcdata for this subplot: + cdSubplot = []; + var pcd; + for(j = 0; j < calcdata.length; j++) { + cd = calcdata[j]; + trace = cd[0].trace; - for(var i = 0; i < cdSubplot.length; i++) { - var cd = cdSubplot[i]; - var trace = cd[0].trace; + // Skip trace if whitelist provided and it's not whitelisted: + // if (Array.isArray(traces) && traces.indexOf(i) === -1) continue; - if((trace._module === _module) && (trace.visible === true)) { - cdModule.push(cd); + if(trace.xaxis + trace.yaxis === subplot) { + // Okay, so example: traces 0, 1, and 2 have fill = tonext. You animate + // traces 0 and 2. Trace 1 also needs to be updated, otherwise its fill + // is outdated. So this retroactively adds the previous trace if the + // traces are interdependent. + if(pcd && + ['tonextx', 'tonexty', 'tonext'].indexOf(trace.fill) !== -1 && + cdSubplot.indexOf(pcd) === -1) { + cdSubplot.push(pcd); + } + + // If this trace is specifically requested, add it to the list: + if(traces.indexOf(trace.index) !== -1) { + cdSubplot.push(cd); + } + + // Track the previous trace on this subplot for the retroactive-add step + // above: + pcd = cd; } } - return cdModule; - } - - for(var i = 0; i < subplots.length; i++) { - var subplot = subplots[i], - subplotInfo = fullLayout._plots[subplot], - cdSubplot = getCdSubplot(calcdata, subplot); - // remove old traces, then redraw everything - // TODO: use enter/exit appropriately in the plot functions - // so we don't need this - should sometimes be a big speedup - if(subplotInfo.plot) subplotInfo.plot.selectAll('g.trace').remove(); + // TODO: scatterlayer is manually excluded from this since it knows how + // to update instead of fully removing and redrawing every time. The + // remaining plot traces should also be able to do this. Once implemented, + // we won't need this - which should sometimes be a big speedup. + if(subplotInfo.plot) { + subplotInfo.plot.selectAll('g:not(.scatterlayer)').selectAll('g.trace').remove(); + } - for(var j = 0; j < modules.length; j++) { + // Plot all traces for each module at once: + for(j = 0; j < modules.length; j++) { var _module = modules[j]; // skip over non-cartesian trace modules if(_module.basePlotModule.name !== 'cartesian') continue; // plot all traces of this type on this subplot at once - var cdModule = getCdModule(cdSubplot, _module); - _module.plot(gd, subplotInfo, cdModule); + var cdModule = []; + for(k = 0; k < cdSubplot.length; k++) { + cd = cdSubplot[k]; + trace = cd[0].trace; + + if((trace._module === _module) && (trace.visible === true)) { + cdModule.push(cd); + } + } + + _module.plot(gd, subplotInfo, cdModule, transitionOpts); } } }; diff --git a/src/plots/cartesian/transition_axes.js b/src/plots/cartesian/transition_axes.js new file mode 100644 index 00000000000..96aa8b8eca7 --- /dev/null +++ b/src/plots/cartesian/transition_axes.js @@ -0,0 +1,295 @@ +/** +* Copyright 2012-2016, 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 d3 = require('d3'); + +var Plotly = require('../../plotly'); +var Lib = require('../../lib'); +var Axes = require('./axes'); + +var axisRegex = /((x|y)([2-9]|[1-9][0-9]+)?)axis$/; + +module.exports = function transitionAxes(gd, newLayout, transitionConfig) { + var fullLayout = gd._fullLayout; + var axes = []; + + function computeUpdates(layout) { + var ai, attrList, match, axis, update; + var updates = {}; + + for(ai in layout) { + attrList = ai.split('.'); + match = attrList[0].match(axisRegex); + if(match) { + var axisName = match[1]; + axis = fullLayout[axisName + 'axis']; + update = {}; + + if(Array.isArray(layout[ai])) { + update.to = layout[ai].slice(0); + } else { + if(Array.isArray(layout[ai].range)) { + update.to = layout[ai].range.slice(0); + } + } + if(!update.to) continue; + + update.axis = axis; + update.length = axis._length; + + axes.push(axisName); + + updates[axisName] = update; + } + } + + return updates; + } + + function computeAffectedSubplots(fullLayout, updatedAxisIds, updates) { + var plotName; + var plotinfos = fullLayout._plots; + var affectedSubplots = []; + var toX, toY; + + for(plotName in plotinfos) { + var plotinfo = plotinfos[plotName]; + + if(affectedSubplots.indexOf(plotinfo) !== -1) continue; + + var x = plotinfo.xaxis._id; + var y = plotinfo.yaxis._id; + var fromX = plotinfo.xaxis.range; + var fromY = plotinfo.yaxis.range; + if(updates[x]) { + toX = updates[x].to; + } else { + toX = fromX; + } + if(updates[y]) { + toY = updates[y].to; + } else { + toY = fromY; + } + + if(fromX[0] === toX[0] && fromX[1] === toX[1] && fromY[0] === toY[0] && fromY[1] === toY[1]) continue; + + if(updatedAxisIds.indexOf(x) !== -1 || updatedAxisIds.indexOf(y) !== -1) { + affectedSubplots.push(plotinfo); + } + } + + return affectedSubplots; + } + + var updates = computeUpdates(newLayout); + var updatedAxisIds = Object.keys(updates); + var affectedSubplots = computeAffectedSubplots(fullLayout, updatedAxisIds, updates); + + if(!affectedSubplots.length) { + return false; + } + + function ticksAndAnnotations(xa, ya) { + var activeAxIds = [], + i; + + activeAxIds = [xa._id, ya._id]; + + for(i = 0; i < activeAxIds.length; i++) { + Axes.doTicks(gd, activeAxIds[i], true); + } + + function redrawObjs(objArray, module) { + var obji; + for(i = 0; i < objArray.length; i++) { + obji = objArray[i]; + if((activeAxIds.indexOf(obji.xref) !== -1) || + (activeAxIds.indexOf(obji.yref) !== -1)) { + module.draw(gd, i); + } + } + } + + redrawObjs(fullLayout.annotations || [], Plotly.Annotations); + redrawObjs(fullLayout.shapes || [], Plotly.Shapes); + redrawObjs(fullLayout.images || [], Plotly.Images); + } + + function unsetSubplotTransform(subplot) { + var xa2 = subplot.x(); + var ya2 = subplot.y(); + + var viewBox = [0, 0, xa2._length, ya2._length]; + + var xScaleFactor = xa2._length / viewBox[2], + yScaleFactor = ya2._length / viewBox[3]; + + var clipDx = viewBox[0], + clipDy = viewBox[1]; + + var fracDx = (viewBox[0] / viewBox[2] * xa2._length), + fracDy = (viewBox[1] / viewBox[3] * ya2._length); + + var plotDx = xa2._offset - fracDx, + plotDy = ya2._offset - fracDy; + + fullLayout._defs.selectAll('#' + subplot.clipId) + .call(Lib.setTranslate, clipDx, clipDy) + .call(Lib.setScale, 1 / xScaleFactor, 1 / yScaleFactor); + + subplot.plot + .call(Lib.setTranslate, plotDx, plotDy) + .call(Lib.setScale, xScaleFactor, yScaleFactor) + + // This is specifically directed at scatter traces, applying an inverse + // scale to individual points to counteract the scale of the trace + // as a whole: + .selectAll('.points').selectAll('.point') + .call(Lib.setPointGroupScale, 1 / xScaleFactor, 1 / yScaleFactor); + + } + + function updateSubplot(subplot, progress) { + var axis, r0, r1; + var xUpdate = updates[subplot.xaxis._id]; + var yUpdate = updates[subplot.yaxis._id]; + + var viewBox = []; + + if(xUpdate) { + axis = xUpdate.axis; + r0 = axis._r; + r1 = xUpdate.to; + viewBox[0] = (r0[0] * (1 - progress) + progress * r1[0] - r0[0]) / (r0[1] - r0[0]) * subplot.xaxis._length; + var dx1 = r0[1] - r0[0]; + var dx2 = r1[1] - r1[0]; + + axis.range[0] = r0[0] * (1 - progress) + progress * r1[0]; + axis.range[1] = r0[1] * (1 - progress) + progress * r1[1]; + + viewBox[2] = subplot.xaxis._length * ((1 - progress) + progress * dx2 / dx1); + } else { + viewBox[0] = 0; + viewBox[2] = subplot.xaxis._length; + } + + if(yUpdate) { + axis = yUpdate.axis; + r0 = axis._r; + r1 = yUpdate.to; + viewBox[1] = (r0[1] * (1 - progress) + progress * r1[1] - r0[1]) / (r0[0] - r0[1]) * subplot.yaxis._length; + var dy1 = r0[1] - r0[0]; + var dy2 = r1[1] - r1[0]; + + axis.range[0] = r0[0] * (1 - progress) + progress * r1[0]; + axis.range[1] = r0[1] * (1 - progress) + progress * r1[1]; + + viewBox[3] = subplot.yaxis._length * ((1 - progress) + progress * dy2 / dy1); + } else { + viewBox[1] = 0; + viewBox[3] = subplot.yaxis._length; + } + + ticksAndAnnotations(subplot.x(), subplot.y()); + + + var xa2 = subplot.x(); + var ya2 = subplot.y(); + + var editX = !!xUpdate; + var editY = !!yUpdate; + + var xScaleFactor = editX ? xa2._length / viewBox[2] : 1, + yScaleFactor = editY ? ya2._length / viewBox[3] : 1; + + var clipDx = editX ? viewBox[0] : 0, + clipDy = editY ? viewBox[1] : 0; + + var fracDx = editX ? (viewBox[0] / viewBox[2] * xa2._length) : 0, + fracDy = editY ? (viewBox[1] / viewBox[3] * ya2._length) : 0; + + var plotDx = xa2._offset - fracDx, + plotDy = ya2._offset - fracDy; + + fullLayout._defs.selectAll('#' + subplot.clipId) + .call(Lib.setTranslate, clipDx, clipDy) + .call(Lib.setScale, 1 / xScaleFactor, 1 / yScaleFactor); + + subplot.plot + .call(Lib.setTranslate, plotDx, plotDy) + .call(Lib.setScale, xScaleFactor, yScaleFactor) + + // This is specifically directed at scatter traces, applying an inverse + // scale to individual points to counteract the scale of the trace + // as a whole: + .selectAll('.points').selectAll('.point') + .call(Lib.setPointGroupScale, 1 / xScaleFactor, 1 / yScaleFactor); + + } + + // transitionTail - finish a drag event with a redraw + function transitionTail() { + var i; + var attrs = {}; + // revert to the previous axis settings, then apply the new ones + // through relayout - this lets relayout manage undo/redo + for(i = 0; i < updatedAxisIds.length; i++) { + var axi = updates[updatedAxisIds[i]].axis; + if(axi._r[0] !== axi.range[0]) attrs[axi._name + '.range[0]'] = axi.range[0]; + if(axi._r[1] !== axi.range[1]) attrs[axi._name + '.range[1]'] = axi.range[1]; + + axi.range = axi._r.slice(); + } + + for(i = 0; i < affectedSubplots.length; i++) { + unsetSubplotTransform(affectedSubplots[i]); + } + + Plotly.relayout(gd, attrs); + } + + var easeFn = d3.ease(transitionConfig.ease); + + return new Promise(function(resolve, reject) { + var t1, t2, raf; + + gd._frameData._layoutInterrupts.push(function() { + reject(); + cancelAnimationFrame(raf); + raf = null; + transitionTail(); + }); + + function doFrame() { + t2 = Date.now(); + + var tInterp = Math.min(1, (t2 - t1) / transitionConfig.duration); + var progress = easeFn(tInterp); + + for(var i = 0; i < affectedSubplots.length; i++) { + updateSubplot(affectedSubplots[i], progress); + } + + if(t2 - t1 > transitionConfig.duration) { + raf = cancelAnimationFrame(doFrame); + transitionTail(); + resolve(); + } else { + raf = requestAnimationFrame(doFrame); + resolve(); + } + } + + t1 = Date.now(); + raf = requestAnimationFrame(doFrame); + }); +}; diff --git a/src/plots/plots.js b/src/plots/plots.js index 107dc0b2655..7640049a77c 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -401,6 +401,20 @@ plots.supplyDefaults = function(gd) { // relink functions and _ attributes to promote consistency between plots relinkPrivateKeys(newFullLayout, oldFullLayout); + // XXX: This is a hack that should be refactored by more generally removing the + // need for relinkPrivateKeys + var subplots = plots.getSubplotIds(newFullLayout, 'cartesian'); + for(i = 0; i < subplots.length; i++) { + var subplot = newFullLayout._plots[subplots[i]]; + if(subplot.xaxis) { + subplot.xaxis = newFullLayout[subplot.xaxis._name]; + } + if(subplot.yaxis) { + subplot.yaxis = newFullLayout[subplot.yaxis._name]; + } + } + + plots.doAutoMargin(gd); // can't quite figure out how to get rid of this... each axis needs @@ -419,6 +433,31 @@ plots.supplyDefaults = function(gd) { (gd.calcdata[i][0] || {}).trace = trace; } } + + // Set up the default keyframe if it doesn't exist: + if(!gd._frameData) { + gd._frameData = {}; + } + + if(!gd._frameData._frames) { + gd._frameData._frames = []; + } + + if(!gd._frameData._frameHash) { + gd._frameData._frameHash = {}; + } + + if(!gd._frameData._counter) { + gd._frameData._counter = 0; + } + + if(!gd._frameData._layoutInterrupts) { + gd._frameData._layoutInterrupts = []; + } + + if(!gd._frameData._styleInterrupts) { + gd._frameData._styleInterrupts = []; + } }; // helper function to be bound to fullLayout to check @@ -461,12 +500,17 @@ plots.cleanPlot = function(newFullData, newFullLayout, oldFullData, oldFullLayou if(oldUid === newTrace.uid) continue oldLoop; } - // clean old heatmap and contour traces + // clean old heatmap, contour, and scatter traces + // + // Note: This is also how scatter traces (cartesian and scatterternary) get + // removed since otherwise the scatter module is not called (and so the join + // doesn't register the removal) if scatter traces disappear entirely. if(hasPaper) { oldFullLayout._paper.selectAll( '.hm' + oldUid + ',.contour' + oldUid + - ',#clip' + oldUid + ',#clip' + oldUid + + ',.trace' + oldUid ).remove(); } @@ -817,6 +861,7 @@ plots.purge = function(gd) { delete gd.numboxes; delete gd._hoverTimer; delete gd._lastHoverTime; + delete gd._frameData; // remove all event listeners if(gd.removeAllListeners) gd.removeAllListeners(); @@ -1081,3 +1126,68 @@ plots.graphJson = function(gd, dataonly, mode, output, useDefaults) { return (output === 'object') ? obj : JSON.stringify(obj); }; + +/** + * Modify a keyframe using a list of operations: + * + * @param {array of objects} operations + * Sequence of operations to be performed on the keyframes + */ +plots.modifyFrames = function(gd, operations) { + var i, op, frame; + var _frames = gd._frameData._frames; + var _hash = gd._frameData._frameHash; + + for(i = 0; i < operations.length; i++) { + op = operations[i]; + + switch(op.type) { + // No reason this couldn't exist, but is currently unused/untested: + /*case 'rename': + frame = _frames[op.index]; + delete _hash[frame.name]; + _hash[op.name] = frame; + frame.name = op.name; + break;*/ + case 'replace': + frame = op.value; + var oldName = _frames[op.index].name; + var newName = frame.name; + _frames[op.index] = _hash[newName] = frame; + + if(newName !== oldName) { + // If name has changed in addition to replacement, then update + // the lookup table: + delete _hash[oldName]; + _hash[newName] = frame; + } + + break; + case 'insert': + frame = op.value; + _hash[frame.name] = frame; + _frames.splice(op.index, 0, frame); + break; + case 'delete': + frame = _frames[op.index]; + delete _hash[frame.name]; + _frames.splice(op.index, 1); + break; + } + } + + return Promise.resolve(); +}; + +/* + * Compute a keyframe. Merge a keyframe into its base frame(s) and + * expand properties. + * + * @param {string} frame + * The name of the keyframe to be computed + * + * Returns: a new object with the merged content + */ +plots.computeFrame = function(gd, frameName) { + return Lib.computeFrame(gd._frameData._frameHash, frameName); +}; diff --git a/src/traces/scatter/attributes.js b/src/traces/scatter/attributes.js index 28db0d1343e..2cc6cb00a68 100644 --- a/src/traces/scatter/attributes.js +++ b/src/traces/scatter/attributes.js @@ -65,6 +65,10 @@ module.exports = { 'See `y0` for more info.' ].join(' ') }, + identifier: { + valType: 'data_array', + description: 'A list of keys for object constancy of data points during animation' + }, text: { valType: 'string', role: 'info', @@ -152,6 +156,16 @@ module.exports = { 'Sets the style of the lines. Set to a dash string type', 'or a dash length in px.' ].join(' ') + }, + simplify: { + valType: 'boolean', + dflt: true, + role: 'info', + description: [ + 'Simplifies lines by removing nearly-collinear points. When transitioning', + 'lines, it may be desirable to disable this so that the number of points', + 'along the resulting SVG path is unaffected.' + ].join(' ') } }, connectgaps: { diff --git a/src/traces/scatter/calc.js b/src/traces/scatter/calc.js index 3ac952cce2d..61f3a6300cd 100644 --- a/src/traces/scatter/calc.js +++ b/src/traces/scatter/calc.js @@ -115,6 +115,10 @@ module.exports = function calc(gd, trace) { for(i = 0; i < serieslen; i++) { cd[i] = (isNumeric(x[i]) && isNumeric(y[i])) ? {x: x[i], y: y[i]} : {x: false, y: false}; + + if(trace.identifier && trace.identifier[i] !== undefined) { + cd[i].identifier = trace.identifier[i]; + } } // this has migrated up from arraysToCalcdata as we have a reference to 's' here diff --git a/src/traces/scatter/defaults.js b/src/traces/scatter/defaults.js index e8582802450..ff29491eb4c 100644 --- a/src/traces/scatter/defaults.js +++ b/src/traces/scatter/defaults.js @@ -38,11 +38,13 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout coerce('text'); coerce('mode', defaultMode); + coerce('identifier'); if(subTypes.hasLines(traceOut)) { handleLineDefaults(traceIn, traceOut, defaultColor, layout, coerce); handleLineShapeDefaults(traceIn, traceOut, coerce); coerce('connectgaps'); + coerce('line.simplify'); } if(subTypes.hasMarkers(traceOut)) { diff --git a/src/traces/scatter/index.js b/src/traces/scatter/index.js index 3b576a561d0..5c21f7c6710 100644 --- a/src/traces/scatter/index.js +++ b/src/traces/scatter/index.js @@ -29,6 +29,7 @@ Scatter.colorbar = require('./colorbar'); Scatter.style = require('./style'); Scatter.hoverPoints = require('./hover'); Scatter.selectPoints = require('./select'); +Scatter.animatable = true; Scatter.moduleType = 'trace'; Scatter.name = 'scatter'; diff --git a/src/traces/scatter/line_points.js b/src/traces/scatter/line_points.js index 60d7e3c77ea..390242a1fd7 100644 --- a/src/traces/scatter/line_points.js +++ b/src/traces/scatter/line_points.js @@ -15,6 +15,7 @@ var Axes = require('../../plots/cartesian/axes'); module.exports = function linePoints(d, opts) { var xa = opts.xaxis, ya = opts.yaxis, + simplify = opts.simplify, connectGaps = opts.connectGaps, baseTolerance = opts.baseTolerance, linear = opts.linear, @@ -48,6 +49,10 @@ module.exports = function linePoints(d, opts) { clusterMaxDeviation, thisDeviation; + if(!simplify) { + baseTolerance = minTolerance = -1; + } + // turn one calcdata point into pixel coordinates function getPt(index) { var x = xa.c2p(d[index].x), diff --git a/src/traces/scatter/link_traces.js b/src/traces/scatter/link_traces.js new file mode 100644 index 00000000000..801d02b0d64 --- /dev/null +++ b/src/traces/scatter/link_traces.js @@ -0,0 +1,39 @@ +/** +* Copyright 2012-2016, 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 = function linkTraces(gd, plotinfo, cdscatter) { + var cd, trace; + var prevtrace = null; + + for(var i = 0; i < cdscatter.length; ++i) { + cd = cdscatter[i]; + trace = cd[0].trace; + + // Note: The check which ensures all cdscatter here are for the same axis and + // are either cartesian or scatterternary has been removed. This code assumes + // the passed scattertraces have been filtered to the proper plot types and + // the proper subplots. + if(trace.visible === true) { + trace._nexttrace = null; + + if(['tonextx', 'tonexty', 'tonext'].indexOf(trace.fill) !== -1) { + trace._prevtrace = prevtrace; + + if(prevtrace) { + prevtrace._nexttrace = trace; + } + } + + prevtrace = trace; + } else { + trace._prevtrace = trace._nexttrace = null; + } + } +}; diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index b36ce3f05e9..23f9124e924 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -15,80 +15,178 @@ var Lib = require('../../lib'); var Drawing = require('../../components/drawing'); var ErrorBars = require('../../components/errorbars'); -var polygonTester = require('../../lib/polygon').tester; - var subTypes = require('./subtypes'); var arraysToCalcdata = require('./arrays_to_calcdata'); var linePoints = require('./line_points'); +var linkTraces = require('./link_traces'); +var polygonTester = require('../../lib/polygon').tester; +module.exports = function plot(gd, plotinfo, cdscatter, transitionConfig) { + var i, uids, selection, join; -module.exports = function plot(gd, plotinfo, cdscatter) { - selectMarkers(gd, plotinfo, cdscatter); + var scatterlayer = plotinfo.plot.select('g.scatterlayer'); - var xa = plotinfo.x(), - ya = plotinfo.y(); + // If transition config is provided, then it is only a partial replot and traces not + // updated are removed. + var isFullReplot = !transitionConfig; - // make the container for scatter plots - // (so error bars can find them along with bars) - var scattertraces = plotinfo.plot.select('.scatterlayer') - .selectAll('g.trace.scatter') - .data(cdscatter); + selection = scatterlayer.selectAll('g.trace'); + + join = selection.data(cdscatter, function(d) {return d[0].trace.uid;}); - scattertraces.enter().append('g') - .attr('class', 'trace scatter') + // Append new traces: + join.enter().append('g') + .attr('class', function(d) { + return 'trace scatter trace' + d[0].trace.uid; + }) .style('stroke-miterlimit', 2); - // error bars are at the bottom - scattertraces.call(ErrorBars.plot, plotinfo); + // After the elements are created but before they've been draw, we have to perform + // this extra step of linking the traces. This allows appending of fill layers so that + // the z-order of fill layers is correct. + linkTraces(gd, plotinfo, cdscatter); - // BUILD LINES AND FILLS - var prevpath = '', - prevPolygons = [], - ownFillEl3, ownFillDir, tonext, nexttonext; + createFills(gd, scatterlayer); - scattertraces.each(function(d) { - var trace = d[0].trace, - line = trace.line, - tr = d3.select(this); - if(trace.visible !== true) return; - - ownFillDir = trace.fill.charAt(trace.fill.length - 1); - if(ownFillDir !== 'x' && ownFillDir !== 'y') ownFillDir = ''; - - d[0].node3 = tr; // store node for tweaking by selectPoints - - arraysToCalcdata(d); - - if(!subTypes.hasLines(trace) && trace.fill === 'none') return; - - var thispath, - thisrevpath, - // fullpath is all paths for this curve, joined together straight - // across gaps, for filling - fullpath = '', - // revpath is fullpath reversed, for fill-to-next - revpath = '', - // functions for converting a point array to a path - pathfn, revpathbase, revpathfn; - - // make the fill-to-zero path now, so it shows behind the line - // fill to next puts the fill associated with one trace - // grouped with the previous - if(trace.fill.substr(0, 6) === 'tozero' || trace.fill === 'toself' || - (trace.fill.substr(0, 2) === 'to' && !prevpath)) { - ownFillEl3 = tr.append('path') - .classed('js-fill', true); - } - else ownFillEl3 = null; + // Sort the traces, once created, so that the ordering is preserved even when traces + // are shown and hidden. This is needed since we're not just wiping everything out + // and recreating on every update. + for(i = 0, uids = []; i < cdscatter.length; i++) { + uids[i] = cdscatter[i][0].trace.uid; + } + + scatterlayer.selectAll('g.trace').sort(function(a, b) { + var idx1 = uids.indexOf(a[0].trace.uid); + var idx2 = uids.indexOf(b[0].trace.uid); + return idx1 > idx2 ? 1 : -1; + }); + + // Must run the selection again since otherwise enters/updates get grouped together + // and these get executed out of order. Except we need them in order! + scatterlayer.selectAll('g.trace').each(function(d, i) { + plotOne(gd, i, plotinfo, d, cdscatter, this, transitionConfig); + }); + + if(isFullReplot) { + join.exit().remove(); + } + + // remove paths that didn't get used + scatterlayer.selectAll('path:not([d])').remove(); +}; + +function createFills(gd, scatterlayer) { + var trace; + + scatterlayer.selectAll('g.trace').each(function(d) { + var tr = d3.select(this); + + // Loop only over the traces being redrawn: + trace = d[0].trace; // make the fill-to-next path now for the NEXT trace, so it shows // behind both lines. - // nexttonext was created last time, but give it - // this curve's data for fill color - if(nexttonext) tonext = nexttonext.datum(d); + if(trace._nexttrace) { + trace._nextFill = tr.select('.js-fill.js-tonext'); + if(!trace._nextFill.size()) { + + // If there is an existing tozero fill, we must insert this *after* that fill: + var loc = ':first-child'; + if(tr.select('.js-fill.js-tozero').size()) { + loc += ' + *'; + } + + trace._nextFill = tr.insert('path', loc).attr('class', 'js-fill js-tonext'); + } + } else { + tr.selectAll('.js-fill.js-tonext').remove(); + trace._nextFill = null; + } + + if(trace.fill && (trace.fill.substr(0, 6) === 'tozero' || trace.fill === 'toself' || + (trace.fill.substr(0, 2) === 'to' && !trace._prevtrace))) { + trace._ownFill = tr.select('.js-fill.js-tozero'); + if(!trace._ownFill.size()) { + trace._ownFill = tr.insert('path', ':first-child').attr('class', 'js-fill js-tozero'); + } + } else { + tr.selectAll('.js-fill.js-tozero').remove(); + trace._ownFill = null; + } + }); +} + +function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transitionConfig) { + var join, i; + + // Since this has been reorganized and we're executing this on individual traces, + // we need to pass it the full list of cdscatter as well as this trace's index (idx) + // since it does an internal n^2 loop over comparisons with other traces: + selectMarkers(gd, idx, plotinfo, cdscatter, cdscatterAll); + + var hasTransition = !!transitionConfig && transitionConfig.duration > 0; + + function transition(selection) { + if(hasTransition) { + return selection.transition() + .duration(transitionConfig.duration) + .delay(transitionConfig.delay) + .ease(transitionConfig.ease); + } else { + return selection; + } + } - // now make a new nexttonext for next time - nexttonext = tr.append('path').classed('js-fill', true); + var xa = plotinfo.x(), + ya = plotinfo.y(); + + var trace = cdscatter[0].trace, + line = trace.line, + tr = d3.select(element); + + // (so error bars can find them along with bars) + // error bars are at the bottom + tr.call(ErrorBars.plot, plotinfo, transitionConfig); + + if(trace.visible !== true) return; + + // BUILD LINES AND FILLS + var ownFillEl3, tonext; + var ownFillDir = trace.fill.charAt(trace.fill.length - 1); + if(ownFillDir !== 'x' && ownFillDir !== 'y') ownFillDir = ''; + + // store node for tweaking by selectPoints + cdscatter[0].node3 = tr; + + arraysToCalcdata(cdscatter); + + var prevRevpath = ''; + var prevPolygons = []; + var prevtrace = trace._prevtrace; + + if(prevtrace) { + prevRevpath = prevtrace._prevRevpath || ''; + tonext = prevtrace._nextFill; + prevPolygons = prevtrace._polygons; + } + + var thispath, + thisrevpath, + // fullpath is all paths for this curve, joined together straight + // across gaps, for filling + fullpath = '', + // revpath is fullpath reversed, for fill-to-next + revpath = '', + // functions for converting a point array to a path + pathfn, revpathbase, revpathfn; + + ownFillEl3 = trace._ownFill; + + if(subTypes.hasLines(trace) || trace.fill !== 'none') { + if(tonext) { + // This tells .style which trace to use for fill information: + tonext.datum(cdscatter); + } if(['hv', 'vh', 'hvh', 'vhv'].indexOf(line.shape) !== -1) { pathfn = Drawing.steps(line.shape); @@ -120,32 +218,38 @@ module.exports = function plot(gd, plotinfo, cdscatter) { return revpathbase(pts.reverse()); }; - var segments = linePoints(d, { + var segments = linePoints(cdscatter, { xaxis: xa, yaxis: ya, connectGaps: trace.connectgaps, baseTolerance: Math.max(line.width || 1, 3) / 4, - linear: line.shape === 'linear' + linear: line.shape === 'linear', + simplify: line.simplify }); // since we already have the pixel segments here, use them to make // polygons for hover on fill // TODO: can we skip this if hoveron!=fills? That would mean we // need to redraw when you change hoveron... - var thisPolygons = trace._polygons = new Array(segments.length), - i; - + var thisPolygons = trace._polygons = new Array(segments.length); for(i = 0; i < segments.length; i++) { trace._polygons[i] = polygonTester(segments[i]); } + var pt0, lastSegment, pt1; + if(segments.length) { - var pt0 = segments[0][0], - lastSegment = segments[segments.length - 1], - pt1 = lastSegment[lastSegment.length - 1]; + pt0 = segments[0][0]; + lastSegment = segments[segments.length - 1]; + pt1 = lastSegment[lastSegment.length - 1]; + } - for(i = 0; i < segments.length; i++) { - var pts = segments[i]; + var lineSegments = segments.filter(function(s) { + return s.length > 1; + }); + + var makeUpdate = function(isEnter) { + return function(pts) { thispath = pathfn(pts); thisrevpath = revpathfn(pts); if(!fullpath) { @@ -160,13 +264,41 @@ module.exports = function plot(gd, plotinfo, cdscatter) { fullpath += 'Z' + thispath; revpath = thisrevpath + 'Z' + revpath; } + if(subTypes.hasLines(trace) && pts.length > 1) { - tr.append('path') - .classed('js-line', true) - .style('vector-effect', 'non-scaling-stroke') - .attr('d', thispath); + var el = d3.select(this); + + // This makes the coloring work correctly: + el.datum(cdscatter); + + if(isEnter) { + transition(el.style('opacity', 0) + .attr('d', thispath) + .call(Drawing.lineGroupStyle)) + .style('opacity', 1); + } else { + transition(el).attr('d', thispath) + .call(Drawing.lineGroupStyle); + } } - } + }; + }; + + var lineJoin = tr.selectAll('.js-line').data(lineSegments); + + transition(lineJoin.exit()) + .style('opacity', 0) + .remove(); + + lineJoin.each(makeUpdate(false)); + + lineJoin.enter().append('path') + .classed('js-line', true) + .style('vector-effect', 'non-scaling-stroke') + .call(Drawing.lineGroupStyle) + .each(makeUpdate(true)); + + if(segments.length) { if(ownFillEl3) { if(pt0 && pt1) { if(ownFillDir) { @@ -179,20 +311,24 @@ module.exports = function plot(gd, plotinfo, cdscatter) { // fill to zero: full trace path, plus extension of // the endpoints to the appropriate axis - ownFillEl3.attr('d', fullpath + 'L' + pt1 + 'L' + pt0 + 'Z'); + // For the sake of animations, wrap the points around so that + // the points on the axes are the first two points. Otherwise + // animations get a little crazy if the number of points changes. + transition(ownFillEl3).attr('d', 'M' + pt1 + 'L' + pt0 + 'L' + fullpath.substr(1)); + } else { + // fill to self: just join the path to itself + transition(ownFillEl3).attr('d', fullpath + 'Z'); } - // fill to self: just join the path to itself - else ownFillEl3.attr('d', fullpath + 'Z'); } } - else if(trace.fill.substr(0, 6) === 'tonext' && fullpath && prevpath) { + else if(trace.fill.substr(0, 6) === 'tonext' && fullpath && prevRevpath) { // fill to next: full trace path, plus the previous path reversed if(trace.fill === 'tonext') { // tonext: for use by concentric shapes, like manually constructed // contours, we just add the two paths closed on themselves. // This makes strange results if one path is *not* entirely // inside the other, but then that is a strange usage. - tonext.attr('d', fullpath + 'Z' + prevpath + 'Z'); + transition(tonext).attr('d', fullpath + 'Z' + prevRevpath + 'Z'); } else { // tonextx/y: for now just connect endpoints with lines. This is @@ -200,92 +336,137 @@ module.exports = function plot(gd, plotinfo, cdscatter) { // y/x, but if they *aren't*, we should ideally do more complicated // things depending on whether the new endpoint projects onto the // existing curve or off the end of it - tonext.attr('d', fullpath + 'L' + prevpath.substr(1) + 'Z'); + transition(tonext).attr('d', fullpath + 'L' + prevRevpath.substr(1) + 'Z'); } trace._polygons = trace._polygons.concat(prevPolygons); } - prevpath = revpath; - prevPolygons = thisPolygons; + trace._prevRevpath = revpath; + trace._prevPolygons = thisPolygons; } - }); + } - // remove paths that didn't get used - scattertraces.selectAll('path:not([d])').remove(); function visFilter(d) { return d.filter(function(v) { return v.vis; }); } - scattertraces.append('g') - .attr('class', 'points') - .each(function(d) { - var trace = d[0].trace, - s = d3.select(this), - showMarkers = subTypes.hasMarkers(trace), - showText = subTypes.hasText(trace); - - if((!showMarkers && !showText) || trace.visible !== true) s.remove(); - else { - if(showMarkers) { - s.selectAll('path.point') - .data(trace.marker.maxdisplayed ? visFilter : Lib.identity) - .enter().append('path') - .classed('point', true) - .call(Drawing.translatePoints, xa, ya); - } - if(showText) { - s.selectAll('g') - .data(trace.marker.maxdisplayed ? visFilter : Lib.identity) - // each text needs to go in its own 'g' in case - // it gets converted to mathjax - .enter().append('g') - .append('text') - .call(Drawing.translatePoints, xa, ya); + function keyFunc(d) { + return d.identifier; + } + + // Returns a function if the trace is keyed, otherwise returns undefined + function getKeyFunc(trace) { + if(trace.identifier) { + return keyFunc; + } + } + + function makePoints(d) { + var join, selection; + var trace = d[0].trace, + s = d3.select(this), + showMarkers = subTypes.hasMarkers(trace), + showText = subTypes.hasText(trace); + + if((!showMarkers && !showText) || trace.visible !== true) s.remove(); + else { + if(showMarkers) { + selection = s.selectAll('path.point'); + + join = selection + .data(trace.marker.maxdisplayed ? visFilter : Lib.identity, getKeyFunc(trace)); + + join.enter().append('path') + .classed('point', true) + .call(Drawing.pointStyle, trace) + .call(Drawing.translatePoints, xa, ya, trace, transitionConfig, 1); + + join.transition() + .call(Drawing.translatePoints, xa, ya, trace, transitionConfig, 0) + .call(Drawing.pointStyle, trace); + + if(hasTransition) { + join.exit() + .call(Drawing.translatePoints, xa, ya, trace, transitionConfig, -1); + } else { + join.exit().remove(); } } - }); -}; + if(showText) { + selection = s.selectAll('g'); -function selectMarkers(gd, plotinfo, cdscatter) { + join = selection + .data(trace.marker.maxdisplayed ? visFilter : Lib.identity); + + // each text needs to go in its own 'g' in case + // it gets converted to mathjax + join.enter().append('g') + .append('text') + .call(Drawing.translatePoints, xa, ya); + + selection + .call(Drawing.translatePoints, xa, ya); + + join.exit().remove(); + } + } + } + + // NB: selectAll is evaluated on instantiation: + var pointSelection = tr.selectAll('.points'); + + // Join with new data + join = pointSelection.data([cdscatter]); + + // Transition existing, but don't defer this to an async .transition since + // there's no timing involved: + pointSelection.each(makePoints); + + join.enter().append('g') + .classed('points', true) + .each(makePoints); + + join.exit().remove(); +} + +function selectMarkers(gd, idx, plotinfo, cdscatter, cdscatterAll) { var xa = plotinfo.x(), ya = plotinfo.y(), xr = d3.extent(xa.range.map(xa.l2c)), yr = d3.extent(ya.range.map(ya.l2c)); - cdscatter.forEach(function(d, i) { - var trace = d[0].trace; - if(!subTypes.hasMarkers(trace)) return; - // if marker.maxdisplayed is used, select a maximum of - // mnum markers to show, from the set that are in the viewport - var mnum = trace.marker.maxdisplayed; - - // TODO: remove some as we get away from the viewport? - if(mnum === 0) return; - - var cd = d.filter(function(v) { - return v.x >= xr[0] && v.x <= xr[1] && v.y >= yr[0] && v.y <= yr[1]; - }), - inc = Math.ceil(cd.length / mnum), - tnum = 0; - cdscatter.forEach(function(cdj, j) { - var tracei = cdj[0].trace; - if(subTypes.hasMarkers(tracei) && - tracei.marker.maxdisplayed > 0 && j < i) { - tnum++; - } - }); + var trace = cdscatter[0].trace; + if(!subTypes.hasMarkers(trace)) return; + // if marker.maxdisplayed is used, select a maximum of + // mnum markers to show, from the set that are in the viewport + var mnum = trace.marker.maxdisplayed; + + // TODO: remove some as we get away from the viewport? + if(mnum === 0) return; + + var cd = cdscatter.filter(function(v) { + return v.x >= xr[0] && v.x <= xr[1] && v.y >= yr[0] && v.y <= yr[1]; + }), + inc = Math.ceil(cd.length / mnum), + tnum = 0; + cdscatterAll.forEach(function(cdj, j) { + var tracei = cdj[0].trace; + if(subTypes.hasMarkers(tracei) && + tracei.marker.maxdisplayed > 0 && j < idx) { + tnum++; + } + }); - // if multiple traces use maxdisplayed, stagger which markers we - // display this formula offsets successive traces by 1/3 of the - // increment, adding an extra small amount after each triplet so - // it's not quite periodic - var i0 = Math.round(tnum * inc / 3 + Math.floor(tnum / 3) * inc / 7.1); - - // for error bars: save in cd which markers to show - // so we don't have to repeat this - d.forEach(function(v) { delete v.vis; }); - cd.forEach(function(v, i) { - if(Math.round((i + i0) % inc) === 0) v.vis = true; - }); + // if multiple traces use maxdisplayed, stagger which markers we + // display this formula offsets successive traces by 1/3 of the + // increment, adding an extra small amount after each triplet so + // it's not quite periodic + var i0 = Math.round(tnum * inc / 3 + Math.floor(tnum / 3) * inc / 7.1); + + // for error bars: save in cd which markers to show + // so we don't have to repeat this + cdscatter.forEach(function(v) { delete v.vis; }); + cd.forEach(function(v, i) { + if(Math.round((i + i0) % inc) === 0) v.vis = true; }); } diff --git a/src/traces/scatter/select.js b/src/traces/scatter/select.js index fbe0cd63a6f..03f5ed8f39a 100644 --- a/src/traces/scatter/select.js +++ b/src/traces/scatter/select.js @@ -45,7 +45,8 @@ module.exports = function selectPoints(searchInfo, polygon) { curveNumber: curveNumber, pointNumber: i, x: di.x, - y: di.y + y: di.y, + identifier: di.identifier }); di.dim = 0; } diff --git a/test/jasmine/assets/fail_test.js b/test/jasmine/assets/fail_test.js index 12b591a35f7..468a7640c59 100644 --- a/test/jasmine/assets/fail_test.js +++ b/test/jasmine/assets/fail_test.js @@ -18,5 +18,9 @@ * See ./with_setup_teardown.js for a different example. */ module.exports = function failTest(error) { - expect(error).toBeUndefined(); + if(error === undefined) { + expect(error).not.toBeUndefined(); + } else { + expect(error).toBeUndefined(); + } }; diff --git a/test/jasmine/tests/animate_test.js b/test/jasmine/tests/animate_test.js new file mode 100644 index 00000000000..9a8c3b4a444 --- /dev/null +++ b/test/jasmine/tests/animate_test.js @@ -0,0 +1,145 @@ +var Plotly = require('@lib/index'); +var PlotlyInternal = require('@src/plotly'); +var Lib = require('@src/lib'); + +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var fail = require('../assets/fail_test'); + +var mock = { + 'data': [ + { + 'x': [0, 1, 2], + 'y': [0, 2, 8], + 'type': 'scatter' + }, + { + 'x': [0, 1, 2], + 'y': [4, 2, 3], + 'type': 'scatter' + } + ], + 'layout': { + 'title': 'Animation test', + 'showlegend': true, + 'autosize': false, + 'xaxis': { + 'range': [0, 2], + 'domain': [0, 1] + }, + 'yaxis': { + 'range': [0, 10], + 'domain': [0, 1] + } + }, + 'frames': [{ + 'name': 'base', + 'data': [ + {'y': [0, 2, 8]}, + {'y': [4, 2, 3]} + ], + 'layout': { + 'xaxis': { + 'range': [0, 2] + }, + 'yaxis': { + 'range': [0, 10] + } + } + }, { + 'name': 'frame0', + 'data': [ + {'y': [0.5, 1.5, 7.5]}, + {'y': [4.25, 2.25, 3.05]} + ], + 'baseFrame': 'base', + 'traceIndices': [0, 1], + 'layout': { } + }, { + 'name': 'frame1', + 'data': [ + {'y': [2.1, 1, 7]}, + {'y': [4.5, 2.5, 3.1]} + ], + 'baseFrame': 'base', + 'traceIndices': [0, 1], + 'layout': { } + }, { + 'name': 'frame2', + 'data': [ + {'y': [3.5, 0.5, 6]}, + {'y': [5.7, 2.7, 3.9]} + ], + 'baseFrame': 'base', + 'traceIndices': [0, 1], + 'layout': { } + }, { + 'name': 'frame3', + 'data': [ + {'y': [5.1, 0.25, 5]}, + {'y': [7, 2.9, 6]} + ], + 'baseFrame': 'base', + 'traceIndices': [0, 1], + 'layout': { + 'xaxis': { + 'range': [-1, 4] + }, + 'yaxis': { + 'range': [-5, 15] + } + } + }] +}; + +describe('Test animate API', function() { + 'use strict'; + + var gd; + + beforeEach(function(done) { + gd = createGraphDiv(); + + //var mock = require('@mocks/animation'; + var mockCopy = Lib.extendDeep({}, mock); + + spyOn(PlotlyInternal, 'transition').and.callFake(function() { + return Promise.resolve(); + }); + + Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(function() { + Plotly.addFrames(gd, mockCopy.frames); + }).then(done); + }); + + afterEach(function() { + destroyGraphDiv(); + }); + + it('rejects if the frame is not found', function(done) { + Plotly.animate(gd, 'foobar').then(fail).then(done, done); + }); + + it('animates to a frame', function(done) { + Plotly.animate(gd, 'frame0').then(function() { + expect(PlotlyInternal.transition).toHaveBeenCalled(); + + var args = PlotlyInternal.transition.calls.mostRecent().args; + + // was called with gd, data, layout, traceIndices, transitionConfig: + expect(args.length).toEqual(5); + + // data has two traces: + expect(args[1].length).toEqual(2); + + // layout + expect(args[2]).toEqual({ + xaxis: {range: [0, 2]}, + yaxis: {range: [0, 10]} + }); + + // traces are [0, 1]: + expect(args[3]).toEqual([0, 1]); + }).catch(fail).then(done); + }); +}); diff --git a/test/jasmine/tests/calcdata_test.js b/test/jasmine/tests/calcdata_test.js index 1a6b1c01d4b..9ac9dda0e98 100644 --- a/test/jasmine/tests/calcdata_test.js +++ b/test/jasmine/tests/calcdata_test.js @@ -18,15 +18,15 @@ describe('calculated data and points', function() { it('should exclude null and undefined points when false', function() { Plotly.plot(gd, [{ x: [1, 2, 3, undefined, 5], y: [1, null, 3, 4, 5]}], {}); - expect(gd.calcdata[0][1]).toEqual({ x: false, y: false}); - expect(gd.calcdata[0][3]).toEqual({ x: false, y: false}); + expect(gd.calcdata[0][1]).toEqual(jasmine.objectContaining({ x: false, y: false})); + expect(gd.calcdata[0][3]).toEqual(jasmine.objectContaining({ x: false, y: false})); }); it('should exclude null and undefined points as categories when false', function() { Plotly.plot(gd, [{ x: [1, 2, 3, undefined, 5], y: [1, null, 3, 4, 5] }], { xaxis: { type: 'category' }}); - expect(gd.calcdata[0][1]).toEqual({ x: false, y: false}); - expect(gd.calcdata[0][3]).toEqual({ x: false, y: false}); + expect(gd.calcdata[0][1]).toEqual(jasmine.objectContaining({ x: false, y: false})); + expect(gd.calcdata[0][3]).toEqual(jasmine.objectContaining({ x: false, y: false})); }); }); @@ -192,9 +192,9 @@ describe('calculated data and points', function() { }}); expect(gd.calcdata[0][0]).toEqual(jasmine.objectContaining({x: 4, y: 15})); - expect(gd.calcdata[0][1]).toEqual({ x: false, y: false}); + expect(gd.calcdata[0][1]).toEqual(jasmine.objectContaining({ x: false, y: false})); expect(gd.calcdata[0][2]).toEqual(jasmine.objectContaining({x: 3, y: 12})); - expect(gd.calcdata[0][3]).toEqual({ x: false, y: false}); + expect(gd.calcdata[0][3]).toEqual(jasmine.objectContaining({ x: false, y: false})); expect(gd.calcdata[0][4]).toEqual(jasmine.objectContaining({x: 2, y: 14})); }); @@ -269,7 +269,7 @@ describe('calculated data and points', function() { }}); expect(gd.calcdata[0][0]).toEqual(jasmine.objectContaining({x: 6, y: 15})); - expect(gd.calcdata[0][1]).toEqual({x: false, y: false}); + expect(gd.calcdata[0][1]).toEqual(jasmine.objectContaining({x: false, y: false})); expect(gd.calcdata[0][2]).toEqual(jasmine.objectContaining({x: 5, y: 12})); expect(gd.calcdata[0][3]).toEqual(jasmine.objectContaining({x: 0, y: 13})); expect(gd.calcdata[0][4]).toEqual(jasmine.objectContaining({x: 3, y: 14})); diff --git a/test/jasmine/tests/cartesian_test.js b/test/jasmine/tests/cartesian_test.js index 7fbe66fbef5..e43b9265aa6 100644 --- a/test/jasmine/tests/cartesian_test.js +++ b/test/jasmine/tests/cartesian_test.js @@ -50,6 +50,104 @@ describe('zoom box element', function() { }); }); +describe('restyle', function() { + describe('scatter traces', function() { + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + it('reuses SVG fills', function(done) { + var fills, firstToZero, secondToZero, firstToNext, secondToNext; + var mock = Lib.extendDeep({}, require('@mocks/basic_area.json')); + + Plotly.plot(gd, mock.data, mock.layout).then(function() { + // Assert there are two fills: + fills = d3.selectAll('g.trace.scatter .js-fill')[0]; + + // First is tozero, second is tonext: + expect(d3.selectAll('g.trace.scatter .js-fill').size()).toEqual(2); + expect(fills[0].classList.contains('js-tozero')).toBe(true); + expect(fills[0].classList.contains('js-tonext')).toBe(false); + expect(fills[1].classList.contains('js-tozero')).toBe(false); + expect(fills[1].classList.contains('js-tonext')).toBe(true); + + firstToZero = fills[0]; + firstToNext = fills[1]; + }).then(function() { + return Plotly.restyle(gd, {visible: [false]}, [1]); + }).then(function() { + // Trace 1 hidden leaves only trace zero's tozero fill: + expect(d3.selectAll('g.trace.scatter .js-fill').size()).toEqual(1); + expect(fills[0].classList.contains('js-tozero')).toBe(true); + expect(fills[0].classList.contains('js-tonext')).toBe(false); + }).then(function() { + return Plotly.restyle(gd, {visible: [true]}, [1]); + }).then(function() { + // Reshow means two fills again AND order is preserved: + fills = d3.selectAll('g.trace.scatter .js-fill')[0]; + + // First is tozero, second is tonext: + expect(d3.selectAll('g.trace.scatter .js-fill').size()).toEqual(2); + expect(fills[0].classList.contains('js-tozero')).toBe(true); + expect(fills[0].classList.contains('js-tonext')).toBe(false); + expect(fills[1].classList.contains('js-tozero')).toBe(false); + expect(fills[1].classList.contains('js-tonext')).toBe(true); + + secondToZero = fills[0]; + secondToNext = fills[1]; + + // The identity of the first is retained: + expect(firstToZero).toBe(secondToZero); + + // The second has been recreated so is different: + expect(firstToNext).not.toBe(secondToNext); + }).then(done); + }); + + it('reuses SVG lines', function(done) { + var lines, firstLine1, secondLine1, firstLine2, secondLine2; + var mock = Lib.extendDeep({}, require('@mocks/basic_line.json')); + + Plotly.plot(gd, mock.data, mock.layout).then(function() { + lines = d3.selectAll('g.scatter.trace .js-line'); + + firstLine1 = lines[0][0]; + firstLine2 = lines[0][1]; + + // One line for each trace: + expect(lines.size()).toEqual(2); + }).then(function() { + return Plotly.restyle(gd, {visible: [false]}, [0]); + }).then(function() { + lines = d3.selectAll('g.scatter.trace .js-line'); + + // Only one line now and it's equal to the second trace's line from above: + expect(lines.size()).toEqual(1); + expect(lines[0][0]).toBe(firstLine2); + }).then(function() { + return Plotly.restyle(gd, {visible: [true]}, [0]); + }).then(function() { + lines = d3.selectAll('g.scatter.trace .js-line'); + secondLine1 = lines[0][0]; + secondLine2 = lines[0][1]; + + // Two lines once again: + expect(lines.size()).toEqual(2); + + // First line has been removed and recreated: + expect(firstLine1).not.toBe(secondLine1); + + // Second line was persisted: + expect(firstLine2).toBe(secondLine2); + }).then(done); + }); + }); +}); + describe('relayout', function() { describe('axis category attributes', function() { diff --git a/test/jasmine/tests/compute_frame_test.js b/test/jasmine/tests/compute_frame_test.js new file mode 100644 index 00000000000..e2d6b026c96 --- /dev/null +++ b/test/jasmine/tests/compute_frame_test.js @@ -0,0 +1,236 @@ +var Plotly = require('@lib/index'); +var Lib = require('@src/lib'); + +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var computeFrame = require('@src/plots/plots').computeFrame; + +function clone(obj) { + return Lib.extendDeep({}, obj); +} + +describe('Test mergeFrames', function() { + 'use strict'; + + var gd, mock; + + beforeEach(function(done) { + mock = [{x: [1, 2, 3], y: [2, 1, 3]}, {x: [1, 2, 3], y: [6, 4, 5]}]; + gd = createGraphDiv(); + Plotly.plot(gd, mock).then(done); + }); + + afterEach(destroyGraphDiv); + + describe('computing a single frame', function() { + var frame1, input; + + beforeEach(function(done) { + frame1 = { + name: 'frame1', + data: [{ + x: [1, 2, 3], + 'marker.size': 8, + marker: {color: 'red'} + }] + }; + + input = clone(frame1); + Plotly.addFrames(gd, [input]).then(done); + }); + + it('returns false if the frame does not exist', function() { + expect(computeFrame(gd, 'frame8')).toBe(false); + }); + + it('returns a new object', function() { + var result = computeFrame(gd, 'frame1'); + expect(result).not.toBe(input); + }); + + it('copies objects', function() { + var result = computeFrame(gd, 'frame1'); + expect(result.data).not.toBe(input.data); + expect(result.data[0].marker).not.toBe(input.data[0].marker); + }); + + it('does NOT copy arrays', function() { + var result = computeFrame(gd, 'frame1'); + expect(result.data[0].x).toBe(input.data[0].x); + }); + + it('computes a single frame', function() { + var computed = computeFrame(gd, 'frame1'); + var expected = {data: [{x: [1, 2, 3], marker: {size: 8, color: 'red'}}], traceIndices: [0]}; + expect(computed).toEqual(expected); + }); + + it('leaves the frame unaffected', function() { + computeFrame(gd, 'frame1'); + expect(gd._frameData._frameHash.frame1).toEqual(frame1); + }); + }); + + describe('circularly defined frames', function() { + var frames, results; + + beforeEach(function(done) { + frames = [ + {name: 'frame0', baseFrame: 'frame1', data: [{'marker.size': 0}]}, + {name: 'frame1', baseFrame: 'frame2', data: [{'marker.size': 1}]}, + {name: 'frame2', baseFrame: 'frame0', data: [{'marker.size': 2}]} + ]; + + results = [ + {traceIndices: [0], data: [{marker: {size: 0}}]}, + {traceIndices: [0], data: [{marker: {size: 1}}]}, + {traceIndices: [0], data: [{marker: {size: 2}}]} + ]; + + Plotly.addFrames(gd, frames).then(done); + }); + + function doTest(i) { + it('avoid infinite recursion (starting point = ' + i + ')', function() { + var result = computeFrame(gd, 'frame' + i); + expect(result).toEqual(results[i]); + }); + } + + for(var ii = 0; ii < 3; ii++) { + doTest(ii); + } + }); + + describe('computing trace data', function() { + var frames; + + beforeEach(function() { + frames = [{ + name: 'frame0', + data: [{'marker.size': 0}], + traceIndices: [2] + }, { + name: 'frame1', + data: [{'marker.size': 1}], + traceIndices: [8] + }, { + name: 'frame2', + data: [{'marker.size': 2}], + traceIndices: [2] + }, { + name: 'frame3', + data: [{'marker.size': 3}, {'marker.size': 4}], + traceIndices: [2, 8] + }, { + name: 'frame4', + data: [ + {'marker.size': 5}, + {'marker.size': 6}, + {'marker.size': 7} + ] + }]; + }); + + it('merges orthogonal traces', function() { + frames[0].baseFrame = frames[1].name; + + // This technically returns a promise, but it's not actually asynchronous so + // that we'll just keep this synchronous: + Plotly.addFrames(gd, frames.map(clone)); + + expect(computeFrame(gd, 'frame0')).toEqual({ + traceIndices: [8, 2], + data: [ + {marker: {size: 1}}, + {marker: {size: 0}} + ] + }); + + // Verify that the frames are untouched (by value, at least, but they should + // also be unmodified by identity too) by the computation: + expect(gd._frameData._frames).toEqual(frames); + }); + + it('merges overlapping traces', function() { + frames[0].baseFrame = frames[2].name; + + Plotly.addFrames(gd, frames.map(clone)); + + expect(computeFrame(gd, 'frame0')).toEqual({ + traceIndices: [2], + data: [{marker: {size: 0}}] + }); + + expect(gd._frameData._frames).toEqual(frames); + }); + + it('merges partially overlapping traces', function() { + frames[0].baseFrame = frames[1].name; + frames[1].baseFrame = frames[2].name; + frames[2].baseFrame = frames[3].name; + + Plotly.addFrames(gd, frames.map(clone)); + + expect(computeFrame(gd, 'frame0')).toEqual({ + traceIndices: [2, 8], + data: [ + {marker: {size: 0}}, + {marker: {size: 1}} + ] + }); + + expect(gd._frameData._frames).toEqual(frames); + }); + + it('assumes serial order without traceIndices specified', function() { + frames[4].baseFrame = frames[3].name; + + Plotly.addFrames(gd, frames.map(clone)); + + expect(computeFrame(gd, 'frame4')).toEqual({ + traceIndices: [2, 8, 0, 1], + data: [ + {marker: {size: 7}}, + {marker: {size: 4}}, + {marker: {size: 5}}, + {marker: {size: 6}} + ] + }); + + expect(gd._frameData._frames).toEqual(frames); + }); + }); + + describe('computing trace layout', function() { + var frames, frameCopies; + + beforeEach(function(done) { + frames = [{ + name: 'frame0', + layout: {'margin.l': 40} + }, { + name: 'frame1', + layout: {'margin.l': 80} + }]; + + frameCopies = frames.map(clone); + + Plotly.addFrames(gd, frames).then(done); + }); + + it('merges layouts', function() { + frames[0].baseFrame = frames[1].name; + var result = computeFrame(gd, 'frame0'); + + expect(result).toEqual({ + layout: {margin: {l: 40}} + }); + }); + + it('leaves the frame unaffected', function() { + computeFrame(gd, 'frame0'); + expect(gd._frameData._frames).toEqual(frameCopies); + }); + }); +}); diff --git a/test/jasmine/tests/frame_api_test.js b/test/jasmine/tests/frame_api_test.js new file mode 100644 index 00000000000..b105d63f1fb --- /dev/null +++ b/test/jasmine/tests/frame_api_test.js @@ -0,0 +1,207 @@ +var Plotly = require('@lib/index'); + +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var fail = require('../assets/fail_test'); + +describe('Test frame api', function() { + 'use strict'; + + var gd, mock, f, h; + + beforeEach(function(done) { + mock = [{x: [1, 2, 3], y: [2, 1, 3]}, {x: [1, 2, 3], y: [6, 4, 5]}]; + gd = createGraphDiv(); + Plotly.plot(gd, mock).then(function() { + f = gd._frameData._frames; + h = gd._frameData._frameHash; + }).then(function() { + Plotly.setPlotConfig({ queueLength: 10 }); + }).then(done); + }); + + afterEach(function() { + destroyGraphDiv(); + Plotly.setPlotConfig({queueLength: 0}); + }); + + describe('gd initialization', function() { + it('creates an empty list for frames', function() { + expect(gd._frameData._frames).toEqual([]); + }); + + it('creates an empty lookup table for frames', function() { + expect(gd._frameData._counter).toEqual(0); + }); + }); + + describe('#addFrames', function() { + it('names an unnamed frame', function(done) { + Plotly.addFrames(gd, [{}]).then(function() { + expect(Object.keys(h)).toEqual(['frame 0']); + }).catch(fail).then(done); + }); + + it('creates multiple unnamed frames at the same time', function(done) { + Plotly.addFrames(gd, [{}, {}]).then(function() { + expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 1'}]); + }).catch(fail).then(done); + }); + + it('creates multiple unnamed frames in series', function(done) { + Plotly.addFrames(gd, [{}]).then( + Plotly.addFrames(gd, [{}]) + ).then(function() { + expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 1'}]); + }).catch(fail).then(done); + }); + + it('avoids name collisions', function(done) { + Plotly.addFrames(gd, [{name: 'frame 0'}, {name: 'frame 2'}]).then(function() { + expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 2'}]); + + return Plotly.addFrames(gd, [{}, {name: 'foobar'}, {}]); + }).then(function() { + expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 2'}, {name: 'frame 1'}, {name: 'foobar'}, {name: 'frame 3'}]); + }).catch(fail).then(done); + }); + + it('inserts frames at specific indices', function(done) { + var i; + var frames = []; + for(i = 0; i < 10; i++) { + frames.push({name: 'frame' + i}); + } + + function validate() { + for(i = 0; i < f.length; i++) { + expect(f[i].name).toEqual('frame' + i); + } + } + + Plotly.addFrames(gd, frames).then(validate).then(function() { + return Plotly.addFrames(gd, [{name: 'frame5', x: [1]}, {name: 'frame7', x: [2]}, {name: 'frame10', x: [3]}], [5, 7, undefined]); + }).then(function() { + expect(f[5]).toEqual({name: 'frame5', x: [1]}); + expect(f[7]).toEqual({name: 'frame7', x: [2]}); + expect(f[10]).toEqual({name: 'frame10', x: [3]}); + + return Plotly.Queue.undo(gd); + }).then(validate).catch(fail).then(done); + }); + + it('inserts frames at specific indices (reversed)', function(done) { + var i; + var frames = []; + for(i = 0; i < 10; i++) { + frames.push({name: 'frame' + i}); + } + + function validate() { + for(i = 0; i < f.length; i++) { + expect(f[i].name).toEqual('frame' + i); + } + } + + Plotly.addFrames(gd, frames).then(validate).then(function() { + return Plotly.addFrames(gd, [{name: 'frame10', x: [3]}, {name: 'frame7', x: [2]}, {name: 'frame5', x: [1]}], [undefined, 7, 5]); + }).then(function() { + expect(f[5]).toEqual({name: 'frame5', x: [1]}); + expect(f[7]).toEqual({name: 'frame7', x: [2]}); + expect(f[10]).toEqual({name: 'frame10', x: [3]}); + + return Plotly.Queue.undo(gd); + }).then(validate).catch(fail).then(done); + }); + + it('implements undo/redo', function(done) { + function validate() { + expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 1'}]); + expect(h).toEqual({'frame 0': {name: 'frame 0'}, 'frame 1': {name: 'frame 1'}}); + } + + Plotly.addFrames(gd, [{name: 'frame 0'}, {name: 'frame 1'}]).then(validate).then(function() { + return Plotly.Queue.undo(gd); + }).then(function() { + expect(f).toEqual([]); + expect(h).toEqual({}); + + return Plotly.Queue.redo(gd); + }).then(validate).catch(fail).then(done); + }); + + it('overwrites frames', function(done) { + // The whole shebang. This hits insertion + replacements + deletion + undo + redo: + Plotly.addFrames(gd, [{name: 'test1', x: 'y'}, {name: 'test2'}]).then(function() { + expect(f).toEqual([{name: 'test1', x: 'y'}, {name: 'test2'}]); + expect(Object.keys(h)).toEqual(['test1', 'test2']); + + return Plotly.addFrames(gd, [{name: 'test1'}, {name: 'test3'}]); + }).then(function() { + expect(f).toEqual([{name: 'test1'}, {name: 'test2'}, {name: 'test3'}]); + expect(Object.keys(h)).toEqual(['test1', 'test2', 'test3']); + + return Plotly.Queue.undo(gd); + }).then(function() { + expect(f).toEqual([{name: 'test1', x: 'y'}, {name: 'test2'}]); + expect(Object.keys(h)).toEqual(['test1', 'test2']); + + return Plotly.Queue.redo(gd); + }).then(function() { + expect(f).toEqual([{name: 'test1'}, {name: 'test2'}, {name: 'test3'}]); + expect(Object.keys(h)).toEqual(['test1', 'test2', 'test3']); + }).catch(fail).then(done); + }); + }); + + describe('#deleteFrames', function() { + it('deletes a frame', function(done) { + Plotly.addFrames(gd, [{name: 'frame1'}]).then(function() { + expect(f).toEqual([{name: 'frame1'}]); + expect(Object.keys(h)).toEqual(['frame1']); + + return Plotly.deleteFrames(gd, [0]); + }).then(function() { + expect(f).toEqual([]); + expect(Object.keys(h)).toEqual([]); + + return Plotly.Queue.undo(gd); + }).then(function() { + expect(f).toEqual([{name: 'frame1'}]); + + return Plotly.Queue.redo(gd); + }).then(function() { + expect(f).toEqual([]); + expect(Object.keys(h)).toEqual([]); + }).catch(fail).then(done); + }); + + it('deletes multiple frames', function(done) { + var i; + var frames = []; + for(i = 0; i < 10; i++) { + frames.push({name: 'frame' + i}); + } + + function validate() { + var expected = ['frame0', 'frame1', 'frame3', 'frame5', 'frame7', 'frame9']; + expect(f.length).toEqual(expected.length); + for(i = 0; i < expected.length; i++) { + expect(f[i].name).toEqual(expected[i]); + } + } + + Plotly.addFrames(gd, frames).then(function() { + return Plotly.deleteFrames(gd, [2, 8, 4, 6]); + }).then(validate).then(function() { + return Plotly.Queue.undo(gd); + }).then(function() { + for(i = 0; i < 10; i++) { + expect(f[i]).toEqual({name: 'frame' + i}); + } + + return Plotly.Queue.redo(gd); + }).then(validate).catch(fail).then(done); + }); + }); +}); diff --git a/test/jasmine/tests/lib_test.js b/test/jasmine/tests/lib_test.js index 16361f79cae..68dbf8e544b 100644 --- a/test/jasmine/tests/lib_test.js +++ b/test/jasmine/tests/lib_test.js @@ -476,6 +476,69 @@ describe('Test lib.js:', function() { }); }); + describe('expandObjectPaths', function() { + it('returns the original object', function() { + var x = {}; + expect(Lib.expandObjectPaths(x)).toBe(x); + }); + + it('unpacks top-level paths', function() { + var input = {'marker.color': 'red', 'marker.size': [1, 2, 3]}; + var expected = {marker: {color: 'red', size: [1, 2, 3]}}; + expect(Lib.expandObjectPaths(input)).toEqual(expected); + }); + + it('unpacks recursively', function() { + var input = {'marker.color': {'red.certainty': 'definitely'}}; + var expected = {marker: {color: {red: {certainty: 'definitely'}}}}; + expect(Lib.expandObjectPaths(input)).toEqual(expected); + }); + + it('unpacks deep paths', function() { + var input = {'foo.bar.baz': 'red'}; + var expected = {foo: {bar: {baz: 'red'}}}; + expect(Lib.expandObjectPaths(input)).toEqual(expected); + }); + + it('unpacks non-top-level deep paths', function() { + var input = {color: {'foo.bar.baz': 'red'}}; + var expected = {color: {foo: {bar: {baz: 'red'}}}}; + expect(Lib.expandObjectPaths(input)).toEqual(expected); + }); + + it('merges dotted properties into objects', function() { + var input = {marker: {color: 'red'}, 'marker.size': 8}; + var expected = {marker: {color: 'red', size: 8}}; + expect(Lib.expandObjectPaths(input)).toEqual(expected); + }); + + it('merges objects into dotted properties', function() { + var input = {'marker.size': 8, marker: {color: 'red'}}; + var expected = {marker: {color: 'red', size: 8}}; + expect(Lib.expandObjectPaths(input)).toEqual(expected); + }); + + it('retains the identity of nested objects', function() { + var input = {marker: {size: 8}}; + var origNested = input.marker; + var expanded = Lib.expandObjectPaths(input); + var newNested = expanded.marker; + + expect(input).toBe(expanded); + expect(origNested).toBe(newNested); + }); + + it('retains the identity of nested arrays', function() { + var input = {'marker.size': [1, 2, 3]}; + var origArray = input['marker.size']; + var expanded = Lib.expandObjectPaths(input); + var newArray = expanded.marker.size; + + expect(input).toBe(expanded); + expect(origArray).toBe(newArray); + }); + }); + describe('coerce', function() { var coerce = Lib.coerce, out; diff --git a/test/jasmine/tests/scatter_test.js b/test/jasmine/tests/scatter_test.js index cfc6e132353..8c105732800 100644 --- a/test/jasmine/tests/scatter_test.js +++ b/test/jasmine/tests/scatter_test.js @@ -203,7 +203,8 @@ describe('Test scatter', function() { yaxis: ax, connectGaps: false, baseTolerance: 1, - linear: true + linear: true, + simplify: true }; function makeCalcData(ptsIn) { diff --git a/test/jasmine/tests/select_test.js b/test/jasmine/tests/select_test.js index 885d993afb3..dc01190d123 100644 --- a/test/jasmine/tests/select_test.js +++ b/test/jasmine/tests/select_test.js @@ -179,12 +179,14 @@ describe('select box and lasso', function() { curveNumber: 0, pointNumber: 0, x: 0.002, - y: 16.25 + y: 16.25, + identifier: undefined }, { curveNumber: 0, pointNumber: 1, x: 0.004, - y: 12.5 + y: 12.5, + identifier: undefined }], 'with the correct selecting points'); assertRange(selectingData.range, { x: [0.002000, 0.0046236], @@ -196,12 +198,14 @@ describe('select box and lasso', function() { curveNumber: 0, pointNumber: 0, x: 0.002, - y: 16.25 + y: 16.25, + identifier: undefined }, { curveNumber: 0, pointNumber: 1, x: 0.004, - y: 12.5 + y: 12.5, + identifier: undefined }], 'with the correct selected points'); assertRange(selectedData.range, { x: [0.002000, 0.0046236], @@ -255,7 +259,8 @@ describe('select box and lasso', function() { curveNumber: 0, pointNumber: 10, x: 0.099, - y: 2.75 + y: 2.75, + identifier: undefined }], 'with the correct selecting points'); expect(selectedCnt).toEqual(1, 'with the correct selected count'); @@ -263,7 +268,8 @@ describe('select box and lasso', function() { curveNumber: 0, pointNumber: 10, x: 0.099, - y: 2.75 + y: 2.75, + identifier: undefined }], 'with the correct selected points'); doubleClick(250, 200).then(function() { diff --git a/test/jasmine/tests/ternary_test.js b/test/jasmine/tests/ternary_test.js index c563a500b1b..9b773dad96b 100644 --- a/test/jasmine/tests/ternary_test.js +++ b/test/jasmine/tests/ternary_test.js @@ -67,6 +67,7 @@ describe('ternary plots', function() { expect(countTraces('scatter')).toEqual(0); var trace = Lib.extendDeep({}, mock.data[0]); + delete trace.uid; return Plotly.addTraces(gd, [trace]); }).then(function() { @@ -74,6 +75,7 @@ describe('ternary plots', function() { expect(countTraces('scatter')).toEqual(1); var trace = Lib.extendDeep({}, mock.data[0]); + delete trace.uid; return Plotly.addTraces(gd, [trace]); }).then(function() { From ae7c54cc2208b94db60b241bb6a340080e92fbe2 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Wed, 3 Aug 2016 23:05:00 -0400 Subject: [PATCH 02/45] Partial draw: Correct traces selected based on fill --- src/plots/cartesian/index.js | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/plots/cartesian/index.js b/src/plots/cartesian/index.js index 63aa425bf1a..4caf0e3c217 100644 --- a/src/plots/cartesian/index.js +++ b/src/plots/cartesian/index.js @@ -57,20 +57,19 @@ exports.plot = function(gd, traces, transitionOpts) { // Skip trace if whitelist provided and it's not whitelisted: // if (Array.isArray(traces) && traces.indexOf(i) === -1) continue; - if(trace.xaxis + trace.yaxis === subplot) { - // Okay, so example: traces 0, 1, and 2 have fill = tonext. You animate - // traces 0 and 2. Trace 1 also needs to be updated, otherwise its fill - // is outdated. So this retroactively adds the previous trace if the - // traces are interdependent. - if(pcd && - ['tonextx', 'tonexty', 'tonext'].indexOf(trace.fill) !== -1 && - cdSubplot.indexOf(pcd) === -1) { - cdSubplot.push(pcd); - } - // If this trace is specifically requested, add it to the list: if(traces.indexOf(trace.index) !== -1) { + // Okay, so example: traces 0, 1, and 2 have fill = tonext. You animate + // traces 0 and 2. Trace 1 also needs to be updated, otherwise its fill + // is outdated. So this retroactively adds the previous trace if the + // traces are interdependent. + if(pcd && + ['tonextx', 'tonexty', 'tonext'].indexOf(trace.fill) !== -1 && + cdSubplot.indexOf(pcd) === -1) { + cdSubplot.push(pcd); + } + cdSubplot.push(cd); } From 4e23c21d9bd81366cd23b3be2bf7b462600e40b5 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 4 Aug 2016 08:25:49 -0400 Subject: [PATCH 03/45] Handle the case where no module found --- src/plot_api/plot_api.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 7c39b9ecda8..cdf7ceaeb3b 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2573,7 +2573,7 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { var trace = gd._fullData[traceIdx]; var module = trace._module; - if(!module.animatable) { + if(!module || !module.animatable) { continue; } @@ -2659,6 +2659,8 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { var contFull = gd._fullData[traceIdx]; var module = contFull._module; + if (!module) continue; + if(!module.animatable) { var thisUpdate = {}; From b98be715b86d31b73d08489dbf910e29e33112a8 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 4 Aug 2016 12:03:13 -0400 Subject: [PATCH 04/45] supplyDataDefaults instead of supplyDefaults for transitions --- src/plot_api/plot_api.js | 2 +- src/plots/cartesian/dragbox.js | 1 - src/plots/plots.js | 17 +++-------------- 3 files changed, 4 insertions(+), 16 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index cdf7ceaeb3b..db08acc55b9 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2591,7 +2591,7 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { Lib.extendDeepNoArrays(gd.data[traceIndices[i]], update); } - Plots.supplyDefaults(gd); + Plots.supplyDataDefaults(gd.data, gd._fullData, gd._fullLayout); // TODO: Add logic that computes transitionedTraces to avoid unnecessary work while // still handling things like box plots that are interrelated. diff --git a/src/plots/cartesian/dragbox.js b/src/plots/cartesian/dragbox.js index eb7ad269d1d..e153b89e8fd 100644 --- a/src/plots/cartesian/dragbox.js +++ b/src/plots/cartesian/dragbox.js @@ -617,7 +617,6 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { editX = ew && xa.indexOf(xa2) !== -1 && !xa2.fixedrange, editY = ns && ya.indexOf(ya2) !== -1 && !ya2.fixedrange; - var xScaleFactor = editX ? xa2._length / viewBox[2] : 1, yScaleFactor = editY ? ya2._length / viewBox[3] : 1; diff --git a/src/plots/plots.js b/src/plots/plots.js index 7640049a77c..99768857df5 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -401,20 +401,6 @@ plots.supplyDefaults = function(gd) { // relink functions and _ attributes to promote consistency between plots relinkPrivateKeys(newFullLayout, oldFullLayout); - // XXX: This is a hack that should be refactored by more generally removing the - // need for relinkPrivateKeys - var subplots = plots.getSubplotIds(newFullLayout, 'cartesian'); - for(i = 0; i < subplots.length; i++) { - var subplot = newFullLayout._plots[subplots[i]]; - if(subplot.xaxis) { - subplot.xaxis = newFullLayout[subplot.xaxis._name]; - } - if(subplot.yaxis) { - subplot.yaxis = newFullLayout[subplot.yaxis._name]; - } - } - - plots.doAutoMargin(gd); // can't quite figure out how to get rid of this... each axis needs @@ -571,6 +557,9 @@ plots.supplyDataDefaults = function(dataIn, dataOut, layout) { basePlotModules = layout._basePlotModules = [], cnt = 0; + // Make this idempotent by emptying the existing array: + dataOut.length = 0; + function pushModule(fullTrace) { dataOut.push(fullTrace); From 00bfd52f04264cdf08981afeac7fa03014a0c150 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 4 Aug 2016 12:06:00 -0400 Subject: [PATCH 05/45] Dragbox compares axes by value instead of reference --- src/plot_api/plot_api.js | 11 ++++++- src/plots/cartesian/dragbox.js | 52 ++++++++++++++++++++++++++++-- src/plots/cartesian/set_convert.js | 34 +++++++++++-------- test/jasmine/karma.conf.js | 5 ++- 4 files changed, 84 insertions(+), 18 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index db08acc55b9..3512c379aaa 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2600,6 +2600,15 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { doCalcdata(gd); ErrorBars.calc(gd); + + // While transitions are occuring, occurring, we get a double-transform + // issue if we transform the drawn layer *and* use the new axis range to + // draw the data. This causes setConvert to use the pre-interaction values + // of the axis range: + var axList = Plotly.Axes.list(gd); + for(i = 0; i < axList.length; i++) { + axList[i].setScale(true); + } } var restyleList = []; @@ -2659,7 +2668,7 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { var contFull = gd._fullData[traceIdx]; var module = contFull._module; - if (!module) continue; + if(!module) continue; if(!module.animatable) { var thisUpdate = {}; diff --git a/src/plots/cartesian/dragbox.js b/src/plots/cartesian/dragbox.js index e153b89e8fd..05d7e7a0f8a 100644 --- a/src/plots/cartesian/dragbox.js +++ b/src/plots/cartesian/dragbox.js @@ -158,6 +158,28 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { zb, corners; + function recomputeAxisLists() { + xa = [plotinfo.x()]; + ya = [plotinfo.y()]; + pw = xa[0]._length; + ph = ya[0]._length; + + for(var i = 1; i < subplots.length; i++) { + var subplotXa = subplots[i].x(), + subplotYa = subplots[i].y(); + if(xa.indexOf(subplotXa) === -1) xa.push(subplotXa); + if(ya.indexOf(subplotYa) === -1) ya.push(subplotYa); + } + allaxes = xa.concat(ya); + xActive = isDirectionActive(xa, ew); + yActive = isDirectionActive(ya, ns); + cursor = getDragCursor(yActive + xActive, fullLayout.dragmode); + xs = plotinfo.x()._offset; + ys = plotinfo.y()._offset; + dragOptions.xa = xa; + dragOptions.ya = ya; + } + function zoomPrep(e, startX, startY) { var dragBBox = dragger.getBoundingClientRect(); x0 = startX - dragBBox.left; @@ -358,6 +380,7 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { fullLayout._plots[plotinfo.mainplot] : plotinfo; function zoomWheel(e) { + recomputeAxisLists(); // deactivate mousewheel scrolling on embedded graphs // devs can override this with layout._enablescrollzoom, // but _ ensures this setting won't leave their page @@ -433,6 +456,8 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { // plotDrag: move the plot in response to a drag function plotDrag(dx, dy) { + recomputeAxisLists(); + function dragAxList(axList, pix) { for(var i = 0; i < axList.length; i++) { var axi = axList[i]; @@ -606,6 +631,7 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { // affected by this drag, and update them. look for all plots // sharing an affected axis (including the one being dragged) function updateSubplots(viewBox) { + var j; var plotinfos = fullLayout._plots, subplots = Object.keys(plotinfos); @@ -614,8 +640,30 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { var subplot = plotinfos[subplots[i]], xa2 = subplot.x(), ya2 = subplot.y(), - editX = ew && xa.indexOf(xa2) !== -1 && !xa2.fixedrange, - editY = ns && ya.indexOf(ya2) !== -1 && !ya2.fixedrange; + editX = ew && !xa2.fixedrange, + editY = ns && !ya2.fixedrange; + + if(editX) { + var isInX = false; + for(j = 0; j < xa.length; j++) { + if(xa[j]._id === xa2._id) { + isInX = true; + break; + } + } + editX = editX && isInX; + } + + if(editY) { + var isInY = false; + for(j = 0; j < ya.length; j++) { + if(ya[j]._id === ya2._id) { + isInY = true; + break; + } + } + editY = editY && isInY; + } var xScaleFactor = editX ? xa2._length / viewBox[2] : 1, yScaleFactor = editY ? ya2._length / viewBox[3] : 1; diff --git a/src/plots/cartesian/set_convert.js b/src/plots/cartesian/set_convert.js index 565c4ce53b3..b5e1b0258e4 100644 --- a/src/plots/cartesian/set_convert.js +++ b/src/plots/cartesian/set_convert.js @@ -64,7 +64,7 @@ module.exports = function setConvert(ax) { ax.p2d = function(v) { return ax.l2d(ax.p2l(v)); }; // set scaling to pixels - ax.setScale = function() { + ax.setScale = function(usePrivateRange) { var gs = ax._gd._fullLayout._size, i; @@ -78,23 +78,29 @@ module.exports = function setConvert(ax) { ax.domain = ax2.domain; } + // While transitions are occuring, occurring, we get a double-transform + // issue if we transform the drawn layer *and* use the new axis range to + // draw the data. This allows us to construct setConvert using the pre- + // interaction values of the range: + var range = (usePrivateRange && ax._r) ? ax._r : ax.range; + // make sure we have a range (linearized data values) // and that it stays away from the limits of javascript numbers - if(!ax.range || ax.range.length !== 2 || ax.range[0] === ax.range[1]) { - ax.range = [-1, 1]; + if(!range || range.length !== 2 || range[0] === range[1]) { + range = [-1, 1]; } for(i = 0; i < 2; i++) { - if(!isNumeric(ax.range[i])) { - ax.range[i] = isNumeric(ax.range[1 - i]) ? - (ax.range[1 - i] * (i ? 10 : 0.1)) : + if(!isNumeric(range[i])) { + range[i] = isNumeric(range[1 - i]) ? + (range[1 - i] * (i ? 10 : 0.1)) : (i ? 1 : -1); } - if(ax.range[i] < -(Number.MAX_VALUE / 2)) { - ax.range[i] = -(Number.MAX_VALUE / 2); + if(range[i] < -(Number.MAX_VALUE / 2)) { + range[i] = -(Number.MAX_VALUE / 2); } - else if(ax.range[i] > Number.MAX_VALUE / 2) { - ax.range[i] = Number.MAX_VALUE / 2; + else if(range[i] > Number.MAX_VALUE / 2) { + range[i] = Number.MAX_VALUE / 2; } } @@ -102,14 +108,14 @@ module.exports = function setConvert(ax) { if(ax._id.charAt(0) === 'y') { ax._offset = gs.t + (1 - ax.domain[1]) * gs.h; ax._length = gs.h * (ax.domain[1] - ax.domain[0]); - ax._m = ax._length / (ax.range[0] - ax.range[1]); - ax._b = -ax._m * ax.range[1]; + ax._m = ax._length / (range[0] - range[1]); + ax._b = -ax._m * range[1]; } else { ax._offset = gs.l + ax.domain[0] * gs.w; ax._length = gs.w * (ax.domain[1] - ax.domain[0]); - ax._m = ax._length / (ax.range[1] - ax.range[0]); - ax._b = -ax._m * ax.range[0]; + ax._m = ax._length / (range[1] - range[0]); + ax._b = -ax._m * range[0]; } if(!isFinite(ax._m) || !isFinite(ax._b)) { diff --git a/test/jasmine/karma.conf.js b/test/jasmine/karma.conf.js index b689af3019a..eb5c2d9ba1d 100644 --- a/test/jasmine/karma.conf.js +++ b/test/jasmine/karma.conf.js @@ -48,7 +48,10 @@ func.defaultConfig = { // N.B. this field is filled below files: [], - exclude: [], + exclude: [ + 'tests/gl_plot_interact_test.js', + 'tests/mapbox_test.js', + ], // preprocess matching files before serving them to the browser // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor From f46e5fbb4b030d59b75e3f19a46dd2115770d183 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 9 Aug 2016 16:12:00 -0400 Subject: [PATCH 06/45] Attempt simultaneous axis and data transitions --- src/lib/index.js | 2 +- src/plot_api/plot_api.js | 95 +++++++++------ src/plots/cartesian/transition_axes.js | 141 ++++++++++++----------- src/plots/plots.js | 41 ++++--- test/image/mocks/animation.json | 85 ++++++++++++++ test/jasmine/tests/animate_test.js | 87 +------------- test/jasmine/tests/compute_frame_test.js | 12 +- test/jasmine/tests/frame_api_test.js | 8 +- test/jasmine/tests/transition_test.js | 53 +++++++++ 9 files changed, 308 insertions(+), 216 deletions(-) create mode 100644 test/image/mocks/animation.json create mode 100644 test/jasmine/tests/transition_test.js diff --git a/src/lib/index.js b/src/lib/index.js index b3acba1c613..d4f50b07c2c 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -666,7 +666,7 @@ lib.numSeparate = function(value, separators, separatethousands) { * expand properties. * * @param {object} frameLookup - * An object containing frames keyed by name (i.e. gd._frameData._frameHash) + * An object containing frames keyed by name (i.e. gd._transitionData._frameHash) * @param {string} frame * The name of the keyframe to be computed * diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 3512c379aaa..5bd72466bc8 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -9,6 +9,7 @@ 'use strict'; + var d3 = require('d3'); var m4FromQuat = require('gl-mat4/fromQuat'); var isNumeric = require('fast-isnumeric'); @@ -2568,6 +2569,7 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { var transitionedTraces = []; function prepareTransitions() { + var plotinfo, i; for(i = 0; i < traceIndices.length; i++) { var traceIdx = traceIndices[i]; var trace = gd._fullData[traceIdx]; @@ -2591,30 +2593,46 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { Lib.extendDeepNoArrays(gd.data[traceIndices[i]], update); } - Plots.supplyDataDefaults(gd.data, gd._fullData, gd._fullLayout); + // Supply defaults after applying the incoming properties. Note that any attempt + // to simplify this step and reduce the amount of work resulted in the reconstruction + // of essentially the whole supplyDefaults step, so that it seems sensible to just use + // supplyDefaults even though it's heavier than would otherwise be desired for + // transitions: + Plots.supplyDefaults(gd); - // TODO: Add logic that computes transitionedTraces to avoid unnecessary work while - // still handling things like box plots that are interrelated. - // doCalcdata(gd, transitionedTraces); + //Plotly.Axes.saveRangeInitial(gd, true); + + // This step fies the .xaxis and .yaxis references that otherwise + // aren't updated by the supplyDefaults step: + var subplots = Plotly.Axes.getSubplots(gd); + for(i = 0; i < subplots.length; i++) { + plotinfo = gd._fullLayout._plots[subplots[i]]; + plotinfo.xaxis = plotinfo.x(); + plotinfo.yaxis = plotinfo.y(); + } doCalcdata(gd); ErrorBars.calc(gd); + } - // While transitions are occuring, occurring, we get a double-transform - // issue if we transform the drawn layer *and* use the new axis range to - // draw the data. This causes setConvert to use the pre-interaction values - // of the axis range: - var axList = Plotly.Axes.list(gd); - for(i = 0; i < axList.length; i++) { - axList[i].setScale(true); + function executeCallbacks(list) { + var p = Promise.resolve(); + if (!list) return p; + while(list.length) { + p = p.then((list.shift())); } + return p; } - var restyleList = []; - var completionTimeout = null; - var resolveTransitionCallback = null; + function flushCallbacks(list) { + if (!list) return; + while(list.length) { + list.shift(); + } + } + var restyleList = []; function executeTransitions() { var hasTraceTransition = false; var j; @@ -2637,30 +2655,33 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { } } + gd._transitionData._completionTimeout = setTimeout(completeTransition, transitionConfig.duration); + if(!hasAxisTransition && !hasTraceTransition) { return false; } + } - return new Promise(function(resolve) { - resolveTransitionCallback = resolve; - completionTimeout = setTimeout(resolve, transitionConfig.duration); - }); + function completeTransition() { + flushCallbacks(gd._transitionData._interruptCallbacks); + + gd.emit('plotly_endtransition', []); + + return executeCallbacks(gd._transitionData._cleanupCallbacks); } function interruptPreviousTransitions() { - clearTimeout(completionTimeout); - - if(resolveTransitionCallback) { - resolveTransitionCallback(); - } + if (gd._transitionData._completionTimeout) { + // Prevent the previous completion from occurring: + clearTimeout(gd._transitionData._completionTimeout); + gd._transitionData._completionTimeout = null; - while(gd._frameData._layoutInterrupts.length) { - (gd._frameData._layoutInterrupts.pop())(); + // Interrupt an event to indicate that a transition was running: + gd.emit('plotly_interrupttransition', []); } - while(gd._frameData._styleInterrupts.length) { - (gd._frameData._styleInterrupts.pop())(); - } + flushCallbacks(gd._transitionData._cleanupCallbacks); + return executeCallbacks(gd._transitionData._interruptCallbacks); } for(i = 0; i < traceIndices.length; i++) { @@ -2677,23 +2698,23 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { thisUpdate[ai] = [data[i][ai]]; } - restyleList.push((function(md, data, traces) { + /*restyleList.push((function(md, data, traces) { return function() { return Plotly.restyle(gd, data, traces); }; - }(module, thisUpdate, [traceIdx]))); + }(module, thisUpdate, [traceIdx])));*/ } } var seq = [Plots.previousPromises, interruptPreviousTransitions, prepareTransitions, executeTransitions]; - seq = seq.concat(restyleList); + //seq = seq.concat(restyleList); var plotDone = Lib.syncOrAsync(seq, gd); if(!plotDone || !plotDone.then) plotDone = Promise.resolve(); return plotDone.then(function() { - gd.emit('plotly_beginanimate', []); + gd.emit('plotly_begintransition', []); return gd; }); }; @@ -2709,7 +2730,7 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { Plotly.animate = function(gd, frameName, transitionConfig) { gd = getGraphDiv(gd); - if(!gd._frameData._frameHash[frameName]) { + if(!gd._transitionData._frameHash[frameName]) { Lib.warn('animateToFrame failure: keyframe does not exist', frameName); return Promise.reject(); } @@ -2739,8 +2760,8 @@ Plotly.addFrames = function(gd, frameList, indices) { gd = getGraphDiv(gd); var i, frame, j, idx; - var _frames = gd._frameData._frames; - var _hash = gd._frameData._frameHash; + var _frames = gd._transitionData._frames; + var _hash = gd._transitionData._frameHash; if(!Array.isArray(frameList)) { @@ -2780,7 +2801,7 @@ Plotly.addFrames = function(gd, frameList, indices) { if(!frame.name) { // Repeatedly assign a default name, incrementing the counter each time until // we get a name that's not in the hashed lookup table: - while(_hash[(frame.name = 'frame ' + gd._frameData._counter++)]); + while(_hash[(frame.name = 'frame ' + gd._transitionData._counter++)]); } if(_hash[frame.name]) { @@ -2820,7 +2841,7 @@ Plotly.deleteFrames = function(gd, frameList) { gd = getGraphDiv(gd); var i, idx; - var _frames = gd._frameData._frames; + var _frames = gd._transitionData._frames; var ops = []; var revops = []; diff --git a/src/plots/cartesian/transition_axes.js b/src/plots/cartesian/transition_axes.js index 96aa8b8eca7..5994d208fc9 100644 --- a/src/plots/cartesian/transition_axes.js +++ b/src/plots/cartesian/transition_axes.js @@ -14,7 +14,6 @@ var d3 = require('d3'); var Plotly = require('../../plotly'); var Lib = require('../../lib'); var Axes = require('./axes'); - var axisRegex = /((x|y)([2-9]|[1-9][0-9]+)?)axis$/; module.exports = function transitionAxes(gd, newLayout, transitionConfig) { @@ -29,8 +28,9 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { attrList = ai.split('.'); match = attrList[0].match(axisRegex); if(match) { - var axisName = match[1]; - axis = fullLayout[axisName + 'axis']; + var axisLetter = match[1]; + var axisName = axisLetter + 'axis'; + axis = fullLayout[axisName]; update = {}; if(Array.isArray(layout[ai])) { @@ -42,12 +42,12 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { } if(!update.to) continue; - update.axis = axis; + update.axisName = axisName; update.length = axis._length; - axes.push(axisName); + axes.push(axisLetter); - updates[axisName] = update; + updates[axisLetter] = update; } } @@ -69,6 +69,11 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { var y = plotinfo.yaxis._id; var fromX = plotinfo.xaxis.range; var fromY = plotinfo.yaxis.range; + + // Store the initial range at the beginning of this transition: + plotinfo.xaxis._r = plotinfo.xaxis.range.slice(); + plotinfo.yaxis._r = plotinfo.yaxis.range.slice(); + if(updates[x]) { toX = updates[x].to; } else { @@ -128,33 +133,19 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { var xa2 = subplot.x(); var ya2 = subplot.y(); - var viewBox = [0, 0, xa2._length, ya2._length]; - - var xScaleFactor = xa2._length / viewBox[2], - yScaleFactor = ya2._length / viewBox[3]; - - var clipDx = viewBox[0], - clipDy = viewBox[1]; - - var fracDx = (viewBox[0] / viewBox[2] * xa2._length), - fracDy = (viewBox[1] / viewBox[3] * ya2._length); - - var plotDx = xa2._offset - fracDx, - plotDy = ya2._offset - fracDy; - fullLayout._defs.selectAll('#' + subplot.clipId) - .call(Lib.setTranslate, clipDx, clipDy) - .call(Lib.setScale, 1 / xScaleFactor, 1 / yScaleFactor); + .call(Lib.setTranslate, 0, 0) + .call(Lib.setScale, 1, 1); subplot.plot - .call(Lib.setTranslate, plotDx, plotDy) - .call(Lib.setScale, xScaleFactor, yScaleFactor) + .call(Lib.setTranslate, xa2._offset, ya2._offset) + .call(Lib.setScale, 1, 1) // This is specifically directed at scatter traces, applying an inverse // scale to individual points to counteract the scale of the trace // as a whole: .selectAll('.points').selectAll('.point') - .call(Lib.setPointGroupScale, 1 / xScaleFactor, 1 / yScaleFactor); + .call(Lib.setPointGroupScale, 1, 1); } @@ -166,7 +157,7 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { var viewBox = []; if(xUpdate) { - axis = xUpdate.axis; + axis = gd._fullLayout[xUpdate.axisName]; r0 = axis._r; r1 = xUpdate.to; viewBox[0] = (r0[0] * (1 - progress) + progress * r1[0] - r0[0]) / (r0[1] - r0[0]) * subplot.xaxis._length; @@ -183,7 +174,7 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { } if(yUpdate) { - axis = yUpdate.axis; + axis = gd._fullLayout[yUpdate.axisName]; r0 = axis._r; r1 = yUpdate.to; viewBox[1] = (r0[1] * (1 - progress) + progress * r1[1] - r0[1]) / (r0[0] - r0[1]) * subplot.yaxis._length; @@ -236,60 +227,78 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { } - // transitionTail - finish a drag event with a redraw - function transitionTail() { - var i; + + function transitionComplete() { var attrs = {}; - // revert to the previous axis settings, then apply the new ones - // through relayout - this lets relayout manage undo/redo - for(i = 0; i < updatedAxisIds.length; i++) { - var axi = updates[updatedAxisIds[i]].axis; - if(axi._r[0] !== axi.range[0]) attrs[axi._name + '.range[0]'] = axi.range[0]; - if(axi._r[1] !== axi.range[1]) attrs[axi._name + '.range[1]'] = axi.range[1]; + for(var i = 0; i < updatedAxisIds.length; i++) { + var axi = gd._fullLayout[updates[updatedAxisIds[i]].axisName]; + var to = updates[updatedAxisIds[i]].to; + attrs[axi._name + '.range[0]'] = to[0]; + attrs[axi._name + '.range[1]'] = to[1]; - axi.range = axi._r.slice(); + axi.range = to.slice(); } - for(i = 0; i < affectedSubplots.length; i++) { - unsetSubplotTransform(affectedSubplots[i]); + return Plotly.relayout(gd, attrs).then(function () { + for(var i = 0; i < affectedSubplots.length; i++) { + unsetSubplotTransform(affectedSubplots[i]); + } + + }); + } + + function transitionTail() { + var attrs = {}; + for(var i = 0; i < updatedAxisIds.length; i++) { + var axi = gd._fullLayout[updatedAxisIds[i] + 'axis']; + attrs[axi._name + '.range[0]'] = axi.range[0]; + attrs[axi._name + '.range[1]'] = axi.range[1]; + + axi.range = axi._r.slice(); } - Plotly.relayout(gd, attrs); + return Plotly.relayout(gd, attrs).then(function() { + for(var i = 0; i < affectedSubplots.length; i++) { + unsetSubplotTransform(affectedSubplots[i]); + } + }); } var easeFn = d3.ease(transitionConfig.ease); - return new Promise(function(resolve, reject) { - var t1, t2, raf; + gd._transitionData._interruptCallbacks.push(function() { + cancelAnimationFrame(raf); + raf = null; + return transitionTail(); + }); - gd._frameData._layoutInterrupts.push(function() { - reject(); - cancelAnimationFrame(raf); - raf = null; - transitionTail(); - }); + gd._transitionData._cleanupCallbacks.push(function () { + cancelAnimationFrame(raf); + raf = null; + return transitionComplete(); + }); - function doFrame() { - t2 = Date.now(); + var t1, t2, raf; - var tInterp = Math.min(1, (t2 - t1) / transitionConfig.duration); - var progress = easeFn(tInterp); + function doFrame() { + t2 = Date.now(); - for(var i = 0; i < affectedSubplots.length; i++) { - updateSubplot(affectedSubplots[i], progress); - } + var tInterp = Math.min(1, (t2 - t1) / transitionConfig.duration); + var progress = easeFn(tInterp); - if(t2 - t1 > transitionConfig.duration) { - raf = cancelAnimationFrame(doFrame); - transitionTail(); - resolve(); - } else { - raf = requestAnimationFrame(doFrame); - resolve(); - } + for(var i = 0; i < affectedSubplots.length; i++) { + updateSubplot(affectedSubplots[i], progress); } - t1 = Date.now(); - raf = requestAnimationFrame(doFrame); - }); + if(t2 - t1 > transitionConfig.duration) { + raf = cancelAnimationFrame(doFrame); + } else { + raf = requestAnimationFrame(doFrame); + } + } + + t1 = Date.now(); + raf = requestAnimationFrame(doFrame); + + return Promise.resolve(); }; diff --git a/src/plots/plots.js b/src/plots/plots.js index 99768857df5..528279ecf05 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -420,29 +420,38 @@ plots.supplyDefaults = function(gd) { } } + // Create all the storage space for frames, but only if doesn't already + // exist: + if(!gd._transitionData) { + plots.createTransitionData(gd); + } +}; + +// Create storage for all of the data related to frames and transitions: +plots.createTransitionData = function(gd) { // Set up the default keyframe if it doesn't exist: - if(!gd._frameData) { - gd._frameData = {}; + if(!gd._transitionData) { + gd._transitionData = {}; } - if(!gd._frameData._frames) { - gd._frameData._frames = []; + if(!gd._transitionData._frames) { + gd._transitionData._frames = []; } - if(!gd._frameData._frameHash) { - gd._frameData._frameHash = {}; + if(!gd._transitionData._frameHash) { + gd._transitionData._frameHash = {}; } - if(!gd._frameData._counter) { - gd._frameData._counter = 0; + if(!gd._transitionData._counter) { + gd._transitionData._counter = 0; } - if(!gd._frameData._layoutInterrupts) { - gd._frameData._layoutInterrupts = []; + if(!gd._transitionData._cleanupCallbacks) { + gd._transitionData._cleanupCallbacks = []; } - if(!gd._frameData._styleInterrupts) { - gd._frameData._styleInterrupts = []; + if(!gd._transitionData._interruptCallbacks) { + gd._transitionData._interruptCallbacks = []; } }; @@ -850,7 +859,7 @@ plots.purge = function(gd) { delete gd.numboxes; delete gd._hoverTimer; delete gd._lastHoverTime; - delete gd._frameData; + delete gd._transitionData; // remove all event listeners if(gd.removeAllListeners) gd.removeAllListeners(); @@ -1124,8 +1133,8 @@ plots.graphJson = function(gd, dataonly, mode, output, useDefaults) { */ plots.modifyFrames = function(gd, operations) { var i, op, frame; - var _frames = gd._frameData._frames; - var _hash = gd._frameData._frameHash; + var _frames = gd._transitionData._frames; + var _hash = gd._transitionData._frameHash; for(i = 0; i < operations.length; i++) { op = operations[i]; @@ -1178,5 +1187,5 @@ plots.modifyFrames = function(gd, operations) { * Returns: a new object with the merged content */ plots.computeFrame = function(gd, frameName) { - return Lib.computeFrame(gd._frameData._frameHash, frameName); + return Lib.computeFrame(gd._transitionData._frameHash, frameName); }; diff --git a/test/image/mocks/animation.json b/test/image/mocks/animation.json new file mode 100644 index 00000000000..34cb8e737cb --- /dev/null +++ b/test/image/mocks/animation.json @@ -0,0 +1,85 @@ +{ + "data": [ + { + "x": [0, 1, 2], + "y": [0, 2, 8], + "type": "scatter" + }, + { + "x": [0, 1, 2], + "y": [4, 2, 3], + "type": "scatter" + } + ], + "layout": { + "title": "Animation test", + "showlegend": true, + "autosize": false, + "xaxis": { + "range": [0, 2], + "domain": [0, 1] + }, + "yaxis": { + "range": [0, 10], + "domain": [0, 1] + } + }, + "frames": [{ + "name": "base", + "data": [ + {"y": [0, 2, 8]}, + {"y": [4, 2, 3]} + ], + "layout": { + "xaxis": { + "range": [0, 2] + }, + "yaxis": { + "range": [0, 10] + } + } + }, { + "name": "frame0", + "data": [ + {"y": [0.5, 1.5, 7.5]}, + {"y": [4.25, 2.25, 3.05]} + ], + "baseFrame": "base", + "traceIndices": [0, 1], + "layout": { } + }, { + "name": "frame1", + "data": [ + {"y": [2.1, 1, 7]}, + {"y": [4.5, 2.5, 3.1]} + ], + "baseFrame": "base", + "traceIndices": [0, 1], + "layout": { } + }, { + "name": "frame2", + "data": [ + {"y": [3.5, 0.5, 6]}, + {"y": [5.7, 2.7, 3.9]} + ], + "baseFrame": "base", + "traceIndices": [0, 1], + "layout": { } + }, { + "name": "frame3", + "data": [ + {"y": [5.1, 0.25, 5]}, + {"y": [7, 2.9, 6]} + ], + "baseFrame": "base", + "traceIndices": [0, 1], + "layout": { + "xaxis": { + "range": [-1, 4] + }, + "yaxis": { + "range": [-5, 15] + } + } + }] +} diff --git a/test/jasmine/tests/animate_test.js b/test/jasmine/tests/animate_test.js index 9a8c3b4a444..9eb59cd7a6e 100644 --- a/test/jasmine/tests/animate_test.js +++ b/test/jasmine/tests/animate_test.js @@ -6,91 +6,7 @@ var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var fail = require('../assets/fail_test'); -var mock = { - 'data': [ - { - 'x': [0, 1, 2], - 'y': [0, 2, 8], - 'type': 'scatter' - }, - { - 'x': [0, 1, 2], - 'y': [4, 2, 3], - 'type': 'scatter' - } - ], - 'layout': { - 'title': 'Animation test', - 'showlegend': true, - 'autosize': false, - 'xaxis': { - 'range': [0, 2], - 'domain': [0, 1] - }, - 'yaxis': { - 'range': [0, 10], - 'domain': [0, 1] - } - }, - 'frames': [{ - 'name': 'base', - 'data': [ - {'y': [0, 2, 8]}, - {'y': [4, 2, 3]} - ], - 'layout': { - 'xaxis': { - 'range': [0, 2] - }, - 'yaxis': { - 'range': [0, 10] - } - } - }, { - 'name': 'frame0', - 'data': [ - {'y': [0.5, 1.5, 7.5]}, - {'y': [4.25, 2.25, 3.05]} - ], - 'baseFrame': 'base', - 'traceIndices': [0, 1], - 'layout': { } - }, { - 'name': 'frame1', - 'data': [ - {'y': [2.1, 1, 7]}, - {'y': [4.5, 2.5, 3.1]} - ], - 'baseFrame': 'base', - 'traceIndices': [0, 1], - 'layout': { } - }, { - 'name': 'frame2', - 'data': [ - {'y': [3.5, 0.5, 6]}, - {'y': [5.7, 2.7, 3.9]} - ], - 'baseFrame': 'base', - 'traceIndices': [0, 1], - 'layout': { } - }, { - 'name': 'frame3', - 'data': [ - {'y': [5.1, 0.25, 5]}, - {'y': [7, 2.9, 6]} - ], - 'baseFrame': 'base', - 'traceIndices': [0, 1], - 'layout': { - 'xaxis': { - 'range': [-1, 4] - }, - 'yaxis': { - 'range': [-5, 15] - } - } - }] -}; +var mock = require('@mocks/animation'); describe('Test animate API', function() { 'use strict'; @@ -100,7 +16,6 @@ describe('Test animate API', function() { beforeEach(function(done) { gd = createGraphDiv(); - //var mock = require('@mocks/animation'; var mockCopy = Lib.extendDeep({}, mock); spyOn(PlotlyInternal, 'transition').and.callFake(function() { diff --git a/test/jasmine/tests/compute_frame_test.js b/test/jasmine/tests/compute_frame_test.js index e2d6b026c96..a77d4b77e1a 100644 --- a/test/jasmine/tests/compute_frame_test.js +++ b/test/jasmine/tests/compute_frame_test.js @@ -67,7 +67,7 @@ describe('Test mergeFrames', function() { it('leaves the frame unaffected', function() { computeFrame(gd, 'frame1'); - expect(gd._frameData._frameHash.frame1).toEqual(frame1); + expect(gd._transitionData._frameHash.frame1).toEqual(frame1); }); }); @@ -149,7 +149,7 @@ describe('Test mergeFrames', function() { // Verify that the frames are untouched (by value, at least, but they should // also be unmodified by identity too) by the computation: - expect(gd._frameData._frames).toEqual(frames); + expect(gd._transitionData._frames).toEqual(frames); }); it('merges overlapping traces', function() { @@ -162,7 +162,7 @@ describe('Test mergeFrames', function() { data: [{marker: {size: 0}}] }); - expect(gd._frameData._frames).toEqual(frames); + expect(gd._transitionData._frames).toEqual(frames); }); it('merges partially overlapping traces', function() { @@ -180,7 +180,7 @@ describe('Test mergeFrames', function() { ] }); - expect(gd._frameData._frames).toEqual(frames); + expect(gd._transitionData._frames).toEqual(frames); }); it('assumes serial order without traceIndices specified', function() { @@ -198,7 +198,7 @@ describe('Test mergeFrames', function() { ] }); - expect(gd._frameData._frames).toEqual(frames); + expect(gd._transitionData._frames).toEqual(frames); }); }); @@ -230,7 +230,7 @@ describe('Test mergeFrames', function() { it('leaves the frame unaffected', function() { computeFrame(gd, 'frame0'); - expect(gd._frameData._frames).toEqual(frameCopies); + expect(gd._transitionData._frames).toEqual(frameCopies); }); }); }); diff --git a/test/jasmine/tests/frame_api_test.js b/test/jasmine/tests/frame_api_test.js index b105d63f1fb..a37174b5adc 100644 --- a/test/jasmine/tests/frame_api_test.js +++ b/test/jasmine/tests/frame_api_test.js @@ -13,8 +13,8 @@ describe('Test frame api', function() { mock = [{x: [1, 2, 3], y: [2, 1, 3]}, {x: [1, 2, 3], y: [6, 4, 5]}]; gd = createGraphDiv(); Plotly.plot(gd, mock).then(function() { - f = gd._frameData._frames; - h = gd._frameData._frameHash; + f = gd._transitionData._frames; + h = gd._transitionData._frameHash; }).then(function() { Plotly.setPlotConfig({ queueLength: 10 }); }).then(done); @@ -27,11 +27,11 @@ describe('Test frame api', function() { describe('gd initialization', function() { it('creates an empty list for frames', function() { - expect(gd._frameData._frames).toEqual([]); + expect(gd._transitionData._frames).toEqual([]); }); it('creates an empty lookup table for frames', function() { - expect(gd._frameData._counter).toEqual(0); + expect(gd._transitionData._counter).toEqual(0); }); }); diff --git a/test/jasmine/tests/transition_test.js b/test/jasmine/tests/transition_test.js new file mode 100644 index 00000000000..7373b84004b --- /dev/null +++ b/test/jasmine/tests/transition_test.js @@ -0,0 +1,53 @@ +var Plotly = require('@lib/index'); +var PlotlyInternal = require('@src/plotly'); +var Lib = require('@src/lib'); + +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var fail = require('../assets/fail_test'); + +var mock = require('@mocks/animation'); + +describe('Transition API', function() { + 'use strict'; + + var gd; + + beforeEach(function(done) { + gd = createGraphDiv(); + + var mockCopy = Lib.extendDeep({}, mock); + + Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(done); + }); + + afterEach(function() { + destroyGraphDiv(); + }); + + it('resolves without waiting for transition to complete', function(done) { + var t1 = Date.now(); + var duration = 100; + Plotly.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {duration: duration}).then(function() { + var t2 = Date.now(); + expect(t2 - t1).toBeLessThan(duration); + }).catch(fail).then(done); + }); + + it('emits plotly_begintransition on transition start', function(done) { + var beginTransitionCnt = 0; + + gd.on('plotly_begintransition', function () { + beginTransitionCnt++; + }); + + Plotly.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {duration: 0}).then(function() { + expect(beginTransitionCnt).toBe(1); + }).catch(fail).then(done); + }); + + it('emits plotly_endtransition on transition end', function(done) { + Plotly.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {duration: 50}); + gd.on('plotly_endtransition', done); + }); +}); From 981c1a88df301fc97410f2a64307834f596f9a7f Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 11 Aug 2016 11:51:20 -0400 Subject: [PATCH 07/45] Fall back to disallowing simultaneous data + axis transitions --- src/plot_api/plot_api.js | 19 +++++++++++++++---- src/plots/cartesian/transition_axes.js | 7 +++---- test/image/baselines/animation.png | Bin 0 -> 22858 bytes test/jasmine/tests/transition_test.js | 3 +-- 4 files changed, 19 insertions(+), 10 deletions(-) create mode 100644 test/image/baselines/animation.png diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 5bd72466bc8..ee4297e4e12 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2618,7 +2618,7 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { function executeCallbacks(list) { var p = Promise.resolve(); - if (!list) return p; + if(!list) return p; while(list.length) { p = p.then((list.shift())); } @@ -2626,14 +2626,14 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { } function flushCallbacks(list) { - if (!list) return; + if(!list) return; while(list.length) { list.shift(); } } - var restyleList = []; function executeTransitions() { + var traceTransitionConfig; var hasTraceTransition = false; var j; var basePlotModules = fullLayout._basePlotModules; @@ -2655,6 +2655,17 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { } } + if(hasAxisTransition) { + traceTransitionConfig = Lib.extendFlat({}, transitionConfig); + traceTransitionConfig.duration = 0; + } else { + traceTransitionConfig = transitionConfig; + } + + for(j = 0; j < basePlotModules.length; j++) { + basePlotModules[j].plot(gd, transitionedTraces, traceTransitionConfig); + } + gd._transitionData._completionTimeout = setTimeout(completeTransition, transitionConfig.duration); if(!hasAxisTransition && !hasTraceTransition) { @@ -2671,7 +2682,7 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { } function interruptPreviousTransitions() { - if (gd._transitionData._completionTimeout) { + if(gd._transitionData._completionTimeout) { // Prevent the previous completion from occurring: clearTimeout(gd._transitionData._completionTimeout); gd._transitionData._completionTimeout = null; diff --git a/src/plots/cartesian/transition_axes.js b/src/plots/cartesian/transition_axes.js index 5994d208fc9..7942a7ec8cc 100644 --- a/src/plots/cartesian/transition_axes.js +++ b/src/plots/cartesian/transition_axes.js @@ -239,7 +239,7 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { axi.range = to.slice(); } - return Plotly.relayout(gd, attrs).then(function () { + return Plotly.relayout(gd, attrs).then(function() { for(var i = 0; i < affectedSubplots.length; i++) { unsetSubplotTransform(affectedSubplots[i]); } @@ -264,6 +264,7 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { }); } + var t1, t2, raf; var easeFn = d3.ease(transitionConfig.ease); gd._transitionData._interruptCallbacks.push(function() { @@ -272,14 +273,12 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { return transitionTail(); }); - gd._transitionData._cleanupCallbacks.push(function () { + gd._transitionData._cleanupCallbacks.push(function() { cancelAnimationFrame(raf); raf = null; return transitionComplete(); }); - var t1, t2, raf; - function doFrame() { t2 = Date.now(); diff --git a/test/image/baselines/animation.png b/test/image/baselines/animation.png new file mode 100644 index 0000000000000000000000000000000000000000..627670db86e06a24abb057f2b1a81fd52f2905dd GIT binary patch literal 22858 zcmeIacTkke);CHLhbRbw1e5v5z`an;NXy{ zt6kB>!NEh};NY&q2*EF7h6iIfIIKA8R}^o+O@E{jCEVyOZ;*ry@^^+K!owpjDpKKl z-??&q%}oj*GrC2ym>vPO*{4*KLdNc%)C*4v zIJiCFhn|&g#^03Upxb2s*$J5CPAU%C?viQhgX0;Y$ra$$C;ymHgGvFe*>uhA57J?R zo4L7f0^+QkN&qY1D4oWX4_-Ax3wbTGI_;ztiEnj=5Qq@ zuvy>0#UKDq_Lv>C*#NNhZ_zrR@=RYM+83Qi6S8Km?g7f)t7g+NI_g zx3{KZW&#i90xg?k*+$p)u&elqYicQXp8cG%`bAH>WhTkYv|zn{ zcPYNFz^JVw@LL2@DkR$}ht0n5g53ptI- z(8<7^*B3^8m!GP#_}{1)utrXgUHs%#wLOcRi4?5=dKqrv7n%QXXR)+nIzdnCiS$wx z2jv9W>;C*qXEp*>9Werm(HDts$ zvToV^{hfs(tDW`v)$O^Aw$1uYS*gY1_w3bnozE5B_Zgf}Y_w*6E}WPlr-nMeHH@U| z^b^%qGRa}q4!K?X_05I8g7^{7A%35Xr+fyjN~dHwBWd{KxHMCnV*>Xqq;?levknX4 z`+((9qiYXN$%TI!^5{32Nov@8xz``KZ{Yq&y0G}pyO8^9Oyc(w82zyCy7c;Fe|}X8 zBV)L^y}35mk+i$qU z)ztULnL0C*COK31#0eLf{z4NYsYHgr?rin0L(>ZTlwtqP5fO_|q=u!|j~A=w(!1bH z(w_OKgYtgU__4!-P0Dypq=d5et5dS;I`bp7UUhXj3xyUHK!@prJKJkHPlCtGReZnq zywu7Es;t~vEz_pQ474p%{FTEcp;!UqDxtVhJ#AU>IKxftg(MSCL(W$XTOS3#pBc5r zq_%(s`A#xA2l;LE8gxmSNtzlOrtIzXH$)>km>xRYIQp)Cqr~J+&RD*+YD+8u9B#g8 zt#t{JV0kv7>(&yd@C@U+F6DMCPL%vBuaN*UhE5*7X6t)jZO`C*u-qPFi*9r9sqA5mfaUP0yP zZ9;NJ9t=`4DK{hFwyEFB$9t&dz{A~D@E_@&TG9549I07Udx854X1cn%?DE?!+TVYO zf2?HFdx%3oN~r)R3svW%D8Ah2Uz$qQ+QjU8A+7|-e1?eoH=XK*!diC2Jh+sLalK!) z3^=+c?l)1-sxDEYcE0B=oEd$8O5Ql^6pN|hCXkDBaieDY-wk@(`!B8dZqFof;N~yP zq^s0&qa;V+S?=@Q=W6UfCkuBii+5oAs7<-QEb-<^JL+tRo1+O_v~5_trzvfRt6XN{ zzo)LbX{V$`)i|{d`7YPlrM;lKu^EWBgj#FAuS%Z|pL`Tv?puR;PcD6>e2HjjJ~x9| zw!kxPufO3y{4R9W`@IQk3QY@!n9N_r7DU*k@ziKrzoKZai59EciO6j%^!&Lv&?Q+>eaUP3`Q%J?r)+ha zh}G3J)QH}VP0otRua^bTzF)4ZDr=XG=zV`9=wA7)B()~iNZ5x*!>V#I z)x<^8(taN(Rj+94rfuEy=Ql;&(vtpB_U-jr!0wMqWtKDjH(^E6trO9vUw*z>AS&{8 zJpigrS(W}x{j)mqM|FTRT}Kuv_PX zu(vf6>se!U@s^Nz&w{E0D~fhpwdn+FzP18u zu!xr8vYGJkXY^n1>XHoX-9YrGnRw1UvWxI#L@#8I5&3D3I@)__WKSk5ee|uKB6q6a zwqBp^PYd?c&Y&Dk2zV>(0HdbFMO22PbM#>dt>=@--eF0#UcbTl*?{?%Nh)K;v-OV( zV$loHr`8t-^maCv%^tb;g)IZZx=;Sjj8kJlSw{Pmv37pK}>8Br*z-+2?oEPH8(i*NJo z?NA@|Lb^z;ThS7#XqVgHi}XEaz#sj-`3Xl>46~accg_+If=X%gt<@4wSbS^hn&%jH zHrl1iftd|NjMIl^{-!2Jjq1;=uRd$wgQhAbDW)+7Fl5r zw9o!VzpAbw8*0hTX@rt&bGt%INDzfZe~WT(So37rpW*CI<0#xRHk0fNJlqU4c+m+f zVwm3OH;Xwq6}mg`u~h2GCpU{}sgho6XnMOGv_zyh2mA29j40OrwwoBnRX~!G1$W3TPM4g_4PXBS(cjV1>6*MA-lM zNdf#6guZkB4{*2w@ZywBYxo~O;R20{PyFEZAJFxKHqON33|@}QzX2}+z|dqoq&awh zQ1M^F{spT4%dy`c#s5ELY+oYkaBogt}9p>*Mrj>9|v1J#f+UAj@@vB0I5 zqwyp~)TZ^RfoY4$iQhLZ_B1f7;TLX~silh9vUkX=MTyz<AZ9}tqonZFNHe@M>HGw{Mf zqIR;L2>hje%%g+8Q5GlI9k{^@l%v~gb8Q&Ou?tjO8sUY5#pVyzzxVQ;Hu369;vk!^ zYKeb#4rvnS{+@+lf=oUu+oqX5R?Pg{N50e8Oy5g2h>X5Q!Y#Wjo$#5zU2Ltdhgd5BzrKb4%I; zYOgdg+^%$djHPKaPz#eASd7?JQGiEE2lmU_;Q(*ETM=hMfBFNJ=?NK*m-e12A?x<| z6B`6JucE{ob&|{O0yjS|oOVLLQB=3p+J;lDLxRFo+c~WD7CtF}x=<`40#5mV;$wD} zfUQjX;i~JNBOd*z9_NfGk0vIs@Lf9CV+P5@cx^dDpP!3#LoyVmC$1t4a68m^k!ep@ zWrq~tvhV25TaXD+TVK8Ha(C;);ckpyiEF{)kfZNFrHP;XXaI17N;XG*+uogIdFDkA z{EK|ldiOO~;5D?SirY=^t#u?l;ZSV_J}q6FF_>TFe=$Gt?d`k0m!u{N4QA@jncZyn z{C2p++i=NUaAPsee$;cw0W3siJ6QJG7&sEEt8Id96+@1EPIViJpHs#8c6N&z4sJU( z?0Xu%y&EcK|5c}5cGGwOc*R+pUfSVK)iX5P;}1@~$SvzP3#?l#>(T1cE~wjGsZmzzVG?Pfa)v?fLU{%c@ElM z-%m0NpnJ47mk}uNK8!5hW$Kf9-pzs+ETc2g8qb{3M7^V9z111M#qY_Ha~q!+oSMOD z@*0@=d-VDDdaQ8-FDUTsFZr7sTv{$&9IFlv zw{JaItts4dIAm#i=Ud)-6;3N)e3ps-)sOOkeIKcVt*M#Ux#@A6V}bH<1COYHZEGu_Yk2MlrT5R?1Bnp)MXLQ zmb_iJO`o|mFU#*=UPHdNH@J|MaCYB*6R(!_`JR{g`4?&e0Drmy$9xtFdYv-@cII6% z;{8{&qem8c4a}a}_T;P}B*!GJ9G07?Mk#c878$=T61O#UE=OZ&E(N0-65SCpNtP|V$^!5#UfudTp2J*sRmX+Oz22qu7Ull)A zkDMT(xsuUc3}2a43m>5jt#BA>{hT8Dk;%dN#e!T~rqfuxD1_8MxF&)SSZEV$km-|RMG-%P z72%~n(Up+qQ1qz?#`=Va-uOg+hv#!yS&40Y^H5B9{@yLi{(WW70r`p)?Nl-KpQFA` zQd{E>EEq(Gg5`D>Md@UH$`X>2+JHKW##)mIu=KN`wx2Wp^er9;P1Ei!Rf5NJF&=E? z<4)LE-Ve)5iX8UJZq9Y9w_D(DpplwB?ST}mTaO$5-=xm7fi~>w8BFsWR5uOW_o)7h zJ_ouoE|i3}6~Hdb)&yQy(WS1lCl%lph?MTkuZz0$#u_~;a0|SYN%LFyx{RhUy~DNg zYRugUGv$N*)&-h9_N*9~%uW>x7BOjaG0dXfS@{8j^t=y;>nN>B;3=Wm3R|$hJ zuO+EW4(4bg)htK!%s1PZLNBg;7M%fhCh~j3-fElLvyq-b0C!rdT&A-1^KKqN5LGU% zixY_3bJA|J8s6Vu=TeL=_GlbjMM!#o%51<#ntUTVCoPYVG_1Ehn0}Jm zc0b7ukp#sPO)1izO`<0whmw2~9@-|V(Ts)*;|u*oNmDWMa;Lxip{alVFEusM1ct37 z#54j+Lmf!r)NW0Ch4ciH0Bt0+{thKyaBN@rSyOu+iDPd%d>L%Ln-g9kugYIIrLvBsANakR@+gImT^8EmKj)#vp|MD zp&S*0LCv->Qri*Jf8=uB@9c%(4xJ)tj7$flE#;PPYLNYL4 z$)CF~NGD!crieiPr8ycw)Q$3XlLC5{J!py4Nq@qm#PSg-v&=1KpKoluyu44K_&9UG z*2K4#Lg`WGPjoiTjluXYRb!X|J}vma)3V-ZWa1F@gyuEf7EC-wK&ry_EZu~r($$vs zC*rJz^-<|&cuRUT0=0`J-xxXt>eh9=0Tg{hRD^siowYkBe`t91+O?Ul7BP^y(wFNs zDZZn)&Px_aD;SN~O0_F|0`zKx@BM{niHltx8SR~wPG(YjSpEjv_QxGmqkGory|rv8 zLvs;-N@UtRTvXyA*7}O&0bU=@8fDLOO1!;sBIVe>s{oDL!XQ_s{~_}4U?KYql*3X+ z{rBU4bmo5<_8*Ay|GkimlCHD^RkBn)$81>ZSs>;xcq5FIj-Ba}d-xlWLN#MB6mPOl zI>S{;%Z9Sf!qf=&1?68)sl}G$&NW*&1@O2PT^u%cqG7lUUr_e|*v)nF5qB#{qp&*+ zmOTOKnHGo25iZ~pNTTgl^4?`}OGux(7j~7xRLR_@0!=?oBrs#8<+&Vab^Xi77YorE z-jW}M{SlSv&kPD~pKI8g5uDj$EzWVvf&(>Sldw(-v^p1K`<5GR zQ37SWP2_H!p*qL>u%~p&_B&Vb#k275X+&tFQ|U>jKI)@>Yns*LAtdLET=8T;dS?Kn z)Igwocv@Wxs8RKriI~UOu#D^7qLyT8sm^DoYPoBcS_*y9LoQ7sbVIeJ36;Dg7OtP! z`JhNc@@z+jV85wvK@^L;DeytLfc{LJc>ho}qq`IMkO;{x6*6uEpaj9b6eH1^AA3{c*F{$X>qS>Y|E=fDKZ*(A3fI)t!CV$To^hroLphc|HA=Z~5e6>W0D=9@ zcg!nuT^^%_{6{y$I~|~7*){&|Bj>1bR&j7O@b}DJctk%1dywWwX{wy~SDnnQ;e{|C zf8f-r+Ge1dfgZ0H{!y$|@I&m@bQ%aUFaTBmpD_q~#C)`nv9O2Zm#AIpF!lW!^&y&< z5~*2{;UH{LMG1WO|A=a^j+pn!pgS9y97Squylaqmf0mzCfS-{hMCS$BWG7(S)nd~f zSW)TYq)*=xW=m>B3uQ}6XQXhT-)z{xwP5QQlr?X1~AAoB_?8{G~E4 z;i*J>2Xak-3sgl!oc-%j3_xOYVeBqF6F7>;e_OgG6dc9$SDLA2z%q}&u}qB%90lk5 z@?=$Ia1CPnGJf>8T^)dl-eoX7tp=y+W^>cyEy1cWbk{3=vb|}zlYQ%q2gx|?azf*>#-A-uDt{=%pX{KbZ+n&>eWbF3eh|}>+ z){hZlyj}kw>c>b52Wi;Su2=f?|jRV7$ipL!D#+-4-x)k@jq&NS5lBYsEh=cTXgH>b&kU>h><5|9f0K?mleS~NRhSo3SH#F>URL|=AB1K#% z!Gk7EG@tCLUL;ZJCiI@~9$KoL44*u*Q@s4yWQm3<<+M&Q`e z+#X$wx{`2@0p|1qt|wgeQT)!#cMp-J&~4Y{Hj*q-a{e(zNXJ>de606`C?28U01@JU zd>P8C2IwAf2?AT@{f==RdCLfYR=|{(gR6sPv)1>q+#BN^mMWu8Iw2{Tr4r5rCQO&9 z|M)%@H~^-%7=cAcbx!W8B=!ZhIQdiWhu_&mR8B=(b?J?)N?5%svAIjp3j_lCNUAE1=_P9tu~V2?iU+ zU7c=;w*u8HB6mN)psZDS9_6UaaAt6W@}NX*qf3sX56tO1d!#?w_BJ?t3g11_WOfMU znLB`V#?Hn9H^>UbmXz?zr+y?_27jLcQ2grkX$^5LJditk5*HVD@u6itHRV5GoEnq* z`ja{q^H@>P^l2sC5G1hi^O(x;zP`kSGL=t8^Su zD=OPvF#(()<8YHx0t9n zr}f-E?vJ`73#KREZ^l{FE}qrfB%K0!ZocXinA+6``2W)&nUNGJP@_gJgH=e853C0@ z7PmnXuU;)csyj9sDbr$@GoK+EKdqB*;g+3wEB>%>ITpIT&ViT5r6FE;y$8Y884z}U z!r)kyx%vhq{1iaVh#;s#y8_?78W$@mJ*l1!xGgi)ImY%uJV@IdwqIx-5DEmLPsx8} z@}q0Zxuh%F?tG>o&XNv=LVkX=IEARB@Up+NOgCvwdzQVYM-fz@BK6%e3vm62m2SRu zxlReNo+u*VV0*4D^C9zjRYn&CphLB=?&qLd5QC&+6qaU!Rs)l$G_)Ab*#KC%w@SV; za{1^SCz;UCE`v0iwHjJ>#$&8%Ho$412HIm zgqIe5lR-fGX80jRvj(_jpI^1}9ivhbI{ zS=(!LXm#h=4c+p!6oTD?q+j>Sk#45(DHRP7E(g;8`msR8BU)Fos?BARnTXeJS*Dkk zZgE-odL^z(7zJ4S*<(Z~2z0@!`iNsx%0F#wLty>}q{#G`%r3+WexM(Z7K%#VtI)_O z<@}x*7&Bt&-m!XX$DHD*&ggs`zykp8(%c0rwqF7I^U+y-&1qk*}(~YBk{(8MM%AGfCT_5Ei=yCgxCOWe6l4BNfaX zpeN-u$SCT$_=v~HK#Vf&(jf@G52-P-V>O0p0)w8mfI;zZXTRL&hVHRz#DhpS9c~Ci zxE<@PcxYXbA+WB*U_Qe&S4buF34&oByHBY?21e!*cAb1ZXlFvLl=IS6A67~YpWrN@N82TZq#}9%P4xp9#jtvS1 za7pnxusSpzd)L?GATCuovBm~M3rQU<^u?xBwEL1nI1Jt95PM`Nm*`;BVE|H$M;~n( z;{gUpIQU%yRoFM1t;o*)0>!Y~O-yY^sHZ}imx2u^SOy|3_L#J4AjY?I9zzz;@u7Vm z&an^IVn>pw(zvtvRa0g2!bxUO+@uN!!IL=NF%qceyt3;gxQL`7_8$6i&i#%r6t}1Qp6q@%fp~ zV~Su!ztg~Z4v?@;pfZ+t=l0zNl}vhw3A$B*1X>=$bY=MCe+{P+o$x-l3HEmlYp}5#{WS*FYuS2u-axp7&9XI-~ag~DoWd2%{xe=fd~5h>wo-v zRsL+Q(6IaBsO!BsPu`{Q*GkVXLNoo6so!jRS(abHLCVnQcMhtjuvOJy!21XhZh3r5 ze-kcXWwpU%euqrWFj=GW9<=JW<5htS{HhyBdQqYwuNETCs%$RO`vSBXoCi^(|LHRu zUu`YfrY7tQNvx(`@XAqIv+RqVaqxzmLIzH ze8;!`cTQViyyO>1x7mNdc?%zpJpQ%fb7E+$^nZ-)EaA(+JoPm*J&RfDTxGJfUv3A; zylQlygph!qt0zQ(3<%$3A`q$n;p`jVvHGQ=t0N6mL>Z2ed1bzJ3w`VvE`bn*cMwZp z|FIVx!(I+j>2jW@>i)Ir-pG=CsN6&RyZ}N$5qvy1BF6x{#}%2sxP#2kiwkO-%tHurJ#a9*6AaV6@jJy z6$mkAZkv=t2VrN0Wk!kdgrRAK{$rZg7ako=gPw9=r4WUtq5k)2&{pcq<_d5s@|X!W zs36h+_t;hVW2F!)brxs{X-pLA(ySTF9Kq?sF~kiW;KU2iO`WIp`H-` zRZ&ot+_vhCz@2z3B@^~QAsterpfnQ7H2&ty-3!RQKGn(e#X{pcEzkSc&&Th0#slap zT(7jR^pT@i%XSOD#OC8u3LZ}I zog&M>f7e<9M5{Htr4%5r{Ol)D^8@NW9u9eoy+zN&8-XWF(F{4xij4pMT|~(n)w96* z)5!K><|xBb^=!y=5bC@Ht7_mLH~vQ?NY(In;ILyCoPIxt%vDwj956Q}-oJB-vF-9D z=w#0_Cn>Q~0A{ko0M9|a4chLXav-UMP*1>244aHVr~*{ldExyn&}Be+R!9K~hC`Lq zH)ar>#bbtQPKg)zO~U^8RF85M`V>h}O^7!EecC(?x;T=&M!i)l zMt$t&LE`NPdI)Z`owF=9u(hvc7}b34ChXz_FF${IH@W|`+MSUakL$`)pSJfG`x$dn z-n8X@-nmt{Jted4it(B6tVuEU(x`U7HzM|x`4)c2V=gBuK3?9IA>S1h5Ly@|T?Ccd z=Rr*Pbrh5IO(+wv{q9tNPAxV-x;@9C)pANe7W_GBe-MhZJC?@#$`~|V6n}lm`U;U# z6|lc^Z(8Ls{ke7WkD~ch!*&@+K`r>mLb8B#>qq$^(Vb@_v1a+WY`O6n)hnGG^3Teb zL$KDY7ok!=P~ZY`xB{*qQQ60jD)@QYu=4hF`ki$W#n%MVNbJtZ zr-|C;&0pB;6u2($$r7Le;+>6Ea?pm05<6j5D90JY9|rOwO}6OHNhu-vV;oasguW~$ zi#IMaKPSf928L!g|l^+-_7SJk8nV7y(zd;bhfmyiiVH&c1Q8 zqhxSza>gC%O4RO&o|;&Z7gs(7=7I~=g-GYS;j34Qh-5dg!lxd zDZ#BLxAwr^Rzx*>!eLG$w!OdO*r}74`-S zp=d+=3;xPIkyg;CksB~UynMBbZeWzPN@9@$yy05qt!N=uRAR%6d5j29AWt06AI(&6 z#S6trzL14s9i#J;p)s#_MXb;i;SdO6c!$8=C#c$x15$c}LL-nDjXWR~XJSRMe}pti zuir-Kf+$Tt(#8gN-#s&W7qU|HTj-aeFQDVRD|i*K zDGyrcXwK{KNJwD*n3HLu;DqTD*?+C74wpjf?2@(7-x()Kp@{(Tn!Iqj2@GDk3$Ar( z+a$A)pZZ&vpz7K0^^XkkNdtp>Qh6cHX(9UY(}ls1wW^b(PpKbZT@jfF42j~6owQ;F zP(SARNyytee^pPQ(cV*d7@wCJT>=KN)iT73vr20jp9sDTMX({n0QY#BqY*T)c)IGO zu2{DIgulZ(UAmQr1FK!Dz1@)6_;tq0Ez?eK@^QB6)a(}X%O(p3xb@Y?L7*RKWH`=9M+#eYU1Gh@EF_iojlK@hDly z?q>q=?INVR)0jwF=CpTTKCU-m`;g+FC`SG1tRbK>j#z|g>Ndq_s_I#;jUVShpsR;c z0jJcG48}^3gA;rK$daMLn}yS0H9@nNL?l8;lll#&&cLsQ%n*4QJX450~mKVa!?S&Dju%=I3K7|(4ZPzD~Z2{`rfmoLQ_a2@Mn)M_7G8Dy&3 zHc($Hb8nJ)O3=*?wxC03qAi6+G7ddgFkXh77t2NV4EL~t8Jc;!p9!kK-Bf5ygMEwNPP4H01_ajDjQ2^} zrDMaAZ23h&o=5l?IA&oSKQa=0RZFj~pga=3ezE;RRrr3@O!*su%z~{od#CyD% z4Y8v`&lIwcI&-E0U_YT_-@v+jfUQxbd$i;+MJV9dcHKzHZTwZ~bH2+rc{0mYOI}F> z5?78B6Lk{OV1XTWHX?Axh~1353vfeI?pG~GbcP`xXQr^BB7Wh{xAvYe=ljcHI?y46 z)`s3S#3z>A-$RLBMzXY{uGJld7>;uSnwF(XL3(ap2#8-ii$CI;>2^uB{4xH6CUH@h_2ei{=Mz{bX(&{ z5wDfj@D7k0(gPR7^j%5GrMIYj+|aP3gwC5V$IRWS{m5Us1IlaOj>VAUlFu&jCSbKP zO0TGB1Q-ohkNl438wwdm*s`B6#GfSG`*@PwXm4lJE8`k+e*e4A<;YHJ_NKA9C(unj zcG<}8f(?;)N}jn4FG`j$Sn~Ha|FV`jBi;OUG!3}SF7M7#?=qZxOK3@)d33BA8!!i8 zcKwMV)f{{5z$iKb{*5jb`T_gPL7EkFKI!1(Qevg4ch(>g!COMsvf*@!W&kfS;jflR zetlx^nR1K0F$Ly*=wA=9eU$Q?X>yV{PNt@e>S&`JWF3A5Jp_>^!}%2BU&CjWpxXtq zJfzfZ^g*3mabyoJKR*vfy2u~VDNB%YO$(M%)lOaUGV1re4k9=-dZY=6JhWm zMDZ@(5gtzMwN9!qb)&qqnUXT}Bd&tn0_miSZletQ%6`@F`8~3GSgRiyuCYHPLmi6z z)Yfot&Td2R0#ueZd)7K0+C}n_3^wouD4n0E)HwN_fLVg|iUoApgfR zjbSAtDlURd)+c|e;!f+D_lEc)l#|PB6ok;3;ve0C1dKo!tKNl1IkZs%NS<6MOIdR6 z3(`Pr`N9xKO$NslT9sa7ZKfu27C=jTtz4xO_O7TJ8?jrzKGS{JIC2{2RuEhH<)NLE z?B|9aRdsj>*dF{?9`fHzDLZkGcc>DOmq^{O_`Dd9cOvkJ&j5)qhmhV1Dm^m_(iEIa zElZ$4#4?xWrO0b1NnR(t#FKF0I%VC8Wnxz!)iwUViE-e!U4+BeDEt;2qthh=B z&agHI5nPPNUi#&@MK2HD{KTj)089=EPGNebd&cWU)29uG4r_<)KP_s(y|YueCwk)J zANV(d@%SFtd*TupD&Q7s!}FNU??HL!^+nFQnhJi>;u=K7WQQo`O?apT75!hiLn72H zL+osVHUe1?DDDP=6D`0czDMH8Q9?*fD(gfV{0tnU)Wn;BCRDvQE}iT5u-?I*|?7+IR}mzT9!+l(_~smJN42%F_Hw4Qp9 z(YHl$9ddBg%Zcw$huw}5Vl5+dOc*q}Lng$)|CkO?&`0$R^aJohU9F%CRi$FY(+H{! zx12zSLLEzp{;#wlPSOBbOyd={T@I2r>e0DfV0fe8k-X!u0sSHte|x#^c8kQJx7FI1 z)vl&FaBKYhXO>So9E>Src~9bw5gBSe7H8$tFeV5NfzrX@<7S6DNyE^oMu#+(7i@5EN`2Xn0WjCBh&j7_qOI4ZMWWyy_`5%bfuv!6(fIoSuK{ z?eMkO&Es9U=Buh&3o@RiJ(EqDb+sm0kYz~evB~h~qBVtQ8VBxgJe$D8844SJlD8SW zHsHTDU`LYF3WBUhfz~)1c)ak>qCKmCsqG9t7!bOy0Nvnt_=ck~n-8et&{A&}w(YaaJY zHAkJeX4T%rH&cG1ZMq!%Kgcfkc8|PPehr|x?oR;Ecj_D>bvfoEvGIMMugs6Y?1CgO z5wpb_06T{#c|=IrSxIT`s-ghCN-bx*`U@li+VIDI6Q91S2p8fCPg?N6rXK@fqHqjM z_`@&)@#DbHT^!1-9J=O>oXtrex319yMaWQ1n_!rDV?c!aEvP&~x%HS$kw_DPkcDU- zPz)mxXT1dSUe%(dH_cthiW4dK6&fKBwt1YN|AMxDIKDF$1uK%5=6edV&wPfRCkeho zN^t4au}!9zlqQg@N1mE^9AkA`_39LT?9IN<40!aAcffqK9WgQ_QwVYx6Ncq#lHlja zH78Rexi(7_%n4ZgtLmzo0-7aIk`|4W@5`*$3%65vm7h#YKn&IYbRI+6u;Kx>s?gdL zic$32Q!{Bwx9~5Uzms95IP#Fmj~Pb+R$^$!JivybY#36r-a0WxoYkxjD@Rcbnf&bI%4d+PQgZHb1BtNtmh-PuxZVF#{s>59w7VyWKOrHa?G@*(uo&X z-pB6R4A7%N(Az^tt(9k_AWwyt-=x|n?encF4b%Obj&lO0kH&Mv+Aiii#(q*19 zc85fQ5V!Hwhstn0dgCalDFo4?L!lN6IRcPue7uqB1m>|IZf_QX(miLux5lYGkXPF1}E?|Eh_ekxk0H^CM2Q2Y<$M% z6M+FzYjqZ24ViQ_oMm2R`8}=VboA9 zC{7GEf*J|-5g~Z?!An@tI~_NB&`CmhHc9C@2w0n**t$zS3Kd?_ktDX}fj_{uM(mD> zo9&i6@$McAB;9_=-rdsDXO6R@fSjm&vv>4&WX*RxDUo87G1x_lfEc_~pdVDpgBpMp`rfZStkiaD8v-3ZISl~B?bF@A zqyQIRxBpT?__g_oz_ANrL}5x`;DR3xL#9QW4T3_GGVWIy@^(S#Y#a)#ED+^0V#Ip34Ao zW^i2_or1B1j9TeApXg(`J5gi=3DCY8IgcjN4FdbZQ#kdFpK>$^&C_|~7CtdSdS$3W zG!P2LVEsB7@(E?ZHV@E%74UpDPK{=y<& z&>`MhYh4L*a+zDMu^}7(C9)gSWiC@HQv2&YR^K`^y9A$o71bRUVH0kU9AaeRBbk4{j&U2_R{-D}GkmSc#n2j_|%M{4dlf_`gq&uEPchIsk7EvuUEzxnU})O;@%84wNJ zhRQ|~K@^2-SKU!5JiUlz_)#ei;LW#=^YtzT-e*BHBZDL*wJ zqZd;H?_6<#HVYo~P-r6CKQGfM>;QGJVJQ)K;8zVE39|x?Q(gLyGhm?GN%aHGrS?i=LJryN7{miZXHRx(JF!fdj)l;9?QTq4j{`FWvHb}A}*(yQb zXfyQk7wFf0y$Cw&yH>)OJioH-tP^@ozw`I{*}|Q1LDu)D#vD^)zUb*!%caSAG-mLL zP!XGUMkp<^-UNEJ%0O<6b!y8vRts;bb`5N7=Ub1F0BE~&mw1<7$|VH)g{x;%?WS{( zj&UPiV|9I(K;MW<&cWqP!NzzlO{hQcs=E54^`#M`fR#wI?0UgAGVpZTJZN|?VvX)P3R7%>_tPxb zzjf)FnhInD9!Q}X@Oxq(Cd+u2Ku=S3`5fk%?Z45Q05(nPBYaN6f5&~mHa9IBdR*vK z&ZW&^x0yk^qNl)L{J5(UMZ*A|AH<(Imt@QiMDUsWk3sP_p7OD1SzNh={8AZ}EKWT*9M>hbp2|n7r z@UsSyB+kmO{zD~Cz?g?IEd(4euzMv%7T_40!5)e8wC8G;49^uD!@2 zS;}APet9j+%=bsR!Md=1;MRMV7o{V9)^5%7-{-=x-|2E}5vgFE*qrsrw7lJHF0W~_ zfyYU9aanArboXWOdZ3!mh)rS5A?;YMHcQ779+ET#hm4Pl^>moD5oT#m`*rGm|Emv; zwuT9z7sFIp3}ymIH$VeFi;wSTZ7BBCc+K2a6UdL6dAP9V0F!}XwX0RR_KZu$@H8`G zg&16H25W)O#Q(bL3anf}FKDpR5&rR$2KXttT8Iz!ujhz@lY!_72V0)~<0nWq%xBU0 zf4qcw7bLnr5yYAQ@e>+owXhVD(m&n@4Sxivpi&D1k01Us5cXff{`Jz#e>wJB?EWib n|9T{h^}o0B|GBz2WDzpExUq5avLkpS8An}N>q_C}J0brUdEncp literal 0 HcmV?d00001 diff --git a/test/jasmine/tests/transition_test.js b/test/jasmine/tests/transition_test.js index 7373b84004b..f75bd059bfc 100644 --- a/test/jasmine/tests/transition_test.js +++ b/test/jasmine/tests/transition_test.js @@ -1,5 +1,4 @@ var Plotly = require('@lib/index'); -var PlotlyInternal = require('@src/plotly'); var Lib = require('@src/lib'); var createGraphDiv = require('../assets/create_graph_div'); @@ -37,7 +36,7 @@ describe('Transition API', function() { it('emits plotly_begintransition on transition start', function(done) { var beginTransitionCnt = 0; - gd.on('plotly_begintransition', function () { + gd.on('plotly_begintransition', function() { beginTransitionCnt++; }); From 80078e4c228b2944c4098ba4696b4a42a165932a Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 11 Aug 2016 20:11:27 -0400 Subject: [PATCH 08/45] lib function to interleave trace updates into a restyle Conflicts: src/lib/index.js lib function to interleave trace updates into a restyle Conflicts: src/lib/index.js --- src/lib/index.js | 39 ++++++++++++++++++++++++++++++++++ test/jasmine/tests/lib_test.js | 28 ++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/src/lib/index.js b/src/lib/index.js index d4f50b07c2c..b99b2f94296 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -746,3 +746,42 @@ lib.computeFrame = function(frameLookup, frameName) { return result; }; + +/** + * Interleaves separate trace updates (frames) into a restyle command. + * Attributes not specified in both traces are set to `undefined` so that + * they are not altered by restyle. Object paths are *not* expanded. + * + * @example + * lib.interleaveTraceUpdates([{x: [1]}, {x: [2]}]) + * // returns {x: [[1], [2]]} + * + * @param {array} traces the trace updates to be interleaved + * + * @return {object} an object contianing the interleaved properties + */ +lib.interleaveTraceUpdates = function(traces) { + var i, j, k, prop, trace, props; + var n = traces.length; + var output = {}; + + for(i = 0; i < traces.length; i++) { + trace = traces[i]; + props = Object.keys(trace); + for(j = 0; j < props.length; j++) { + prop = props[j]; + if(!output[prop]) { + // If not present, allocate a new array: + output[prop] = []; + + // Explicitly fill this array with undefined: + for(k = 0; k < n; k++) { + output[prop][k] = undefined; + } + } + output[prop][i] = traces[i][prop]; + } + } + + return output; +}; diff --git a/test/jasmine/tests/lib_test.js b/test/jasmine/tests/lib_test.js index 68dbf8e544b..2c041a7fe70 100644 --- a/test/jasmine/tests/lib_test.js +++ b/test/jasmine/tests/lib_test.js @@ -1401,6 +1401,34 @@ describe('Test lib.js:', function() { }).toThrowError('Separator string required for formatting!'); }); }); + + describe('interleaveTraceUpdates', function() { + it('wraps property updates in arrays', function() { + var input = [{x: [1], 'marker.color': 'red'}]; + var output = Lib.interleaveTraceUpdates(input); + + expect(output).toEqual({ + x: [[1]], + 'marker.color': ['red'] + }); + }); + + it('merges traces into a single restyle', function() { + var input = [ + {x: [1], 'marker.color': 'red'}, + {y: [[7, 8], [4, 2]], 'marker.goodness': 'very', symbols: {visible: 'please'}} + ]; + var output = Lib.interleaveTraceUpdates(input); + + expect(output).toEqual({ + x: [[1], undefined], + y: [undefined, [[7, 8], [4, 2]]], + 'marker.color': ['red', undefined], + 'marker.goodness': [undefined, 'very'], + 'symbols': [undefined, {visible: 'please'}] + }); + }); + }); }); describe('Queue', function() { From a36fb6ec52d9d1cc04d9eaa089f4a8dfb8be3452 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Fri, 12 Aug 2016 13:05:50 -0400 Subject: [PATCH 09/45] Add supplyTransitionDefaults to coerce input --- src/plot_api/plot_api.js | 19 +----- src/plots/plots.js | 16 +++++ src/plots/transition_attributes.js | 85 +++++++++++++++++++++++++++ test/jasmine/tests/transition_test.js | 43 +++++++++++++- 4 files changed, 143 insertions(+), 20 deletions(-) create mode 100644 src/plots/transition_attributes.js diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index ee4297e4e12..16d2d22d2a9 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2537,22 +2537,7 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { var i, traceIdx; var fullLayout = gd._fullLayout; - transitionConfig = Lib.extendFlat({ - ease: 'cubic-in-out', - duration: 500, - delay: 0, - cascade: 0 - }, transitionConfig || {}); - - // Create a single transition to be passed around: - if(transitionConfig.duration > 0) { - gd._currentTransition = d3.transition() - .duration(transitionConfig.duration) - .delay(transitionConfig.delay) - .ease(transitionConfig.ease); - } else { - gd._currentTransition = null; - } + transitionConfig = Plots.supplyTransitionDefaults(transitionConfig); var dataLength = Array.isArray(data) ? data.length : 0; @@ -2666,7 +2651,7 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { basePlotModules[j].plot(gd, transitionedTraces, traceTransitionConfig); } - gd._transitionData._completionTimeout = setTimeout(completeTransition, transitionConfig.duration); + gd._transitionData._completionTimeout = setTimeout(completeTransition, transitionConfig.duration + transitionConfig.delay); if(!hasAxisTransition && !hasTraceTransition) { return false; diff --git a/src/plots/plots.js b/src/plots/plots.js index 528279ecf05..de64824334a 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -18,6 +18,7 @@ var Lib = require('../lib'); var Color = require('../components/color'); var plots = module.exports = {}; +var transitionAttrs = require('./transition_attributes'); // Expose registry methods on Plots for backward-compatibility Lib.extendFlat(plots, Registry); @@ -618,6 +619,21 @@ plots.supplyDataDefaults = function(dataIn, dataOut, layout) { } }; +plots.supplyTransitionDefaults = function(config) { + var configOut = {}; + + function coerce(attr, dflt) { + return Lib.coerce(config, configOut, transitionAttrs, attr, dflt); + } + + coerce('duration'); + coerce('ease'); + coerce('delay'); + coerce('redraw'); + + return configOut; +}; + plots.supplyTraceDefaults = function(traceIn, traceIndex, layout) { var traceOut = {}, defaultColor = Color.defaults[traceIndex % Color.defaults.length]; diff --git a/src/plots/transition_attributes.js b/src/plots/transition_attributes.js new file mode 100644 index 00000000000..05e9eeae3ae --- /dev/null +++ b/src/plots/transition_attributes.js @@ -0,0 +1,85 @@ +/** +* Copyright 2012-2016, 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 = { + duration: { + valType: 'number', + role: 'info', + dflt: 500, + description: [ + 'The duration of the transition, in milliseconds. If equal to zero,', + 'updates are synchronous.' + ].join(' ') + }, + ease: { + valType: 'enumerated', + dflt: 'cubic-in-out', + values: [ + 'linear', + 'quad', + 'cubic', + 'sin', + 'exp', + 'circle', + 'elastic', + 'back', + 'bounce', + 'linear-in', + 'quad-in', + 'cubic-in', + 'sin-in', + 'exp-in', + 'circle-in', + 'elastic-in', + 'back-in', + 'bounce-in', + 'linear-out', + 'quad-out', + 'cubic-out', + 'sin-out', + 'exp-out', + 'circle-out', + 'elastic-out', + 'back-out', + 'bounce-out', + 'linear-in-out', + 'quad-in-out', + 'cubic-in-out', + 'sin-in-out', + 'exp-in-out', + 'circle-in-out', + 'elastic-in-out', + 'back-in-out', + 'bounce-in-out' + ], + role: 'info', + description: 'The easing function used for the transition' + }, + delay: { + valType: 'number', + role: 'info', + dflt: 0, + description: [ + 'The duration of the transition, in milliseconds. If equal to zero,', + 'updates are synchronous.' + ].join(' ') + }, + redraw: { + valType: 'boolean', + role: 'info', + dflt: true, + description: [ + 'Redraw the plot at completion of the transition. This is desirable', + 'for transitions that include properties that cannot be transitioned,', + 'but may significantly slow down updates that do not require a full', + 'redraw of the plot' + ].join(' ') + }, +}; diff --git a/test/jasmine/tests/transition_test.js b/test/jasmine/tests/transition_test.js index f75bd059bfc..8d8b2a08b5a 100644 --- a/test/jasmine/tests/transition_test.js +++ b/test/jasmine/tests/transition_test.js @@ -1,5 +1,6 @@ var Plotly = require('@lib/index'); var Lib = require('@src/lib'); +var Plots = Plotly.Plots; var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); @@ -7,7 +8,35 @@ var fail = require('../assets/fail_test'); var mock = require('@mocks/animation'); -describe('Transition API', function() { +describe('Plots.supplyTransitionDefaults', function() { + 'use strict'; + + it('supplies transition defaults', function() { + expect(Plots.supplyTransitionDefaults({})).toEqual({ + duration: 500, + ease: 'cubic-in-out', + redraw: true, + delay: 0, + }); + }); + + it('uses provided values', function() { + expect(Plots.supplyTransitionDefaults({ + duration: 100, + ease: 'quad-in-out', + redraw: false, + delay: 50, + })).toEqual({ + duration: 100, + ease: 'quad-in-out', + redraw: false, + delay: 50, + }); + }); + +}); + +describe('Plotly.transition', function() { 'use strict'; var gd; @@ -26,11 +55,18 @@ describe('Transition API', function() { it('resolves without waiting for transition to complete', function(done) { var t1 = Date.now(); - var duration = 100; + var duration = 50; + var calls = 0; + // Callback to exit only after called twice: + function end() {if(++calls === 2) done();} + + // Not testing this, but make sure not to exit before the transition is all done: + gd.on('plotly_endtransition', end); + Plotly.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {duration: duration}).then(function() { var t2 = Date.now(); expect(t2 - t1).toBeLessThan(duration); - }).catch(fail).then(done); + }).catch(fail).then(end); }); it('emits plotly_begintransition on transition start', function(done) { @@ -49,4 +85,5 @@ describe('Transition API', function() { Plotly.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {duration: 50}); gd.on('plotly_endtransition', done); }); + }); From b249f7b245358bf6947c207b9d294ef726a1eaa2 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Fri, 19 Aug 2016 13:23:56 -0400 Subject: [PATCH 10/45] Remove unexposed cascade option --- src/components/drawing/index.js | 2 +- src/plot_api/plot_api.js | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/components/drawing/index.js b/src/components/drawing/index.js index ae72f4f308c..7fd2313999c 100644 --- a/src/components/drawing/index.js +++ b/src/components/drawing/index.js @@ -69,7 +69,7 @@ drawing.translatePoints = function(s, xa, ya, trace, transitionConfig, joinDirec var trans; if(!joinDirection) { trans = p.transition() - .delay(transitionConfig.delay + transitionConfig.cascade / size * i) + .delay(transitionConfig.delay) .duration(transitionConfig.duration) .ease(transitionConfig.ease) .attr('transform', 'translate(' + x + ',' + y + ')'); diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 16d2d22d2a9..0be93d8d653 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2651,7 +2651,7 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { basePlotModules[j].plot(gd, transitionedTraces, traceTransitionConfig); } - gd._transitionData._completionTimeout = setTimeout(completeTransition, transitionConfig.duration + transitionConfig.delay); + gd._transitionData._completionTimeout = setTimeout(completeTransition, transitionConfig.duration + transitionConfig.delay + 1000); if(!hasAxisTransition && !hasTraceTransition) { return false; @@ -2661,9 +2661,14 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { function completeTransition() { flushCallbacks(gd._transitionData._interruptCallbacks); - gd.emit('plotly_endtransition', []); - - return executeCallbacks(gd._transitionData._cleanupCallbacks); + return Promise.resolve().then(function () { + if (transitionConfig.redraw) { + return Plotly.redraw(gd); + } + }).then(function () { + gd.emit('plotly_endtransition', []); + return executeCallbacks(gd._transitionData._cleanupCallbacks); + }); } function interruptPreviousTransitions() { From d06699dc6eccb6dea15e127ec8836e9000621843 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 23 Aug 2016 12:07:53 -0400 Subject: [PATCH 11/45] Group transitions to avoid race conditions --- src/components/color/index.js | 5 +- src/components/drawing/index.js | 182 ++++++++++++-------------- src/plot_api/plot_api.js | 36 +++-- src/traces/scatter/plot.js | 56 +++++--- test/jasmine/tests/transition_test.js | 1 - 5 files changed, 150 insertions(+), 130 deletions(-) diff --git a/src/components/color/index.js b/src/components/color/index.js index 35ac2a0552e..67738c3029b 100644 --- a/src/components/color/index.js +++ b/src/components/color/index.js @@ -74,7 +74,10 @@ color.stroke = function(s, c) { color.fill = function(s, c) { var tc = tinycolor(c); - s.style({'fill': color.tinyRGB(tc), 'fill-opacity': tc.getAlpha()}); + s.style({ + 'fill': color.tinyRGB(tc), + 'fill-opacity': tc.getAlpha() + }); }; // search container for colors with the deprecated rgb(fractions) format diff --git a/src/components/drawing/index.js b/src/components/drawing/index.js index 7fd2313999c..f25162fb863 100644 --- a/src/components/drawing/index.js +++ b/src/components/drawing/index.js @@ -46,64 +46,26 @@ drawing.setRect = function(s, x, y, w, h) { s.call(drawing.setPosition, x, y).call(drawing.setSize, w, h); }; -drawing.translatePoints = function(s, xa, ya, trace, transitionConfig, joinDirection) { - var size; - - var hasTransition = transitionConfig && (transitionConfig || {}).duration > 0; - - if(hasTransition) { - size = s.size(); +drawing.translatePoint = function(d, sel, xa, ya, trace, transitionConfig, joinDirection) { + // put xp and yp into d if pixel scaling is already done + var x = d.xp || xa.c2p(d.x), + y = d.yp || ya.c2p(d.y); + + if(isNumeric(x) && isNumeric(y)) { + // for multiline text this works better + if(this.nodeName === 'text') { + sel.node().attr('x', x).attr('y', y); + } else { + sel.attr('transform', 'translate(' + x + ',' + y + ')'); + } } + else sel.remove(); +}; +drawing.translatePoints = function(s, xa, ya, trace, transitionConfig, joinDirection) { s.each(function(d, i) { - // put xp and yp into d if pixel scaling is already done - var x = d.xp || xa.c2p(d.x), - y = d.yp || ya.c2p(d.y), - p = d3.select(this); - if(isNumeric(x) && isNumeric(y)) { - // for multiline text this works better - if(this.nodeName === 'text') { - p.attr('x', x).attr('y', y); - } else { - if(hasTransition) { - var trans; - if(!joinDirection) { - trans = p.transition() - .delay(transitionConfig.delay) - .duration(transitionConfig.duration) - .ease(transitionConfig.ease) - .attr('transform', 'translate(' + x + ',' + y + ')'); - - if(trace) { - trans.call(drawing.pointStyle, trace); - } - } else if(joinDirection === -1) { - trans = p.style('opacity', 1) - .transition() - .duration(transitionConfig.duration) - .ease(transitionConfig.ease) - .style('opacity', 0) - .remove(); - } else if(joinDirection === 1) { - trans = p.attr('transform', 'translate(' + x + ',' + y + ')'); - - if(trace) { - trans.call(drawing.pointStyle, trace); - } - - trans.style('opacity', 0) - .transition() - .duration(transitionConfig.duration) - .ease(transitionConfig.ease) - .style('opacity', 1); - } - - } else { - p.attr('transform', 'translate(' + x + ',' + y + ')'); - } - } - } - else p.remove(); + var sel = d3.select(this); + drawing.translatePoint(d, sel, xa, ya, trace, transitionConfig, joinDirection); }); }; @@ -126,6 +88,16 @@ drawing.crispRound = function(td, lineWidth, dflt) { return Math.round(lineWidth); }; +drawing.singleLineStyle = function(d, s, lw, lc, ld) { + s.style('fill', 'none') + var line = (((d || [])[0] || {}).trace || {}).line || {}, + lw1 = lw || line.width||0, + dash = ld || line.dash || ''; + + Color.stroke(s, lc || line.color); + drawing.dashLine(s, dash, lw1); +}; + drawing.lineGroupStyle = function(s, lw, lc, ld) { s.style('fill', 'none') .each(function(d) { @@ -221,6 +193,64 @@ drawing.symbolNumber = function(v) { return Math.floor(Math.max(v, 0)); }; +function singlePointStyle (d, sel, trace, markerScale, lineScale, marker, markerLine) { + + // 'so' is suspected outliers, for box plots + var fillColor, + lineColor, + lineWidth; + if(d.so) { + lineWidth = markerLine.outlierwidth; + lineColor = markerLine.outliercolor; + fillColor = marker.outliercolor; + } + else { + lineWidth = (d.mlw + 1 || markerLine.width + 1 || + // TODO: we need the latter for legends... can we get rid of it? + (d.trace ? d.trace.marker.line.width : 0) + 1) - 1; + + if('mlc' in d) lineColor = d.mlcc = lineScale(d.mlc); + // weird case: array wasn't long enough to apply to every point + else if(Array.isArray(markerLine.color)) lineColor = Color.defaultLine; + else lineColor = markerLine.color; + + if('mc' in d) fillColor = d.mcc = markerScale(d.mc); + else if(Array.isArray(marker.color)) fillColor = Color.defaultLine; + else fillColor = marker.color || 'rgba(0,0,0,0)'; + } + + if(d.om) { + // open markers can't have zero linewidth, default to 1px, + // and use fill color as stroke color + sel.call(Color.stroke, fillColor) + .style({ + 'stroke-width': (lineWidth || 1) + 'px', + fill: 'none' + }); + } + else { + sel.style('stroke-width', lineWidth + 'px') + .call(Color.fill, fillColor); + if(lineWidth) { + sel.call(Color.stroke, lineColor); + } + } +}; + +drawing.singlePointStyle = function(d, sel, trace) { + var marker = trace.marker, + markerLine = marker.line; + + // allow array marker and marker line colors to be + // scaled by given max and min to colorscales + var markerIn = (trace._input || {}).marker || {}, + markerScale = drawing.tryColorscale(marker, markerIn, ''), + lineScale = drawing.tryColorscale(marker, markerIn, 'line.'); + + singlePointStyle(d, sel, trace, markerScale, lineScale, marker, markerLine); + +} + drawing.pointStyle = function(s, trace) { if(!s.size()) return; @@ -265,47 +295,7 @@ drawing.pointStyle = function(s, trace) { lineScale = drawing.tryColorscale(marker, markerIn, 'line.'); s.each(function(d) { - // 'so' is suspected outliers, for box plots - var fillColor, - lineColor, - lineWidth; - if(d.so) { - lineWidth = markerLine.outlierwidth; - lineColor = markerLine.outliercolor; - fillColor = marker.outliercolor; - } - else { - lineWidth = (d.mlw + 1 || markerLine.width + 1 || - // TODO: we need the latter for legends... can we get rid of it? - (d.trace ? d.trace.marker.line.width : 0) + 1) - 1; - - if('mlc' in d) lineColor = d.mlcc = lineScale(d.mlc); - // weird case: array wasn't long enough to apply to every point - else if(Array.isArray(markerLine.color)) lineColor = Color.defaultLine; - else lineColor = markerLine.color; - - if('mc' in d) fillColor = d.mcc = markerScale(d.mc); - else if(Array.isArray(marker.color)) fillColor = Color.defaultLine; - else fillColor = marker.color || 'rgba(0,0,0,0)'; - } - - var p = d3.select(this); - if(d.om) { - // open markers can't have zero linewidth, default to 1px, - // and use fill color as stroke color - p.call(Color.stroke, fillColor) - .style({ - 'stroke-width': (lineWidth || 1) + 'px', - fill: 'none' - }); - } - else { - p.style('stroke-width', lineWidth + 'px') - .call(Color.fill, fillColor); - if(lineWidth) { - p.call(Color.stroke, lineColor); - } - } + drawing.singlePointStyle(d, d3.select(this), trace, markerScale, lineScale) }); }; diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 0be93d8d653..b403da307d6 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2617,7 +2617,13 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { } } + var aborted = false; + function executeTransitions() { + gd._transitionData._interruptCallbacks.push(function () { + aborted = true; + }); + var traceTransitionConfig; var hasTraceTransition = false; var j; @@ -2647,15 +2653,30 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { traceTransitionConfig = transitionConfig; } - for(j = 0; j < basePlotModules.length; j++) { - basePlotModules[j].plot(gd, transitionedTraces, traceTransitionConfig); + // Construct callbacks that are executed on transition end. This ensures the d3 transitions + // are *complete* before anything else is done. + var numCallbacks = 0; + var numCompleted = 0; + function makeCallback () { + numCallbacks++; + return function () { + numCompleted++; + // When all are complete, perform a redraw: + if (!aborted && numCompleted === numCallbacks) { + completeTransition(); + } + } } - gd._transitionData._completionTimeout = setTimeout(completeTransition, transitionConfig.duration + transitionConfig.delay + 1000); + for(j = 0; j < basePlotModules.length; j++) { + var _config = Lib.extendFlat({onComplete: makeCallback()}, traceTransitionConfig); + basePlotModules[j].plot(gd, transitionedTraces, _config); + } if(!hasAxisTransition && !hasTraceTransition) { return false; } + } function completeTransition() { @@ -2672,14 +2693,7 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { } function interruptPreviousTransitions() { - if(gd._transitionData._completionTimeout) { - // Prevent the previous completion from occurring: - clearTimeout(gd._transitionData._completionTimeout); - gd._transitionData._completionTimeout = null; - - // Interrupt an event to indicate that a transition was running: - gd.emit('plotly_interrupttransition', []); - } + gd.emit('plotly_interrupttransition', []); flushCallbacks(gd._transitionData._cleanupCallbacks); return executeCallbacks(gd._transitionData._interruptCallbacks); diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index 23f9124e924..5e421438d13 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -29,6 +29,7 @@ module.exports = function plot(gd, plotinfo, cdscatter, transitionConfig) { // If transition config is provided, then it is only a partial replot and traces not // updated are removed. var isFullReplot = !transitionConfig; + var hasTransition = !!transitionConfig && transitionConfig.duration > 0; selection = scatterlayer.selectAll('g.trace'); @@ -61,11 +62,27 @@ module.exports = function plot(gd, plotinfo, cdscatter, transitionConfig) { return idx1 > idx2 ? 1 : -1; }); - // Must run the selection again since otherwise enters/updates get grouped together - // and these get executed out of order. Except we need them in order! - scatterlayer.selectAll('g.trace').each(function(d, i) { - plotOne(gd, i, plotinfo, d, cdscatter, this, transitionConfig); - }); + if (hasTransition) { + var transition = d3.transition() + .duration(transitionConfig.duration) + .ease(transitionConfig.ease) + .delay(transitionConfig.delay) + .each('end', function () { + transitionConfig.onComplete && transitionConfig.onComplete(); + }); + + transition.each(function () { + // Must run the selection again since otherwise enters/updates get grouped together + // and these get executed out of order. Except we need them in order! + scatterlayer.selectAll('g.trace').each(function(d, i) { + plotOne(gd, i, plotinfo, d, cdscatter, this, transitionConfig); + }); + }); + } else { + scatterlayer.selectAll('g.trace').each(function(d, i) { + plotOne(gd, i, plotinfo, d, cdscatter, this, transitionConfig); + }); + } if(isFullReplot) { join.exit().remove(); @@ -127,14 +144,7 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition var hasTransition = !!transitionConfig && transitionConfig.duration > 0; function transition(selection) { - if(hasTransition) { - return selection.transition() - .duration(transitionConfig.duration) - .delay(transitionConfig.delay) - .ease(transitionConfig.ease); - } else { - return selection; - } + return hasTransition ? selection.transition() : selection; } var xa = plotinfo.x(), @@ -277,8 +287,9 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition .call(Drawing.lineGroupStyle)) .style('opacity', 1); } else { - transition(el).attr('d', thispath) - .call(Drawing.lineGroupStyle); + var sel = transition(el); + sel.attr('d', thispath); + Drawing.singleLineStyle(cdscatter, sel); } } }; @@ -376,14 +387,17 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition join = selection .data(trace.marker.maxdisplayed ? visFilter : Lib.identity, getKeyFunc(trace)); - join.enter().append('path') - .classed('point', true) - .call(Drawing.pointStyle, trace) + var enter = join.enter().append('path') + .classed('point', true); + + enter.call(Drawing.pointStyle, trace) .call(Drawing.translatePoints, xa, ya, trace, transitionConfig, 1); - join.transition() - .call(Drawing.translatePoints, xa, ya, trace, transitionConfig, 0) - .call(Drawing.pointStyle, trace); + join.transition().each(function (d) { + var sel = transition(d3.select(this)); + Drawing.translatePoint(d, sel, xa, ya, trace, transitionConfig, 0) + Drawing.singlePointStyle(d, sel, trace); + }); if(hasTransition) { join.exit() diff --git a/test/jasmine/tests/transition_test.js b/test/jasmine/tests/transition_test.js index 8d8b2a08b5a..654c7cf8104 100644 --- a/test/jasmine/tests/transition_test.js +++ b/test/jasmine/tests/transition_test.js @@ -85,5 +85,4 @@ describe('Plotly.transition', function() { Plotly.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {duration: 50}); gd.on('plotly_endtransition', done); }); - }); From 4ea79b24ea366d93be7482c90909c8445f242601 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 23 Aug 2016 13:23:29 -0400 Subject: [PATCH 12/45] Overhaul transition completion behavior --- src/components/drawing/index.js | 21 +++++---- src/plot_api/plot_api.js | 59 ++++++++++++-------------- src/plots/cartesian/index.js | 4 +- src/plots/cartesian/transition_axes.js | 23 +++++----- src/plots/plots.js | 4 -- src/traces/scatter/plot.js | 22 +++++++--- 6 files changed, 68 insertions(+), 65 deletions(-) diff --git a/src/components/drawing/index.js b/src/components/drawing/index.js index f25162fb863..20b75c3ee80 100644 --- a/src/components/drawing/index.js +++ b/src/components/drawing/index.js @@ -46,7 +46,7 @@ drawing.setRect = function(s, x, y, w, h) { s.call(drawing.setPosition, x, y).call(drawing.setSize, w, h); }; -drawing.translatePoint = function(d, sel, xa, ya, trace, transitionConfig, joinDirection) { +drawing.translatePoint = function(d, sel, xa, ya) { // put xp and yp into d if pixel scaling is already done var x = d.xp || xa.c2p(d.x), y = d.yp || ya.c2p(d.y); @@ -62,10 +62,10 @@ drawing.translatePoint = function(d, sel, xa, ya, trace, transitionConfig, joinD else sel.remove(); }; -drawing.translatePoints = function(s, xa, ya, trace, transitionConfig, joinDirection) { - s.each(function(d, i) { +drawing.translatePoints = function(s, xa, ya, trace, transitionConfig) { + s.each(function(d) { var sel = d3.select(this); - drawing.translatePoint(d, sel, xa, ya, trace, transitionConfig, joinDirection); + drawing.translatePoint(d, sel, xa, ya, trace, transitionConfig); }); }; @@ -89,7 +89,7 @@ drawing.crispRound = function(td, lineWidth, dflt) { }; drawing.singleLineStyle = function(d, s, lw, lc, ld) { - s.style('fill', 'none') + s.style('fill', 'none'); var line = (((d || [])[0] || {}).trace || {}).line || {}, lw1 = lw || line.width||0, dash = ld || line.dash || ''; @@ -193,7 +193,7 @@ drawing.symbolNumber = function(v) { return Math.floor(Math.max(v, 0)); }; -function singlePointStyle (d, sel, trace, markerScale, lineScale, marker, markerLine) { +function singlePointStyle(d, sel, trace, markerScale, lineScale, marker, markerLine) { // 'so' is suspected outliers, for box plots var fillColor, @@ -235,7 +235,7 @@ function singlePointStyle (d, sel, trace, markerScale, lineScale, marker, marker sel.call(Color.stroke, lineColor); } } -}; +} drawing.singlePointStyle = function(d, sel, trace) { var marker = trace.marker, @@ -249,13 +249,12 @@ drawing.singlePointStyle = function(d, sel, trace) { singlePointStyle(d, sel, trace, markerScale, lineScale, marker, markerLine); -} +}; drawing.pointStyle = function(s, trace) { if(!s.size()) return; - var marker = trace.marker, - markerLine = marker.line; + var marker = trace.marker; // only scatter & box plots get marker path and opacity // bars, histograms don't @@ -295,7 +294,7 @@ drawing.pointStyle = function(s, trace) { lineScale = drawing.tryColorscale(marker, markerIn, 'line.'); s.each(function(d) { - drawing.singlePointStyle(d, d3.select(this), trace, markerScale, lineScale) + drawing.singlePointStyle(d, d3.select(this), trace, markerScale, lineScale); }); }; diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index b403da307d6..97563d8292d 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2620,32 +2620,43 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { var aborted = false; function executeTransitions() { - gd._transitionData._interruptCallbacks.push(function () { + gd._transitionData._interruptCallbacks.push(function() { aborted = true; }); + // Construct callbacks that are executed on transition end. This ensures the d3 transitions + // are *complete* before anything else is done. + var numCallbacks = 0; + var numCompleted = 0; + function makeCallback() { + numCallbacks++; + return function() { + numCompleted++; + // When all are complete, perform a redraw: + if(!aborted && numCompleted === numCallbacks) { + completeTransition(); + } + }; + } + var traceTransitionConfig; var hasTraceTransition = false; var j; var basePlotModules = fullLayout._basePlotModules; - for(j = 0; j < basePlotModules.length; j++) { - if(basePlotModules[j].animatable) { - hasTraceTransition = true; - } - basePlotModules[j].plot(gd, transitionedTraces, transitionConfig); - } - var hasAxisTransition = false; if(layout) { for(j = 0; j < basePlotModules.length; j++) { if(basePlotModules[j].transitionAxes) { var newLayout = Lib.expandObjectPaths(layout); - hasAxisTransition = hasAxisTransition || basePlotModules[j].transitionAxes(gd, newLayout, transitionConfig); + hasAxisTransition = basePlotModules[j].transitionAxes(gd, newLayout, transitionConfig, makeCallback) || hasAxisTransition; } } } + // Here handle the exception that we refuse to animate scales and axes at the same + // time. In other words, if there's an axis transition, then set the data transition + // to instantaneous. if(hasAxisTransition) { traceTransitionConfig = Lib.extendFlat({}, transitionConfig); traceTransitionConfig.duration = 0; @@ -2653,24 +2664,12 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { traceTransitionConfig = transitionConfig; } - // Construct callbacks that are executed on transition end. This ensures the d3 transitions - // are *complete* before anything else is done. - var numCallbacks = 0; - var numCompleted = 0; - function makeCallback () { - numCallbacks++; - return function () { - numCompleted++; - // When all are complete, perform a redraw: - if (!aborted && numCompleted === numCallbacks) { - completeTransition(); - } - } - } - for(j = 0; j < basePlotModules.length; j++) { - var _config = Lib.extendFlat({onComplete: makeCallback()}, traceTransitionConfig); - basePlotModules[j].plot(gd, transitionedTraces, _config); + // Note that we pass a callback to *create* the callback that must be invoked on completion. + // This is since not all traces know about transitions, so it greatly simplifies matters if + // the trace is responsible for creating a callback, if needed, and then executing it when + // the time is right. + basePlotModules[j].plot(gd, transitionedTraces, traceTransitionConfig, makeCallback); } if(!hasAxisTransition && !hasTraceTransition) { @@ -2682,20 +2681,18 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { function completeTransition() { flushCallbacks(gd._transitionData._interruptCallbacks); - return Promise.resolve().then(function () { - if (transitionConfig.redraw) { + return Promise.resolve().then(function() { + if(transitionConfig.redraw) { return Plotly.redraw(gd); } - }).then(function () { + }).then(function() { gd.emit('plotly_endtransition', []); - return executeCallbacks(gd._transitionData._cleanupCallbacks); }); } function interruptPreviousTransitions() { gd.emit('plotly_interrupttransition', []); - flushCallbacks(gd._transitionData._cleanupCallbacks); return executeCallbacks(gd._transitionData._interruptCallbacks); } diff --git a/src/plots/cartesian/index.js b/src/plots/cartesian/index.js index 4caf0e3c217..156d89ca120 100644 --- a/src/plots/cartesian/index.js +++ b/src/plots/cartesian/index.js @@ -27,7 +27,7 @@ exports.attributes = require('./attributes'); exports.transitionAxes = require('./transition_axes'); -exports.plot = function(gd, traces, transitionOpts) { +exports.plot = function(gd, traces, transitionOpts, makeOnCompleteCallback) { var cdSubplot, cd, trace, i, j, k; var fullLayout = gd._fullLayout, @@ -106,7 +106,7 @@ exports.plot = function(gd, traces, transitionOpts) { } } - _module.plot(gd, subplotInfo, cdModule, transitionOpts); + _module.plot(gd, subplotInfo, cdModule, transitionOpts, makeOnCompleteCallback); } } }; diff --git a/src/plots/cartesian/transition_axes.js b/src/plots/cartesian/transition_axes.js index 7942a7ec8cc..7e64db2d91c 100644 --- a/src/plots/cartesian/transition_axes.js +++ b/src/plots/cartesian/transition_axes.js @@ -16,7 +16,7 @@ var Lib = require('../../lib'); var Axes = require('./axes'); var axisRegex = /((x|y)([2-9]|[1-9][0-9]+)?)axis$/; -module.exports = function transitionAxes(gd, newLayout, transitionConfig) { +module.exports = function transitionAxes(gd, newLayout, transitionConfig, makeOnCompleteCallback) { var fullLayout = gd._fullLayout; var axes = []; @@ -227,6 +227,12 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { } + var onComplete; + if(makeOnCompleteCallback) { + // This module makes the choice whether or not it notifies Plotly.transition + // about completion: + onComplete = makeOnCompleteCallback(); + } function transitionComplete() { var attrs = {}; @@ -239,15 +245,17 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { axi.range = to.slice(); } + // Signal that this transition has completed: + onComplete && onComplete(); + return Plotly.relayout(gd, attrs).then(function() { for(var i = 0; i < affectedSubplots.length; i++) { unsetSubplotTransform(affectedSubplots[i]); } - }); } - function transitionTail() { + function transitionInterrupt() { var attrs = {}; for(var i = 0; i < updatedAxisIds.length; i++) { var axi = gd._fullLayout[updatedAxisIds[i] + 'axis']; @@ -270,13 +278,7 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { gd._transitionData._interruptCallbacks.push(function() { cancelAnimationFrame(raf); raf = null; - return transitionTail(); - }); - - gd._transitionData._cleanupCallbacks.push(function() { - cancelAnimationFrame(raf); - raf = null; - return transitionComplete(); + return transitionInterrupt(); }); function doFrame() { @@ -290,6 +292,7 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { } if(t2 - t1 > transitionConfig.duration) { + transitionComplete(); raf = cancelAnimationFrame(doFrame); } else { raf = requestAnimationFrame(doFrame); diff --git a/src/plots/plots.js b/src/plots/plots.js index de64824334a..674880203c4 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -447,10 +447,6 @@ plots.createTransitionData = function(gd) { gd._transitionData._counter = 0; } - if(!gd._transitionData._cleanupCallbacks) { - gd._transitionData._cleanupCallbacks = []; - } - if(!gd._transitionData._interruptCallbacks) { gd._transitionData._interruptCallbacks = []; } diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index 5e421438d13..22e40d2fd23 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -21,7 +21,7 @@ var linePoints = require('./line_points'); var linkTraces = require('./link_traces'); var polygonTester = require('../../lib/polygon').tester; -module.exports = function plot(gd, plotinfo, cdscatter, transitionConfig) { +module.exports = function plot(gd, plotinfo, cdscatter, transitionConfig, makeOnCompleteCallback) { var i, uids, selection, join; var scatterlayer = plotinfo.plot.select('g.scatterlayer'); @@ -31,6 +31,14 @@ module.exports = function plot(gd, plotinfo, cdscatter, transitionConfig) { var isFullReplot = !transitionConfig; var hasTransition = !!transitionConfig && transitionConfig.duration > 0; + var onComplete; + if(makeOnCompleteCallback && hasTransition) { + // If it was passed a callback to register completion, make a callback. If + // this is created, then it must be executed on completion, otherwise the + // pos-transition redraw will not execute: + onComplete = makeOnCompleteCallback(); + } + selection = scatterlayer.selectAll('g.trace'); join = selection.data(cdscatter, function(d) {return d[0].trace.uid;}); @@ -62,16 +70,16 @@ module.exports = function plot(gd, plotinfo, cdscatter, transitionConfig) { return idx1 > idx2 ? 1 : -1; }); - if (hasTransition) { + if(hasTransition) { var transition = d3.transition() .duration(transitionConfig.duration) .ease(transitionConfig.ease) .delay(transitionConfig.delay) - .each('end', function () { - transitionConfig.onComplete && transitionConfig.onComplete(); + .each('end', function() { + onComplete && onComplete(); }); - transition.each(function () { + transition.each(function() { // Must run the selection again since otherwise enters/updates get grouped together // and these get executed out of order. Except we need them in order! scatterlayer.selectAll('g.trace').each(function(d, i) { @@ -393,9 +401,9 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition enter.call(Drawing.pointStyle, trace) .call(Drawing.translatePoints, xa, ya, trace, transitionConfig, 1); - join.transition().each(function (d) { + join.transition().each(function(d) { var sel = transition(d3.select(this)); - Drawing.translatePoint(d, sel, xa, ya, trace, transitionConfig, 0) + Drawing.translatePoint(d, sel, xa, ya, trace, transitionConfig, 0); Drawing.singlePointStyle(d, sel, trace); }); From 639faa2ea65c1274a32426153d252f2594c09020 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 23 Aug 2016 15:06:46 -0400 Subject: [PATCH 13/45] Trigger transition completion even if nothing else happened --- src/plot_api/plot_api.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 97563d8292d..d4fffce81cd 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2672,6 +2672,9 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { basePlotModules[j].plot(gd, transitionedTraces, traceTransitionConfig, makeCallback); } + // If nothing else creates a callback, then this will trigger the completion in the next tick: + setTimeout(makeCallback()); + if(!hasAxisTransition && !hasTraceTransition) { return false; } From 36207eeccac42c0dab79e63d3d2bb855add5d21a Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Wed, 24 Aug 2016 12:42:44 -0400 Subject: [PATCH 14/45] Fixes from first review pass --- src/components/drawing/index.js | 4 +- src/components/errorbars/plot.js | 19 ++-- src/lib/index.js | 125 ------------------------- src/plot_api/plot_api.js | 17 +--- src/plots/cartesian/transition_axes.js | 18 ++-- src/plots/plots.js | 80 +++++++++++++++- src/plots/transition_attributes.js | 10 +- src/traces/scatter/attributes.js | 2 +- src/traces/scatter/calc.js | 4 +- src/traces/scatter/defaults.js | 2 +- src/traces/scatter/plot.js | 5 +- src/traces/scatter/select.js | 2 +- test/image/mocks/ternary_simple.json | 3 +- test/jasmine/karma.conf.js | 5 +- test/jasmine/tests/lib_test.js | 28 ------ test/jasmine/tests/ternary_test.js | 2 - test/jasmine/tests/transition_test.js | 10 +- 17 files changed, 111 insertions(+), 225 deletions(-) diff --git a/src/components/drawing/index.js b/src/components/drawing/index.js index 20b75c3ee80..424bcdd0d40 100644 --- a/src/components/drawing/index.js +++ b/src/components/drawing/index.js @@ -62,10 +62,10 @@ drawing.translatePoint = function(d, sel, xa, ya) { else sel.remove(); }; -drawing.translatePoints = function(s, xa, ya, trace, transitionConfig) { +drawing.translatePoints = function(s, xa, ya, trace) { s.each(function(d) { var sel = d3.select(this); - drawing.translatePoint(d, sel, xa, ya, trace, transitionConfig); + drawing.translatePoint(d, sel, xa, ya, trace); }); }; diff --git a/src/components/errorbars/plot.js b/src/components/errorbars/plot.js index 5f074b63bc2..9d0ab2a57c9 100644 --- a/src/components/errorbars/plot.js +++ b/src/components/errorbars/plot.js @@ -13,16 +13,13 @@ var d3 = require('d3'); var isNumeric = require('fast-isnumeric'); var subTypes = require('../../traces/scatter/subtypes'); -var styleError = require('./style'); - module.exports = function plot(traces, plotinfo, transitionConfig) { var isNew; var xa = plotinfo.x(), ya = plotinfo.y(); - transitionConfig = transitionConfig || {}; - var hasAnimation = isNumeric(transitionConfig.duration) && transitionConfig.duration > 0; + var hasAnimation = transitionConfig && transitionConfig.duration > 0; traces.each(function(d) { var trace = d[0].trace, @@ -35,8 +32,8 @@ module.exports = function plot(traces, plotinfo, transitionConfig) { var keyFunc; - if(trace.identifier) { - keyFunc = function(d) {return d.identifier;}; + if(trace.ids) { + keyFunc = function(d) {return d.id;}; } var sparse = ( @@ -70,7 +67,7 @@ module.exports = function plot(traces, plotinfo, transitionConfig) { var path; - if(yObj.visible && isNumeric(coords.x) && + if(yObj.visible && isNumeric(coords.y) && isNumeric(coords.yh) && isNumeric(coords.ys)) { var yw = yObj.width; @@ -93,8 +90,7 @@ module.exports = function plot(traces, plotinfo, transitionConfig) { yerror = yerror .transition() .duration(transitionConfig.duration) - .ease(transitionConfig.ease) - .delay(transitionConfig.delay); + .ease(transitionConfig.ease); } yerror.attr('d', path); @@ -122,15 +118,12 @@ module.exports = function plot(traces, plotinfo, transitionConfig) { xerror = xerror .transition() .duration(transitionConfig.duration) - .ease(transitionConfig.ease) - .delay(transitionConfig.delay); + .ease(transitionConfig.ease); } xerror.attr('d', path); } }); - - d3.select(this).call(styleError); }); }; diff --git a/src/lib/index.js b/src/lib/index.js index b99b2f94296..cb1cbd88ac8 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -660,128 +660,3 @@ lib.numSeparate = function(value, separators, separatethousands) { return x1 + x2; }; - -/* - * Compute a keyframe. Merge a keyframe into its base frame(s) and - * expand properties. - * - * @param {object} frameLookup - * An object containing frames keyed by name (i.e. gd._transitionData._frameHash) - * @param {string} frame - * The name of the keyframe to be computed - * - * Returns: a new object with the merged content - */ -lib.computeFrame = function(frameLookup, frameName) { - var i, traceIndices, traceIndex, expandedObj, destIndex, copy; - - var framePtr = frameLookup[frameName]; - - // Return false if the name is invalid: - if(!framePtr) { - return false; - } - - var frameStack = [framePtr]; - var frameNameStack = [framePtr.name]; - - // Follow frame pointers: - while((framePtr = frameLookup[framePtr.baseFrame])) { - // Avoid infinite loops: - if(frameNameStack.indexOf(framePtr.name) !== -1) break; - - frameStack.push(framePtr); - frameNameStack.push(framePtr.name); - } - - // A new object for the merged result: - var result = {}; - - // Merge, starting with the last and ending with the desired frame: - while((framePtr = frameStack.pop())) { - if(framePtr.layout) { - copy = lib.extendDeepNoArrays({}, framePtr.layout); - expandedObj = lib.expandObjectPaths(copy); - result.layout = lib.extendDeepNoArrays(result.layout || {}, expandedObj); - } - - if(framePtr.data) { - if(!result.data) { - result.data = []; - } - traceIndices = framePtr.traceIndices; - - if(!traceIndices) { - // If not defined, assume serial order starting at zero - traceIndices = []; - for(i = 0; i < framePtr.data.length; i++) { - traceIndices[i] = i; - } - } - - if(!result.traceIndices) { - result.traceIndices = []; - } - - for(i = 0; i < framePtr.data.length; i++) { - // Loop through this frames data, find out where it should go, - // and merge it! - traceIndex = traceIndices[i]; - if(traceIndex === undefined || traceIndex === null) { - continue; - } - - destIndex = result.traceIndices.indexOf(traceIndex); - if(destIndex === -1) { - destIndex = result.data.length; - result.traceIndices[destIndex] = traceIndex; - } - - copy = lib.extendDeepNoArrays({}, framePtr.data[i]); - expandedObj = lib.expandObjectPaths(copy); - result.data[destIndex] = lib.extendDeepNoArrays(result.data[destIndex] || {}, expandedObj); - } - } - } - - return result; -}; - -/** - * Interleaves separate trace updates (frames) into a restyle command. - * Attributes not specified in both traces are set to `undefined` so that - * they are not altered by restyle. Object paths are *not* expanded. - * - * @example - * lib.interleaveTraceUpdates([{x: [1]}, {x: [2]}]) - * // returns {x: [[1], [2]]} - * - * @param {array} traces the trace updates to be interleaved - * - * @return {object} an object contianing the interleaved properties - */ -lib.interleaveTraceUpdates = function(traces) { - var i, j, k, prop, trace, props; - var n = traces.length; - var output = {}; - - for(i = 0; i < traces.length; i++) { - trace = traces[i]; - props = Object.keys(trace); - for(j = 0; j < props.length; j++) { - prop = props[j]; - if(!output[prop]) { - // If not present, allocate a new array: - output[prop] = []; - - // Explicitly fill this array with undefined: - for(k = 0; k < n; k++) { - output[prop][k] = undefined; - } - } - output[prop][i] = traces[i][prop]; - } - } - - return output; -}; diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index d4fffce81cd..d8f3ca24b67 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2544,7 +2544,7 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { // Select which traces will be updated: if(isNumeric(traceIndices)) traceIndices = [traceIndices]; else if(!Array.isArray(traceIndices) || !traceIndices.length) { - traceIndices = gd._fullData.map(function(v, i) {return i;}); + traceIndices = gd.data.map(function(v, i) { return i; }); } if(traceIndices.length > dataLength) { @@ -2585,8 +2585,6 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { // transitions: Plots.supplyDefaults(gd); - //Plotly.Axes.saveRangeInitial(gd, true); - // This step fies the .xaxis and .yaxis references that otherwise // aren't updated by the supplyDefaults step: var subplots = Plotly.Axes.getSubplots(gd); @@ -2689,12 +2687,12 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { return Plotly.redraw(gd); } }).then(function() { - gd.emit('plotly_endtransition', []); + gd.emit('plotly_transitioned', []); }); } function interruptPreviousTransitions() { - gd.emit('plotly_interrupttransition', []); + gd.emit('plotly_transitioninterrupted', []); return executeCallbacks(gd._transitionData._interruptCallbacks); } @@ -2712,24 +2710,17 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { for(var ai in data[i]) { thisUpdate[ai] = [data[i][ai]]; } - - /*restyleList.push((function(md, data, traces) { - return function() { - return Plotly.restyle(gd, data, traces); - }; - }(module, thisUpdate, [traceIdx])));*/ } } var seq = [Plots.previousPromises, interruptPreviousTransitions, prepareTransitions, executeTransitions]; - //seq = seq.concat(restyleList); var plotDone = Lib.syncOrAsync(seq, gd); if(!plotDone || !plotDone.then) plotDone = Promise.resolve(); return plotDone.then(function() { - gd.emit('plotly_begintransition', []); + gd.emit('plotly_transitioning', []); return gd; }); }; diff --git a/src/plots/cartesian/transition_axes.js b/src/plots/cartesian/transition_axes.js index 7e64db2d91c..b5ab057e8ff 100644 --- a/src/plots/cartesian/transition_axes.js +++ b/src/plots/cartesian/transition_axes.js @@ -119,7 +119,7 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig, makeOn obji = objArray[i]; if((activeAxIds.indexOf(obji.xref) !== -1) || (activeAxIds.indexOf(obji.yref) !== -1)) { - module.draw(gd, i); + module.drawOne(gd, i); } } } @@ -235,12 +235,12 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig, makeOn } function transitionComplete() { - var attrs = {}; + var aobj = {}; for(var i = 0; i < updatedAxisIds.length; i++) { var axi = gd._fullLayout[updates[updatedAxisIds[i]].axisName]; var to = updates[updatedAxisIds[i]].to; - attrs[axi._name + '.range[0]'] = to[0]; - attrs[axi._name + '.range[1]'] = to[1]; + aobj[axi._name + '.range[0]'] = to[0]; + aobj[axi._name + '.range[1]'] = to[1]; axi.range = to.slice(); } @@ -248,7 +248,7 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig, makeOn // Signal that this transition has completed: onComplete && onComplete(); - return Plotly.relayout(gd, attrs).then(function() { + return Plotly.relayout(gd, aobj).then(function() { for(var i = 0; i < affectedSubplots.length; i++) { unsetSubplotTransform(affectedSubplots[i]); } @@ -256,16 +256,16 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig, makeOn } function transitionInterrupt() { - var attrs = {}; + var aobj = {}; for(var i = 0; i < updatedAxisIds.length; i++) { var axi = gd._fullLayout[updatedAxisIds[i] + 'axis']; - attrs[axi._name + '.range[0]'] = axi.range[0]; - attrs[axi._name + '.range[1]'] = axi.range[1]; + aobj[axi._name + '.range[0]'] = axi.range[0]; + aobj[axi._name + '.range[1]'] = axi.range[1]; axi.range = axi._r.slice(); } - return Plotly.relayout(gd, attrs).then(function() { + return Plotly.relayout(gd, aobj).then(function() { for(var i = 0; i < affectedSubplots.length; i++) { unsetSubplotTransform(affectedSubplots[i]); } diff --git a/src/plots/plots.js b/src/plots/plots.js index 674880203c4..e6ed4039ac3 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -563,9 +563,6 @@ plots.supplyDataDefaults = function(dataIn, dataOut, layout) { basePlotModules = layout._basePlotModules = [], cnt = 0; - // Make this idempotent by emptying the existing array: - dataOut.length = 0; - function pushModule(fullTrace) { dataOut.push(fullTrace); @@ -624,7 +621,6 @@ plots.supplyTransitionDefaults = function(config) { coerce('duration'); coerce('ease'); - coerce('delay'); coerce('redraw'); return configOut; @@ -1193,11 +1189,85 @@ plots.modifyFrames = function(gd, operations) { * Compute a keyframe. Merge a keyframe into its base frame(s) and * expand properties. * + * @param {object} frameLookup + * An object containing frames keyed by name (i.e. gd._transitionData._frameHash) * @param {string} frame * The name of the keyframe to be computed * * Returns: a new object with the merged content */ plots.computeFrame = function(gd, frameName) { - return Lib.computeFrame(gd._transitionData._frameHash, frameName); + var frameLookup = gd._transitionData._frameHash; + var i, traceIndices, traceIndex, expandedObj, destIndex, copy; + + var framePtr = frameLookup[frameName]; + + // Return false if the name is invalid: + if(!framePtr) { + return false; + } + + var frameStack = [framePtr]; + var frameNameStack = [framePtr.name]; + + // Follow frame pointers: + while((framePtr = frameLookup[framePtr.baseFrame])) { + // Avoid infinite loops: + if(frameNameStack.indexOf(framePtr.name) !== -1) break; + + frameStack.push(framePtr); + frameNameStack.push(framePtr.name); + } + + // A new object for the merged result: + var result = {}; + + // Merge, starting with the last and ending with the desired frame: + while((framePtr = frameStack.pop())) { + if(framePtr.layout) { + copy = Lib.extendDeepNoArrays({}, framePtr.layout); + expandedObj = Lib.expandObjectPaths(copy); + result.layout = Lib.extendDeepNoArrays(result.layout || {}, expandedObj); + } + + if(framePtr.data) { + if(!result.data) { + result.data = []; + } + traceIndices = framePtr.traceIndices; + + if(!traceIndices) { + // If not defined, assume serial order starting at zero + traceIndices = []; + for(i = 0; i < framePtr.data.length; i++) { + traceIndices[i] = i; + } + } + + if(!result.traceIndices) { + result.traceIndices = []; + } + + for(i = 0; i < framePtr.data.length; i++) { + // Loop through this frames data, find out where it should go, + // and merge it! + traceIndex = traceIndices[i]; + if(traceIndex === undefined || traceIndex === null) { + continue; + } + + destIndex = result.traceIndices.indexOf(traceIndex); + if(destIndex === -1) { + destIndex = result.data.length; + result.traceIndices[destIndex] = traceIndex; + } + + copy = Lib.extendDeepNoArrays({}, framePtr.data[i]); + expandedObj = Lib.expandObjectPaths(copy); + result.data[destIndex] = Lib.extendDeepNoArrays(result.data[destIndex] || {}, expandedObj); + } + } + } + + return result; }; diff --git a/src/plots/transition_attributes.js b/src/plots/transition_attributes.js index 05e9eeae3ae..743ef2304d5 100644 --- a/src/plots/transition_attributes.js +++ b/src/plots/transition_attributes.js @@ -13,6 +13,7 @@ module.exports = { valType: 'number', role: 'info', dflt: 500, + min: 0, description: [ 'The duration of the transition, in milliseconds. If equal to zero,', 'updates are synchronous.' @@ -62,15 +63,6 @@ module.exports = { role: 'info', description: 'The easing function used for the transition' }, - delay: { - valType: 'number', - role: 'info', - dflt: 0, - description: [ - 'The duration of the transition, in milliseconds. If equal to zero,', - 'updates are synchronous.' - ].join(' ') - }, redraw: { valType: 'boolean', role: 'info', diff --git a/src/traces/scatter/attributes.js b/src/traces/scatter/attributes.js index 2cc6cb00a68..d9f83dd78ce 100644 --- a/src/traces/scatter/attributes.js +++ b/src/traces/scatter/attributes.js @@ -65,7 +65,7 @@ module.exports = { 'See `y0` for more info.' ].join(' ') }, - identifier: { + ids: { valType: 'data_array', description: 'A list of keys for object constancy of data points during animation' }, diff --git a/src/traces/scatter/calc.js b/src/traces/scatter/calc.js index 61f3a6300cd..cb889ab396e 100644 --- a/src/traces/scatter/calc.js +++ b/src/traces/scatter/calc.js @@ -116,8 +116,8 @@ module.exports = function calc(gd, trace) { cd[i] = (isNumeric(x[i]) && isNumeric(y[i])) ? {x: x[i], y: y[i]} : {x: false, y: false}; - if(trace.identifier && trace.identifier[i] !== undefined) { - cd[i].identifier = trace.identifier[i]; + if(trace.ids) { + cd[i].id = String(trace.ids[i]); } } diff --git a/src/traces/scatter/defaults.js b/src/traces/scatter/defaults.js index ff29491eb4c..115ef4c757e 100644 --- a/src/traces/scatter/defaults.js +++ b/src/traces/scatter/defaults.js @@ -38,7 +38,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout coerce('text'); coerce('mode', defaultMode); - coerce('identifier'); + coerce('ids'); if(subTypes.hasLines(traceOut)) { handleLineDefaults(traceIn, traceOut, defaultColor, layout, coerce); diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index 22e40d2fd23..19d5eb2f17d 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -74,7 +74,6 @@ module.exports = function plot(gd, plotinfo, cdscatter, transitionConfig, makeOn var transition = d3.transition() .duration(transitionConfig.duration) .ease(transitionConfig.ease) - .delay(transitionConfig.delay) .each('end', function() { onComplete && onComplete(); }); @@ -370,12 +369,12 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition } function keyFunc(d) { - return d.identifier; + return d.id; } // Returns a function if the trace is keyed, otherwise returns undefined function getKeyFunc(trace) { - if(trace.identifier) { + if(trace.ids) { return keyFunc; } } diff --git a/src/traces/scatter/select.js b/src/traces/scatter/select.js index 03f5ed8f39a..532e7933825 100644 --- a/src/traces/scatter/select.js +++ b/src/traces/scatter/select.js @@ -46,7 +46,7 @@ module.exports = function selectPoints(searchInfo, polygon) { pointNumber: i, x: di.x, y: di.y, - identifier: di.identifier + ids: di.id }); di.dim = 0; } diff --git a/test/image/mocks/ternary_simple.json b/test/image/mocks/ternary_simple.json index ea1d78ff2a3..62bc4574a66 100644 --- a/test/image/mocks/ternary_simple.json +++ b/test/image/mocks/ternary_simple.json @@ -16,8 +16,7 @@ 1, 2.12345 ], - "type": "scatterternary", - "uid": "412fa8" + "type": "scatterternary" } ], "layout": { diff --git a/test/jasmine/karma.conf.js b/test/jasmine/karma.conf.js index eb5c2d9ba1d..b689af3019a 100644 --- a/test/jasmine/karma.conf.js +++ b/test/jasmine/karma.conf.js @@ -48,10 +48,7 @@ func.defaultConfig = { // N.B. this field is filled below files: [], - exclude: [ - 'tests/gl_plot_interact_test.js', - 'tests/mapbox_test.js', - ], + exclude: [], // preprocess matching files before serving them to the browser // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor diff --git a/test/jasmine/tests/lib_test.js b/test/jasmine/tests/lib_test.js index 2c041a7fe70..68dbf8e544b 100644 --- a/test/jasmine/tests/lib_test.js +++ b/test/jasmine/tests/lib_test.js @@ -1401,34 +1401,6 @@ describe('Test lib.js:', function() { }).toThrowError('Separator string required for formatting!'); }); }); - - describe('interleaveTraceUpdates', function() { - it('wraps property updates in arrays', function() { - var input = [{x: [1], 'marker.color': 'red'}]; - var output = Lib.interleaveTraceUpdates(input); - - expect(output).toEqual({ - x: [[1]], - 'marker.color': ['red'] - }); - }); - - it('merges traces into a single restyle', function() { - var input = [ - {x: [1], 'marker.color': 'red'}, - {y: [[7, 8], [4, 2]], 'marker.goodness': 'very', symbols: {visible: 'please'}} - ]; - var output = Lib.interleaveTraceUpdates(input); - - expect(output).toEqual({ - x: [[1], undefined], - y: [undefined, [[7, 8], [4, 2]]], - 'marker.color': ['red', undefined], - 'marker.goodness': [undefined, 'very'], - 'symbols': [undefined, {visible: 'please'}] - }); - }); - }); }); describe('Queue', function() { diff --git a/test/jasmine/tests/ternary_test.js b/test/jasmine/tests/ternary_test.js index 9b773dad96b..c563a500b1b 100644 --- a/test/jasmine/tests/ternary_test.js +++ b/test/jasmine/tests/ternary_test.js @@ -67,7 +67,6 @@ describe('ternary plots', function() { expect(countTraces('scatter')).toEqual(0); var trace = Lib.extendDeep({}, mock.data[0]); - delete trace.uid; return Plotly.addTraces(gd, [trace]); }).then(function() { @@ -75,7 +74,6 @@ describe('ternary plots', function() { expect(countTraces('scatter')).toEqual(1); var trace = Lib.extendDeep({}, mock.data[0]); - delete trace.uid; return Plotly.addTraces(gd, [trace]); }).then(function() { diff --git a/test/jasmine/tests/transition_test.js b/test/jasmine/tests/transition_test.js index 654c7cf8104..03e90354a86 100644 --- a/test/jasmine/tests/transition_test.js +++ b/test/jasmine/tests/transition_test.js @@ -61,7 +61,7 @@ describe('Plotly.transition', function() { function end() {if(++calls === 2) done();} // Not testing this, but make sure not to exit before the transition is all done: - gd.on('plotly_endtransition', end); + gd.on('plotly_transitioned', end); Plotly.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {duration: duration}).then(function() { var t2 = Date.now(); @@ -69,10 +69,10 @@ describe('Plotly.transition', function() { }).catch(fail).then(end); }); - it('emits plotly_begintransition on transition start', function(done) { + it('emits plotly_transitioning on transition start', function(done) { var beginTransitionCnt = 0; - gd.on('plotly_begintransition', function() { + gd.on('plotly_transitioning', function() { beginTransitionCnt++; }); @@ -81,8 +81,8 @@ describe('Plotly.transition', function() { }).catch(fail).then(done); }); - it('emits plotly_endtransition on transition end', function(done) { + it('emits plotly_transitioned on transition end', function(done) { Plotly.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {duration: 50}); - gd.on('plotly_endtransition', done); + gd.on('plotly_transitioned', done); }); }); From 48347034eebb1699dc0cf193abd2becdadc97046 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Wed, 24 Aug 2016 13:28:01 -0400 Subject: [PATCH 15/45] Fix broken tests --- test/jasmine/tests/select_test.js | 12 ++++++------ test/jasmine/tests/transition_test.js | 9 +++------ 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/test/jasmine/tests/select_test.js b/test/jasmine/tests/select_test.js index dc01190d123..f2be564a41d 100644 --- a/test/jasmine/tests/select_test.js +++ b/test/jasmine/tests/select_test.js @@ -180,13 +180,13 @@ describe('select box and lasso', function() { pointNumber: 0, x: 0.002, y: 16.25, - identifier: undefined + id: undefined }, { curveNumber: 0, pointNumber: 1, x: 0.004, y: 12.5, - identifier: undefined + id: undefined }], 'with the correct selecting points'); assertRange(selectingData.range, { x: [0.002000, 0.0046236], @@ -199,13 +199,13 @@ describe('select box and lasso', function() { pointNumber: 0, x: 0.002, y: 16.25, - identifier: undefined + id: undefined }, { curveNumber: 0, pointNumber: 1, x: 0.004, y: 12.5, - identifier: undefined + id: undefined }], 'with the correct selected points'); assertRange(selectedData.range, { x: [0.002000, 0.0046236], @@ -260,7 +260,7 @@ describe('select box and lasso', function() { pointNumber: 10, x: 0.099, y: 2.75, - identifier: undefined + id: undefined }], 'with the correct selecting points'); expect(selectedCnt).toEqual(1, 'with the correct selected count'); @@ -269,7 +269,7 @@ describe('select box and lasso', function() { pointNumber: 10, x: 0.099, y: 2.75, - identifier: undefined + id: undefined }], 'with the correct selected points'); doubleClick(250, 200).then(function() { diff --git a/test/jasmine/tests/transition_test.js b/test/jasmine/tests/transition_test.js index 03e90354a86..c433259f998 100644 --- a/test/jasmine/tests/transition_test.js +++ b/test/jasmine/tests/transition_test.js @@ -15,8 +15,7 @@ describe('Plots.supplyTransitionDefaults', function() { expect(Plots.supplyTransitionDefaults({})).toEqual({ duration: 500, ease: 'cubic-in-out', - redraw: true, - delay: 0, + redraw: true }); }); @@ -24,13 +23,11 @@ describe('Plots.supplyTransitionDefaults', function() { expect(Plots.supplyTransitionDefaults({ duration: 100, ease: 'quad-in-out', - redraw: false, - delay: 50, + redraw: false })).toEqual({ duration: 100, ease: 'quad-in-out', - redraw: false, - delay: 50, + redraw: false }); }); From 6f0b42bda9f82ca3fc8b528a6a27a0a583f41994 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 25 Aug 2016 10:13:51 -0400 Subject: [PATCH 16/45] Mirror changes from #878 and small typo fix --- src/plots/cartesian/transition_axes.js | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/plots/cartesian/transition_axes.js b/src/plots/cartesian/transition_axes.js index b5ab057e8ff..09b41d22693 100644 --- a/src/plots/cartesian/transition_axes.js +++ b/src/plots/cartesian/transition_axes.js @@ -12,6 +12,7 @@ var d3 = require('d3'); var Plotly = require('../../plotly'); +var Registry = require('../../registry'); var Lib = require('../../lib'); var Axes = require('./axes'); var axisRegex = /((x|y)([2-9]|[1-9][0-9]+)?)axis$/; @@ -113,20 +114,23 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig, makeOn Axes.doTicks(gd, activeAxIds[i], true); } - function redrawObjs(objArray, module) { - var obji; + function redrawObjs(objArray, method) { for(i = 0; i < objArray.length; i++) { - obji = objArray[i]; + var obji = objArray[i]; + if((activeAxIds.indexOf(obji.xref) !== -1) || (activeAxIds.indexOf(obji.yref) !== -1)) { - module.drawOne(gd, i); + method(gd, i); } } } - redrawObjs(fullLayout.annotations || [], Plotly.Annotations); - redrawObjs(fullLayout.shapes || [], Plotly.Shapes); - redrawObjs(fullLayout.images || [], Plotly.Images); + // annotations and shapes 'draw' method is slow, + // use the finer-grained 'drawOne' method instead + + redrawObjs(fullLayout.annotations || [], Registry.getComponentMethod('annotations', 'drawOne')); + redrawObjs(fullLayout.shapes || [], Registry.getComponentMethod('shapes', 'drawOne')); + redrawObjs(fullLayout.images || [], Registry.getComponentMethod('images', 'draw')); } function unsetSubplotTransform(subplot) { From a3119224f4bb1c555d7b60c417b37f400e9378a2 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 25 Aug 2016 10:26:14 -0400 Subject: [PATCH 17/45] Fix errorbar typo --- src/components/errorbars/plot.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/errorbars/plot.js b/src/components/errorbars/plot.js index 9d0ab2a57c9..7420dcb90ef 100644 --- a/src/components/errorbars/plot.js +++ b/src/components/errorbars/plot.js @@ -67,7 +67,7 @@ module.exports = function plot(traces, plotinfo, transitionConfig) { var path; - if(yObj.visible && isNumeric(coords.y) && + if(yObj.visible && isNumeric(coords.x) && isNumeric(coords.yh) && isNumeric(coords.ys)) { var yw = yObj.width; @@ -96,7 +96,7 @@ module.exports = function plot(traces, plotinfo, transitionConfig) { yerror.attr('d', path); } - if(xObj.visible && isNumeric(coords.x) && + if(xObj.visible && isNumeric(coords.y) && isNumeric(coords.xh) && isNumeric(coords.xs)) { var xw = (xObj.copy_ystyle ? yObj : xObj).width; From d5c82c6ffb928791c6979b3a26615a73d93f5f5f Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 25 Aug 2016 10:30:14 -0400 Subject: [PATCH 18/45] Add a tests for scatter simplify: false --- src/plots/transition_attributes.js | 2 +- test/jasmine/tests/scatter_test.js | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/plots/transition_attributes.js b/src/plots/transition_attributes.js index 743ef2304d5..9ab8d945403 100644 --- a/src/plots/transition_attributes.js +++ b/src/plots/transition_attributes.js @@ -12,8 +12,8 @@ module.exports = { duration: { valType: 'number', role: 'info', - dflt: 500, min: 0, + dflt: 500, description: [ 'The duration of the transition, in milliseconds. If equal to zero,', 'updates are synchronous.' diff --git a/test/jasmine/tests/scatter_test.js b/test/jasmine/tests/scatter_test.js index 8c105732800..8c948779459 100644 --- a/test/jasmine/tests/scatter_test.js +++ b/test/jasmine/tests/scatter_test.js @@ -237,6 +237,12 @@ describe('Test scatter', function() { expect(ptsOut).toEqual([[[0, 0], [15, 30], [22, 16], [30, 0]]]); }); + it('should not collapse straight lines if simplify is false', function() { + var ptsIn = [[0, 0], [5, 10], [13, 26], [15, 30], [22, 16], [28, 4], [30, 0]]; + var ptsOut = callLinePoints(ptsIn, {simplify: false}); + expect(ptsOut).toEqual([ptsIn]); + }); + it('should separate out blanks, unless connectgaps is true', function() { var ptsIn = [ [0, 0], [10, 20], [20, 10], [undefined, undefined], From 0f848abeeba86fbe32b1b48bca8672d091b65201 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 25 Aug 2016 10:53:58 -0400 Subject: [PATCH 19/45] Fix the scatter select id field --- src/traces/scatter/select.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/traces/scatter/select.js b/src/traces/scatter/select.js index 532e7933825..91da6fda453 100644 --- a/src/traces/scatter/select.js +++ b/src/traces/scatter/select.js @@ -46,7 +46,7 @@ module.exports = function selectPoints(searchInfo, polygon) { pointNumber: i, x: di.x, y: di.y, - ids: di.id + id: di.id }); di.dim = 0; } From a6be16621982f1b752a0b4d4e6185ef988e8acbf Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 25 Aug 2016 12:17:36 -0400 Subject: [PATCH 20/45] Disable mouse interaction during finite-duration transition --- src/plot_api/plot_api.js | 42 +++++++++++++++++++++++++--------- src/plots/cartesian/dragbox.js | 20 +++++++++++++++- src/traces/scatter/plot.js | 20 ++++++++-------- 3 files changed, 61 insertions(+), 21 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index d8f3ca24b67..453b082f324 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -107,7 +107,7 @@ Plotly.plot = function(gd, data, layout, config) { // if the user is trying to drag the axes, allow new data and layout // to come in but don't allow a replot. - if(gd._dragging) { + if(gd._dragging && !gd._transitioning) { // signal to drag handler that after everything else is done // we need to replot, because something has changed gd._replotPending = true; @@ -235,6 +235,7 @@ Plotly.plot = function(gd, data, layout, config) { } function doAutoRange() { + if(gd._transitioning) return; var axList = Plotly.Axes.list(gd, '', true); for(var i = 0; i < axList.length; i++) { Plotly.Axes.doAutoRange(axList[i]); @@ -2597,6 +2598,8 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { doCalcdata(gd); ErrorBars.calc(gd); + + return Promise.resolve(); } function executeCallbacks(list) { @@ -2618,6 +2621,16 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { var aborted = false; function executeTransitions() { + // This flag is used to disabled things like autorange: + gd._transitioning = true; + + // When instantaneous updates are coming through quickly, it's too much to simply disable + // all interaction, so store this flag so we can disambiguate whether mouse interactions + // should be fully disabled or not: + if(transitionConfig.duration > 0) { + gd._transitioningWithDuration = true; + } + gd._transitionData._interruptCallbacks.push(function() { aborted = true; }); @@ -2638,7 +2651,6 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { } var traceTransitionConfig; - var hasTraceTransition = false; var j; var basePlotModules = fullLayout._basePlotModules; var hasAxisTransition = false; @@ -2671,12 +2683,7 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { } // If nothing else creates a callback, then this will trigger the completion in the next tick: - setTimeout(makeCallback()); - - if(!hasAxisTransition && !hasTraceTransition) { - return false; - } - + setTimeout(makeCallback('first')); } function completeTransition() { @@ -2687,6 +2694,11 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { return Plotly.redraw(gd); } }).then(function() { + // Set transitioning false again once the redraw has occurred. This is used, for example, + // to prevent the trailing redraw from autoranging: + gd._transitioning = false; + gd._transitioningWithDuration = false; + gd.emit('plotly_transitioned', []); }); } @@ -2694,6 +2706,13 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { function interruptPreviousTransitions() { gd.emit('plotly_transitioninterrupted', []); + // If a transition is interrupted, set this to false. At the moment, the only thing that would + // interrupt a transition is another transition, so that it will momentarily be set to true + // again, but this determines whether autorange or dragbox work, so it's for the sake of + // cleanliness: + gd._transitioning = false; + gd._transtionWithDuration = false; + return executeCallbacks(gd._transitionData._interruptCallbacks); } @@ -2715,11 +2734,12 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { var seq = [Plots.previousPromises, interruptPreviousTransitions, prepareTransitions, executeTransitions]; - var plotDone = Lib.syncOrAsync(seq, gd); - if(!plotDone || !plotDone.then) plotDone = Promise.resolve(); + var transitionStarting = Lib.syncOrAsync(seq, gd); - return plotDone.then(function() { + if(!transitionStarting || !transitionStarting.then) transitionStarting = Promise.resolve(); + + return transitionStarting.then(function() { gd.emit('plotly_transitioning', []); return gd; }); diff --git a/src/plots/cartesian/dragbox.js b/src/plots/cartesian/dragbox.js index 05d7e7a0f8a..85058a998d7 100644 --- a/src/plots/cartesian/dragbox.js +++ b/src/plots/cartesian/dragbox.js @@ -224,6 +224,10 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { } function zoomMove(dx0, dy0) { + if(gd._transitioningWithDuration) { + return false; + } + var x1 = Math.max(0, Math.min(pw, dx0 + x0)), y1 = Math.max(0, Math.min(ph, dy0 + y0)), dx = Math.abs(x1 - x0), @@ -380,15 +384,22 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { fullLayout._plots[plotinfo.mainplot] : plotinfo; function zoomWheel(e) { - recomputeAxisLists(); // deactivate mousewheel scrolling on embedded graphs // devs can override this with layout._enablescrollzoom, // but _ ensures this setting won't leave their page if(!gd._context.scrollZoom && !fullLayout._enablescrollzoom) { return; } + + // If a transition is in progress, then disable any behavior: + if(gd._transitioningWithDuration) { + return Lib.pauseEvent(e); + } + var pc = gd.querySelector('.plotly'); + recomputeAxisLists(); + // if the plot has scrollbars (more than a tiny excess) // disable scrollzoom too. if(pc.scrollHeight - pc.clientHeight > 10 || @@ -456,6 +467,11 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { // plotDrag: move the plot in response to a drag function plotDrag(dx, dy) { + // If a transition is in progress, then disable any behavior: + if(gd._transitioningWithDuration) { + return; + } + recomputeAxisLists(); function dragAxList(axList, pix) { @@ -558,6 +574,8 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { } function doubleClick() { + if(gd._transitioningWithDuration) return; + var doubleClickConfig = gd._context.doubleClick, axList = (xActive ? xa : []).concat(yActive ? ya : []), attrs = {}; diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index 19d5eb2f17d..96e2ecd43ca 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -22,7 +22,7 @@ var linkTraces = require('./link_traces'); var polygonTester = require('../../lib/polygon').tester; module.exports = function plot(gd, plotinfo, cdscatter, transitionConfig, makeOnCompleteCallback) { - var i, uids, selection, join; + var i, uids, selection, join, onComplete; var scatterlayer = plotinfo.plot.select('g.scatterlayer'); @@ -31,14 +31,6 @@ module.exports = function plot(gd, plotinfo, cdscatter, transitionConfig, makeOn var isFullReplot = !transitionConfig; var hasTransition = !!transitionConfig && transitionConfig.duration > 0; - var onComplete; - if(makeOnCompleteCallback && hasTransition) { - // If it was passed a callback to register completion, make a callback. If - // this is created, then it must be executed on completion, otherwise the - // pos-transition redraw will not execute: - onComplete = makeOnCompleteCallback(); - } - selection = scatterlayer.selectAll('g.trace'); join = selection.data(cdscatter, function(d) {return d[0].trace.uid;}); @@ -71,11 +63,21 @@ module.exports = function plot(gd, plotinfo, cdscatter, transitionConfig, makeOn }); if(hasTransition) { + if(makeOnCompleteCallback) { + // If it was passed a callback to register completion, make a callback. If + // this is created, then it must be executed on completion, otherwise the + // pos-transition redraw will not execute: + onComplete = makeOnCompleteCallback(); + } + var transition = d3.transition() .duration(transitionConfig.duration) .ease(transitionConfig.ease) .each('end', function() { onComplete && onComplete(); + }) + .each('interrupt', function() { + onComplete && onComplete(); }); transition.each(function() { From dd0b1f8d1c7f7e0ab213dbda5104bafca4a30cef Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 25 Aug 2016 16:05:26 -0400 Subject: [PATCH 21/45] Expand the animate API --- src/plot_api/plot_api.js | 141 ++++++++++++++++++++--- src/plots/frame_attributes.js | 55 +++++++++ src/plots/plots.js | 30 ++++- test/image/mocks/animation.json | 20 ++-- test/jasmine/tests/animate_test.js | 121 +++++++++++++++++-- test/jasmine/tests/compute_frame_test.js | 20 ++-- test/jasmine/tests/frame_api_test.js | 22 ++-- 7 files changed, 352 insertions(+), 57 deletions(-) create mode 100644 src/plots/frame_attributes.js diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 453b082f324..3dfa5754a23 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2532,7 +2532,7 @@ Plotly.relayout = function relayout(gd, astr, val) { * @param {string id or DOM element} gd * the id or DOM element of the graph container div */ -Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { +Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig, onTransitioned) { gd = getGraphDiv(gd); var i, traceIdx; @@ -2700,6 +2700,8 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { gd._transitioningWithDuration = false; gd.emit('plotly_transitioned', []); + onTransitioned && onTransitioned(); + onTransitioned = null; }); } @@ -2753,22 +2755,133 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { * @param {object} transitionConfig * configuration for transition */ -Plotly.animate = function(gd, frameName, transitionConfig) { +Plotly.animate = function(gd, groupNameOrFrameList, transitionConfig) { gd = getGraphDiv(gd); + var trans = gd._transitionData; - if(!gd._transitionData._frameHash[frameName]) { - Lib.warn('animateToFrame failure: keyframe does not exist', frameName); - return Promise.reject(); + // This is the queue of frames that will be animated as soon as possible. They + // are popped immediately upon the *start* of a transition: + if(!trans._frameQueue) { + trans._frameQueue = []; + } + + // Since frames are popped immediately, an empty queue only means all frames have + // *started* to transition, not that the animation is complete. To solve that, + // track a separate counter that increments at the same time as frames are added + // to the queue, but decrements only when the transition is complete. + if(trans._frameWaitingCnt === undefined) { + trans._frameWaitingCnt = 0; + } + + function queueFrames(frameList) { + if(frameList.length === 0) return; + + for(var i = 0; i < frameList.length; i++) { + var computedFrame = Plots.computeFrame(gd, frameList[i].name); + + trans._frameWaitingCnt++; + trans._frameQueue.push({ + frame: computedFrame, + name: frameList[i].name, + transitionConfig: frameList[i].transitionConfig || {}, + frameduration: 0, + }); + } + + if(!trans._animationRaf) { + beginAnimation(); + } } - var computedFrame = Plots.computeFrame(gd, frameName); + function completeAnimation() { + cancelAnimationFrame(trans._animationRaf); + trans._animationRaf = null; + } + + function beginAnimation() { + gd.emit('plotly_animating'); + + // If no timer is running, then set last frame = long ago: + trans._lastframeat = 0; + trans._timetonext = 0; + + var doFrame = function() { + // Check if we need to pop a frame: + if(Date.now() - trans._lastframeat > trans._timetonext) { + var newFrame = trans._frameQueue.shift(); + + var onTransitioned = function() { + trans._frameWaitingCnt--; + if(trans._frameWaitingCnt === 0) { + gd.emit('plotly_animated'); + } + }; - return Plotly.transition(gd, - computedFrame.data, - computedFrame.layout, - computedFrame.traceIndices, - transitionConfig - ); + if(newFrame) { + trans._lastframeat = Date.now(); + trans._timetonext = newFrame.transitionConfig.frameduration === undefined ? 50 : newFrame.transitionConfig.frameduration; + + + Plotly.transition(gd, + newFrame.frame.data, + newFrame.frame.layout, + newFrame.frame.traces, + newFrame.transitionConfig, + onTransitioned + ); + } + + if(trans._frameQueue.length === 0) { + completeAnimation(); + return; + } + } + + trans._animationRaf = requestAnimationFrame(doFrame); + }; + + return doFrame(); + } + + var counter = 0; + function setTransitionConfig(frame) { + if(Array.isArray(transitionConfig)) { + frame.transitionConfig = transitionConfig[counter]; + } else { + frame.transitionConfig = transitionConfig; + } + counter++; + return frame; + } + + var i, frame; + var frameList = []; + var allFrames = typeof groupNameOrFrameList === 'undefined'; + if(allFrames || typeof groupNameOrFrameList === 'string') { + for(i = 0; i < trans._frames.length; i++) { + frame = trans._frames[i]; + + if(allFrames || frame.group === groupNameOrFrameList) { + frameList.push(setTransitionConfig({name: frame.name})); + } + } + } else if(Array.isArray(groupNameOrFrameList)) { + for(i = 0; i < groupNameOrFrameList.length; i++) { + frameList.push(setTransitionConfig({name: groupNameOrFrameList[i]})); + } + } + + // Verify that all of these frames actually exist; return and reject if not: + for(i = 0; i < frameList.length; i++) { + if(!trans._frameHash[frameList[i].name]) { + Lib.warn('animate failure: frame not found: "' + frameList[i].name + '"'); + return Promise.reject(); + } + } + + queueFrames(frameList); + + return Promise.resolve(); }; /** @@ -2780,7 +2893,7 @@ Plotly.animate = function(gd, frameName, transitionConfig) { * - data: {array of objects} trace data * - layout {object} layout definition * - traces {array} trace indices - * - baseFrame {string} name of keyframe from which this keyframe gets defaults + * - baseframe {string} name of keyframe from which this keyframe gets defaults */ Plotly.addFrames = function(gd, frameList, indices) { gd = getGraphDiv(gd); @@ -2805,7 +2918,7 @@ Plotly.addFrames = function(gd, frameList, indices) { var insertions = []; for(i = frameList.length - 1; i >= 0; i--) { insertions.push({ - frame: frameList[i], + frame: Plots.supplyFrameDefaults(frameList[i]), index: (indices && indices[i] !== undefined && indices[i] !== null) ? indices[i] : bigIndex + i }); } diff --git a/src/plots/frame_attributes.js b/src/plots/frame_attributes.js new file mode 100644 index 00000000000..629a1225876 --- /dev/null +++ b/src/plots/frame_attributes.js @@ -0,0 +1,55 @@ +/** +* Copyright 2012-2016, 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 = { + group: { + valType: 'string', + role: 'info', + description: [ + 'An identifier that specifies the group to which the frame belongs,', + 'used by animate to select a subset of frames.' + ].join(' ') + }, + name: { + valType: 'string', + role: 'info', + description: ['A label by which to identify the frame'] + }, + traces: { + valType: 'data_array', + description: [ + 'A list of trace indices that identify the respective traces in the', + 'data attribute' + ].join(' ') + }, + baseframe: { + valType: 'string', + role: 'info', + description: [ + 'The name of the frame into which this frame\'s properties are merged', + 'before applying. This is used to unify properties and avoid needing', + 'to specify the same values for the same properties in multiple frames.' + ].join(' ') + }, + data: { + valType: 'data_array', + description: [ + 'A list of traces this frame modifies. The format is identical to the', + 'normal trace definition.' + ] + }, + layout: { + valType: 'any', + description: [ + 'Layout properties which this frame modifies. The format is identical', + 'to the normal layout definition.' + ] + } +}; diff --git a/src/plots/plots.js b/src/plots/plots.js index e6ed4039ac3..e97d96b843e 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -19,6 +19,7 @@ var Color = require('../components/color'); var plots = module.exports = {}; var transitionAttrs = require('./transition_attributes'); +var frameAttrs = require('./frame_attributes'); // Expose registry methods on Plots for backward-compatibility Lib.extendFlat(plots, Registry); @@ -626,6 +627,23 @@ plots.supplyTransitionDefaults = function(config) { return configOut; }; +plots.supplyFrameDefaults = function(frameIn) { + var frameOut = {}; + + function coerce(attr, dflt) { + return Lib.coerce(frameIn, frameOut, frameAttrs, attr, dflt); + } + + coerce('group'); + coerce('name'); + coerce('traces'); + coerce('baseframe'); + coerce('data'); + coerce('layout'); + + return frameOut; +}; + plots.supplyTraceDefaults = function(traceIn, traceIndex, layout) { var traceOut = {}, defaultColor = Color.defaults[traceIndex % Color.defaults.length]; @@ -1211,7 +1229,7 @@ plots.computeFrame = function(gd, frameName) { var frameNameStack = [framePtr.name]; // Follow frame pointers: - while((framePtr = frameLookup[framePtr.baseFrame])) { + while((framePtr = frameLookup[framePtr.baseframe])) { // Avoid infinite loops: if(frameNameStack.indexOf(framePtr.name) !== -1) break; @@ -1234,7 +1252,7 @@ plots.computeFrame = function(gd, frameName) { if(!result.data) { result.data = []; } - traceIndices = framePtr.traceIndices; + traceIndices = framePtr.traces; if(!traceIndices) { // If not defined, assume serial order starting at zero @@ -1244,8 +1262,8 @@ plots.computeFrame = function(gd, frameName) { } } - if(!result.traceIndices) { - result.traceIndices = []; + if(!result.traces) { + result.traces = []; } for(i = 0; i < framePtr.data.length; i++) { @@ -1256,10 +1274,10 @@ plots.computeFrame = function(gd, frameName) { continue; } - destIndex = result.traceIndices.indexOf(traceIndex); + destIndex = result.traces.indexOf(traceIndex); if(destIndex === -1) { destIndex = result.data.length; - result.traceIndices[destIndex] = traceIndex; + result.traces[destIndex] = traceIndex; } copy = Lib.extendDeepNoArrays({}, framePtr.data[i]); diff --git a/test/image/mocks/animation.json b/test/image/mocks/animation.json index 34cb8e737cb..cfb411ac943 100644 --- a/test/image/mocks/animation.json +++ b/test/image/mocks/animation.json @@ -40,39 +40,43 @@ } }, { "name": "frame0", + "group": "even-frames", "data": [ {"y": [0.5, 1.5, 7.5]}, {"y": [4.25, 2.25, 3.05]} ], - "baseFrame": "base", - "traceIndices": [0, 1], + "baseframe": "base", + "traces": [0, 1], "layout": { } }, { "name": "frame1", + "group": "odd-frames", "data": [ {"y": [2.1, 1, 7]}, {"y": [4.5, 2.5, 3.1]} ], - "baseFrame": "base", - "traceIndices": [0, 1], + "baseframe": "base", + "traces": [0, 1], "layout": { } }, { "name": "frame2", + "group": "even-frames", "data": [ {"y": [3.5, 0.5, 6]}, {"y": [5.7, 2.7, 3.9]} ], - "baseFrame": "base", - "traceIndices": [0, 1], + "baseframe": "base", + "traces": [0, 1], "layout": { } }, { "name": "frame3", + "group": "odd-frames", "data": [ {"y": [5.1, 0.25, 5]}, {"y": [7, 2.9, 6]} ], - "baseFrame": "base", - "traceIndices": [0, 1], + "baseframe": "base", + "traces": [0, 1], "layout": { "xaxis": { "range": [-1, 4] diff --git a/test/jasmine/tests/animate_test.js b/test/jasmine/tests/animate_test.js index 9eb59cd7a6e..c1b0802b3d5 100644 --- a/test/jasmine/tests/animate_test.js +++ b/test/jasmine/tests/animate_test.js @@ -11,14 +11,33 @@ var mock = require('@mocks/animation'); describe('Test animate API', function() { 'use strict'; - var gd; + var gd, mockCopy; + + function verifyQueueEmpty(gd) { + expect(gd._transitionData._frameQueue.length).toEqual(0); + } + + function verifyFrameTransitionOrder(gd, expectedFrames) { + var calls = PlotlyInternal.transition.calls; + + expect(calls.count()).toEqual(expectedFrames.length); + + for(var i = 0; i < calls.count(); i++) { + expect(calls.argsFor(i)[1]).toEqual( + gd._transitionData._frameHash[expectedFrames[i]].data + ); + } + } beforeEach(function(done) { gd = createGraphDiv(); - var mockCopy = Lib.extendDeep({}, mock); + mockCopy = Lib.extendDeep({}, mock); spyOn(PlotlyInternal, 'transition').and.callFake(function() { + // Just a fake; invoke the callback after a bit of time: + if(arguments[5]) setTimeout(arguments[5], 50); + return Promise.resolve(); }); @@ -31,18 +50,14 @@ describe('Test animate API', function() { destroyGraphDiv(); }); - it('rejects if the frame is not found', function(done) { - Plotly.animate(gd, 'foobar').then(fail).then(done, done); - }); - it('animates to a frame', function(done) { - Plotly.animate(gd, 'frame0').then(function() { + Plotly.animate(gd, ['frame0']).then(function() { expect(PlotlyInternal.transition).toHaveBeenCalled(); var args = PlotlyInternal.transition.calls.mostRecent().args; // was called with gd, data, layout, traceIndices, transitionConfig: - expect(args.length).toEqual(5); + expect(args.length).toEqual(6); // data has two traces: expect(args[1].length).toEqual(2); @@ -57,4 +72,94 @@ describe('Test animate API', function() { expect(args[3]).toEqual([0, 1]); }).catch(fail).then(done); }); + + it('rejects if a frame is not found', function(done) { + Plotly.animate(gd, ['foobar']).then(fail).then(done, done); + }); + + it('animates to a single frame', function(done) { + gd.on('plotly_animated', function() { + expect(PlotlyInternal.transition.calls.count()).toEqual(1); + verifyQueueEmpty(gd); + done(); + }); + + Plotly.animate(gd, ['frame0']); + }); + + it('animates to a single frame', function(done) { + gd.on('plotly_animated', function() { + expect(PlotlyInternal.transition.calls.count()).toEqual(2); + verifyQueueEmpty(gd); + done(); + }); + + Plotly.animate(gd, ['frame0', 'frame1']); + }); + + it('animates frames by group', function(done) { + gd.on('plotly_animated', function() { + expect(PlotlyInternal.transition.calls.count()).toEqual(2); + verifyQueueEmpty(gd); + done(); + }); + + Plotly.animate(gd, 'even-frames'); + }); + + it('animates groups in the correct order', function(done) { + gd.on('plotly_animated', function() { + verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame1', 'frame3']); + verifyQueueEmpty(gd); + done(); + }); + + Plotly.animate(gd, 'even-frames'); + Plotly.animate(gd, 'odd-frames'); + }); + + it('animates frames in the correct order', function(done) { + gd.on('plotly_animated', function() { + verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame1', 'frame3']); + verifyQueueEmpty(gd); + done(); + }); + + Plotly.animate(gd, ['frame0', 'frame2', 'frame1', 'frame3']); + }); + + it('animates frames and groups in sequence', function(done) { + gd.on('plotly_animated', function() { + verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame0', 'frame2', 'frame1', 'frame3']); + verifyQueueEmpty(gd); + done(); + }); + + Plotly.animate(gd, 'even-frames'); + Plotly.animate(gd, ['frame0', 'frame2', 'frame1', 'frame3']); + }); + + it('queues successive animations', function(done) { + var starts = 0; + var ends = 0; + + // make sure we don't ignore accidental double-ends: + function checkEndCount() { + expect(ends).toEqual(1); + verifyQueueEmpty(gd); + done(); + } + + gd.on('plotly_animating', function() { + starts++; + }).on('plotly_animated', function() { + ends++; + expect(PlotlyInternal.transition.calls.count()).toEqual(4); + expect(starts).toEqual(1); + setTimeout(checkEndCount, 10); + }); + + Plotly.animate(gd, 'even-frames'); + Plotly.animate(gd, 'odd-frames'); + }); }); diff --git a/test/jasmine/tests/compute_frame_test.js b/test/jasmine/tests/compute_frame_test.js index a77d4b77e1a..fde87f7a71c 100644 --- a/test/jasmine/tests/compute_frame_test.js +++ b/test/jasmine/tests/compute_frame_test.js @@ -76,9 +76,9 @@ describe('Test mergeFrames', function() { beforeEach(function(done) { frames = [ - {name: 'frame0', baseFrame: 'frame1', data: [{'marker.size': 0}]}, - {name: 'frame1', baseFrame: 'frame2', data: [{'marker.size': 1}]}, - {name: 'frame2', baseFrame: 'frame0', data: [{'marker.size': 2}]} + {name: 'frame0', baseframe: 'frame1', data: [{'marker.size': 0}]}, + {name: 'frame1', baseframe: 'frame2', data: [{'marker.size': 1}]}, + {name: 'frame2', baseframe: 'frame0', data: [{'marker.size': 2}]} ]; results = [ @@ -133,7 +133,7 @@ describe('Test mergeFrames', function() { }); it('merges orthogonal traces', function() { - frames[0].baseFrame = frames[1].name; + frames[0].baseframe = frames[1].name; // This technically returns a promise, but it's not actually asynchronous so // that we'll just keep this synchronous: @@ -153,7 +153,7 @@ describe('Test mergeFrames', function() { }); it('merges overlapping traces', function() { - frames[0].baseFrame = frames[2].name; + frames[0].baseframe = frames[2].name; Plotly.addFrames(gd, frames.map(clone)); @@ -166,9 +166,9 @@ describe('Test mergeFrames', function() { }); it('merges partially overlapping traces', function() { - frames[0].baseFrame = frames[1].name; - frames[1].baseFrame = frames[2].name; - frames[2].baseFrame = frames[3].name; + frames[0].baseframe = frames[1].name; + frames[1].baseframe = frames[2].name; + frames[2].baseframe = frames[3].name; Plotly.addFrames(gd, frames.map(clone)); @@ -184,7 +184,7 @@ describe('Test mergeFrames', function() { }); it('assumes serial order without traceIndices specified', function() { - frames[4].baseFrame = frames[3].name; + frames[4].baseframe = frames[3].name; Plotly.addFrames(gd, frames.map(clone)); @@ -220,7 +220,7 @@ describe('Test mergeFrames', function() { }); it('merges layouts', function() { - frames[0].baseFrame = frames[1].name; + frames[0].baseframe = frames[1].name; var result = computeFrame(gd, 'frame0'); expect(result).toEqual({ diff --git a/test/jasmine/tests/frame_api_test.js b/test/jasmine/tests/frame_api_test.js index a37174b5adc..fed2b0cdba5 100644 --- a/test/jasmine/tests/frame_api_test.js +++ b/test/jasmine/tests/frame_api_test.js @@ -80,11 +80,11 @@ describe('Test frame api', function() { } Plotly.addFrames(gd, frames).then(validate).then(function() { - return Plotly.addFrames(gd, [{name: 'frame5', x: [1]}, {name: 'frame7', x: [2]}, {name: 'frame10', x: [3]}], [5, 7, undefined]); + return Plotly.addFrames(gd, [{name: 'frame5', data: [1]}, {name: 'frame7', data: [2]}, {name: 'frame10', data: [3]}], [5, 7, undefined]); }).then(function() { - expect(f[5]).toEqual({name: 'frame5', x: [1]}); - expect(f[7]).toEqual({name: 'frame7', x: [2]}); - expect(f[10]).toEqual({name: 'frame10', x: [3]}); + expect(f[5]).toEqual({name: 'frame5', data: [1]}); + expect(f[7]).toEqual({name: 'frame7', data: [2]}); + expect(f[10]).toEqual({name: 'frame10', data: [3]}); return Plotly.Queue.undo(gd); }).then(validate).catch(fail).then(done); @@ -104,11 +104,11 @@ describe('Test frame api', function() { } Plotly.addFrames(gd, frames).then(validate).then(function() { - return Plotly.addFrames(gd, [{name: 'frame10', x: [3]}, {name: 'frame7', x: [2]}, {name: 'frame5', x: [1]}], [undefined, 7, 5]); + return Plotly.addFrames(gd, [{name: 'frame10', data: [3]}, {name: 'frame7', data: [2]}, {name: 'frame5', data: [1]}], [undefined, 7, 5]); }).then(function() { - expect(f[5]).toEqual({name: 'frame5', x: [1]}); - expect(f[7]).toEqual({name: 'frame7', x: [2]}); - expect(f[10]).toEqual({name: 'frame10', x: [3]}); + expect(f[5]).toEqual({name: 'frame5', data: [1]}); + expect(f[7]).toEqual({name: 'frame7', data: [2]}); + expect(f[10]).toEqual({name: 'frame10', data: [3]}); return Plotly.Queue.undo(gd); }).then(validate).catch(fail).then(done); @@ -132,8 +132,8 @@ describe('Test frame api', function() { it('overwrites frames', function(done) { // The whole shebang. This hits insertion + replacements + deletion + undo + redo: - Plotly.addFrames(gd, [{name: 'test1', x: 'y'}, {name: 'test2'}]).then(function() { - expect(f).toEqual([{name: 'test1', x: 'y'}, {name: 'test2'}]); + Plotly.addFrames(gd, [{name: 'test1', data: ['y']}, {name: 'test2'}]).then(function() { + expect(f).toEqual([{name: 'test1', data: ['y']}, {name: 'test2'}]); expect(Object.keys(h)).toEqual(['test1', 'test2']); return Plotly.addFrames(gd, [{name: 'test1'}, {name: 'test3'}]); @@ -143,7 +143,7 @@ describe('Test frame api', function() { return Plotly.Queue.undo(gd); }).then(function() { - expect(f).toEqual([{name: 'test1', x: 'y'}, {name: 'test2'}]); + expect(f).toEqual([{name: 'test1', data: ['y']}, {name: 'test2'}]); expect(Object.keys(h)).toEqual(['test1', 'test2']); return Plotly.Queue.redo(gd); From bd6e8c0024a189f8b1d2b196e89ef9e58a7f7c12 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Sun, 28 Aug 2016 11:34:37 -0400 Subject: [PATCH 22/45] Rework transition and animate to resolve at the *end* --- src/plot_api/plot_api.js | 339 +++++++++++++++----------- src/plots/animation_attributes.js | 22 ++ src/plots/plots.js | 27 +- src/plots/transition_attributes.js | 7 + test/jasmine/assets/delay.js | 17 ++ test/jasmine/tests/animate_test.js | 326 ++++++++++++++++--------- test/jasmine/tests/transition_test.js | 125 +++++++--- 7 files changed, 565 insertions(+), 298 deletions(-) create mode 100644 src/plots/animation_attributes.js create mode 100644 test/jasmine/assets/delay.js diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 3dfa5754a23..889e1a06bc1 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2532,13 +2532,13 @@ Plotly.relayout = function relayout(gd, astr, val) { * @param {string id or DOM element} gd * the id or DOM element of the graph container div */ -Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig, onTransitioned) { +Plotly.transition = function(gd, data, layout, traceIndices, transitionOpts) { gd = getGraphDiv(gd); var i, traceIdx; var fullLayout = gd._fullLayout; - transitionConfig = Plots.supplyTransitionDefaults(transitionConfig); + transitionOpts = Plots.supplyTransitionDefaults(transitionOpts); var dataLength = Array.isArray(data) ? data.length : 0; @@ -2621,76 +2621,79 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig, o var aborted = false; function executeTransitions() { - // This flag is used to disabled things like autorange: - gd._transitioning = true; + return new Promise(function(resolve) { + // This flag is used to disabled things like autorange: + gd._transitioning = true; - // When instantaneous updates are coming through quickly, it's too much to simply disable - // all interaction, so store this flag so we can disambiguate whether mouse interactions - // should be fully disabled or not: - if(transitionConfig.duration > 0) { - gd._transitioningWithDuration = true; - } + // When instantaneous updates are coming through quickly, it's too much to simply disable + // all interaction, so store this flag so we can disambiguate whether mouse interactions + // should be fully disabled or not: + if(transitionOpts.duration > 0) { + gd._transitioningWithDuration = true; + } - gd._transitionData._interruptCallbacks.push(function() { - aborted = true; - }); + gd._transitionData._interruptCallbacks.push(function() { + aborted = true; + }); - // Construct callbacks that are executed on transition end. This ensures the d3 transitions - // are *complete* before anything else is done. - var numCallbacks = 0; - var numCompleted = 0; - function makeCallback() { - numCallbacks++; - return function() { - numCompleted++; - // When all are complete, perform a redraw: - if(!aborted && numCompleted === numCallbacks) { - completeTransition(); - } - }; - } + // Construct callbacks that are executed on transition end. This ensures the d3 transitions + // are *complete* before anything else is done. + var numCallbacks = 0; + var numCompleted = 0; + function makeCallback() { + numCallbacks++; + return function() { + numCompleted++; + // When all are complete, perform a redraw: + if(!aborted && numCompleted === numCallbacks) { + completeTransition(resolve); + } + }; + } - var traceTransitionConfig; - var j; - var basePlotModules = fullLayout._basePlotModules; - var hasAxisTransition = false; + var traceTransitionOpts; + var j; + var basePlotModules = fullLayout._basePlotModules; + var hasAxisTransition = false; - if(layout) { - for(j = 0; j < basePlotModules.length; j++) { - if(basePlotModules[j].transitionAxes) { - var newLayout = Lib.expandObjectPaths(layout); - hasAxisTransition = basePlotModules[j].transitionAxes(gd, newLayout, transitionConfig, makeCallback) || hasAxisTransition; + if(layout) { + for(j = 0; j < basePlotModules.length; j++) { + if(basePlotModules[j].transitionAxes) { + var newLayout = Lib.expandObjectPaths(layout); + hasAxisTransition = basePlotModules[j].transitionAxes(gd, newLayout, transitionOpts, makeCallback) || hasAxisTransition; + } } } - } - // Here handle the exception that we refuse to animate scales and axes at the same - // time. In other words, if there's an axis transition, then set the data transition - // to instantaneous. - if(hasAxisTransition) { - traceTransitionConfig = Lib.extendFlat({}, transitionConfig); - traceTransitionConfig.duration = 0; - } else { - traceTransitionConfig = transitionConfig; - } + // Here handle the exception that we refuse to animate scales and axes at the same + // time. In other words, if there's an axis transition, then set the data transition + // to instantaneous. + if(hasAxisTransition) { + traceTransitionOpts = Lib.extendFlat({}, transitionOpts); + traceTransitionOpts.duration = 0; + } else { + traceTransitionOpts = transitionOpts; + } - for(j = 0; j < basePlotModules.length; j++) { - // Note that we pass a callback to *create* the callback that must be invoked on completion. - // This is since not all traces know about transitions, so it greatly simplifies matters if - // the trace is responsible for creating a callback, if needed, and then executing it when - // the time is right. - basePlotModules[j].plot(gd, transitionedTraces, traceTransitionConfig, makeCallback); - } + for(j = 0; j < basePlotModules.length; j++) { + // Note that we pass a callback to *create* the callback that must be invoked on completion. + // This is since not all traces know about transitions, so it greatly simplifies matters if + // the trace is responsible for creating a callback, if needed, and then executing it when + // the time is right. + basePlotModules[j].plot(gd, transitionedTraces, traceTransitionOpts, makeCallback); + } + + // If nothing else creates a callback, then this will trigger the completion in the next tick: + setTimeout(makeCallback()); - // If nothing else creates a callback, then this will trigger the completion in the next tick: - setTimeout(makeCallback('first')); + }); } - function completeTransition() { + function completeTransition(callback) { flushCallbacks(gd._transitionData._interruptCallbacks); return Promise.resolve().then(function() { - if(transitionConfig.redraw) { + if(transitionOpts.redraw) { return Plotly.redraw(gd); } }).then(function() { @@ -2700,9 +2703,7 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig, o gd._transitioningWithDuration = false; gd.emit('plotly_transitioned', []); - onTransitioned && onTransitioned(); - onTransitioned = null; - }); + }).then(callback); } function interruptPreviousTransitions() { @@ -2752,10 +2753,10 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig, o * * @param {string} name * name of the keyframe to create - * @param {object} transitionConfig + * @param {object} transitionOpts * configuration for transition */ -Plotly.animate = function(gd, groupNameOrFrameList, transitionConfig) { +Plotly.animate = function(gd, groupNameOrFrameList, transitionOpts, animationOpts) { gd = getGraphDiv(gd); var trans = gd._transitionData; @@ -2765,6 +2766,8 @@ Plotly.animate = function(gd, groupNameOrFrameList, transitionConfig) { trans._frameQueue = []; } + animationOpts = Plots.supplyAnimationDefaults(animationOpts); + // Since frames are popped immediately, an empty queue only means all frames have // *started* to transition, not that the animation is complete. To solve that, // track a separate counter that increments at the same time as frames are added @@ -2773,115 +2776,161 @@ Plotly.animate = function(gd, groupNameOrFrameList, transitionConfig) { trans._frameWaitingCnt = 0; } - function queueFrames(frameList) { - if(frameList.length === 0) return; + function getTransitionOpts(i) { + if(Array.isArray(transitionOpts)) { + if(i >= transitionOpts.length) { + return transitionOpts[0]; + } else { + return transitionOpts[i]; + } + } else { + return transitionOpts; + } + } - for(var i = 0; i < frameList.length; i++) { - var computedFrame = Plots.computeFrame(gd, frameList[i].name); + return new Promise(function(resolve, reject) { + function discardExistingFrames() { + if(trans._frameQueue.length === 0) { + return; + } - trans._frameWaitingCnt++; - trans._frameQueue.push({ - frame: computedFrame, - name: frameList[i].name, - transitionConfig: frameList[i].transitionConfig || {}, - frameduration: 0, - }); - } + while(trans._frameQueue.length) { + var next = trans._frameQueue.pop(); + if(next.onInterrupt) { + next.onInterrupt(); + } + } - if(!trans._animationRaf) { - beginAnimation(); + gd.emit('plotly_animationinterrupted', []); } - } - function completeAnimation() { - cancelAnimationFrame(trans._animationRaf); - trans._animationRaf = null; - } + function queueFrames(frameList) { + if(frameList.length === 0) return; - function beginAnimation() { - gd.emit('plotly_animating'); + for(var i = 0; i < frameList.length; i++) { + var computedFrame = Plots.computeFrame(gd, frameList[i].name); - // If no timer is running, then set last frame = long ago: - trans._lastframeat = 0; - trans._timetonext = 0; + var opts = Plots.supplyTransitionDefaults(getTransitionOpts(i)); - var doFrame = function() { - // Check if we need to pop a frame: - if(Date.now() - trans._lastframeat > trans._timetonext) { - var newFrame = trans._frameQueue.shift(); - - var onTransitioned = function() { - trans._frameWaitingCnt--; - if(trans._frameWaitingCnt === 0) { - gd.emit('plotly_animated'); - } + var nextFrame = { + frame: computedFrame, + name: frameList[i].name, + transitionOpts: opts }; - if(newFrame) { - trans._lastframeat = Date.now(); - trans._timetonext = newFrame.transitionConfig.frameduration === undefined ? 50 : newFrame.transitionConfig.frameduration; + if(i === frameList.length - 1) { + nextFrame.onComplete = resolve; + nextFrame.onInterrupt = reject; + } + trans._frameQueue.push(nextFrame); + } - Plotly.transition(gd, - newFrame.frame.data, - newFrame.frame.layout, - newFrame.frame.traces, - newFrame.transitionConfig, - onTransitioned - ); - } + if(!trans._animationRaf) { + beginAnimationLoop(); + } + } + + function stopAnimationLoop() { + cancelAnimationFrame(trans._animationRaf); + trans._animationRaf = null; + } - if(trans._frameQueue.length === 0) { - completeAnimation(); - return; + function beginAnimationLoop() { + gd.emit('plotly_animating'); + + // If no timer is running, then set last frame = long ago: + trans._lastframeat = 0; + trans._timetonext = 0; + trans._runningTransitions = 0; + trans._currentFrame = null; + + var doFrame = function() { + // Check if we need to pop a frame: + if(Date.now() - trans._lastframeat > trans._timetonext) { + if(trans._currentFrame) { + if(trans._currentFrame.onComplete) { + trans._currentFrame.onComplete(); + } + } + + var newFrame = trans._currentFrame = trans._frameQueue.shift(); + + if(newFrame) { + trans._lastframeat = Date.now(); + trans._timetonext = newFrame.transitionOpts.frameduration; + + Plotly.transition(gd, + newFrame.frame.data, + newFrame.frame.layout, + newFrame.frame.traces, + newFrame.transitionOpts + ).then(function() { + if(trans._frameQueue.length === 0) { + gd.emit('plotly_animated'); + if(trans._currentFrame && trans._currentFrame.onComplete) { + trans._currentFrame.onComplete(); + trans._currentFrame = null; + } + } + }); + } + + if(trans._frameQueue.length === 0) { + stopAnimationLoop(); + return; + } } - } - trans._animationRaf = requestAnimationFrame(doFrame); - }; + trans._animationRaf = requestAnimationFrame(doFrame); + }; - return doFrame(); - } + return doFrame(); + } - var counter = 0; - function setTransitionConfig(frame) { - if(Array.isArray(transitionConfig)) { - frame.transitionConfig = transitionConfig[counter]; - } else { - frame.transitionConfig = transitionConfig; + var counter = 0; + function setTransitionConfig(frame) { + if(Array.isArray(transitionOpts)) { + frame.transitionOpts = transitionOpts[counter]; + } else { + frame.transitionOpts = transitionOpts; + } + counter++; + return frame; } - counter++; - return frame; - } - var i, frame; - var frameList = []; - var allFrames = typeof groupNameOrFrameList === 'undefined'; - if(allFrames || typeof groupNameOrFrameList === 'string') { - for(i = 0; i < trans._frames.length; i++) { - frame = trans._frames[i]; + var i, frame; + var frameList = []; + var allFrames = typeof groupNameOrFrameList === 'undefined'; + if(allFrames || typeof groupNameOrFrameList === 'string') { + for(i = 0; i < trans._frames.length; i++) { + frame = trans._frames[i]; - if(allFrames || frame.group === groupNameOrFrameList) { - frameList.push(setTransitionConfig({name: frame.name})); + if(allFrames || frame.group === groupNameOrFrameList) { + frameList.push(setTransitionConfig({name: frame.name})); + } + } + } else if(Array.isArray(groupNameOrFrameList)) { + for(i = 0; i < groupNameOrFrameList.length; i++) { + frameList.push(setTransitionConfig({name: groupNameOrFrameList[i]})); } } - } else if(Array.isArray(groupNameOrFrameList)) { - for(i = 0; i < groupNameOrFrameList.length; i++) { - frameList.push(setTransitionConfig({name: groupNameOrFrameList[i]})); - } - } - // Verify that all of these frames actually exist; return and reject if not: - for(i = 0; i < frameList.length; i++) { - if(!trans._frameHash[frameList[i].name]) { - Lib.warn('animate failure: frame not found: "' + frameList[i].name + '"'); - return Promise.reject(); + // Verify that all of these frames actually exist; return and reject if not: + for(i = 0; i < frameList.length; i++) { + if(!trans._frameHash[frameList[i].name]) { + Lib.warn('animate failure: frame not found: "' + frameList[i].name + '"'); + reject(); + return; + } } - } - queueFrames(frameList); + if(animationOpts.immediate) { + discardExistingFrames(); + } - return Promise.resolve(); + queueFrames(frameList); + }); }; /** diff --git a/src/plots/animation_attributes.js b/src/plots/animation_attributes.js new file mode 100644 index 00000000000..63bc7d0a42b --- /dev/null +++ b/src/plots/animation_attributes.js @@ -0,0 +1,22 @@ +/** +* Copyright 2012-2016, 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 = { + immediate: { + valType: 'boolean', + role: 'info', + dflt: false, + description: [ + 'If true, exisitng queued animation frames are discarded before beginning', + 'the next animation sequence. Promises for exising `Plotly.animate` calls', + 'are rejected and a `plotly_animateinterrupt` event is emitted.' + ].join(' ') + }, +}; diff --git a/src/plots/plots.js b/src/plots/plots.js index e97d96b843e..a99a164227b 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -19,6 +19,7 @@ var Color = require('../components/color'); var plots = module.exports = {}; var transitionAttrs = require('./transition_attributes'); +var animationAttrs = require('./animation_attributes'); var frameAttrs = require('./frame_attributes'); // Expose registry methods on Plots for backward-compatibility @@ -613,18 +614,31 @@ plots.supplyDataDefaults = function(dataIn, dataOut, layout) { } }; -plots.supplyTransitionDefaults = function(config) { - var configOut = {}; +plots.supplyAnimationDefaults = function(opts) { + var optsOut = {}; function coerce(attr, dflt) { - return Lib.coerce(config, configOut, transitionAttrs, attr, dflt); + return Lib.coerce(opts || {}, optsOut, animationAttrs, attr, dflt); } + coerce('immediate'); + + return optsOut; +}; + +plots.supplyTransitionDefaults = function(opts) { + var optsOut = {}; + + function coerce(attr, dflt) { + return Lib.coerce(opts || {}, optsOut, transitionAttrs, attr, dflt); + } + + coerce('frameduration'); coerce('duration'); coerce('ease'); coerce('redraw'); - return configOut; + return optsOut; }; plots.supplyFrameDefaults = function(frameIn) { @@ -856,6 +870,10 @@ plots.purge = function(gd) { // remove modebar if(fullLayout._modeBar) fullLayout._modeBar.destroy(); + if(gd._transitionData._animationRaf) { + cancelAnimationFrame(gd._transitionData._animationRaf); + } + // data and layout delete gd.data; delete gd.layout; @@ -886,6 +904,7 @@ plots.purge = function(gd) { delete gd._hoverTimer; delete gd._lastHoverTime; delete gd._transitionData; + delete gd._transitioning; // remove all event listeners if(gd.removeAllListeners) gd.removeAllListeners(); diff --git a/src/plots/transition_attributes.js b/src/plots/transition_attributes.js index 9ab8d945403..c95a7639ac0 100644 --- a/src/plots/transition_attributes.js +++ b/src/plots/transition_attributes.js @@ -9,6 +9,13 @@ 'use strict'; module.exports = { + frameduration: { + valType: 'number', + role: 'info', + min: 0, + dflt: 500, + description: 'The duration in milliseconds of each frame.' + }, duration: { valType: 'number', role: 'info', diff --git a/test/jasmine/assets/delay.js b/test/jasmine/assets/delay.js new file mode 100644 index 00000000000..8e57a7bf840 --- /dev/null +++ b/test/jasmine/assets/delay.js @@ -0,0 +1,17 @@ +'use strict'; + +/** + * This is a very quick and simple promise delayer. It's not full-featured + * like the `delay` module. + * + * Promise.resolve().then(delay(50)).then(...); + */ +module.exports = function delay(duration) { + return function(value) { + return new Promise(function(resolve) { + setTimeout(function() { + resolve(value); + }, duration || 0); + }); + }; +}; diff --git a/test/jasmine/tests/animate_test.js b/test/jasmine/tests/animate_test.js index c1b0802b3d5..af0c48b6cc8 100644 --- a/test/jasmine/tests/animate_test.js +++ b/test/jasmine/tests/animate_test.js @@ -5,161 +5,265 @@ var Lib = require('@src/lib'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var fail = require('../assets/fail_test'); +var delay = require('../assets/delay'); var mock = require('@mocks/animation'); -describe('Test animate API', function() { - 'use strict'; +function runTests(duration) { + describe('Test animate API (frame duration = ' + duration + ')', function() { + 'use strict'; - var gd, mockCopy; + var gd, mockCopy; + var transOpts = {frameduration: duration}; - function verifyQueueEmpty(gd) { - expect(gd._transitionData._frameQueue.length).toEqual(0); - } + function verifyQueueEmpty(gd) { + expect(gd._transitionData._frameQueue.length).toEqual(0); + } - function verifyFrameTransitionOrder(gd, expectedFrames) { - var calls = PlotlyInternal.transition.calls; + function verifyFrameTransitionOrder(gd, expectedFrames) { + var calls = PlotlyInternal.transition.calls; - expect(calls.count()).toEqual(expectedFrames.length); + expect(calls.count()).toEqual(expectedFrames.length); - for(var i = 0; i < calls.count(); i++) { - expect(calls.argsFor(i)[1]).toEqual( - gd._transitionData._frameHash[expectedFrames[i]].data - ); + for(var i = 0; i < calls.count(); i++) { + expect(calls.argsFor(i)[1]).toEqual( + gd._transitionData._frameHash[expectedFrames[i]].data + ); + } } - } - beforeEach(function(done) { - gd = createGraphDiv(); + beforeEach(function(done) { + gd = createGraphDiv(); - mockCopy = Lib.extendDeep({}, mock); + mockCopy = Lib.extendDeep({}, mock); - spyOn(PlotlyInternal, 'transition').and.callFake(function() { - // Just a fake; invoke the callback after a bit of time: - if(arguments[5]) setTimeout(arguments[5], 50); + spyOn(PlotlyInternal, 'transition').and.callFake(function() { + // Transition's fake behaviro is to resolve after a short period of time: + return Promise.resolve().then(delay(duration)); + }); - return Promise.resolve(); + Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(function() { + Plotly.addFrames(gd, mockCopy.frames); + }).then(done); }); - Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(function() { - Plotly.addFrames(gd, mockCopy.frames); - }).then(done); - }); + afterEach(function() { + // *must* purge between tests otherwise dangling async events might not get cleaned up properly: + Plotly.purge(gd); + destroyGraphDiv(); + }); - afterEach(function() { - destroyGraphDiv(); - }); + it('animates to a frame', function(done) { + Plotly.animate(gd, ['frame0'], {duration: 1.2345}).then(function() { + expect(PlotlyInternal.transition).toHaveBeenCalled(); - it('animates to a frame', function(done) { - Plotly.animate(gd, ['frame0']).then(function() { - expect(PlotlyInternal.transition).toHaveBeenCalled(); + var args = PlotlyInternal.transition.calls.mostRecent().args; - var args = PlotlyInternal.transition.calls.mostRecent().args; + // was called with gd, data, layout, traceIndices, transitionConfig: + expect(args.length).toEqual(5); - // was called with gd, data, layout, traceIndices, transitionConfig: - expect(args.length).toEqual(6); + // data has two traces: + expect(args[1].length).toEqual(2); - // data has two traces: - expect(args[1].length).toEqual(2); + // Verify transition config has been passed: + expect(args[4].duration).toEqual(1.2345); - // layout - expect(args[2]).toEqual({ - xaxis: {range: [0, 2]}, - yaxis: {range: [0, 10]} - }); + // layout + expect(args[2]).toEqual({ + xaxis: {range: [0, 2]}, + yaxis: {range: [0, 10]} + }); - // traces are [0, 1]: - expect(args[3]).toEqual([0, 1]); - }).catch(fail).then(done); - }); + // traces are [0, 1]: + expect(args[3]).toEqual([0, 1]); + }).catch(fail).then(done); + }); - it('rejects if a frame is not found', function(done) { - Plotly.animate(gd, ['foobar']).then(fail).then(done, done); - }); + it('rejects if a frame is not found', function(done) { + Plotly.animate(gd, ['foobar'], transOpts).then(fail).then(done, done); + }); - it('animates to a single frame', function(done) { - gd.on('plotly_animated', function() { - expect(PlotlyInternal.transition.calls.count()).toEqual(1); - verifyQueueEmpty(gd); - done(); + it('animates to a single frame', function(done) { + Plotly.animate(gd, ['frame0'], transOpts).then(function() { + expect(PlotlyInternal.transition.calls.count()).toEqual(1); + verifyQueueEmpty(gd); + }).catch(fail).then(done); }); - Plotly.animate(gd, ['frame0']); - }); + it('animates to a list of frames', function(done) { + Plotly.animate(gd, ['frame0', 'frame1'], transOpts).then(function() { + expect(PlotlyInternal.transition.calls.count()).toEqual(2); + verifyQueueEmpty(gd); + }).catch(fail).then(done); + }); - it('animates to a single frame', function(done) { - gd.on('plotly_animated', function() { - expect(PlotlyInternal.transition.calls.count()).toEqual(2); - verifyQueueEmpty(gd); - done(); + it('animates frames by group', function(done) { + Plotly.animate(gd, 'even-frames', transOpts).then(function() { + expect(PlotlyInternal.transition.calls.count()).toEqual(2); + verifyQueueEmpty(gd); + }).catch(fail).then(done); }); - Plotly.animate(gd, ['frame0', 'frame1']); - }); + it('animates groups in the correct order', function(done) { + Plotly.animate(gd, 'even-frames', transOpts); + Plotly.animate(gd, 'odd-frames', transOpts).then(function() { + verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame1', 'frame3']); + verifyQueueEmpty(gd); + }).catch(fail).then(done); + }); - it('animates frames by group', function(done) { - gd.on('plotly_animated', function() { - expect(PlotlyInternal.transition.calls.count()).toEqual(2); - verifyQueueEmpty(gd); - done(); + it('drops queued frames when immediate = true', function(done) { + Plotly.animate(gd, 'even-frames', transOpts); + Plotly.animate(gd, 'odd-frames', transOpts, {immediate: true}).then(function() { + verifyFrameTransitionOrder(gd, ['frame0', 'frame1', 'frame3']); + verifyQueueEmpty(gd); + }).catch(fail).then(done); }); - Plotly.animate(gd, 'even-frames'); - }); + it('animates frames in the correct order', function(done) { + Plotly.animate(gd, ['frame0', 'frame2', 'frame1', 'frame3'], transOpts).then(function() { + verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame1', 'frame3']); + verifyQueueEmpty(gd); + }).catch(fail).then(done); + }); - it('animates groups in the correct order', function(done) { - gd.on('plotly_animated', function() { - verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame1', 'frame3']); - verifyQueueEmpty(gd); - done(); + it('animates frames and groups in sequence', function(done) { + Plotly.animate(gd, 'even-frames', transOpts); + Plotly.animate(gd, ['frame0', 'frame2', 'frame1', 'frame3'], transOpts).then(function() { + verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame0', 'frame2', 'frame1', 'frame3']); + verifyQueueEmpty(gd); + }).catch(fail).then(done); }); - Plotly.animate(gd, 'even-frames'); - Plotly.animate(gd, 'odd-frames'); - }); + it('accepts a single transitionOpts', function(done) { + Plotly.animate(gd, ['frame0', 'frame1'], {duration: 1.12345}).then(function() { + var calls = PlotlyInternal.transition.calls; + expect(calls.argsFor(0)[4].duration).toEqual(1.12345); + expect(calls.argsFor(1)[4].duration).toEqual(1.12345); + }).catch(fail).then(done); + }); - it('animates frames in the correct order', function(done) { - gd.on('plotly_animated', function() { - verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame1', 'frame3']); - verifyQueueEmpty(gd); - done(); + it('accepts an array of transitionOpts', function(done) { + Plotly.animate(gd, ['frame0', 'frame1'], [{duration: 1.123}, {duration: 1.456}]).then(function() { + var calls = PlotlyInternal.transition.calls; + expect(calls.argsFor(0)[4].duration).toEqual(1.123); + expect(calls.argsFor(1)[4].duration).toEqual(1.456); + }).catch(fail).then(done); }); - Plotly.animate(gd, ['frame0', 'frame2', 'frame1', 'frame3']); - }); + it('falls back to transitionOpts[0] if not enough supplied in array', function(done) { + Plotly.animate(gd, ['frame0', 'frame1'], [{duration: 1.123}]).then(function() { + var calls = PlotlyInternal.transition.calls; + expect(calls.argsFor(0)[4].duration).toEqual(1.123); + expect(calls.argsFor(1)[4].duration).toEqual(1.123); + }).catch(fail).then(done); + }); - it('animates frames and groups in sequence', function(done) { - gd.on('plotly_animated', function() { - verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame0', 'frame2', 'frame1', 'frame3']); - verifyQueueEmpty(gd); - done(); + it('chains animations as promises', function(done) { + Plotly.animate(gd, ['frame0', 'frame1'], transOpts).then(function() { + return Plotly.animate(gd, ['frame2', 'frame3'], transOpts); + }).then(function() { + verifyFrameTransitionOrder(gd, ['frame0', 'frame1', 'frame2', 'frame3']); + verifyQueueEmpty(gd); + }).catch(fail).then(done); }); - Plotly.animate(gd, 'even-frames'); - Plotly.animate(gd, ['frame0', 'frame2', 'frame1', 'frame3']); - }); + it('emits plotly_animated before the promise is resolved', function(done) { + var animated = false; + gd.on('plotly_animated', function() { animated = true; }); - it('queues successive animations', function(done) { - var starts = 0; - var ends = 0; + Plotly.animate(gd, ['frame0'], transOpts).then(function() { + expect(animated).toBe(true); + }).catch(fail).then(done); + }); - // make sure we don't ignore accidental double-ends: - function checkEndCount() { - expect(ends).toEqual(1); - verifyQueueEmpty(gd); - done(); - } + it('emits plotly_animated as each animation in a sequence completes', function(done) { + var completed = 0; + var test1 = 0, test2 = 0; + gd.on('plotly_animated', function() { + completed++; + if(completed === 1) { + // Verify that after the first plotly_animated, precisely frame0 and frame1 + // have been transitioned to: + verifyFrameTransitionOrder(gd, ['frame0', 'frame1']); + test1++; + } else { + // Verify that after the second plotly_animated, precisely all frames + // have been transitioned to: + verifyFrameTransitionOrder(gd, ['frame0', 'frame1', 'frame2', 'frame3']); + test2++; + } + }); + + Plotly.animate(gd, ['frame0', 'frame1'], transOpts).then(function() { + return Plotly.animate(gd, ['frame2', 'frame3'], transOpts); + }).then(function() { + // Verify both behaviors were actually tested: + expect(test1).toBe(1); + expect(test2).toBe(1); + }).catch(fail).then(done); + }); + + it('rejects when an animation is interrupted', function(done) { + var interrupted = false; + Plotly.animate(gd, ['frame0', 'frame1'], transOpts).then(fail, function() { + interrupted = true; + }); + + Plotly.animate(gd, ['frame2'], transOpts, {immediate: true}).then(function() { + expect(interrupted).toBe(true); + verifyFrameTransitionOrder(gd, ['frame0', 'frame2']); + verifyQueueEmpty(gd); + }).catch(fail).then(done); + }); + + it('emits plotly_animationinterrupted when an animation is interrupted', function(done) { + var interrupted = false; + gd.on('plotly_animationinterrupted', function() { + interrupted = true; + }); + + Plotly.animate(gd, ['frame0', 'frame1'], transOpts); + + Plotly.animate(gd, ['frame2'], transOpts, {immediate: true}).then(function() { + expect(interrupted).toBe(true); + verifyQueueEmpty(gd); + }).catch(fail).then(done); + }); + + it('queues successive animations', function(done) { + var starts = 0; + var ends = 0; - gd.on('plotly_animating', function() { - starts++; - }).on('plotly_animated', function() { - ends++; - expect(PlotlyInternal.transition.calls.count()).toEqual(4); - expect(starts).toEqual(1); - setTimeout(checkEndCount, 10); + gd.on('plotly_animating', function() { + starts++; + }).on('plotly_animated', function() { + ends++; + expect(PlotlyInternal.transition.calls.count()).toEqual(4); + expect(starts).toEqual(1); + }); + + Plotly.animate(gd, 'even-frames', transOpts); + Plotly.animate(gd, 'odd-frames', transOpts).then(delay(10)).then(function() { + expect(ends).toEqual(1); + verifyQueueEmpty(gd); + }).catch(fail).then(done); }); - Plotly.animate(gd, 'even-frames'); - Plotly.animate(gd, 'odd-frames'); + it('resolves at the end of each animation sequence', function(done) { + Plotly.animate(gd, 'even-frames', transOpts).then(function() { + return Plotly.animate(gd, ['frame0', 'frame2', 'frame1', 'frame3'], transOpts); + }).then(function() { + verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame0', 'frame2', 'frame1', 'frame3']); + verifyQueueEmpty(gd); + }).catch(fail).then(done); + }); }); -}); +} + +for(var i = 0; i < 2; i++) { + // Set a duration: + var d = 30 * i; + + runTests(d); +} diff --git a/test/jasmine/tests/transition_test.js b/test/jasmine/tests/transition_test.js index c433259f998..a7898cb9bbc 100644 --- a/test/jasmine/tests/transition_test.js +++ b/test/jasmine/tests/transition_test.js @@ -5,14 +5,16 @@ var Plots = Plotly.Plots; var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var fail = require('../assets/fail_test'); - +var delay = require('../assets/delay'); var mock = require('@mocks/animation'); + describe('Plots.supplyTransitionDefaults', function() { 'use strict'; it('supplies transition defaults', function() { expect(Plots.supplyTransitionDefaults({})).toEqual({ + frameduration: 500, duration: 500, ease: 'cubic-in-out', redraw: true @@ -21,10 +23,12 @@ describe('Plots.supplyTransitionDefaults', function() { it('uses provided values', function() { expect(Plots.supplyTransitionDefaults({ + frameduration: 200, duration: 100, ease: 'quad-in-out', redraw: false })).toEqual({ + frameduration: 200, duration: 100, ease: 'quad-in-out', redraw: false @@ -33,53 +37,98 @@ describe('Plots.supplyTransitionDefaults', function() { }); -describe('Plotly.transition', function() { - 'use strict'; - - var gd; +function runTests(transitionDuration) { + describe('Plotly.transition (duration = ' + transitionDuration + ')', function() { + 'use strict'; - beforeEach(function(done) { - gd = createGraphDiv(); + var gd; - var mockCopy = Lib.extendDeep({}, mock); + beforeEach(function(done) { + gd = createGraphDiv(); - Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(done); - }); + var mockCopy = Lib.extendDeep({}, mock); - afterEach(function() { - destroyGraphDiv(); - }); + Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(done); + }); - it('resolves without waiting for transition to complete', function(done) { - var t1 = Date.now(); - var duration = 50; - var calls = 0; - // Callback to exit only after called twice: - function end() {if(++calls === 2) done();} + afterEach(function() { + Plotly.purge(gd); + destroyGraphDiv(); + }); - // Not testing this, but make sure not to exit before the transition is all done: - gd.on('plotly_transitioned', end); + it('resolves only once the transition has completed', function(done) { + var t1 = Date.now(); - Plotly.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {duration: duration}).then(function() { - var t2 = Date.now(); - expect(t2 - t1).toBeLessThan(duration); - }).catch(fail).then(end); - }); + Plotly.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {duration: transitionDuration}) + .then(delay(20)) + .then(function() { + expect(Date.now() - t1).toBeGreaterThan(transitionDuration); + }).catch(fail).then(done); + }); - it('emits plotly_transitioning on transition start', function(done) { - var beginTransitionCnt = 0; + it('emits plotly_transitioning on transition start', function(done) { + var beginTransitionCnt = 0; + gd.on('plotly_transitioning', function() { beginTransitionCnt++; }); - gd.on('plotly_transitioning', function() { - beginTransitionCnt++; + Plotly.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {duration: transitionDuration}) + .then(delay(20)) + .then(function() { + expect(beginTransitionCnt).toBe(1); + }).catch(fail).then(done); }); - Plotly.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {duration: 0}).then(function() { - expect(beginTransitionCnt).toBe(1); - }).catch(fail).then(done); - }); + it('emits plotly_transitioned on transition end', function(done) { + var trEndCnt = 0; + gd.on('plotly_transitioned', function() { trEndCnt++; }); - it('emits plotly_transitioned on transition end', function(done) { - Plotly.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {duration: 50}); - gd.on('plotly_transitioned', done); + Plotly.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {duration: transitionDuration}) + .then(delay(20)) + .then(function() { + expect(trEndCnt).toEqual(1); + }).catch(fail).then(done); + }); + + // This doesn't really test anything that the above tests don't cover, but it combines + // the behavior and attempts to ensure chaining and events happen in the correct order. + it('transitions may be chained', function(done) { + var currentlyRunning = 0; + var beginCnt = 0; + var endCnt = 0; + + gd.on('plotly_transitioning', function() { currentlyRunning++; beginCnt++; }); + gd.on('plotly_transitioned', function() { currentlyRunning--; endCnt++; }); + + function doTransition() { + return Plotly.transition(gd, [{x: [1, 2]}], null, null, {duration: transitionDuration}); + } + + function checkNoneRunning() { + expect(currentlyRunning).toEqual(0); + } + + doTransition() + .then(checkNoneRunning) + .then(doTransition) + .then(checkNoneRunning) + .then(doTransition) + .then(checkNoneRunning) + .then(delay(10)) + .then(function() { + expect(beginCnt).toEqual(3); + expect(endCnt).toEqual(3); + }) + .then(checkNoneRunning) + .catch(fail).then(done); + }); }); -}); +} + +for(var i = 0; i < 2; i++) { + var duration = i * 20; + // Run the whole set of tests twice: once with zero duration and once with + // nonzero duration since the behavior should be identical, but there's a + // very real possibility of race conditions or other timing issues. + // + // And of course, remember to put the async loop in a closure: + runTests(duration); +} From 1b497e37fd2545aaa49db81b1a9890ba8fe0285b Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Sun, 28 Aug 2016 12:06:44 -0400 Subject: [PATCH 23/45] Regroup animate tests to prevent race conditions --- src/plot_api/plot_api.js | 9 +- src/plots/plots.js | 2 +- test/jasmine/tests/animate_test.js | 209 ++++++++++++++--------- test/jasmine/tests/compute_frame_test.js | 26 +-- 4 files changed, 154 insertions(+), 92 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 889e1a06bc1..21a756237ea 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2901,7 +2901,7 @@ Plotly.animate = function(gd, groupNameOrFrameList, transitionOpts, animationOpt var i, frame; var frameList = []; - var allFrames = typeof groupNameOrFrameList === 'undefined'; + var allFrames = groupNameOrFrameList === undefined || groupNameOrFrameList === null; if(allFrames || typeof groupNameOrFrameList === 'string') { for(i = 0; i < trans._frames.length; i++) { frame = trans._frames[i]; @@ -2929,7 +2929,12 @@ Plotly.animate = function(gd, groupNameOrFrameList, transitionOpts, animationOpt discardExistingFrames(); } - queueFrames(frameList); + if(frameList.length > 0) { + queueFrames(frameList); + } else { + gd.emit('plotly_animated'); + resolve(); + } }); }; diff --git a/src/plots/plots.js b/src/plots/plots.js index a99a164227b..a070981e14c 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -870,7 +870,7 @@ plots.purge = function(gd) { // remove modebar if(fullLayout._modeBar) fullLayout._modeBar.destroy(); - if(gd._transitionData._animationRaf) { + if(gd._transitionData && gd._transitionData._animationRaf) { cancelAnimationFrame(gd._transitionData._animationRaf); } diff --git a/test/jasmine/tests/animate_test.js b/test/jasmine/tests/animate_test.js index af0c48b6cc8..6224579e0f0 100644 --- a/test/jasmine/tests/animate_test.js +++ b/test/jasmine/tests/animate_test.js @@ -9,49 +9,55 @@ var delay = require('../assets/delay'); var mock = require('@mocks/animation'); -function runTests(duration) { - describe('Test animate API (frame duration = ' + duration + ')', function() { - 'use strict'; +describe('Test animate API', function() { + 'use strict'; - var gd, mockCopy; - var transOpts = {frameduration: duration}; + var gd, mockCopy; - function verifyQueueEmpty(gd) { - expect(gd._transitionData._frameQueue.length).toEqual(0); - } + function verifyQueueEmpty(gd) { + expect(gd._transitionData._frameQueue.length).toEqual(0); + } - function verifyFrameTransitionOrder(gd, expectedFrames) { - var calls = PlotlyInternal.transition.calls; + function verifyFrameTransitionOrder(gd, expectedFrames) { + var calls = PlotlyInternal.transition.calls; - expect(calls.count()).toEqual(expectedFrames.length); + expect(calls.count()).toEqual(expectedFrames.length); - for(var i = 0; i < calls.count(); i++) { - expect(calls.argsFor(i)[1]).toEqual( - gd._transitionData._frameHash[expectedFrames[i]].data - ); - } + for(var i = 0; i < calls.count(); i++) { + expect(calls.argsFor(i)[1]).toEqual( + gd._transitionData._frameHash[expectedFrames[i]].data + ); } + } - beforeEach(function(done) { - gd = createGraphDiv(); + beforeEach(function(done) { + gd = createGraphDiv(); - mockCopy = Lib.extendDeep({}, mock); - - spyOn(PlotlyInternal, 'transition').and.callFake(function() { - // Transition's fake behaviro is to resolve after a short period of time: - return Promise.resolve().then(delay(duration)); - }); + mockCopy = Lib.extendDeep({}, mock); - Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(function() { - Plotly.addFrames(gd, mockCopy.frames); - }).then(done); + spyOn(PlotlyInternal, 'transition').and.callFake(function() { + // Transition's fake behaviro is to resolve after a short period of time: + return Promise.resolve().then(delay(5)); }); - afterEach(function() { - // *must* purge between tests otherwise dangling async events might not get cleaned up properly: - Plotly.purge(gd); - destroyGraphDiv(); - }); + Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(function() { + Plotly.addFrames(gd, mockCopy.frames); + }).then(done); + }); + + afterEach(function() { + // *must* purge between tests otherwise dangling async events might not get cleaned up properly: + Plotly.purge(gd); + destroyGraphDiv(); + }); + + for(var i = 0; i < 2; i++) { + // Run tests for 0ms and 30ms duration: + runTests(10 + 30 * i); + } + + function runTests(duration) { + var transOpts = {frameduration: duration}; it('animates to a frame', function(done) { Plotly.animate(gd, ['frame0'], {duration: 1.2345}).then(function() { @@ -83,54 +89,51 @@ function runTests(duration) { Plotly.animate(gd, ['foobar'], transOpts).then(fail).then(done, done); }); - it('animates to a single frame', function(done) { - Plotly.animate(gd, ['frame0'], transOpts).then(function() { - expect(PlotlyInternal.transition.calls.count()).toEqual(1); + it('animates all frames if list is null', function(done) { + Plotly.animate(gd, null, transOpts).then(function() { + verifyFrameTransitionOrder(gd, ['base', 'frame0', 'frame1', 'frame2', 'frame3']); verifyQueueEmpty(gd); }).catch(fail).then(done); }); - it('animates to a list of frames', function(done) { - Plotly.animate(gd, ['frame0', 'frame1'], transOpts).then(function() { - expect(PlotlyInternal.transition.calls.count()).toEqual(2); + it('animates all frames if list is undefined', function(done) { + Plotly.animate(gd, undefined, transOpts).then(function() { + verifyFrameTransitionOrder(gd, ['base', 'frame0', 'frame1', 'frame2', 'frame3']); verifyQueueEmpty(gd); }).catch(fail).then(done); }); - it('animates frames by group', function(done) { - Plotly.animate(gd, 'even-frames', transOpts).then(function() { - expect(PlotlyInternal.transition.calls.count()).toEqual(2); + it('animates to a single frame', function(done) { + Plotly.animate(gd, ['frame0'], transOpts).then(function() { + expect(PlotlyInternal.transition.calls.count()).toEqual(1); verifyQueueEmpty(gd); }).catch(fail).then(done); }); - it('animates groups in the correct order', function(done) { - Plotly.animate(gd, 'even-frames', transOpts); - Plotly.animate(gd, 'odd-frames', transOpts).then(function() { - verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame1', 'frame3']); + it('animates to an empty list', function(done) { + Plotly.animate(gd, [], transOpts).then(function() { + expect(PlotlyInternal.transition.calls.count()).toEqual(0); verifyQueueEmpty(gd); }).catch(fail).then(done); }); - it('drops queued frames when immediate = true', function(done) { - Plotly.animate(gd, 'even-frames', transOpts); - Plotly.animate(gd, 'odd-frames', transOpts, {immediate: true}).then(function() { - verifyFrameTransitionOrder(gd, ['frame0', 'frame1', 'frame3']); + it('animates to a list of frames', function(done) { + Plotly.animate(gd, ['frame0', 'frame1'], transOpts).then(function() { + expect(PlotlyInternal.transition.calls.count()).toEqual(2); verifyQueueEmpty(gd); }).catch(fail).then(done); }); - it('animates frames in the correct order', function(done) { - Plotly.animate(gd, ['frame0', 'frame2', 'frame1', 'frame3'], transOpts).then(function() { - verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame1', 'frame3']); + it('animates frames by group', function(done) { + Plotly.animate(gd, 'even-frames', transOpts).then(function() { + expect(PlotlyInternal.transition.calls.count()).toEqual(2); verifyQueueEmpty(gd); }).catch(fail).then(done); }); - it('animates frames and groups in sequence', function(done) { - Plotly.animate(gd, 'even-frames', transOpts); + it('animates frames in the correct order', function(done) { Plotly.animate(gd, ['frame0', 'frame2', 'frame1', 'frame3'], transOpts).then(function() { - verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame0', 'frame2', 'frame1', 'frame3']); + verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame1', 'frame3']); verifyQueueEmpty(gd); }).catch(fail).then(done); }); @@ -204,18 +207,23 @@ function runTests(duration) { }).catch(fail).then(done); }); - it('rejects when an animation is interrupted', function(done) { - var interrupted = false; - Plotly.animate(gd, ['frame0', 'frame1'], transOpts).then(fail, function() { - interrupted = true; - }); - - Plotly.animate(gd, ['frame2'], transOpts, {immediate: true}).then(function() { - expect(interrupted).toBe(true); - verifyFrameTransitionOrder(gd, ['frame0', 'frame2']); + it('resolves at the end of each animation sequence', function(done) { + Plotly.animate(gd, 'even-frames', transOpts).then(function() { + return Plotly.animate(gd, ['frame0', 'frame2', 'frame1', 'frame3'], transOpts); + }).then(function() { + verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame0', 'frame2', 'frame1', 'frame3']); verifyQueueEmpty(gd); }).catch(fail).then(done); }); + } + + // The tests above use promises to ensure ordering, but the tests below this call Plotly.animate + // without chaining promises which would result in race conditions. This is not invalid behavior, + // but it doesn't ensure proper ordering and completion, so these must be performed with finite + // duration. Stricly speaking, these tests *do* involve race conditions, but the finite duration + // prevents that from causing problems. + describe('Calling Plotly.animate synchronously in series', function() { + var transOpts = {frameduration: 30}; it('emits plotly_animationinterrupted when an animation is interrupted', function(done) { var interrupted = false; @@ -231,6 +239,7 @@ function runTests(duration) { }).catch(fail).then(done); }); + it('queues successive animations', function(done) { var starts = 0; var ends = 0; @@ -243,27 +252,75 @@ function runTests(duration) { expect(starts).toEqual(1); }); - Plotly.animate(gd, 'even-frames', transOpts); - Plotly.animate(gd, 'odd-frames', transOpts).then(delay(10)).then(function() { + Plotly.animate(gd, 'even-frames', {duration: 16}); + Plotly.animate(gd, 'odd-frames', {duration: 16}).then(delay(10)).then(function() { expect(ends).toEqual(1); verifyQueueEmpty(gd); }).catch(fail).then(done); }); - it('resolves at the end of each animation sequence', function(done) { - Plotly.animate(gd, 'even-frames', transOpts).then(function() { - return Plotly.animate(gd, ['frame0', 'frame2', 'frame1', 'frame3'], transOpts); - }).then(function() { + it('an empty list with immediate dumps previous frames', function(done) { + Plotly.animate(gd, ['frame0', 'frame1'], {frameduration: 50}); + Plotly.animate(gd, [], null, {immediate: true}).then(function() { + expect(PlotlyInternal.transition.calls.count()).toEqual(1); + verifyQueueEmpty(gd); + }).catch(fail).then(done); + }); + + it('animates groups in the correct order', function(done) { + Plotly.animate(gd, 'even-frames', transOpts); + Plotly.animate(gd, 'odd-frames', transOpts).then(function() { + verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame1', 'frame3']); + verifyQueueEmpty(gd); + }).catch(fail).then(done); + }); + + it('drops queued frames when immediate = true', function(done) { + Plotly.animate(gd, 'even-frames', transOpts); + Plotly.animate(gd, 'odd-frames', transOpts, {immediate: true}).then(function() { + verifyFrameTransitionOrder(gd, ['frame0', 'frame1', 'frame3']); + verifyQueueEmpty(gd); + }).catch(fail).then(done); + }); + + it('animates frames and groups in sequence', function(done) { + Plotly.animate(gd, 'even-frames', transOpts); + Plotly.animate(gd, ['frame0', 'frame2', 'frame1', 'frame3'], transOpts).then(function() { verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame0', 'frame2', 'frame1', 'frame3']); verifyQueueEmpty(gd); }).catch(fail).then(done); }); + + it('rejects when an animation is interrupted', function(done) { + var interrupted = false; + Plotly.animate(gd, ['frame0', 'frame1'], transOpts).then(fail, function() { + interrupted = true; + }); + + Plotly.animate(gd, ['frame2'], transOpts, {immediate: true}).then(function() { + expect(interrupted).toBe(true); + verifyFrameTransitionOrder(gd, ['frame0', 'frame2']); + verifyQueueEmpty(gd); + }).catch(fail).then(done); + }); }); -} -for(var i = 0; i < 2; i++) { - // Set a duration: - var d = 30 * i; + it('animates reasonably even when transition duration >> frame duration', function(done) { + var starts = 0; + var ends= 0; + + gd.on('plotly_animating', function() { + starts++; + }).on('plotly_animated', function() { + ends++; + }); + + Plotly.animate(gd, ['frame0', 'frame1'], {duration: 200, frameduration: 20}).then(function() { + expect(starts).toEqual(1); + expect(ends).toEqual(1); + expect(PlotlyInternal.transition.calls.count()).toEqual(2); + verifyQueueEmpty(gd); + }).catch(fail).then(done); + }); - runTests(d); -} +}); diff --git a/test/jasmine/tests/compute_frame_test.js b/test/jasmine/tests/compute_frame_test.js index fde87f7a71c..9010838ee5a 100644 --- a/test/jasmine/tests/compute_frame_test.js +++ b/test/jasmine/tests/compute_frame_test.js @@ -61,7 +61,7 @@ describe('Test mergeFrames', function() { it('computes a single frame', function() { var computed = computeFrame(gd, 'frame1'); - var expected = {data: [{x: [1, 2, 3], marker: {size: 8, color: 'red'}}], traceIndices: [0]}; + var expected = {data: [{x: [1, 2, 3], marker: {size: 8, color: 'red'}}], traces: [0]}; expect(computed).toEqual(expected); }); @@ -82,9 +82,9 @@ describe('Test mergeFrames', function() { ]; results = [ - {traceIndices: [0], data: [{marker: {size: 0}}]}, - {traceIndices: [0], data: [{marker: {size: 1}}]}, - {traceIndices: [0], data: [{marker: {size: 2}}]} + {traces: [0], data: [{marker: {size: 0}}]}, + {traces: [0], data: [{marker: {size: 1}}]}, + {traces: [0], data: [{marker: {size: 2}}]} ]; Plotly.addFrames(gd, frames).then(done); @@ -109,19 +109,19 @@ describe('Test mergeFrames', function() { frames = [{ name: 'frame0', data: [{'marker.size': 0}], - traceIndices: [2] + traces: [2] }, { name: 'frame1', data: [{'marker.size': 1}], - traceIndices: [8] + traces: [8] }, { name: 'frame2', data: [{'marker.size': 2}], - traceIndices: [2] + traces: [2] }, { name: 'frame3', data: [{'marker.size': 3}, {'marker.size': 4}], - traceIndices: [2, 8] + traces: [2, 8] }, { name: 'frame4', data: [ @@ -140,7 +140,7 @@ describe('Test mergeFrames', function() { Plotly.addFrames(gd, frames.map(clone)); expect(computeFrame(gd, 'frame0')).toEqual({ - traceIndices: [8, 2], + traces: [8, 2], data: [ {marker: {size: 1}}, {marker: {size: 0}} @@ -158,7 +158,7 @@ describe('Test mergeFrames', function() { Plotly.addFrames(gd, frames.map(clone)); expect(computeFrame(gd, 'frame0')).toEqual({ - traceIndices: [2], + traces: [2], data: [{marker: {size: 0}}] }); @@ -173,7 +173,7 @@ describe('Test mergeFrames', function() { Plotly.addFrames(gd, frames.map(clone)); expect(computeFrame(gd, 'frame0')).toEqual({ - traceIndices: [2, 8], + traces: [2, 8], data: [ {marker: {size: 0}}, {marker: {size: 1}} @@ -183,13 +183,13 @@ describe('Test mergeFrames', function() { expect(gd._transitionData._frames).toEqual(frames); }); - it('assumes serial order without traceIndices specified', function() { + it('assumes serial order without traces specified', function() { frames[4].baseframe = frames[3].name; Plotly.addFrames(gd, frames.map(clone)); expect(computeFrame(gd, 'frame4')).toEqual({ - traceIndices: [2, 8, 0, 1], + traces: [2, 8, 0, 1], data: [ {marker: {size: 7}}, {marker: {size: 4}}, From 9079632ca69d210d1f4fb610e0889aaa2ab3e9f8 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Sun, 28 Aug 2016 12:19:35 -0400 Subject: [PATCH 24/45] Add a couple more purge tests --- test/jasmine/tests/plots_test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/jasmine/tests/plots_test.js b/test/jasmine/tests/plots_test.js index af2c1f42ea4..4f9284b3753 100644 --- a/test/jasmine/tests/plots_test.js +++ b/test/jasmine/tests/plots_test.js @@ -331,6 +331,8 @@ describe('Test Plots', function() { expect(gd.numboxes).toBeUndefined(); expect(gd._hoverTimer).toBeUndefined(); expect(gd._lastHoverTime).toBeUndefined(); + expect(gd._transitionData).toBeUndefined(); + expect(gd._transitioning).toBeUndefined(); }); }); }); From 14516cfbd8f3869bc632d003bf07148a4a9e3f16 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Sun, 28 Aug 2016 12:34:02 -0400 Subject: [PATCH 25/45] Tweak point enter/exit transitions --- src/traces/scatter/plot.js | 16 +++++++++++----- test/jasmine/tests/animate_test.js | 2 +- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index 96e2ecd43ca..69fce1d6909 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -400,17 +400,23 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition .classed('point', true); enter.call(Drawing.pointStyle, trace) - .call(Drawing.translatePoints, xa, ya, trace, transitionConfig, 1); + .call(Drawing.translatePoints, xa, ya, trace); - join.transition().each(function(d) { + if(hasTransition) { + enter.style('opacity', 0).transition() + .style('opacity', 1); + } + + join.each(function(d) { var sel = transition(d3.select(this)); - Drawing.translatePoint(d, sel, xa, ya, trace, transitionConfig, 0); + Drawing.translatePoint(d, sel, xa, ya); Drawing.singlePointStyle(d, sel, trace); }); if(hasTransition) { - join.exit() - .call(Drawing.translatePoints, xa, ya, trace, transitionConfig, -1); + join.exit().transition() + .style('opacity', 0) + .remove(); } else { join.exit().remove(); } diff --git a/test/jasmine/tests/animate_test.js b/test/jasmine/tests/animate_test.js index 6224579e0f0..5d76dcfd867 100644 --- a/test/jasmine/tests/animate_test.js +++ b/test/jasmine/tests/animate_test.js @@ -307,7 +307,7 @@ describe('Test animate API', function() { it('animates reasonably even when transition duration >> frame duration', function(done) { var starts = 0; - var ends= 0; + var ends = 0; gd.on('plotly_animating', function() { starts++; From 9c83914713615e943b8512faf98dceabc82da353 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Sun, 28 Aug 2016 15:54:58 -0400 Subject: [PATCH 26/45] Write Plotly.transition out of the picture yayyy --- src/components/errorbars/plot.js | 8 +- src/plot_api/plot_api.js | 132 ++++++++++++++++--------- src/plots/cartesian/transition_axes.js | 4 +- src/plots/plots.js | 2 +- src/plots/transition_attributes.js | 2 +- src/traces/scatter/plot.js | 6 +- test/jasmine/tests/animate_test.js | 53 +++++++--- test/jasmine/tests/transition_test.js | 14 +-- 8 files changed, 145 insertions(+), 76 deletions(-) diff --git a/src/components/errorbars/plot.js b/src/components/errorbars/plot.js index 7420dcb90ef..d8fbbc8b5db 100644 --- a/src/components/errorbars/plot.js +++ b/src/components/errorbars/plot.js @@ -19,7 +19,7 @@ module.exports = function plot(traces, plotinfo, transitionConfig) { var xa = plotinfo.x(), ya = plotinfo.y(); - var hasAnimation = transitionConfig && transitionConfig.duration > 0; + var hasAnimation = transitionConfig && transitionConfig.transitionduration > 0; traces.each(function(d) { var trace = d[0].trace, @@ -55,7 +55,7 @@ module.exports = function plot(traces, plotinfo, transitionConfig) { if(hasAnimation) { enter.style('opacity', 0).transition() - .duration(transitionConfig.duration) + .duration(transitionConfig.transitionduration) .style('opacity', 1); } @@ -89,7 +89,7 @@ module.exports = function plot(traces, plotinfo, transitionConfig) { } else if(hasAnimation) { yerror = yerror .transition() - .duration(transitionConfig.duration) + .duration(transitionConfig.transitionduration) .ease(transitionConfig.ease); } @@ -117,7 +117,7 @@ module.exports = function plot(traces, plotinfo, transitionConfig) { } else if(hasAnimation) { xerror = xerror .transition() - .duration(transitionConfig.duration) + .duration(transitionConfig.transitionduration) .ease(transitionConfig.ease); } diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 21a756237ea..3f1657bc03b 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2628,7 +2628,7 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionOpts) { // When instantaneous updates are coming through quickly, it's too much to simply disable // all interaction, so store this flag so we can disambiguate whether mouse interactions // should be fully disabled or not: - if(transitionOpts.duration > 0) { + if(transitionOpts.transitionduration > 0) { gd._transitioningWithDuration = true; } @@ -2670,7 +2670,7 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionOpts) { // to instantaneous. if(hasAxisTransition) { traceTransitionOpts = Lib.extendFlat({}, transitionOpts); - traceTransitionOpts.duration = 0; + traceTransitionOpts.transitionduration = 0; } else { traceTransitionOpts = transitionOpts; } @@ -2756,7 +2756,7 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionOpts) { * @param {object} transitionOpts * configuration for transition */ -Plotly.animate = function(gd, groupNameOrFrameList, transitionOpts, animationOpts) { +Plotly.animate = function(gd, frameOrGroupNameOrFrameList, transitionOpts, animationOpts) { gd = getGraphDiv(gd); var trans = gd._transitionData; @@ -2808,7 +2808,12 @@ Plotly.animate = function(gd, groupNameOrFrameList, transitionOpts, animationOpt if(frameList.length === 0) return; for(var i = 0; i < frameList.length; i++) { - var computedFrame = Plots.computeFrame(gd, frameList[i].name); + var computedFrame; + if(frameList[i].name) { + computedFrame = Plots.computeFrame(gd, frameList[i].name); + } else { + computedFrame = frameList[i].frame; + } var opts = Plots.supplyTransitionDefaults(getTransitionOpts(i)); @@ -2836,9 +2841,54 @@ Plotly.animate = function(gd, groupNameOrFrameList, transitionOpts, animationOpt trans._animationRaf = null; } + function nextFrame() { + if(trans._currentFrame) { + if(trans._currentFrame.onComplete) { + trans._currentFrame.onComplete(); + } + } + + var newFrame = trans._currentFrame = trans._frameQueue.shift(); + + if(newFrame) { + trans._lastframeat = Date.now(); + trans._timetonext = newFrame.transitionOpts.frameduration; + + Plotly.transition(gd, + newFrame.frame.data, + newFrame.frame.layout, + newFrame.frame.traces, + newFrame.transitionOpts + ).then(function() { + if(trans._frameQueue.length === 0) { + gd.emit('plotly_animated'); + if(trans._currentFrame && trans._currentFrame.onComplete) { + trans._currentFrame.onComplete(); + trans._currentFrame = null; + } + } + }); + } + + if(trans._frameQueue.length === 0) { + stopAnimationLoop(); + return; + } + } + function beginAnimationLoop() { gd.emit('plotly_animating'); + var canAnimateSynchronously = !trans._animationRaf && trans._frameQueue.length === 1; + + if(canAnimateSynchronously) { + // If there is no animation running and only one frame has been received, then + // simply transition this frame synchonously and avoid starting and stopping the + // timing loop. + nextFrame(); + return; + } + // If no timer is running, then set last frame = long ago: trans._lastframeat = 0; trans._timetonext = 0; @@ -2848,38 +2898,7 @@ Plotly.animate = function(gd, groupNameOrFrameList, transitionOpts, animationOpt var doFrame = function() { // Check if we need to pop a frame: if(Date.now() - trans._lastframeat > trans._timetonext) { - if(trans._currentFrame) { - if(trans._currentFrame.onComplete) { - trans._currentFrame.onComplete(); - } - } - - var newFrame = trans._currentFrame = trans._frameQueue.shift(); - - if(newFrame) { - trans._lastframeat = Date.now(); - trans._timetonext = newFrame.transitionOpts.frameduration; - - Plotly.transition(gd, - newFrame.frame.data, - newFrame.frame.layout, - newFrame.frame.traces, - newFrame.transitionOpts - ).then(function() { - if(trans._frameQueue.length === 0) { - gd.emit('plotly_animated'); - if(trans._currentFrame && trans._currentFrame.onComplete) { - trans._currentFrame.onComplete(); - trans._currentFrame = null; - } - } - }); - } - - if(trans._frameQueue.length === 0) { - stopAnimationLoop(); - return; - } + nextFrame(); } trans._animationRaf = requestAnimationFrame(doFrame); @@ -2891,7 +2910,11 @@ Plotly.animate = function(gd, groupNameOrFrameList, transitionOpts, animationOpt var counter = 0; function setTransitionConfig(frame) { if(Array.isArray(transitionOpts)) { - frame.transitionOpts = transitionOpts[counter]; + if(counter >= transitionOpts.length) { + frame.transitionOpts = transitionOpts[counter]; + } else { + frame.transitionOpts = transitionOpts[0]; + } } else { frame.transitionOpts = transitionOpts; } @@ -2899,26 +2922,47 @@ Plotly.animate = function(gd, groupNameOrFrameList, transitionOpts, animationOpt return frame; } + // Disambiguate what's been received. The possibilities are: + // + // - group: 'groupname': select frames by group name + // - frames ['frame1', frame2']: a list of frames + // - object: {data: ...}: a single frame itself + // - frames [{data: ...}, {data: ...}]: a list of frames + // var i, frame; var frameList = []; - var allFrames = groupNameOrFrameList === undefined || groupNameOrFrameList === null; - if(allFrames || typeof groupNameOrFrameList === 'string') { + var allFrames = frameOrGroupNameOrFrameList === undefined || frameOrGroupNameOrFrameList === null; + var isFrameArray = Array.isArray(frameOrGroupNameOrFrameList); + var isSingleFrame = !allFrames && !isFrameArray && typeof frameOrGroupNameOrFrameList === 'object'; + + if(isSingleFrame) { + frameList.push(setTransitionConfig({ + frame: Lib.extendFlat({}, frameOrGroupNameOrFrameList) + })); + } else if(allFrames || typeof frameOrGroupNameOrFrameList === 'string') { for(i = 0; i < trans._frames.length; i++) { frame = trans._frames[i]; - if(allFrames || frame.group === groupNameOrFrameList) { + if(allFrames || frame.group === frameOrGroupNameOrFrameList) { frameList.push(setTransitionConfig({name: frame.name})); } } - } else if(Array.isArray(groupNameOrFrameList)) { - for(i = 0; i < groupNameOrFrameList.length; i++) { - frameList.push(setTransitionConfig({name: groupNameOrFrameList[i]})); + } else if(isFrameArray) { + for(i = 0; i < frameOrGroupNameOrFrameList.length; i++) { + var frameOrName = frameOrGroupNameOrFrameList[i]; + if(typeof frameOrName === 'string') { + frameList.push(setTransitionConfig({name: frameOrName})); + } else { + frameList.push(setTransitionConfig({ + frame: Lib.extendFlat({}, frameOrName) + })); + } } } // Verify that all of these frames actually exist; return and reject if not: for(i = 0; i < frameList.length; i++) { - if(!trans._frameHash[frameList[i].name]) { + if(frameList[i].name && !trans._frameHash[frameList[i].name]) { Lib.warn('animate failure: frame not found: "' + frameList[i].name + '"'); reject(); return; diff --git a/src/plots/cartesian/transition_axes.js b/src/plots/cartesian/transition_axes.js index 09b41d22693..572d87e648c 100644 --- a/src/plots/cartesian/transition_axes.js +++ b/src/plots/cartesian/transition_axes.js @@ -288,14 +288,14 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig, makeOn function doFrame() { t2 = Date.now(); - var tInterp = Math.min(1, (t2 - t1) / transitionConfig.duration); + var tInterp = Math.min(1, (t2 - t1) / transitionConfig.transitionduration); var progress = easeFn(tInterp); for(var i = 0; i < affectedSubplots.length; i++) { updateSubplot(affectedSubplots[i], progress); } - if(t2 - t1 > transitionConfig.duration) { + if(t2 - t1 > transitionConfig.transitionduration) { transitionComplete(); raf = cancelAnimationFrame(doFrame); } else { diff --git a/src/plots/plots.js b/src/plots/plots.js index a070981e14c..6fbd0bee06c 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -634,7 +634,7 @@ plots.supplyTransitionDefaults = function(opts) { } coerce('frameduration'); - coerce('duration'); + coerce('transitionduration'); coerce('ease'); coerce('redraw'); diff --git a/src/plots/transition_attributes.js b/src/plots/transition_attributes.js index c95a7639ac0..247d145f95f 100644 --- a/src/plots/transition_attributes.js +++ b/src/plots/transition_attributes.js @@ -16,7 +16,7 @@ module.exports = { dflt: 500, description: 'The duration in milliseconds of each frame.' }, - duration: { + transitionduration: { valType: 'number', role: 'info', min: 0, diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index 69fce1d6909..ba2744ce297 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -29,7 +29,7 @@ module.exports = function plot(gd, plotinfo, cdscatter, transitionConfig, makeOn // If transition config is provided, then it is only a partial replot and traces not // updated are removed. var isFullReplot = !transitionConfig; - var hasTransition = !!transitionConfig && transitionConfig.duration > 0; + var hasTransition = !!transitionConfig && transitionConfig.transitionduration > 0; selection = scatterlayer.selectAll('g.trace'); @@ -71,7 +71,7 @@ module.exports = function plot(gd, plotinfo, cdscatter, transitionConfig, makeOn } var transition = d3.transition() - .duration(transitionConfig.duration) + .duration(transitionConfig.transitionduration) .ease(transitionConfig.ease) .each('end', function() { onComplete && onComplete(); @@ -150,7 +150,7 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition // since it does an internal n^2 loop over comparisons with other traces: selectMarkers(gd, idx, plotinfo, cdscatter, cdscatterAll); - var hasTransition = !!transitionConfig && transitionConfig.duration > 0; + var hasTransition = !!transitionConfig && transitionConfig.transitionduration > 0; function transition(selection) { return hasTransition ? selection.transition() : selection; diff --git a/test/jasmine/tests/animate_test.js b/test/jasmine/tests/animate_test.js index 5d76dcfd867..eb0ad347b13 100644 --- a/test/jasmine/tests/animate_test.js +++ b/test/jasmine/tests/animate_test.js @@ -60,7 +60,7 @@ describe('Test animate API', function() { var transOpts = {frameduration: duration}; it('animates to a frame', function(done) { - Plotly.animate(gd, ['frame0'], {duration: 1.2345}).then(function() { + Plotly.animate(gd, ['frame0'], {transitionduration: 1.2345}).then(function() { expect(PlotlyInternal.transition).toHaveBeenCalled(); var args = PlotlyInternal.transition.calls.mostRecent().args; @@ -72,7 +72,7 @@ describe('Test animate API', function() { expect(args[1].length).toEqual(2); // Verify transition config has been passed: - expect(args[4].duration).toEqual(1.2345); + expect(args[4].transitionduration).toEqual(1.2345); // layout expect(args[2]).toEqual({ @@ -89,6 +89,31 @@ describe('Test animate API', function() { Plotly.animate(gd, ['foobar'], transOpts).then(fail).then(done, done); }); + it('treats objects as frames', function(done) { + var frame = {data: [{x: [1, 2, 3]}]}; + Plotly.animate(gd, frame, transOpts).then(function() { + expect(PlotlyInternal.transition.calls.count()).toEqual(1); + verifyQueueEmpty(gd); + }).catch(fail).then(done); + }); + + it('treats a list of objects as frames', function(done) { + var frame1 = {data: [{x: [1, 2, 3]}], traces: [0], layout: {foo: 'bar'}}; + var frame2 = {data: [{x: [3, 4, 5]}], traces: [1], layout: {foo: 'baz'}}; + Plotly.animate(gd, [frame1, frame2], transOpts).then(function() { + expect(PlotlyInternal.transition.calls.argsFor(0)[1]).toEqual(frame1.data); + expect(PlotlyInternal.transition.calls.argsFor(0)[2]).toEqual(frame1.layout); + expect(PlotlyInternal.transition.calls.argsFor(0)[3]).toEqual(frame1.traces); + + expect(PlotlyInternal.transition.calls.argsFor(1)[1]).toEqual(frame2.data); + expect(PlotlyInternal.transition.calls.argsFor(1)[2]).toEqual(frame2.layout); + expect(PlotlyInternal.transition.calls.argsFor(1)[3]).toEqual(frame2.traces); + + expect(PlotlyInternal.transition.calls.count()).toEqual(2); + verifyQueueEmpty(gd); + }).catch(fail).then(done); + }); + it('animates all frames if list is null', function(done) { Plotly.animate(gd, null, transOpts).then(function() { verifyFrameTransitionOrder(gd, ['base', 'frame0', 'frame1', 'frame2', 'frame3']); @@ -139,26 +164,26 @@ describe('Test animate API', function() { }); it('accepts a single transitionOpts', function(done) { - Plotly.animate(gd, ['frame0', 'frame1'], {duration: 1.12345}).then(function() { + Plotly.animate(gd, ['frame0', 'frame1'], {transitionduration: 1.12345}).then(function() { var calls = PlotlyInternal.transition.calls; - expect(calls.argsFor(0)[4].duration).toEqual(1.12345); - expect(calls.argsFor(1)[4].duration).toEqual(1.12345); + expect(calls.argsFor(0)[4].transitionduration).toEqual(1.12345); + expect(calls.argsFor(1)[4].transitionduration).toEqual(1.12345); }).catch(fail).then(done); }); it('accepts an array of transitionOpts', function(done) { - Plotly.animate(gd, ['frame0', 'frame1'], [{duration: 1.123}, {duration: 1.456}]).then(function() { + Plotly.animate(gd, ['frame0', 'frame1'], [{transitionduration: 1.123}, {transitionduration: 1.456}]).then(function() { var calls = PlotlyInternal.transition.calls; - expect(calls.argsFor(0)[4].duration).toEqual(1.123); - expect(calls.argsFor(1)[4].duration).toEqual(1.456); + expect(calls.argsFor(0)[4].transitionduration).toEqual(1.123); + expect(calls.argsFor(1)[4].transitionduration).toEqual(1.456); }).catch(fail).then(done); }); it('falls back to transitionOpts[0] if not enough supplied in array', function(done) { - Plotly.animate(gd, ['frame0', 'frame1'], [{duration: 1.123}]).then(function() { + Plotly.animate(gd, ['frame0', 'frame1'], [{transitionduration: 1.123}]).then(function() { var calls = PlotlyInternal.transition.calls; - expect(calls.argsFor(0)[4].duration).toEqual(1.123); - expect(calls.argsFor(1)[4].duration).toEqual(1.123); + expect(calls.argsFor(0)[4].transitionduration).toEqual(1.123); + expect(calls.argsFor(1)[4].transitionduration).toEqual(1.123); }).catch(fail).then(done); }); @@ -252,8 +277,8 @@ describe('Test animate API', function() { expect(starts).toEqual(1); }); - Plotly.animate(gd, 'even-frames', {duration: 16}); - Plotly.animate(gd, 'odd-frames', {duration: 16}).then(delay(10)).then(function() { + Plotly.animate(gd, 'even-frames', {transitionduration: 16}); + Plotly.animate(gd, 'odd-frames', {transitionduration: 16}).then(delay(10)).then(function() { expect(ends).toEqual(1); verifyQueueEmpty(gd); }).catch(fail).then(done); @@ -315,7 +340,7 @@ describe('Test animate API', function() { ends++; }); - Plotly.animate(gd, ['frame0', 'frame1'], {duration: 200, frameduration: 20}).then(function() { + Plotly.animate(gd, ['frame0', 'frame1'], {transitionduration: 200, frameduration: 20}).then(function() { expect(starts).toEqual(1); expect(ends).toEqual(1); expect(PlotlyInternal.transition.calls.count()).toEqual(2); diff --git a/test/jasmine/tests/transition_test.js b/test/jasmine/tests/transition_test.js index a7898cb9bbc..f3e3ec6ad0e 100644 --- a/test/jasmine/tests/transition_test.js +++ b/test/jasmine/tests/transition_test.js @@ -15,7 +15,7 @@ describe('Plots.supplyTransitionDefaults', function() { it('supplies transition defaults', function() { expect(Plots.supplyTransitionDefaults({})).toEqual({ frameduration: 500, - duration: 500, + transitionduration: 500, ease: 'cubic-in-out', redraw: true }); @@ -24,12 +24,12 @@ describe('Plots.supplyTransitionDefaults', function() { it('uses provided values', function() { expect(Plots.supplyTransitionDefaults({ frameduration: 200, - duration: 100, + transitionduration: 100, ease: 'quad-in-out', redraw: false })).toEqual({ frameduration: 200, - duration: 100, + transitionduration: 100, ease: 'quad-in-out', redraw: false }); @@ -59,7 +59,7 @@ function runTests(transitionDuration) { it('resolves only once the transition has completed', function(done) { var t1 = Date.now(); - Plotly.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {duration: transitionDuration}) + Plotly.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {transitionduration: transitionDuration}) .then(delay(20)) .then(function() { expect(Date.now() - t1).toBeGreaterThan(transitionDuration); @@ -70,7 +70,7 @@ function runTests(transitionDuration) { var beginTransitionCnt = 0; gd.on('plotly_transitioning', function() { beginTransitionCnt++; }); - Plotly.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {duration: transitionDuration}) + Plotly.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {transitionduration: transitionDuration}) .then(delay(20)) .then(function() { expect(beginTransitionCnt).toBe(1); @@ -81,7 +81,7 @@ function runTests(transitionDuration) { var trEndCnt = 0; gd.on('plotly_transitioned', function() { trEndCnt++; }); - Plotly.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {duration: transitionDuration}) + Plotly.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {transitionduration: transitionDuration}) .then(delay(20)) .then(function() { expect(trEndCnt).toEqual(1); @@ -99,7 +99,7 @@ function runTests(transitionDuration) { gd.on('plotly_transitioned', function() { currentlyRunning--; endCnt++; }); function doTransition() { - return Plotly.transition(gd, [{x: [1, 2]}], null, null, {duration: transitionDuration}); + return Plotly.transition(gd, [{x: [1, 2]}], null, null, {transitionduration: transitionDuration}); } function checkNoneRunning() { From ee95be1ad789d7688feba5c3838e6be09e2695f1 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Mon, 29 Aug 2016 12:59:07 -0400 Subject: [PATCH 27/45] Fix frame attributes --- src/plots/frame_attributes.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plots/frame_attributes.js b/src/plots/frame_attributes.js index 629a1225876..41db176e23e 100644 --- a/src/plots/frame_attributes.js +++ b/src/plots/frame_attributes.js @@ -43,13 +43,13 @@ module.exports = { description: [ 'A list of traces this frame modifies. The format is identical to the', 'normal trace definition.' - ] + ].join(' ') }, layout: { valType: 'any', description: [ 'Layout properties which this frame modifies. The format is identical', 'to the normal layout definition.' - ] + ].join(' ') } }; From dfd2037f672ff20478767576c8122cc1f43974fa Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Mon, 29 Aug 2016 13:43:36 -0400 Subject: [PATCH 28/45] Update attributes to improve interop with animations --- src/components/updatemenus/attributes.js | 2 +- src/plots/frame_attributes.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/updatemenus/attributes.js b/src/components/updatemenus/attributes.js index b27416ee424..0a136827a13 100644 --- a/src/components/updatemenus/attributes.js +++ b/src/components/updatemenus/attributes.js @@ -17,7 +17,7 @@ var buttonsAttrs = { method: { valType: 'enumerated', - values: ['restyle', 'relayout'], + values: ['restyle', 'relayout', 'animate'], dflt: 'restyle', role: 'info', description: [ diff --git a/src/plots/frame_attributes.js b/src/plots/frame_attributes.js index 41db176e23e..8dc2f57712a 100644 --- a/src/plots/frame_attributes.js +++ b/src/plots/frame_attributes.js @@ -20,7 +20,7 @@ module.exports = { name: { valType: 'string', role: 'info', - description: ['A label by which to identify the frame'] + description: 'A label by which to identify the frame' }, traces: { valType: 'data_array', From 82fefa9ff68b6aecd940c8625875713f88e941eb Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 30 Aug 2016 17:01:30 -0400 Subject: [PATCH 29/45] Move point symbol style to single point style func --- src/components/drawing/index.js | 65 ++++++++++++++++----------------- 1 file changed, 32 insertions(+), 33 deletions(-) diff --git a/src/components/drawing/index.js b/src/components/drawing/index.js index 424bcdd0d40..3997fd40f31 100644 --- a/src/components/drawing/index.js +++ b/src/components/drawing/index.js @@ -194,6 +194,37 @@ drawing.symbolNumber = function(v) { }; function singlePointStyle(d, sel, trace, markerScale, lineScale, marker, markerLine) { + // only scatter & box plots get marker path and opacity + // bars, histograms don't + if(Registry.traceIs(trace, 'symbols')) { + var sizeFn = makeBubbleSizeFn(trace); + + sel.attr('d', function(d) { + var r; + + // handle multi-trace graph edit case + if(d.ms === 'various' || marker.size === 'various') r = 3; + else r = subTypes.isBubble(trace) ? + sizeFn(d.ms) : (marker.size || 6) / 2; + + // store the calculated size so hover can use it + d.mrc = r; + + // turn the symbol into a sanitized number + var x = drawing.symbolNumber(d.mx || marker.symbol) || 0, + xBase = x % 100; + + // save if this marker is open + // because that impacts how to handle colors + d.om = x % 200 >= 100; + + return drawing.symbolFuncs[xBase](r) + + (x >= 200 ? DOTPATH : ''); + }) + .style('opacity', function(d) { + return (d.mo + 1 || marker.opacity + 1) - 1; + }); + } // 'so' is suspected outliers, for box plots var fillColor, @@ -254,41 +285,9 @@ drawing.singlePointStyle = function(d, sel, trace) { drawing.pointStyle = function(s, trace) { if(!s.size()) return; - var marker = trace.marker; - - // only scatter & box plots get marker path and opacity - // bars, histograms don't - if(Registry.traceIs(trace, 'symbols')) { - var sizeFn = makeBubbleSizeFn(trace); - - s.attr('d', function(d) { - var r; - - // handle multi-trace graph edit case - if(d.ms === 'various' || marker.size === 'various') r = 3; - else r = subTypes.isBubble(trace) ? - sizeFn(d.ms) : (marker.size || 6) / 2; - - // store the calculated size so hover can use it - d.mrc = r; - - // turn the symbol into a sanitized number - var x = drawing.symbolNumber(d.mx || marker.symbol) || 0, - xBase = x % 100; - - // save if this marker is open - // because that impacts how to handle colors - d.om = x % 200 >= 100; - - return drawing.symbolFuncs[xBase](r) + - (x >= 200 ? DOTPATH : ''); - }) - .style('opacity', function(d) { - return (d.mo + 1 || marker.opacity + 1) - 1; - }); - } // allow array marker and marker line colors to be // scaled by given max and min to colorscales + var marker = trace.marker; var markerIn = (trace._input || {}).marker || {}, markerScale = drawing.tryColorscale(marker, markerIn, ''), lineScale = drawing.tryColorscale(marker, markerIn, 'line.'); From db6b942ef59fe0edf67110eaac7df07b0e906272 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Fri, 2 Sep 2016 10:09:02 -0600 Subject: [PATCH 30/45] Test moving transition and doCalcdata to plots.js --- src/plot_api/plot_api.js | 297 +-------------------------------------- src/plots/plots.js | 294 +++++++++++++++++++++++++++++++++++++- 2 files changed, 295 insertions(+), 296 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 3f1657bc03b..3aaddeefa31 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -158,7 +158,7 @@ Plotly.plot = function(gd, data, layout, config) { // generate calcdata, if we need to // to force redoing calcdata, just delete it before calling Plotly.plot var recalc = !gd.calcdata || gd.calcdata.length !== (gd.data || []).length; - if(recalc) doCalcdata(gd); + if(recalc) Plots.doCalcdata(gd); // in case it has changed, attach fullData traces to calcdata for(var i = 0; i < gd.calcdata.length; i++) { @@ -853,77 +853,6 @@ Plotly.newPlot = function(gd, data, layout, config) { return Plotly.plot(gd, data, layout, config); }; -function doCalcdata(gd, traces) { - var axList = Plotly.Axes.list(gd), - fullData = gd._fullData, - fullLayout = gd._fullLayout, - i; - - // XXX: Is this correct? Needs a closer look so that *some* traces can be recomputed without - // *all* needing doCalcdata: - var calcdata = new Array(fullData.length); - var oldCalcdata = (gd.calcdata || []).slice(0); - gd.calcdata = calcdata; - - // extra helper variables - // firstscatter: fill-to-next on the first trace goes to zero - gd.firstscatter = true; - - // how many box plots do we have (in case they're grouped) - gd.numboxes = 0; - - // for calculating avg luminosity of heatmaps - gd._hmpixcount = 0; - gd._hmlumcount = 0; - - // for sharing colors across pies (and for legend) - fullLayout._piecolormap = {}; - fullLayout._piedefaultcolorcount = 0; - - // initialize the category list, if there is one, so we start over - // to be filled in later by ax.d2c - for(i = 0; i < axList.length; i++) { - axList[i]._categories = axList[i]._initialCategories.slice(); - } - - for(i = 0; i < fullData.length; i++) { - // If traces were specified and this trace was not included, then transfer it over from - // the old calcdata: - if(Array.isArray(traces) && traces.indexOf(i) === -1) { - calcdata[i] = oldCalcdata[i]; - continue; - } - - var trace = fullData[i], - _module = trace._module, - cd = []; - - // If traces were specified and this trace was not included, then transfer it over from - // the old calcdata: - if(Array.isArray(traces) && traces.indexOf(i) === -1) { - calcdata[i] = oldCalcdata[i]; - continue; - } - - if(_module && trace.visible === true) { - if(_module.calc) cd = _module.calc(gd, trace); - } - - // make sure there is a first point - // this ensures there is a calcdata item for every trace, - // even if cartesian logic doesn't handle it - if(!Array.isArray(cd) || !cd[0]) cd = [{x: false, y: false}]; - - // add the trace-wide properties to the first point, - // per point properties to every point - // t is the holder for trace-wide properties - if(!cd[0].t) cd[0].t = {}; - cd[0].trace = trace; - - calcdata[i] = cd; - } -} - /** * Wrap negative indicies to their positive counterparts. * @@ -2526,228 +2455,6 @@ Plotly.relayout = function relayout(gd, astr, val) { }); }; -/** - * Transition to a set of new data and layout properties - * - * @param {string id or DOM element} gd - * the id or DOM element of the graph container div - */ -Plotly.transition = function(gd, data, layout, traceIndices, transitionOpts) { - gd = getGraphDiv(gd); - - var i, traceIdx; - var fullLayout = gd._fullLayout; - - transitionOpts = Plots.supplyTransitionDefaults(transitionOpts); - - var dataLength = Array.isArray(data) ? data.length : 0; - - // Select which traces will be updated: - if(isNumeric(traceIndices)) traceIndices = [traceIndices]; - else if(!Array.isArray(traceIndices) || !traceIndices.length) { - traceIndices = gd.data.map(function(v, i) { return i; }); - } - - if(traceIndices.length > dataLength) { - traceIndices = traceIndices.slice(0, dataLength); - } - - var transitionedTraces = []; - - function prepareTransitions() { - var plotinfo, i; - for(i = 0; i < traceIndices.length; i++) { - var traceIdx = traceIndices[i]; - var trace = gd._fullData[traceIdx]; - var module = trace._module; - - if(!module || !module.animatable) { - continue; - } - - transitionedTraces.push(traceIdx); - - // This is a multi-step process. First clone w/o arrays so that - // we're not modifying the original: - var update = Lib.extendDeepNoArrays({}, data[i]); - - // Then expand object paths since we don't obey object-overwrite - // semantics here: - update = Lib.expandObjectPaths(update); - - // Finally apply the update (without copying arrays, of course): - Lib.extendDeepNoArrays(gd.data[traceIndices[i]], update); - } - - // Supply defaults after applying the incoming properties. Note that any attempt - // to simplify this step and reduce the amount of work resulted in the reconstruction - // of essentially the whole supplyDefaults step, so that it seems sensible to just use - // supplyDefaults even though it's heavier than would otherwise be desired for - // transitions: - Plots.supplyDefaults(gd); - - // This step fies the .xaxis and .yaxis references that otherwise - // aren't updated by the supplyDefaults step: - var subplots = Plotly.Axes.getSubplots(gd); - for(i = 0; i < subplots.length; i++) { - plotinfo = gd._fullLayout._plots[subplots[i]]; - plotinfo.xaxis = plotinfo.x(); - plotinfo.yaxis = plotinfo.y(); - } - - doCalcdata(gd); - - ErrorBars.calc(gd); - - return Promise.resolve(); - } - - function executeCallbacks(list) { - var p = Promise.resolve(); - if(!list) return p; - while(list.length) { - p = p.then((list.shift())); - } - return p; - } - - function flushCallbacks(list) { - if(!list) return; - while(list.length) { - list.shift(); - } - } - - var aborted = false; - - function executeTransitions() { - return new Promise(function(resolve) { - // This flag is used to disabled things like autorange: - gd._transitioning = true; - - // When instantaneous updates are coming through quickly, it's too much to simply disable - // all interaction, so store this flag so we can disambiguate whether mouse interactions - // should be fully disabled or not: - if(transitionOpts.transitionduration > 0) { - gd._transitioningWithDuration = true; - } - - gd._transitionData._interruptCallbacks.push(function() { - aborted = true; - }); - - // Construct callbacks that are executed on transition end. This ensures the d3 transitions - // are *complete* before anything else is done. - var numCallbacks = 0; - var numCompleted = 0; - function makeCallback() { - numCallbacks++; - return function() { - numCompleted++; - // When all are complete, perform a redraw: - if(!aborted && numCompleted === numCallbacks) { - completeTransition(resolve); - } - }; - } - - var traceTransitionOpts; - var j; - var basePlotModules = fullLayout._basePlotModules; - var hasAxisTransition = false; - - if(layout) { - for(j = 0; j < basePlotModules.length; j++) { - if(basePlotModules[j].transitionAxes) { - var newLayout = Lib.expandObjectPaths(layout); - hasAxisTransition = basePlotModules[j].transitionAxes(gd, newLayout, transitionOpts, makeCallback) || hasAxisTransition; - } - } - } - - // Here handle the exception that we refuse to animate scales and axes at the same - // time. In other words, if there's an axis transition, then set the data transition - // to instantaneous. - if(hasAxisTransition) { - traceTransitionOpts = Lib.extendFlat({}, transitionOpts); - traceTransitionOpts.transitionduration = 0; - } else { - traceTransitionOpts = transitionOpts; - } - - for(j = 0; j < basePlotModules.length; j++) { - // Note that we pass a callback to *create* the callback that must be invoked on completion. - // This is since not all traces know about transitions, so it greatly simplifies matters if - // the trace is responsible for creating a callback, if needed, and then executing it when - // the time is right. - basePlotModules[j].plot(gd, transitionedTraces, traceTransitionOpts, makeCallback); - } - - // If nothing else creates a callback, then this will trigger the completion in the next tick: - setTimeout(makeCallback()); - - }); - } - - function completeTransition(callback) { - flushCallbacks(gd._transitionData._interruptCallbacks); - - return Promise.resolve().then(function() { - if(transitionOpts.redraw) { - return Plotly.redraw(gd); - } - }).then(function() { - // Set transitioning false again once the redraw has occurred. This is used, for example, - // to prevent the trailing redraw from autoranging: - gd._transitioning = false; - gd._transitioningWithDuration = false; - - gd.emit('plotly_transitioned', []); - }).then(callback); - } - - function interruptPreviousTransitions() { - gd.emit('plotly_transitioninterrupted', []); - - // If a transition is interrupted, set this to false. At the moment, the only thing that would - // interrupt a transition is another transition, so that it will momentarily be set to true - // again, but this determines whether autorange or dragbox work, so it's for the sake of - // cleanliness: - gd._transitioning = false; - gd._transtionWithDuration = false; - - return executeCallbacks(gd._transitionData._interruptCallbacks); - } - - for(i = 0; i < traceIndices.length; i++) { - traceIdx = traceIndices[i]; - var contFull = gd._fullData[traceIdx]; - var module = contFull._module; - - if(!module) continue; - - if(!module.animatable) { - var thisUpdate = {}; - - for(var ai in data[i]) { - thisUpdate[ai] = [data[i][ai]]; - } - } - } - - var seq = [Plots.previousPromises, interruptPreviousTransitions, prepareTransitions, executeTransitions]; - - - var transitionStarting = Lib.syncOrAsync(seq, gd); - - if(!transitionStarting || !transitionStarting.then) transitionStarting = Promise.resolve(); - - return transitionStarting.then(function() { - gd.emit('plotly_transitioning', []); - return gd; - }); -}; - /** * Animate to a keyframe * @@ -2854,7 +2561,7 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, transitionOpts, anima trans._lastframeat = Date.now(); trans._timetonext = newFrame.transitionOpts.frameduration; - Plotly.transition(gd, + Plots.transition(gd, newFrame.frame.data, newFrame.frame.layout, newFrame.frame.traces, diff --git a/src/plots/plots.js b/src/plots/plots.js index 6fbd0bee06c..2fe5f751724 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -16,7 +16,6 @@ var Plotly = require('../plotly'); var Registry = require('../registry'); var Lib = require('../lib'); var Color = require('../components/color'); - var plots = module.exports = {}; var transitionAttrs = require('./transition_attributes'); var animationAttrs = require('./animation_attributes'); @@ -37,6 +36,8 @@ var subplotsRegistry = plots.subplotsRegistry; var transformsRegistry = plots.transformsRegistry; +var ErrorBars = require('../components/errorbars'); + /** * Find subplot ids in data. * Meant to be used in the defaults step. @@ -1308,3 +1309,294 @@ plots.computeFrame = function(gd, frameName) { return result; }; + +/** + * Transition to a set of new data and layout properties + * + * @param {string id or DOM element} gd + * the id or DOM element of the graph container div + */ +plots.transition = function(gd, data, layout, traceIndices, transitionOpts) { + var i, traceIdx; + var fullLayout = gd._fullLayout; + + transitionOpts = plots.supplyTransitionDefaults(transitionOpts); + + var dataLength = Array.isArray(data) ? data.length : 0; + + // Select which traces will be updated: + if(isNumeric(traceIndices)) traceIndices = [traceIndices]; + else if(!Array.isArray(traceIndices) || !traceIndices.length) { + traceIndices = gd.data.map(function(v, i) { return i; }); + } + + if(traceIndices.length > dataLength) { + traceIndices = traceIndices.slice(0, dataLength); + } + + var transitionedTraces = []; + + function prepareTransitions() { + var plotinfo, i; + for(i = 0; i < traceIndices.length; i++) { + var traceIdx = traceIndices[i]; + var trace = gd._fullData[traceIdx]; + var module = trace._module; + + if(!module || !module.animatable) { + continue; + } + + transitionedTraces.push(traceIdx); + + // This is a multi-step process. First clone w/o arrays so that + // we're not modifying the original: + var update = Lib.extendDeepNoArrays({}, data[i]); + + // Then expand object paths since we don't obey object-overwrite + // semantics here: + update = Lib.expandObjectPaths(update); + + // Finally apply the update (without copying arrays, of course): + Lib.extendDeepNoArrays(gd.data[traceIndices[i]], update); + } + + // Supply defaults after applying the incoming properties. Note that any attempt + // to simplify this step and reduce the amount of work resulted in the reconstruction + // of essentially the whole supplyDefaults step, so that it seems sensible to just use + // supplyDefaults even though it's heavier than would otherwise be desired for + // transitions: + plots.supplyDefaults(gd); + + // This step fies the .xaxis and .yaxis references that otherwise + // aren't updated by the supplyDefaults step: + var subplots = Plotly.Axes.getSubplots(gd); + for(i = 0; i < subplots.length; i++) { + plotinfo = gd._fullLayout._plots[subplots[i]]; + plotinfo.xaxis = plotinfo.x(); + plotinfo.yaxis = plotinfo.y(); + } + + plots.doCalcdata(gd); + + ErrorBars.calc(gd); + + return Promise.resolve(); + } + + function executeCallbacks(list) { + var p = Promise.resolve(); + if(!list) return p; + while(list.length) { + p = p.then((list.shift())); + } + return p; + } + + function flushCallbacks(list) { + if(!list) return; + while(list.length) { + list.shift(); + } + } + + var aborted = false; + + function executeTransitions() { + return new Promise(function(resolve) { + // This flag is used to disabled things like autorange: + gd._transitioning = true; + + // When instantaneous updates are coming through quickly, it's too much to simply disable + // all interaction, so store this flag so we can disambiguate whether mouse interactions + // should be fully disabled or not: + if(transitionOpts.transitionduration > 0) { + gd._transitioningWithDuration = true; + } + + gd._transitionData._interruptCallbacks.push(function() { + aborted = true; + }); + + // Construct callbacks that are executed on transition end. This ensures the d3 transitions + // are *complete* before anything else is done. + var numCallbacks = 0; + var numCompleted = 0; + function makeCallback() { + numCallbacks++; + return function() { + numCompleted++; + // When all are complete, perform a redraw: + if(!aborted && numCompleted === numCallbacks) { + completeTransition(resolve); + } + }; + } + + var traceTransitionOpts; + var j; + var basePlotModules = fullLayout._basePlotModules; + var hasAxisTransition = false; + + if(layout) { + for(j = 0; j < basePlotModules.length; j++) { + if(basePlotModules[j].transitionAxes) { + var newLayout = Lib.expandObjectPaths(layout); + hasAxisTransition = basePlotModules[j].transitionAxes(gd, newLayout, transitionOpts, makeCallback) || hasAxisTransition; + } + } + } + + // Here handle the exception that we refuse to animate scales and axes at the same + // time. In other words, if there's an axis transition, then set the data transition + // to instantaneous. + if(hasAxisTransition) { + traceTransitionOpts = Lib.extendFlat({}, transitionOpts); + traceTransitionOpts.transitionduration = 0; + } else { + traceTransitionOpts = transitionOpts; + } + + for(j = 0; j < basePlotModules.length; j++) { + // Note that we pass a callback to *create* the callback that must be invoked on completion. + // This is since not all traces know about transitions, so it greatly simplifies matters if + // the trace is responsible for creating a callback, if needed, and then executing it when + // the time is right. + basePlotModules[j].plot(gd, transitionedTraces, traceTransitionOpts, makeCallback); + } + + // If nothing else creates a callback, then this will trigger the completion in the next tick: + setTimeout(makeCallback()); + + }); + } + + function completeTransition(callback) { + flushCallbacks(gd._transitionData._interruptCallbacks); + + return Promise.resolve().then(function() { + if(transitionOpts.redraw) { + return Plotly.redraw(gd); + } + }).then(function() { + // Set transitioning false again once the redraw has occurred. This is used, for example, + // to prevent the trailing redraw from autoranging: + gd._transitioning = false; + gd._transitioningWithDuration = false; + + gd.emit('plotly_transitioned', []); + }).then(callback); + } + + function interruptPreviousTransitions() { + gd.emit('plotly_transitioninterrupted', []); + + // If a transition is interrupted, set this to false. At the moment, the only thing that would + // interrupt a transition is another transition, so that it will momentarily be set to true + // again, but this determines whether autorange or dragbox work, so it's for the sake of + // cleanliness: + gd._transitioning = false; + gd._transtionWithDuration = false; + + return executeCallbacks(gd._transitionData._interruptCallbacks); + } + + for(i = 0; i < traceIndices.length; i++) { + traceIdx = traceIndices[i]; + var contFull = gd._fullData[traceIdx]; + var module = contFull._module; + + if(!module) continue; + + if(!module.animatable) { + var thisUpdate = {}; + + for(var ai in data[i]) { + thisUpdate[ai] = [data[i][ai]]; + } + } + } + + var seq = [plots.previousPromises, interruptPreviousTransitions, prepareTransitions, executeTransitions]; + + + var transitionStarting = Lib.syncOrAsync(seq, gd); + + if(!transitionStarting || !transitionStarting.then) transitionStarting = Promise.resolve(); + + return transitionStarting.then(function() { + gd.emit('plotly_transitioning', []); + return gd; + }); +}; + +plots.doCalcdata = function(gd, traces) { + var axList = Plotly.Axes.list(gd), + fullData = gd._fullData, + fullLayout = gd._fullLayout, + i; + + // XXX: Is this correct? Needs a closer look so that *some* traces can be recomputed without + // *all* needing doCalcdata: + var calcdata = new Array(fullData.length); + var oldCalcdata = (gd.calcdata || []).slice(0); + gd.calcdata = calcdata; + + // extra helper variables + // firstscatter: fill-to-next on the first trace goes to zero + gd.firstscatter = true; + + // how many box plots do we have (in case they're grouped) + gd.numboxes = 0; + + // for calculating avg luminosity of heatmaps + gd._hmpixcount = 0; + gd._hmlumcount = 0; + + // for sharing colors across pies (and for legend) + fullLayout._piecolormap = {}; + fullLayout._piedefaultcolorcount = 0; + + // initialize the category list, if there is one, so we start over + // to be filled in later by ax.d2c + for(i = 0; i < axList.length; i++) { + axList[i]._categories = axList[i]._initialCategories.slice(); + } + + for(i = 0; i < fullData.length; i++) { + // If traces were specified and this trace was not included, then transfer it over from + // the old calcdata: + if(Array.isArray(traces) && traces.indexOf(i) === -1) { + calcdata[i] = oldCalcdata[i]; + continue; + } + + var trace = fullData[i], + _module = trace._module, + cd = []; + + // If traces were specified and this trace was not included, then transfer it over from + // the old calcdata: + if(Array.isArray(traces) && traces.indexOf(i) === -1) { + calcdata[i] = oldCalcdata[i]; + continue; + } + + if(_module && trace.visible === true) { + if(_module.calc) cd = _module.calc(gd, trace); + } + + // make sure there is a first point + // this ensures there is a calcdata item for every trace, + // even if cartesian logic doesn't handle it + if(!Array.isArray(cd) || !cd[0]) cd = [{x: false, y: false}]; + + // add the trace-wide properties to the first point, + // per point properties to every point + // t is the holder for trace-wide properties + if(!cd[0].t) cd[0].t = {}; + cd[0].trace = trace; + + calcdata[i] = cd; + } +}; From 0d3da5ee4d4cb5468e0f4811f7601fbe170053a8 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Fri, 2 Sep 2016 10:23:34 -0600 Subject: [PATCH 31/45] Change namespace of transition --- test/jasmine/tests/animate_test.js | 46 +++++++++++++-------------- test/jasmine/tests/transition_test.js | 10 +++--- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/test/jasmine/tests/animate_test.js b/test/jasmine/tests/animate_test.js index eb0ad347b13..e0ffc6b8ef9 100644 --- a/test/jasmine/tests/animate_test.js +++ b/test/jasmine/tests/animate_test.js @@ -1,6 +1,6 @@ var Plotly = require('@lib/index'); -var PlotlyInternal = require('@src/plotly'); var Lib = require('@src/lib'); +var Plots = Plotly.Plots; var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); @@ -19,7 +19,7 @@ describe('Test animate API', function() { } function verifyFrameTransitionOrder(gd, expectedFrames) { - var calls = PlotlyInternal.transition.calls; + var calls = Plots.transition.calls; expect(calls.count()).toEqual(expectedFrames.length); @@ -35,7 +35,7 @@ describe('Test animate API', function() { mockCopy = Lib.extendDeep({}, mock); - spyOn(PlotlyInternal, 'transition').and.callFake(function() { + spyOn(Plots, 'transition').and.callFake(function() { // Transition's fake behaviro is to resolve after a short period of time: return Promise.resolve().then(delay(5)); }); @@ -61,9 +61,9 @@ describe('Test animate API', function() { it('animates to a frame', function(done) { Plotly.animate(gd, ['frame0'], {transitionduration: 1.2345}).then(function() { - expect(PlotlyInternal.transition).toHaveBeenCalled(); + expect(Plots.transition).toHaveBeenCalled(); - var args = PlotlyInternal.transition.calls.mostRecent().args; + var args = Plots.transition.calls.mostRecent().args; // was called with gd, data, layout, traceIndices, transitionConfig: expect(args.length).toEqual(5); @@ -92,7 +92,7 @@ describe('Test animate API', function() { it('treats objects as frames', function(done) { var frame = {data: [{x: [1, 2, 3]}]}; Plotly.animate(gd, frame, transOpts).then(function() { - expect(PlotlyInternal.transition.calls.count()).toEqual(1); + expect(Plots.transition.calls.count()).toEqual(1); verifyQueueEmpty(gd); }).catch(fail).then(done); }); @@ -101,15 +101,15 @@ describe('Test animate API', function() { var frame1 = {data: [{x: [1, 2, 3]}], traces: [0], layout: {foo: 'bar'}}; var frame2 = {data: [{x: [3, 4, 5]}], traces: [1], layout: {foo: 'baz'}}; Plotly.animate(gd, [frame1, frame2], transOpts).then(function() { - expect(PlotlyInternal.transition.calls.argsFor(0)[1]).toEqual(frame1.data); - expect(PlotlyInternal.transition.calls.argsFor(0)[2]).toEqual(frame1.layout); - expect(PlotlyInternal.transition.calls.argsFor(0)[3]).toEqual(frame1.traces); + expect(Plots.transition.calls.argsFor(0)[1]).toEqual(frame1.data); + expect(Plots.transition.calls.argsFor(0)[2]).toEqual(frame1.layout); + expect(Plots.transition.calls.argsFor(0)[3]).toEqual(frame1.traces); - expect(PlotlyInternal.transition.calls.argsFor(1)[1]).toEqual(frame2.data); - expect(PlotlyInternal.transition.calls.argsFor(1)[2]).toEqual(frame2.layout); - expect(PlotlyInternal.transition.calls.argsFor(1)[3]).toEqual(frame2.traces); + expect(Plots.transition.calls.argsFor(1)[1]).toEqual(frame2.data); + expect(Plots.transition.calls.argsFor(1)[2]).toEqual(frame2.layout); + expect(Plots.transition.calls.argsFor(1)[3]).toEqual(frame2.traces); - expect(PlotlyInternal.transition.calls.count()).toEqual(2); + expect(Plots.transition.calls.count()).toEqual(2); verifyQueueEmpty(gd); }).catch(fail).then(done); }); @@ -130,28 +130,28 @@ describe('Test animate API', function() { it('animates to a single frame', function(done) { Plotly.animate(gd, ['frame0'], transOpts).then(function() { - expect(PlotlyInternal.transition.calls.count()).toEqual(1); + expect(Plots.transition.calls.count()).toEqual(1); verifyQueueEmpty(gd); }).catch(fail).then(done); }); it('animates to an empty list', function(done) { Plotly.animate(gd, [], transOpts).then(function() { - expect(PlotlyInternal.transition.calls.count()).toEqual(0); + expect(Plots.transition.calls.count()).toEqual(0); verifyQueueEmpty(gd); }).catch(fail).then(done); }); it('animates to a list of frames', function(done) { Plotly.animate(gd, ['frame0', 'frame1'], transOpts).then(function() { - expect(PlotlyInternal.transition.calls.count()).toEqual(2); + expect(Plots.transition.calls.count()).toEqual(2); verifyQueueEmpty(gd); }).catch(fail).then(done); }); it('animates frames by group', function(done) { Plotly.animate(gd, 'even-frames', transOpts).then(function() { - expect(PlotlyInternal.transition.calls.count()).toEqual(2); + expect(Plots.transition.calls.count()).toEqual(2); verifyQueueEmpty(gd); }).catch(fail).then(done); }); @@ -165,7 +165,7 @@ describe('Test animate API', function() { it('accepts a single transitionOpts', function(done) { Plotly.animate(gd, ['frame0', 'frame1'], {transitionduration: 1.12345}).then(function() { - var calls = PlotlyInternal.transition.calls; + var calls = Plots.transition.calls; expect(calls.argsFor(0)[4].transitionduration).toEqual(1.12345); expect(calls.argsFor(1)[4].transitionduration).toEqual(1.12345); }).catch(fail).then(done); @@ -173,7 +173,7 @@ describe('Test animate API', function() { it('accepts an array of transitionOpts', function(done) { Plotly.animate(gd, ['frame0', 'frame1'], [{transitionduration: 1.123}, {transitionduration: 1.456}]).then(function() { - var calls = PlotlyInternal.transition.calls; + var calls = Plots.transition.calls; expect(calls.argsFor(0)[4].transitionduration).toEqual(1.123); expect(calls.argsFor(1)[4].transitionduration).toEqual(1.456); }).catch(fail).then(done); @@ -181,7 +181,7 @@ describe('Test animate API', function() { it('falls back to transitionOpts[0] if not enough supplied in array', function(done) { Plotly.animate(gd, ['frame0', 'frame1'], [{transitionduration: 1.123}]).then(function() { - var calls = PlotlyInternal.transition.calls; + var calls = Plots.transition.calls; expect(calls.argsFor(0)[4].transitionduration).toEqual(1.123); expect(calls.argsFor(1)[4].transitionduration).toEqual(1.123); }).catch(fail).then(done); @@ -273,7 +273,7 @@ describe('Test animate API', function() { starts++; }).on('plotly_animated', function() { ends++; - expect(PlotlyInternal.transition.calls.count()).toEqual(4); + expect(Plots.transition.calls.count()).toEqual(4); expect(starts).toEqual(1); }); @@ -287,7 +287,7 @@ describe('Test animate API', function() { it('an empty list with immediate dumps previous frames', function(done) { Plotly.animate(gd, ['frame0', 'frame1'], {frameduration: 50}); Plotly.animate(gd, [], null, {immediate: true}).then(function() { - expect(PlotlyInternal.transition.calls.count()).toEqual(1); + expect(Plots.transition.calls.count()).toEqual(1); verifyQueueEmpty(gd); }).catch(fail).then(done); }); @@ -343,7 +343,7 @@ describe('Test animate API', function() { Plotly.animate(gd, ['frame0', 'frame1'], {transitionduration: 200, frameduration: 20}).then(function() { expect(starts).toEqual(1); expect(ends).toEqual(1); - expect(PlotlyInternal.transition.calls.count()).toEqual(2); + expect(Plots.transition.calls.count()).toEqual(2); verifyQueueEmpty(gd); }).catch(fail).then(done); }); diff --git a/test/jasmine/tests/transition_test.js b/test/jasmine/tests/transition_test.js index f3e3ec6ad0e..841f9e6ecb5 100644 --- a/test/jasmine/tests/transition_test.js +++ b/test/jasmine/tests/transition_test.js @@ -38,7 +38,7 @@ describe('Plots.supplyTransitionDefaults', function() { }); function runTests(transitionDuration) { - describe('Plotly.transition (duration = ' + transitionDuration + ')', function() { + describe('Plots.transition (duration = ' + transitionDuration + ')', function() { 'use strict'; var gd; @@ -59,7 +59,7 @@ function runTests(transitionDuration) { it('resolves only once the transition has completed', function(done) { var t1 = Date.now(); - Plotly.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {transitionduration: transitionDuration}) + Plots.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {transitionduration: transitionDuration}) .then(delay(20)) .then(function() { expect(Date.now() - t1).toBeGreaterThan(transitionDuration); @@ -70,7 +70,7 @@ function runTests(transitionDuration) { var beginTransitionCnt = 0; gd.on('plotly_transitioning', function() { beginTransitionCnt++; }); - Plotly.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {transitionduration: transitionDuration}) + Plots.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {transitionduration: transitionDuration}) .then(delay(20)) .then(function() { expect(beginTransitionCnt).toBe(1); @@ -81,7 +81,7 @@ function runTests(transitionDuration) { var trEndCnt = 0; gd.on('plotly_transitioned', function() { trEndCnt++; }); - Plotly.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {transitionduration: transitionDuration}) + Plots.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {transitionduration: transitionDuration}) .then(delay(20)) .then(function() { expect(trEndCnt).toEqual(1); @@ -99,7 +99,7 @@ function runTests(transitionDuration) { gd.on('plotly_transitioned', function() { currentlyRunning--; endCnt++; }); function doTransition() { - return Plotly.transition(gd, [{x: [1, 2]}], null, null, {transitionduration: transitionDuration}); + return Plots.transition(gd, [{x: [1, 2]}], null, null, {transitionduration: transitionDuration}); } function checkNoneRunning() { From 0221c796bea7313fe50fdbb282a321e082e1e7f0 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Fri, 2 Sep 2016 14:30:37 -0600 Subject: [PATCH 32/45] Refactor animationOpts API --- src/components/errorbars/plot.js | 14 +- src/plot_api/plot_api.js | 28 +- src/plots/animation_attributes.js | 76 +++++ src/plots/cartesian/transition_axes.js | 8 +- src/plots/plots.js | 67 +++-- src/plots/transition_attributes.js | 84 ------ src/traces/scatter/plot.js | 20 +- test/jasmine/tests/animate_test.js | 366 +++++++++++++------------ test/jasmine/tests/transition_test.js | 54 ++-- 9 files changed, 394 insertions(+), 323 deletions(-) delete mode 100644 src/plots/transition_attributes.js diff --git a/src/components/errorbars/plot.js b/src/components/errorbars/plot.js index d8fbbc8b5db..07fdeafc3ec 100644 --- a/src/components/errorbars/plot.js +++ b/src/components/errorbars/plot.js @@ -14,12 +14,12 @@ var isNumeric = require('fast-isnumeric'); var subTypes = require('../../traces/scatter/subtypes'); -module.exports = function plot(traces, plotinfo, transitionConfig) { +module.exports = function plot(traces, plotinfo, transitionOpts) { var isNew; var xa = plotinfo.x(), ya = plotinfo.y(); - var hasAnimation = transitionConfig && transitionConfig.transitionduration > 0; + var hasAnimation = transitionOpts && transitionOpts.duration > 0; traces.each(function(d) { var trace = d[0].trace, @@ -55,7 +55,7 @@ module.exports = function plot(traces, plotinfo, transitionConfig) { if(hasAnimation) { enter.style('opacity', 0).transition() - .duration(transitionConfig.transitionduration) + .duration(transitionOpts.duration) .style('opacity', 1); } @@ -89,8 +89,8 @@ module.exports = function plot(traces, plotinfo, transitionConfig) { } else if(hasAnimation) { yerror = yerror .transition() - .duration(transitionConfig.transitionduration) - .ease(transitionConfig.ease); + .duration(transitionOpts.duration) + .ease(transitionOpts.easing); } yerror.attr('d', path); @@ -117,8 +117,8 @@ module.exports = function plot(traces, plotinfo, transitionConfig) { } else if(hasAnimation) { xerror = xerror .transition() - .duration(transitionConfig.transitionduration) - .ease(transitionConfig.ease); + .duration(transitionOpts.duration) + .ease(transitionOpts.easing); } xerror.attr('d', path); diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 3aaddeefa31..0d809378716 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2460,10 +2460,10 @@ Plotly.relayout = function relayout(gd, astr, val) { * * @param {string} name * name of the keyframe to create - * @param {object} transitionOpts - * configuration for transition + * @param {object} animationOpts + * configuration for animation */ -Plotly.animate = function(gd, frameOrGroupNameOrFrameList, transitionOpts, animationOpts) { +Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { gd = getGraphDiv(gd); var trans = gd._transitionData; @@ -2474,6 +2474,8 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, transitionOpts, anima } animationOpts = Plots.supplyAnimationDefaults(animationOpts); + var transitionOpts = animationOpts.transition; + var frameOpts = animationOpts.frame; // Since frames are popped immediately, an empty queue only means all frames have // *started* to transition, not that the animation is complete. To solve that, @@ -2495,6 +2497,18 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, transitionOpts, anima } } + function getFrameOpts(i) { + if(Array.isArray(frameOpts)) { + if(i >= frameOpts.length) { + return frameOpts[0]; + } else { + return frameOpts[i]; + } + } else { + return frameOpts; + } + } + return new Promise(function(resolve, reject) { function discardExistingFrames() { if(trans._frameQueue.length === 0) { @@ -2522,12 +2536,11 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, transitionOpts, anima computedFrame = frameList[i].frame; } - var opts = Plots.supplyTransitionDefaults(getTransitionOpts(i)); - var nextFrame = { frame: computedFrame, name: frameList[i].name, - transitionOpts: opts + frameOpts: getFrameOpts(i), + transitionOpts: getTransitionOpts(i) }; if(i === frameList.length - 1) { @@ -2559,12 +2572,13 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, transitionOpts, anima if(newFrame) { trans._lastframeat = Date.now(); - trans._timetonext = newFrame.transitionOpts.frameduration; + trans._timetonext = newFrame.frameOpts.duration; Plots.transition(gd, newFrame.frame.data, newFrame.frame.layout, newFrame.frame.traces, + newFrame.frameOpts, newFrame.transitionOpts ).then(function() { if(trans._frameQueue.length === 0) { diff --git a/src/plots/animation_attributes.js b/src/plots/animation_attributes.js index 63bc7d0a42b..58e72013c51 100644 --- a/src/plots/animation_attributes.js +++ b/src/plots/animation_attributes.js @@ -19,4 +19,80 @@ module.exports = { 'are rejected and a `plotly_animateinterrupt` event is emitted.' ].join(' ') }, + frame: { + duration: { + valType: 'number', + role: 'info', + min: 0, + dflt: 500, + description: 'The duration in milliseconds of each frame.' + }, + redraw: { + valType: 'boolean', + role: 'info', + dflt: true, + description: [ + 'Redraw the plot at completion of the transition. This is desirable', + 'for transitions that include properties that cannot be transitioned,', + 'but may significantly slow down updates that do not require a full', + 'redraw of the plot' + ].join(' ') + }, + }, + transition: { + duration: { + valType: 'number', + role: 'info', + min: 0, + dflt: 500, + description: [ + 'The duration of the transition, in milliseconds. If equal to zero,', + 'updates are synchronous.' + ].join(' ') + }, + easing: { + valType: 'enumerated', + dflt: 'cubic-in-out', + values: [ + 'linear', + 'quad', + 'cubic', + 'sin', + 'exp', + 'circle', + 'elastic', + 'back', + 'bounce', + 'linear-in', + 'quad-in', + 'cubic-in', + 'sin-in', + 'exp-in', + 'circle-in', + 'elastic-in', + 'back-in', + 'bounce-in', + 'linear-out', + 'quad-out', + 'cubic-out', + 'sin-out', + 'exp-out', + 'circle-out', + 'elastic-out', + 'back-out', + 'bounce-out', + 'linear-in-out', + 'quad-in-out', + 'cubic-in-out', + 'sin-in-out', + 'exp-in-out', + 'circle-in-out', + 'elastic-in-out', + 'back-in-out', + 'bounce-in-out' + ], + role: 'info', + description: 'The easing function used for the transition' + }, + } }; diff --git a/src/plots/cartesian/transition_axes.js b/src/plots/cartesian/transition_axes.js index 572d87e648c..9250cb06685 100644 --- a/src/plots/cartesian/transition_axes.js +++ b/src/plots/cartesian/transition_axes.js @@ -17,7 +17,7 @@ var Lib = require('../../lib'); var Axes = require('./axes'); var axisRegex = /((x|y)([2-9]|[1-9][0-9]+)?)axis$/; -module.exports = function transitionAxes(gd, newLayout, transitionConfig, makeOnCompleteCallback) { +module.exports = function transitionAxes(gd, newLayout, transitionOpts, makeOnCompleteCallback) { var fullLayout = gd._fullLayout; var axes = []; @@ -277,7 +277,7 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig, makeOn } var t1, t2, raf; - var easeFn = d3.ease(transitionConfig.ease); + var easeFn = d3.ease(transitionOpts.easing); gd._transitionData._interruptCallbacks.push(function() { cancelAnimationFrame(raf); @@ -288,14 +288,14 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig, makeOn function doFrame() { t2 = Date.now(); - var tInterp = Math.min(1, (t2 - t1) / transitionConfig.transitionduration); + var tInterp = Math.min(1, (t2 - t1) / transitionOpts.duration); var progress = easeFn(tInterp); for(var i = 0; i < affectedSubplots.length; i++) { updateSubplot(affectedSubplots[i], progress); } - if(t2 - t1 > transitionConfig.transitionduration) { + if(t2 - t1 > transitionOpts.duration) { transitionComplete(); raf = cancelAnimationFrame(doFrame); } else { diff --git a/src/plots/plots.js b/src/plots/plots.js index 2fe5f751724..057c06ceef9 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -17,7 +17,6 @@ var Registry = require('../registry'); var Lib = require('../lib'); var Color = require('../components/color'); var plots = module.exports = {}; -var transitionAttrs = require('./transition_attributes'); var animationAttrs = require('./animation_attributes'); var frameAttrs = require('./frame_attributes'); @@ -616,6 +615,8 @@ plots.supplyDataDefaults = function(dataIn, dataOut, layout) { }; plots.supplyAnimationDefaults = function(opts) { + opts = opts || {}; + var i; var optsOut = {}; function coerce(attr, dflt) { @@ -624,24 +625,53 @@ plots.supplyAnimationDefaults = function(opts) { coerce('immediate'); + if(Array.isArray(opts.frame)) { + optsOut.frame = []; + for(i = 0; i < opts.frame.length; i++) { + optsOut.frame[i] = plots.supplyAnimationFrameDefaults(opts.frame[i] || {}); + } + } else { + optsOut.frame = plots.supplyAnimationFrameDefaults(opts.frame || {}); + } + + if(Array.isArray(opts.transition)) { + optsOut.transition = []; + for(i = 0; i < opts.transition.length; i++) { + optsOut.transition[i] = plots.supplyAnimationTransitionDefaults(opts.transition[i] || {}); + } + } else { + optsOut.transition = plots.supplyAnimationTransitionDefaults(opts.transition || {}); + } + return optsOut; }; -plots.supplyTransitionDefaults = function(opts) { +plots.supplyAnimationFrameDefaults = function(opts) { var optsOut = {}; function coerce(attr, dflt) { - return Lib.coerce(opts || {}, optsOut, transitionAttrs, attr, dflt); + return Lib.coerce(opts || {}, optsOut, animationAttrs.frame, attr, dflt); } - coerce('frameduration'); - coerce('transitionduration'); - coerce('ease'); + coerce('duration'); coerce('redraw'); return optsOut; }; +plots.supplyAnimationTransitionDefaults = function(opts) { + var optsOut = {}; + + function coerce(attr, dflt) { + return Lib.coerce(opts || {}, optsOut, animationAttrs.transition, attr, dflt); + } + + coerce('duration'); + coerce('easing'); + + return optsOut; +}; + plots.supplyFrameDefaults = function(frameIn) { var frameOut = {}; @@ -1316,11 +1346,8 @@ plots.computeFrame = function(gd, frameName) { * @param {string id or DOM element} gd * the id or DOM element of the graph container div */ -plots.transition = function(gd, data, layout, traceIndices, transitionOpts) { +plots.transition = function(gd, data, layout, traceIndices, frameOpts, transitionOpts) { var i, traceIdx; - var fullLayout = gd._fullLayout; - - transitionOpts = plots.supplyTransitionDefaults(transitionOpts); var dataLength = Array.isArray(data) ? data.length : 0; @@ -1371,10 +1398,14 @@ plots.transition = function(gd, data, layout, traceIndices, transitionOpts) { // This step fies the .xaxis and .yaxis references that otherwise // aren't updated by the supplyDefaults step: var subplots = Plotly.Axes.getSubplots(gd); - for(i = 0; i < subplots.length; i++) { - plotinfo = gd._fullLayout._plots[subplots[i]]; - plotinfo.xaxis = plotinfo.x(); - plotinfo.yaxis = plotinfo.y(); + + // Polar does not have _plots: + if(gd._fullLayout._plots) { + for(i = 0; i < subplots.length; i++) { + plotinfo = gd._fullLayout._plots[subplots[i]]; + plotinfo.xaxis = plotinfo.x(); + plotinfo.yaxis = plotinfo.y(); + } } plots.doCalcdata(gd); @@ -1410,7 +1441,7 @@ plots.transition = function(gd, data, layout, traceIndices, transitionOpts) { // When instantaneous updates are coming through quickly, it's too much to simply disable // all interaction, so store this flag so we can disambiguate whether mouse interactions // should be fully disabled or not: - if(transitionOpts.transitionduration > 0) { + if(transitionOpts.duration > 0) { gd._transitioningWithDuration = true; } @@ -1435,7 +1466,7 @@ plots.transition = function(gd, data, layout, traceIndices, transitionOpts) { var traceTransitionOpts; var j; - var basePlotModules = fullLayout._basePlotModules; + var basePlotModules = gd._fullLayout._basePlotModules; var hasAxisTransition = false; if(layout) { @@ -1452,7 +1483,7 @@ plots.transition = function(gd, data, layout, traceIndices, transitionOpts) { // to instantaneous. if(hasAxisTransition) { traceTransitionOpts = Lib.extendFlat({}, transitionOpts); - traceTransitionOpts.transitionduration = 0; + traceTransitionOpts.duration = 0; } else { traceTransitionOpts = transitionOpts; } @@ -1475,7 +1506,7 @@ plots.transition = function(gd, data, layout, traceIndices, transitionOpts) { flushCallbacks(gd._transitionData._interruptCallbacks); return Promise.resolve().then(function() { - if(transitionOpts.redraw) { + if(frameOpts.redraw) { return Plotly.redraw(gd); } }).then(function() { diff --git a/src/plots/transition_attributes.js b/src/plots/transition_attributes.js deleted file mode 100644 index 247d145f95f..00000000000 --- a/src/plots/transition_attributes.js +++ /dev/null @@ -1,84 +0,0 @@ -/** -* Copyright 2012-2016, 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 = { - frameduration: { - valType: 'number', - role: 'info', - min: 0, - dflt: 500, - description: 'The duration in milliseconds of each frame.' - }, - transitionduration: { - valType: 'number', - role: 'info', - min: 0, - dflt: 500, - description: [ - 'The duration of the transition, in milliseconds. If equal to zero,', - 'updates are synchronous.' - ].join(' ') - }, - ease: { - valType: 'enumerated', - dflt: 'cubic-in-out', - values: [ - 'linear', - 'quad', - 'cubic', - 'sin', - 'exp', - 'circle', - 'elastic', - 'back', - 'bounce', - 'linear-in', - 'quad-in', - 'cubic-in', - 'sin-in', - 'exp-in', - 'circle-in', - 'elastic-in', - 'back-in', - 'bounce-in', - 'linear-out', - 'quad-out', - 'cubic-out', - 'sin-out', - 'exp-out', - 'circle-out', - 'elastic-out', - 'back-out', - 'bounce-out', - 'linear-in-out', - 'quad-in-out', - 'cubic-in-out', - 'sin-in-out', - 'exp-in-out', - 'circle-in-out', - 'elastic-in-out', - 'back-in-out', - 'bounce-in-out' - ], - role: 'info', - description: 'The easing function used for the transition' - }, - redraw: { - valType: 'boolean', - role: 'info', - dflt: true, - description: [ - 'Redraw the plot at completion of the transition. This is desirable', - 'for transitions that include properties that cannot be transitioned,', - 'but may significantly slow down updates that do not require a full', - 'redraw of the plot' - ].join(' ') - }, -}; diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index ba2744ce297..ef049853b8d 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -21,15 +21,15 @@ var linePoints = require('./line_points'); var linkTraces = require('./link_traces'); var polygonTester = require('../../lib/polygon').tester; -module.exports = function plot(gd, plotinfo, cdscatter, transitionConfig, makeOnCompleteCallback) { +module.exports = function plot(gd, plotinfo, cdscatter, transitionOpts, makeOnCompleteCallback) { var i, uids, selection, join, onComplete; var scatterlayer = plotinfo.plot.select('g.scatterlayer'); // If transition config is provided, then it is only a partial replot and traces not // updated are removed. - var isFullReplot = !transitionConfig; - var hasTransition = !!transitionConfig && transitionConfig.transitionduration > 0; + var isFullReplot = !transitionOpts; + var hasTransition = !!transitionOpts && transitionOpts.duration > 0; selection = scatterlayer.selectAll('g.trace'); @@ -71,8 +71,8 @@ module.exports = function plot(gd, plotinfo, cdscatter, transitionConfig, makeOn } var transition = d3.transition() - .duration(transitionConfig.transitionduration) - .ease(transitionConfig.ease) + .duration(transitionOpts.duration) + .ease(transitionOpts.easing) .each('end', function() { onComplete && onComplete(); }) @@ -84,12 +84,12 @@ module.exports = function plot(gd, plotinfo, cdscatter, transitionConfig, makeOn // Must run the selection again since otherwise enters/updates get grouped together // and these get executed out of order. Except we need them in order! scatterlayer.selectAll('g.trace').each(function(d, i) { - plotOne(gd, i, plotinfo, d, cdscatter, this, transitionConfig); + plotOne(gd, i, plotinfo, d, cdscatter, this, transitionOpts); }); }); } else { scatterlayer.selectAll('g.trace').each(function(d, i) { - plotOne(gd, i, plotinfo, d, cdscatter, this, transitionConfig); + plotOne(gd, i, plotinfo, d, cdscatter, this, transitionOpts); }); } @@ -142,7 +142,7 @@ function createFills(gd, scatterlayer) { }); } -function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transitionConfig) { +function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transitionOpts) { var join, i; // Since this has been reorganized and we're executing this on individual traces, @@ -150,7 +150,7 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition // since it does an internal n^2 loop over comparisons with other traces: selectMarkers(gd, idx, plotinfo, cdscatter, cdscatterAll); - var hasTransition = !!transitionConfig && transitionConfig.transitionduration > 0; + var hasTransition = !!transitionOpts && transitionOpts.duration > 0; function transition(selection) { return hasTransition ? selection.transition() : selection; @@ -165,7 +165,7 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition // (so error bars can find them along with bars) // error bars are at the bottom - tr.call(ErrorBars.plot, plotinfo, transitionConfig); + tr.call(ErrorBars.plot, plotinfo, transitionOpts); if(trace.visible !== true) return; diff --git a/test/jasmine/tests/animate_test.js b/test/jasmine/tests/animate_test.js index e0ffc6b8ef9..9f76796f248 100644 --- a/test/jasmine/tests/animate_test.js +++ b/test/jasmine/tests/animate_test.js @@ -53,192 +53,210 @@ describe('Test animate API', function() { for(var i = 0; i < 2; i++) { // Run tests for 0ms and 30ms duration: - runTests(10 + 30 * i); + runTests(30 * i); } function runTests(duration) { - var transOpts = {frameduration: duration}; + describe('With duration = ' + duration, function() { + var animOpts; - it('animates to a frame', function(done) { - Plotly.animate(gd, ['frame0'], {transitionduration: 1.2345}).then(function() { - expect(Plots.transition).toHaveBeenCalled(); - - var args = Plots.transition.calls.mostRecent().args; + beforeEach(function() { + animOpts = {frame: {duration: duration}, transition: {duration: duration * 0.5}}; + }); - // was called with gd, data, layout, traceIndices, transitionConfig: - expect(args.length).toEqual(5); + it('animates to a frame', function(done) { + Plotly.animate(gd, ['frame0'], {transition: {duration: 1.2345}, frame: {duration: 1.5678}}).then(function() { + expect(Plots.transition).toHaveBeenCalled(); - // data has two traces: - expect(args[1].length).toEqual(2); + var args = Plots.transition.calls.mostRecent().args; - // Verify transition config has been passed: - expect(args[4].transitionduration).toEqual(1.2345); + // was called with gd, data, layout, traceIndices, transitionConfig: + expect(args.length).toEqual(6); - // layout - expect(args[2]).toEqual({ - xaxis: {range: [0, 2]}, - yaxis: {range: [0, 10]} - }); + // data has two traces: + expect(args[1].length).toEqual(2); - // traces are [0, 1]: - expect(args[3]).toEqual([0, 1]); - }).catch(fail).then(done); - }); + // Verify frame config has been passed: + expect(args[4].duration).toEqual(1.5678); - it('rejects if a frame is not found', function(done) { - Plotly.animate(gd, ['foobar'], transOpts).then(fail).then(done, done); - }); + // Verify transition config has been passed: + expect(args[5].duration).toEqual(1.2345); - it('treats objects as frames', function(done) { - var frame = {data: [{x: [1, 2, 3]}]}; - Plotly.animate(gd, frame, transOpts).then(function() { - expect(Plots.transition.calls.count()).toEqual(1); - verifyQueueEmpty(gd); - }).catch(fail).then(done); - }); + // layout + expect(args[2]).toEqual({ + xaxis: {range: [0, 2]}, + yaxis: {range: [0, 10]} + }); - it('treats a list of objects as frames', function(done) { - var frame1 = {data: [{x: [1, 2, 3]}], traces: [0], layout: {foo: 'bar'}}; - var frame2 = {data: [{x: [3, 4, 5]}], traces: [1], layout: {foo: 'baz'}}; - Plotly.animate(gd, [frame1, frame2], transOpts).then(function() { - expect(Plots.transition.calls.argsFor(0)[1]).toEqual(frame1.data); - expect(Plots.transition.calls.argsFor(0)[2]).toEqual(frame1.layout); - expect(Plots.transition.calls.argsFor(0)[3]).toEqual(frame1.traces); + // traces are [0, 1]: + expect(args[3]).toEqual([0, 1]); + }).catch(fail).then(done); + }); - expect(Plots.transition.calls.argsFor(1)[1]).toEqual(frame2.data); - expect(Plots.transition.calls.argsFor(1)[2]).toEqual(frame2.layout); - expect(Plots.transition.calls.argsFor(1)[3]).toEqual(frame2.traces); + it('rejects if a frame is not found', function(done) { + Plotly.animate(gd, ['foobar'], animOpts).then(fail).then(done, done); + }); - expect(Plots.transition.calls.count()).toEqual(2); - verifyQueueEmpty(gd); - }).catch(fail).then(done); - }); + it('treats objects as frames', function(done) { + var frame = {data: [{x: [1, 2, 3]}]}; + Plotly.animate(gd, frame, animOpts).then(function() { + expect(Plots.transition.calls.count()).toEqual(1); + verifyQueueEmpty(gd); + }).catch(fail).then(done); + }); - it('animates all frames if list is null', function(done) { - Plotly.animate(gd, null, transOpts).then(function() { - verifyFrameTransitionOrder(gd, ['base', 'frame0', 'frame1', 'frame2', 'frame3']); - verifyQueueEmpty(gd); - }).catch(fail).then(done); - }); + it('treats a list of objects as frames', function(done) { + var frame1 = {data: [{x: [1, 2, 3]}], traces: [0], layout: {foo: 'bar'}}; + var frame2 = {data: [{x: [3, 4, 5]}], traces: [1], layout: {foo: 'baz'}}; + Plotly.animate(gd, [frame1, frame2], animOpts).then(function() { + expect(Plots.transition.calls.argsFor(0)[1]).toEqual(frame1.data); + expect(Plots.transition.calls.argsFor(0)[2]).toEqual(frame1.layout); + expect(Plots.transition.calls.argsFor(0)[3]).toEqual(frame1.traces); + + expect(Plots.transition.calls.argsFor(1)[1]).toEqual(frame2.data); + expect(Plots.transition.calls.argsFor(1)[2]).toEqual(frame2.layout); + expect(Plots.transition.calls.argsFor(1)[3]).toEqual(frame2.traces); + + expect(Plots.transition.calls.count()).toEqual(2); + verifyQueueEmpty(gd); + }).catch(fail).then(done); + }); - it('animates all frames if list is undefined', function(done) { - Plotly.animate(gd, undefined, transOpts).then(function() { - verifyFrameTransitionOrder(gd, ['base', 'frame0', 'frame1', 'frame2', 'frame3']); - verifyQueueEmpty(gd); - }).catch(fail).then(done); - }); + it('animates all frames if list is null', function(done) { + Plotly.animate(gd, null, animOpts).then(function() { + verifyFrameTransitionOrder(gd, ['base', 'frame0', 'frame1', 'frame2', 'frame3']); + verifyQueueEmpty(gd); + }).catch(fail).then(done); + }); - it('animates to a single frame', function(done) { - Plotly.animate(gd, ['frame0'], transOpts).then(function() { - expect(Plots.transition.calls.count()).toEqual(1); - verifyQueueEmpty(gd); - }).catch(fail).then(done); - }); + it('animates all frames if list is undefined', function(done) { + Plotly.animate(gd, undefined, animOpts).then(function() { + verifyFrameTransitionOrder(gd, ['base', 'frame0', 'frame1', 'frame2', 'frame3']); + verifyQueueEmpty(gd); + }).catch(fail).then(done); + }); - it('animates to an empty list', function(done) { - Plotly.animate(gd, [], transOpts).then(function() { - expect(Plots.transition.calls.count()).toEqual(0); - verifyQueueEmpty(gd); - }).catch(fail).then(done); - }); + it('animates to a single frame', function(done) { + Plotly.animate(gd, ['frame0'], animOpts).then(function() { + expect(Plots.transition.calls.count()).toEqual(1); + verifyQueueEmpty(gd); + }).catch(fail).then(done); + }); - it('animates to a list of frames', function(done) { - Plotly.animate(gd, ['frame0', 'frame1'], transOpts).then(function() { - expect(Plots.transition.calls.count()).toEqual(2); - verifyQueueEmpty(gd); - }).catch(fail).then(done); - }); + it('animates to an empty list', function(done) { + Plotly.animate(gd, [], animOpts).then(function() { + expect(Plots.transition.calls.count()).toEqual(0); + verifyQueueEmpty(gd); + }).catch(fail).then(done); + }); - it('animates frames by group', function(done) { - Plotly.animate(gd, 'even-frames', transOpts).then(function() { - expect(Plots.transition.calls.count()).toEqual(2); - verifyQueueEmpty(gd); - }).catch(fail).then(done); - }); + it('animates to a list of frames', function(done) { + Plotly.animate(gd, ['frame0', 'frame1'], animOpts).then(function() { + expect(Plots.transition.calls.count()).toEqual(2); + verifyQueueEmpty(gd); + }).catch(fail).then(done); + }); - it('animates frames in the correct order', function(done) { - Plotly.animate(gd, ['frame0', 'frame2', 'frame1', 'frame3'], transOpts).then(function() { - verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame1', 'frame3']); - verifyQueueEmpty(gd); - }).catch(fail).then(done); - }); + it('animates frames by group', function(done) { + Plotly.animate(gd, 'even-frames', animOpts).then(function() { + expect(Plots.transition.calls.count()).toEqual(2); + verifyQueueEmpty(gd); + }).catch(fail).then(done); + }); - it('accepts a single transitionOpts', function(done) { - Plotly.animate(gd, ['frame0', 'frame1'], {transitionduration: 1.12345}).then(function() { - var calls = Plots.transition.calls; - expect(calls.argsFor(0)[4].transitionduration).toEqual(1.12345); - expect(calls.argsFor(1)[4].transitionduration).toEqual(1.12345); - }).catch(fail).then(done); - }); + it('animates frames in the correct order', function(done) { + Plotly.animate(gd, ['frame0', 'frame2', 'frame1', 'frame3'], animOpts).then(function() { + verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame1', 'frame3']); + verifyQueueEmpty(gd); + }).catch(fail).then(done); + }); - it('accepts an array of transitionOpts', function(done) { - Plotly.animate(gd, ['frame0', 'frame1'], [{transitionduration: 1.123}, {transitionduration: 1.456}]).then(function() { - var calls = Plots.transition.calls; - expect(calls.argsFor(0)[4].transitionduration).toEqual(1.123); - expect(calls.argsFor(1)[4].transitionduration).toEqual(1.456); - }).catch(fail).then(done); - }); + it('accepts a single animationOpts', function(done) { + Plotly.animate(gd, ['frame0', 'frame1'], {transition: {duration: 1.12345}}).then(function() { + var calls = Plots.transition.calls; + expect(calls.argsFor(0)[5].duration).toEqual(1.12345); + expect(calls.argsFor(1)[5].duration).toEqual(1.12345); + }).catch(fail).then(done); + }); - it('falls back to transitionOpts[0] if not enough supplied in array', function(done) { - Plotly.animate(gd, ['frame0', 'frame1'], [{transitionduration: 1.123}]).then(function() { - var calls = Plots.transition.calls; - expect(calls.argsFor(0)[4].transitionduration).toEqual(1.123); - expect(calls.argsFor(1)[4].transitionduration).toEqual(1.123); - }).catch(fail).then(done); - }); + it('accepts an array of animationOpts', function(done) { + Plotly.animate(gd, ['frame0', 'frame1'], { + transition: [{duration: 1.123}, {duration: 1.456}], + frame: [{duration: 8.7654}, {duration: 5.4321}] + }).then(function() { + var calls = Plots.transition.calls; + expect(calls.argsFor(0)[4].duration).toEqual(8.7654); + expect(calls.argsFor(1)[4].duration).toEqual(5.4321); + expect(calls.argsFor(0)[5].duration).toEqual(1.123); + expect(calls.argsFor(1)[5].duration).toEqual(1.456); + }).catch(fail).then(done); + }); - it('chains animations as promises', function(done) { - Plotly.animate(gd, ['frame0', 'frame1'], transOpts).then(function() { - return Plotly.animate(gd, ['frame2', 'frame3'], transOpts); - }).then(function() { - verifyFrameTransitionOrder(gd, ['frame0', 'frame1', 'frame2', 'frame3']); - verifyQueueEmpty(gd); - }).catch(fail).then(done); - }); + it('falls back to animationOpts[0] if not enough supplied in array', function(done) { + Plotly.animate(gd, ['frame0', 'frame1'], { + transition: [{duration: 1.123}], + frame: [{duration: 2.345}] + }).then(function() { + var calls = Plots.transition.calls; + expect(calls.argsFor(0)[4].duration).toEqual(2.345); + expect(calls.argsFor(1)[4].duration).toEqual(2.345); + expect(calls.argsFor(0)[5].duration).toEqual(1.123); + expect(calls.argsFor(1)[5].duration).toEqual(1.123); + }).catch(fail).then(done); + }); - it('emits plotly_animated before the promise is resolved', function(done) { - var animated = false; - gd.on('plotly_animated', function() { animated = true; }); + it('chains animations as promises', function(done) { + Plotly.animate(gd, ['frame0', 'frame1'], animOpts).then(function() { + return Plotly.animate(gd, ['frame2', 'frame3'], animOpts); + }).then(function() { + verifyFrameTransitionOrder(gd, ['frame0', 'frame1', 'frame2', 'frame3']); + verifyQueueEmpty(gd); + }).catch(fail).then(done); + }); - Plotly.animate(gd, ['frame0'], transOpts).then(function() { - expect(animated).toBe(true); - }).catch(fail).then(done); - }); + it('emits plotly_animated before the promise is resolved', function(done) { + var animated = false; + gd.on('plotly_animated', function() { animated = true; }); - it('emits plotly_animated as each animation in a sequence completes', function(done) { - var completed = 0; - var test1 = 0, test2 = 0; - gd.on('plotly_animated', function() { - completed++; - if(completed === 1) { - // Verify that after the first plotly_animated, precisely frame0 and frame1 - // have been transitioned to: - verifyFrameTransitionOrder(gd, ['frame0', 'frame1']); - test1++; - } else { - // Verify that after the second plotly_animated, precisely all frames - // have been transitioned to: - verifyFrameTransitionOrder(gd, ['frame0', 'frame1', 'frame2', 'frame3']); - test2++; - } + Plotly.animate(gd, ['frame0'], animOpts).then(function() { + expect(animated).toBe(true); + }).catch(fail).then(done); }); - Plotly.animate(gd, ['frame0', 'frame1'], transOpts).then(function() { - return Plotly.animate(gd, ['frame2', 'frame3'], transOpts); - }).then(function() { - // Verify both behaviors were actually tested: - expect(test1).toBe(1); - expect(test2).toBe(1); - }).catch(fail).then(done); - }); + it('emits plotly_animated as each animation in a sequence completes', function(done) { + var completed = 0; + var test1 = 0, test2 = 0; + gd.on('plotly_animated', function() { + completed++; + if(completed === 1) { + // Verify that after the first plotly_animated, precisely frame0 and frame1 + // have been transitioned to: + verifyFrameTransitionOrder(gd, ['frame0', 'frame1']); + test1++; + } else { + // Verify that after the second plotly_animated, precisely all frames + // have been transitioned to: + verifyFrameTransitionOrder(gd, ['frame0', 'frame1', 'frame2', 'frame3']); + test2++; + } + }); - it('resolves at the end of each animation sequence', function(done) { - Plotly.animate(gd, 'even-frames', transOpts).then(function() { - return Plotly.animate(gd, ['frame0', 'frame2', 'frame1', 'frame3'], transOpts); - }).then(function() { - verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame0', 'frame2', 'frame1', 'frame3']); - verifyQueueEmpty(gd); - }).catch(fail).then(done); + Plotly.animate(gd, ['frame0', 'frame1'], animOpts).then(function() { + return Plotly.animate(gd, ['frame2', 'frame3'], animOpts); + }).then(function() { + expect(test1).toBe(1); + expect(test2).toBe(1); + }).catch(fail).then(done); + }); + + it('resolves at the end of each animation sequence', function(done) { + Plotly.animate(gd, 'even-frames', animOpts).then(function() { + return Plotly.animate(gd, ['frame0', 'frame2', 'frame1', 'frame3'], animOpts); + }).then(function() { + verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame0', 'frame2', 'frame1', 'frame3']); + verifyQueueEmpty(gd); + }).catch(fail).then(done); + }); }); } @@ -248,7 +266,11 @@ describe('Test animate API', function() { // duration. Stricly speaking, these tests *do* involve race conditions, but the finite duration // prevents that from causing problems. describe('Calling Plotly.animate synchronously in series', function() { - var transOpts = {frameduration: 30}; + var animOpts; + + beforeEach(function() { + animOpts = {frame: {duration: 30}}; + }); it('emits plotly_animationinterrupted when an animation is interrupted', function(done) { var interrupted = false; @@ -256,15 +278,14 @@ describe('Test animate API', function() { interrupted = true; }); - Plotly.animate(gd, ['frame0', 'frame1'], transOpts); + Plotly.animate(gd, ['frame0', 'frame1'], animOpts); - Plotly.animate(gd, ['frame2'], transOpts, {immediate: true}).then(function() { + Plotly.animate(gd, ['frame2'], Lib.extendFlat(animOpts, {immediate: true})).then(function() { expect(interrupted).toBe(true); verifyQueueEmpty(gd); }).catch(fail).then(done); }); - it('queues successive animations', function(done) { var starts = 0; var ends = 0; @@ -277,40 +298,40 @@ describe('Test animate API', function() { expect(starts).toEqual(1); }); - Plotly.animate(gd, 'even-frames', {transitionduration: 16}); - Plotly.animate(gd, 'odd-frames', {transitionduration: 16}).then(delay(10)).then(function() { + Plotly.animate(gd, 'even-frames', {transition: {duration: 16}}); + Plotly.animate(gd, 'odd-frames', {transition: {duration: 16}}).then(delay(10)).then(function() { expect(ends).toEqual(1); verifyQueueEmpty(gd); }).catch(fail).then(done); }); it('an empty list with immediate dumps previous frames', function(done) { - Plotly.animate(gd, ['frame0', 'frame1'], {frameduration: 50}); - Plotly.animate(gd, [], null, {immediate: true}).then(function() { + Plotly.animate(gd, ['frame0', 'frame1'], {frame: {duration: 50}}); + Plotly.animate(gd, [], {immediate: true}).then(function() { expect(Plots.transition.calls.count()).toEqual(1); verifyQueueEmpty(gd); }).catch(fail).then(done); }); it('animates groups in the correct order', function(done) { - Plotly.animate(gd, 'even-frames', transOpts); - Plotly.animate(gd, 'odd-frames', transOpts).then(function() { + Plotly.animate(gd, 'even-frames', animOpts); + Plotly.animate(gd, 'odd-frames', animOpts).then(function() { verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame1', 'frame3']); verifyQueueEmpty(gd); }).catch(fail).then(done); }); it('drops queued frames when immediate = true', function(done) { - Plotly.animate(gd, 'even-frames', transOpts); - Plotly.animate(gd, 'odd-frames', transOpts, {immediate: true}).then(function() { + Plotly.animate(gd, 'even-frames', animOpts); + Plotly.animate(gd, 'odd-frames', Lib.extendFlat(animOpts, {immediate: true})).then(function() { verifyFrameTransitionOrder(gd, ['frame0', 'frame1', 'frame3']); verifyQueueEmpty(gd); }).catch(fail).then(done); }); it('animates frames and groups in sequence', function(done) { - Plotly.animate(gd, 'even-frames', transOpts); - Plotly.animate(gd, ['frame0', 'frame2', 'frame1', 'frame3'], transOpts).then(function() { + Plotly.animate(gd, 'even-frames', animOpts); + Plotly.animate(gd, ['frame0', 'frame2', 'frame1', 'frame3'], animOpts).then(function() { verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame0', 'frame2', 'frame1', 'frame3']); verifyQueueEmpty(gd); }).catch(fail).then(done); @@ -318,11 +339,11 @@ describe('Test animate API', function() { it('rejects when an animation is interrupted', function(done) { var interrupted = false; - Plotly.animate(gd, ['frame0', 'frame1'], transOpts).then(fail, function() { + Plotly.animate(gd, ['frame0', 'frame1'], animOpts).then(fail, function() { interrupted = true; }); - Plotly.animate(gd, ['frame2'], transOpts, {immediate: true}).then(function() { + Plotly.animate(gd, ['frame2'], Lib.extendFlat(animOpts, {immediate: true})).then(function() { expect(interrupted).toBe(true); verifyFrameTransitionOrder(gd, ['frame0', 'frame2']); verifyQueueEmpty(gd); @@ -340,12 +361,11 @@ describe('Test animate API', function() { ends++; }); - Plotly.animate(gd, ['frame0', 'frame1'], {transitionduration: 200, frameduration: 20}).then(function() { + Plotly.animate(gd, ['frame0', 'frame1'], {transition: {duration: 200}, frame: {duration: 20}}).then(function() { expect(starts).toEqual(1); expect(ends).toEqual(1); expect(Plots.transition.calls.count()).toEqual(2); verifyQueueEmpty(gd); }).catch(fail).then(done); }); - }); diff --git a/test/jasmine/tests/transition_test.js b/test/jasmine/tests/transition_test.js index 841f9e6ecb5..81ed4a9538f 100644 --- a/test/jasmine/tests/transition_test.js +++ b/test/jasmine/tests/transition_test.js @@ -9,32 +9,46 @@ var delay = require('../assets/delay'); var mock = require('@mocks/animation'); -describe('Plots.supplyTransitionDefaults', function() { +describe('Plots.supplyAnimationTransitionDefaults', function() { 'use strict'; it('supplies transition defaults', function() { - expect(Plots.supplyTransitionDefaults({})).toEqual({ - frameduration: 500, - transitionduration: 500, - ease: 'cubic-in-out', - redraw: true + expect(Plots.supplyAnimationDefaults({})).toEqual({ + immediate: false, + transition: { + duration: 500, + easing: 'cubic-in-out' + }, + frame: { + duration: 500, + redraw: true + } }); }); it('uses provided values', function() { - expect(Plots.supplyTransitionDefaults({ - frameduration: 200, - transitionduration: 100, - ease: 'quad-in-out', - redraw: false + expect(Plots.supplyAnimationDefaults({ + immediate: true, + transition: { + duration: 600, + easing: 'elastic-in-out' + }, + frame: { + duration: 700, + redraw: false + } })).toEqual({ - frameduration: 200, - transitionduration: 100, - ease: 'quad-in-out', - redraw: false + immediate: true, + transition: { + duration: 600, + easing: 'elastic-in-out' + }, + frame: { + duration: 700, + redraw: false + } }); }); - }); function runTests(transitionDuration) { @@ -59,7 +73,7 @@ function runTests(transitionDuration) { it('resolves only once the transition has completed', function(done) { var t1 = Date.now(); - Plots.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {transitionduration: transitionDuration}) + Plots.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {redraw: true}, {duration: transitionDuration, easing: 'cubic-in-out'}) .then(delay(20)) .then(function() { expect(Date.now() - t1).toBeGreaterThan(transitionDuration); @@ -70,7 +84,7 @@ function runTests(transitionDuration) { var beginTransitionCnt = 0; gd.on('plotly_transitioning', function() { beginTransitionCnt++; }); - Plots.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {transitionduration: transitionDuration}) + Plots.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {redraw: true}, {duration: transitionDuration, easing: 'cubic-in-out'}) .then(delay(20)) .then(function() { expect(beginTransitionCnt).toBe(1); @@ -81,7 +95,7 @@ function runTests(transitionDuration) { var trEndCnt = 0; gd.on('plotly_transitioned', function() { trEndCnt++; }); - Plots.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {transitionduration: transitionDuration}) + Plots.transition(gd, null, {'xaxis.range': [0.2, 0.3]}, null, {redraw: true}, {duration: transitionDuration, easing: 'cubic-in-out'}) .then(delay(20)) .then(function() { expect(trEndCnt).toEqual(1); @@ -99,7 +113,7 @@ function runTests(transitionDuration) { gd.on('plotly_transitioned', function() { currentlyRunning--; endCnt++; }); function doTransition() { - return Plots.transition(gd, [{x: [1, 2]}], null, null, {transitionduration: transitionDuration}); + return Plots.transition(gd, [{x: [1, 2]}], null, null, {redraw: true}, {duration: transitionDuration, easing: 'cubic-in-out'}); } function checkNoneRunning() { From f3a292d4bd88e22b59215d3a0d0b68424af9beb3 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Fri, 2 Sep 2016 14:59:00 -0600 Subject: [PATCH 33/45] Apply non-range props from layout to transition --- src/plots/plots.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/plots/plots.js b/src/plots/plots.js index 057c06ceef9..610eb1a744f 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -34,7 +34,6 @@ plots.fontWeight = 'normal'; var subplotsRegistry = plots.subplotsRegistry; var transformsRegistry = plots.transformsRegistry; - var ErrorBars = require('../components/errorbars'); /** @@ -1388,6 +1387,22 @@ plots.transition = function(gd, data, layout, traceIndices, frameOpts, transitio Lib.extendDeepNoArrays(gd.data[traceIndices[i]], update); } + // Follow the same procedure. Clone it so we don't mangle the input, then + // expand any object paths so we can merge deep into gd.layout: + var layoutUpdate = Lib.expandObjectPaths(Lib.extendDeepNoArrays({}, layout)); + + // Before merging though, we need to modify the incoming layout. We only + // know how to *transition* layout ranges, so it's imperative that a new + // range not be sent to the layout before the transition has started. So + // we must remove the things we can transition: + var axisAttrRe = /^[xy]axis[0-9]*$/; + for(var attr in layoutUpdate) { + if(!axisAttrRe.test(attr)) continue; + delete layoutUpdate[attr].range; + } + + Lib.extendDeepNoArrays(gd.layout, layoutUpdate); + // Supply defaults after applying the incoming properties. Note that any attempt // to simplify this step and reduce the amount of work resulted in the reconstruction // of essentially the whole supplyDefaults step, so that it seems sensible to just use From f70957263a6ba9576da66ad4f113b829bcfe0347 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Fri, 2 Sep 2016 15:11:05 -0600 Subject: [PATCH 34/45] Use Lib.isPlainObject instead of typeof --- src/plot_api/plot_api.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 0d809378716..a12ec499f20 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2654,7 +2654,7 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { var frameList = []; var allFrames = frameOrGroupNameOrFrameList === undefined || frameOrGroupNameOrFrameList === null; var isFrameArray = Array.isArray(frameOrGroupNameOrFrameList); - var isSingleFrame = !allFrames && !isFrameArray && typeof frameOrGroupNameOrFrameList === 'object'; + var isSingleFrame = !allFrames && !isFrameArray && Lib.isPlainObject(frameOrGroupNameOrFrameList); if(isSingleFrame) { frameList.push(setTransitionConfig({ From 68b9a0cf0d4d765de76c205cc8d2b7177447d2fa Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 6 Sep 2016 07:45:53 -0600 Subject: [PATCH 35/45] =?UTF-8?q?Stop=20testing=20the=20things=20that=20do?= =?UTF-8?q?n't=20pass=20the=20tests=20=F0=9F=98=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plot_api/plot_api.js | 13 +---------- src/plots/animation_attributes.js | 17 +++++++++------ src/plots/plots.js | 2 +- test/jasmine/tests/animate_test.js | 31 ++++++--------------------- test/jasmine/tests/transition_test.js | 6 +++--- 5 files changed, 22 insertions(+), 47 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index a12ec499f20..bea90f78d48 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2593,23 +2593,12 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { if(trans._frameQueue.length === 0) { stopAnimationLoop(); - return; } } function beginAnimationLoop() { gd.emit('plotly_animating'); - var canAnimateSynchronously = !trans._animationRaf && trans._frameQueue.length === 1; - - if(canAnimateSynchronously) { - // If there is no animation running and only one frame has been received, then - // simply transition this frame synchonously and avoid starting and stopping the - // timing loop. - nextFrame(); - return; - } - // If no timer is running, then set last frame = long ago: trans._lastframeat = 0; trans._timetonext = 0; @@ -2690,7 +2679,7 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { } } - if(animationOpts.immediate) { + if(['next', 'immediate'].indexOf(animationOpts.mode) !== -1) { discardExistingFrames(); } diff --git a/src/plots/animation_attributes.js b/src/plots/animation_attributes.js index 58e72013c51..e3dd6768def 100644 --- a/src/plots/animation_attributes.js +++ b/src/plots/animation_attributes.js @@ -9,14 +9,17 @@ 'use strict'; module.exports = { - immediate: { - valType: 'boolean', - role: 'info', - dflt: false, + mode: { + valType: 'enumerated', + dflt: 'afterall', + values: ['immediate', 'next', 'afterall'], description: [ - 'If true, exisitng queued animation frames are discarded before beginning', - 'the next animation sequence. Promises for exising `Plotly.animate` calls', - 'are rejected and a `plotly_animateinterrupt` event is emitted.' + 'Describes how a new animate call interacts with currently-running', + 'animations. If `immediate`, current animations are interrupted and', + 'the new animation is started. If `next`, the current frame is allowed', + 'to complete, after which the new animation is started. If `afterall`', + 'all existing frames are animated to completion before the new animation', + 'is started.' ].join(' ') }, frame: { diff --git a/src/plots/plots.js b/src/plots/plots.js index 610eb1a744f..bdab11b3f39 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -622,7 +622,7 @@ plots.supplyAnimationDefaults = function(opts) { return Lib.coerce(opts || {}, optsOut, animationAttrs, attr, dflt); } - coerce('immediate'); + coerce('mode'); if(Array.isArray(opts.frame)) { optsOut.frame = []; diff --git a/test/jasmine/tests/animate_test.js b/test/jasmine/tests/animate_test.js index 9f76796f248..a1d71e9f5b7 100644 --- a/test/jasmine/tests/animate_test.js +++ b/test/jasmine/tests/animate_test.js @@ -36,8 +36,9 @@ describe('Test animate API', function() { mockCopy = Lib.extendDeep({}, mock); spyOn(Plots, 'transition').and.callFake(function() { - // Transition's fake behaviro is to resolve after a short period of time: - return Promise.resolve().then(delay(5)); + // Transition's fake behavior is just to delay by the duration + // and resolve: + return Promise.resolve().then(delay(arguments[5].duration)); }); Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(function() { @@ -280,7 +281,7 @@ describe('Test animate API', function() { Plotly.animate(gd, ['frame0', 'frame1'], animOpts); - Plotly.animate(gd, ['frame2'], Lib.extendFlat(animOpts, {immediate: true})).then(function() { + Plotly.animate(gd, ['frame2'], Lib.extendFlat(animOpts, {mode: 'immediate'})).then(function() { expect(interrupted).toBe(true); verifyQueueEmpty(gd); }).catch(fail).then(done); @@ -307,7 +308,7 @@ describe('Test animate API', function() { it('an empty list with immediate dumps previous frames', function(done) { Plotly.animate(gd, ['frame0', 'frame1'], {frame: {duration: 50}}); - Plotly.animate(gd, [], {immediate: true}).then(function() { + Plotly.animate(gd, [], {mode: 'immediate'}).then(function() { expect(Plots.transition.calls.count()).toEqual(1); verifyQueueEmpty(gd); }).catch(fail).then(done); @@ -323,7 +324,7 @@ describe('Test animate API', function() { it('drops queued frames when immediate = true', function(done) { Plotly.animate(gd, 'even-frames', animOpts); - Plotly.animate(gd, 'odd-frames', Lib.extendFlat(animOpts, {immediate: true})).then(function() { + Plotly.animate(gd, 'odd-frames', Lib.extendFlat(animOpts, {mode: 'immediate'})).then(function() { verifyFrameTransitionOrder(gd, ['frame0', 'frame1', 'frame3']); verifyQueueEmpty(gd); }).catch(fail).then(done); @@ -343,29 +344,11 @@ describe('Test animate API', function() { interrupted = true; }); - Plotly.animate(gd, ['frame2'], Lib.extendFlat(animOpts, {immediate: true})).then(function() { + Plotly.animate(gd, ['frame2'], Lib.extendFlat(animOpts, {mode: 'immediate'})).then(function() { expect(interrupted).toBe(true); verifyFrameTransitionOrder(gd, ['frame0', 'frame2']); verifyQueueEmpty(gd); }).catch(fail).then(done); }); }); - - it('animates reasonably even when transition duration >> frame duration', function(done) { - var starts = 0; - var ends = 0; - - gd.on('plotly_animating', function() { - starts++; - }).on('plotly_animated', function() { - ends++; - }); - - Plotly.animate(gd, ['frame0', 'frame1'], {transition: {duration: 200}, frame: {duration: 20}}).then(function() { - expect(starts).toEqual(1); - expect(ends).toEqual(1); - expect(Plots.transition.calls.count()).toEqual(2); - verifyQueueEmpty(gd); - }).catch(fail).then(done); - }); }); diff --git a/test/jasmine/tests/transition_test.js b/test/jasmine/tests/transition_test.js index 81ed4a9538f..3d00d3b7175 100644 --- a/test/jasmine/tests/transition_test.js +++ b/test/jasmine/tests/transition_test.js @@ -14,7 +14,7 @@ describe('Plots.supplyAnimationTransitionDefaults', function() { it('supplies transition defaults', function() { expect(Plots.supplyAnimationDefaults({})).toEqual({ - immediate: false, + immediate: 'afterall', transition: { duration: 500, easing: 'cubic-in-out' @@ -28,7 +28,7 @@ describe('Plots.supplyAnimationTransitionDefaults', function() { it('uses provided values', function() { expect(Plots.supplyAnimationDefaults({ - immediate: true, + immediate: 'next', transition: { duration: 600, easing: 'elastic-in-out' @@ -38,7 +38,7 @@ describe('Plots.supplyAnimationTransitionDefaults', function() { redraw: false } })).toEqual({ - immediate: true, + immediate: 'next', transition: { duration: 600, easing: 'elastic-in-out' From 29c14d78141d1d617d9a66239027c19cbf92e8e8 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 6 Sep 2016 08:03:38 -0600 Subject: [PATCH 36/45] Switch to window.[request|cancel]AnimationFrame for consistency --- src/plot_api/plot_api.js | 4 ++-- src/plots/cartesian/transition_axes.js | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index bea90f78d48..d929a24e833 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2557,7 +2557,7 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { } function stopAnimationLoop() { - cancelAnimationFrame(trans._animationRaf); + window.cancelAnimationFrame(trans._animationRaf); trans._animationRaf = null; } @@ -2611,7 +2611,7 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { nextFrame(); } - trans._animationRaf = requestAnimationFrame(doFrame); + trans._animationRaf = window.requestAnimationFrame(doFrame); }; return doFrame(); diff --git a/src/plots/cartesian/transition_axes.js b/src/plots/cartesian/transition_axes.js index 9250cb06685..e1ffe9a2167 100644 --- a/src/plots/cartesian/transition_axes.js +++ b/src/plots/cartesian/transition_axes.js @@ -280,7 +280,7 @@ module.exports = function transitionAxes(gd, newLayout, transitionOpts, makeOnCo var easeFn = d3.ease(transitionOpts.easing); gd._transitionData._interruptCallbacks.push(function() { - cancelAnimationFrame(raf); + window.cancelAnimationFrame(raf); raf = null; return transitionInterrupt(); }); @@ -297,14 +297,14 @@ module.exports = function transitionAxes(gd, newLayout, transitionOpts, makeOnCo if(t2 - t1 > transitionOpts.duration) { transitionComplete(); - raf = cancelAnimationFrame(doFrame); + raf = window.cancelAnimationFrame(doFrame); } else { - raf = requestAnimationFrame(doFrame); + raf = window.requestAnimationFrame(doFrame); } } t1 = Date.now(); - raf = requestAnimationFrame(doFrame); + raf = window.requestAnimationFrame(doFrame); return Promise.resolve(); }; From 84c93fe0572583bcbe628fc75d8bf6019547e355 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 6 Sep 2016 08:05:03 -0600 Subject: [PATCH 37/45] Remove .transition from core --- src/core.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core.js b/src/core.js index 05572c773d8..143ae88bdaf 100644 --- a/src/core.js +++ b/src/core.js @@ -45,7 +45,6 @@ exports.downloadImage = require('./snapshot/download'); exports.validate = require('./plot_api/validate'); exports.addFrames = Plotly.addFrames; exports.deleteFrames = Plotly.deleteFrames; -exports.transition = Plotly.transition; exports.animate = Plotly.animate; // scatter is the only trace included by default From 75ec8a5e886198e52b00edf5e47638a0cc6c10f9 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 6 Sep 2016 09:01:28 -0600 Subject: [PATCH 38/45] Add immediate interrupt --- src/plot_api/plot_api.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index d929a24e833..c29f39c37ef 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2551,6 +2551,10 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { trans._frameQueue.push(nextFrame); } + if(animationOpts.mode === 'immediate') { + trans._lastFrameAt = -Infinity; + } + if(!trans._animationRaf) { beginAnimationLoop(); } @@ -2571,7 +2575,7 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { var newFrame = trans._currentFrame = trans._frameQueue.shift(); if(newFrame) { - trans._lastframeat = Date.now(); + trans._lastFrameAt = Date.now(); trans._timetonext = newFrame.frameOpts.duration; Plots.transition(gd, @@ -2600,14 +2604,14 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { gd.emit('plotly_animating'); // If no timer is running, then set last frame = long ago: - trans._lastframeat = 0; + trans._lastFrameAt = -Infinity; trans._timetonext = 0; trans._runningTransitions = 0; trans._currentFrame = null; var doFrame = function() { // Check if we need to pop a frame: - if(Date.now() - trans._lastframeat > trans._timetonext) { + if(Date.now() - trans._lastFrameAt > trans._timetonext) { nextFrame(); } From c8e46f2af2011402d9c4067c322ab4617e9b3bfc Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 6 Sep 2016 09:12:00 -0600 Subject: [PATCH 39/45] Fix one of the animate tests --- test/jasmine/tests/transition_test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/jasmine/tests/transition_test.js b/test/jasmine/tests/transition_test.js index 3d00d3b7175..9c9f1dcca44 100644 --- a/test/jasmine/tests/transition_test.js +++ b/test/jasmine/tests/transition_test.js @@ -14,7 +14,7 @@ describe('Plots.supplyAnimationTransitionDefaults', function() { it('supplies transition defaults', function() { expect(Plots.supplyAnimationDefaults({})).toEqual({ - immediate: 'afterall', + mode: 'afterall', transition: { duration: 500, easing: 'cubic-in-out' @@ -28,7 +28,7 @@ describe('Plots.supplyAnimationTransitionDefaults', function() { it('uses provided values', function() { expect(Plots.supplyAnimationDefaults({ - immediate: 'next', + mode: 'next', transition: { duration: 600, easing: 'elastic-in-out' @@ -38,7 +38,7 @@ describe('Plots.supplyAnimationTransitionDefaults', function() { redraw: false } })).toEqual({ - immediate: 'next', + mode: 'next', transition: { duration: 600, easing: 'elastic-in-out' From 1d43355139671b763d1c7d09a9576d5e263b2835 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 6 Sep 2016 09:25:57 -0600 Subject: [PATCH 40/45] Tweak tests --- src/plot_api/plot_api.js | 6 +++--- src/plots/plots.js | 2 +- test/jasmine/tests/animate_test.js | 5 +---- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index c29f39c37ef..cecd2db5902 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2576,7 +2576,7 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { if(newFrame) { trans._lastFrameAt = Date.now(); - trans._timetonext = newFrame.frameOpts.duration; + trans._timeToNext = newFrame.frameOpts.duration; Plots.transition(gd, newFrame.frame.data, @@ -2605,13 +2605,13 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { // If no timer is running, then set last frame = long ago: trans._lastFrameAt = -Infinity; - trans._timetonext = 0; + trans._timeToNext = 0; trans._runningTransitions = 0; trans._currentFrame = null; var doFrame = function() { // Check if we need to pop a frame: - if(Date.now() - trans._lastFrameAt > trans._timetonext) { + if(Date.now() - trans._lastFrameAt > trans._timeToNext) { nextFrame(); } diff --git a/src/plots/plots.js b/src/plots/plots.js index bdab11b3f39..590452cab9c 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -901,7 +901,7 @@ plots.purge = function(gd) { if(fullLayout._modeBar) fullLayout._modeBar.destroy(); if(gd._transitionData && gd._transitionData._animationRaf) { - cancelAnimationFrame(gd._transitionData._animationRaf); + window.cancelAnimationFrame(gd._transitionData._animationRaf); } // data and layout diff --git a/test/jasmine/tests/animate_test.js b/test/jasmine/tests/animate_test.js index a1d71e9f5b7..1709b711725 100644 --- a/test/jasmine/tests/animate_test.js +++ b/test/jasmine/tests/animate_test.js @@ -52,10 +52,7 @@ describe('Test animate API', function() { destroyGraphDiv(); }); - for(var i = 0; i < 2; i++) { - // Run tests for 0ms and 30ms duration: - runTests(30 * i); - } + runTests(30); function runTests(duration) { describe('With duration = ' + duration, function() { From 1fe652dd5bb42c6a308f05538707be934a3444b1 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 6 Sep 2016 10:29:09 -0600 Subject: [PATCH 41/45] Fix race condition in .animate --- src/plot_api/plot_api.js | 75 ++++++++++++++++++++---------- test/jasmine/tests/animate_test.js | 5 +- 2 files changed, 54 insertions(+), 26 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index cecd2db5902..145b45d2200 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2530,9 +2530,13 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { for(var i = 0; i < frameList.length; i++) { var computedFrame; + if(frameList[i].name) { + // If it's a named frame, compute it: computedFrame = Plots.computeFrame(gd, frameList[i].name); } else { + // Otherwise we must have been given a simple object, so treat + // the input itself as the computed frame. computedFrame = frameList[i].frame; } @@ -2544,6 +2548,11 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { }; if(i === frameList.length - 1) { + // The last frame in this .animate call stores the promise resolve + // and reject callbacks. This is how we ensure that the animation + // loop (which may exist as a result of a *different* .animate call) + // still resolves or rejecdts this .animate call's promise. once it's + // complete. nextFrame.onComplete = resolve; nextFrame.onInterrupt = reject; } @@ -2551,25 +2560,38 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { trans._frameQueue.push(nextFrame); } + // Set it as never having transitioned to a frame. This will cause the animation + // loop to immediately transition to the next frame (which, for immediate mode, + // is the first frame in the list since all others would have been discarded + // below) if(animationOpts.mode === 'immediate') { trans._lastFrameAt = -Infinity; } + // Only it's not already running, start a RAF loop. This could be avoided in the + // case that there's only one frame, but it significantly complicated the logic + // and only sped things up by about 5% or so for a lorenz attractor simulation. + // It would be a fine thing to implement, but the benefit of that optimization + // doesn't seem worth the extra complexity. if(!trans._animationRaf) { beginAnimationLoop(); } } function stopAnimationLoop() { + gd.emit('plotly_animated'); + + // Be sure to unset also since it's how we know whether a loop is already running: window.cancelAnimationFrame(trans._animationRaf); trans._animationRaf = null; } function nextFrame() { - if(trans._currentFrame) { - if(trans._currentFrame.onComplete) { - trans._currentFrame.onComplete(); - } + if(trans._currentFrame && trans._currentFrame.onComplete) { + // Execute the callback and unset it to ensure it doesn't + // accidentally get called twice + trans._currentFrame.onComplete(); + trans._currentFrame.onComplete = null; } var newFrame = trans._currentFrame = trans._frameQueue.shift(); @@ -2578,24 +2600,18 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { trans._lastFrameAt = Date.now(); trans._timeToNext = newFrame.frameOpts.duration; + // This is simply called and it's left to .transition to decide how to manage + // interrupting current transitions. That means we don't need to worry about + // how it resolves or what happens after this: Plots.transition(gd, newFrame.frame.data, newFrame.frame.layout, newFrame.frame.traces, newFrame.frameOpts, newFrame.transitionOpts - ).then(function() { - if(trans._frameQueue.length === 0) { - gd.emit('plotly_animated'); - if(trans._currentFrame && trans._currentFrame.onComplete) { - trans._currentFrame.onComplete(); - trans._currentFrame = null; - } - } - }); - } - - if(trans._frameQueue.length === 0) { + ); + } else { + // If there are no more frames, then stop the RAF loop: stopAnimationLoop(); } } @@ -2603,36 +2619,41 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { function beginAnimationLoop() { gd.emit('plotly_animating'); - // If no timer is running, then set last frame = long ago: + // If no timer is running, then set last frame = long ago so that the next + // frame is immediately transitioned: trans._lastFrameAt = -Infinity; trans._timeToNext = 0; trans._runningTransitions = 0; trans._currentFrame = null; var doFrame = function() { - // Check if we need to pop a frame: + // This *must* be requested before nextFrame since nextFrame may decide + // to cancel it if there's nothing more to animated: + trans._animationRaf = window.requestAnimationFrame(doFrame); + + // Check if we're ready for a new frame: if(Date.now() - trans._lastFrameAt > trans._timeToNext) { nextFrame(); } - - trans._animationRaf = window.requestAnimationFrame(doFrame); }; - return doFrame(); + doFrame(); } - var counter = 0; + // This is an animate-local counter that helps match up option input list + // items with the particular frame. + var configCounter = 0; function setTransitionConfig(frame) { if(Array.isArray(transitionOpts)) { - if(counter >= transitionOpts.length) { - frame.transitionOpts = transitionOpts[counter]; + if(configCounter >= transitionOpts.length) { + frame.transitionOpts = transitionOpts[configCounter]; } else { frame.transitionOpts = transitionOpts[0]; } } else { frame.transitionOpts = transitionOpts; } - counter++; + configCounter++; return frame; } @@ -2683,6 +2704,8 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { } } + // If the mode is either next or immediate, then all currently queued frames must + // be dumped and the corresponding .animate promises rejected. if(['next', 'immediate'].indexOf(animationOpts.mode) !== -1) { discardExistingFrames(); } @@ -2690,6 +2713,8 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { if(frameList.length > 0) { queueFrames(frameList); } else { + // This is the case where there were simply no frames. It's a little strange + // since there's not much to do: gd.emit('plotly_animated'); resolve(); } diff --git a/test/jasmine/tests/animate_test.js b/test/jasmine/tests/animate_test.js index 1709b711725..c92aa7de555 100644 --- a/test/jasmine/tests/animate_test.js +++ b/test/jasmine/tests/animate_test.js @@ -52,6 +52,7 @@ describe('Test animate API', function() { destroyGraphDiv(); }); + runTests(0); runTests(30); function runTests(duration) { @@ -214,7 +215,9 @@ describe('Test animate API', function() { it('emits plotly_animated before the promise is resolved', function(done) { var animated = false; - gd.on('plotly_animated', function() { animated = true; }); + gd.on('plotly_animated', function() { + animated = true; + }); Plotly.animate(gd, ['frame0'], animOpts).then(function() { expect(animated).toBe(true); From 2bc29b80fef7d7c9fce98e4470d06b8f5a571849 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 6 Sep 2016 11:34:38 -0600 Subject: [PATCH 42/45] Limit transition duration to <= frame duration --- src/plot_api/plot_api.js | 11 +++- test/jasmine/tests/animate_test.js | 74 +++++++++++++++++++++++++++ test/jasmine/tests/transition_test.js | 43 ---------------- 3 files changed, 83 insertions(+), 45 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 145b45d2200..93bf1da01e5 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2540,11 +2540,18 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { computedFrame = frameList[i].frame; } + var frameOpts = getFrameOpts(i); + var transitionOpts = getTransitionOpts(i); + + // It doesn't make much sense for the transition duration to be greater than + // the frame duration, so limit it: + transitionOpts.duration = Math.min(transitionOpts.duration, frameOpts.duration); + var nextFrame = { frame: computedFrame, name: frameList[i].name, - frameOpts: getFrameOpts(i), - transitionOpts: getTransitionOpts(i) + frameOpts: frameOpts, + transitionOpts: transitionOpts, }; if(i === frameList.length - 1) { diff --git a/test/jasmine/tests/animate_test.js b/test/jasmine/tests/animate_test.js index c92aa7de555..110938cda24 100644 --- a/test/jasmine/tests/animate_test.js +++ b/test/jasmine/tests/animate_test.js @@ -9,6 +9,48 @@ var delay = require('../assets/delay'); var mock = require('@mocks/animation'); +describe('Plots.supplyAnimationDefaults', function() { + 'use strict'; + + it('supplies transition defaults', function() { + expect(Plots.supplyAnimationDefaults({})).toEqual({ + mode: 'afterall', + transition: { + duration: 500, + easing: 'cubic-in-out' + }, + frame: { + duration: 500, + redraw: true + } + }); + }); + + it('uses provided values', function() { + expect(Plots.supplyAnimationDefaults({ + mode: 'next', + transition: { + duration: 600, + easing: 'elastic-in-out' + }, + frame: { + duration: 700, + redraw: false + } + })).toEqual({ + mode: 'next', + transition: { + duration: 600, + easing: 'elastic-in-out' + }, + frame: { + duration: 700, + redraw: false + } + }); + }); +}); + describe('Test animate API', function() { 'use strict'; @@ -351,4 +393,36 @@ describe('Test animate API', function() { }).catch(fail).then(done); }); }); + + describe('frame vs. transition timing', function() { + it('limits the transition duration to <= frame duration', function(done) { + Plotly.animate(gd, ['frame0'], { + transition: {duration: 100000}, + frame: {duration: 50} + }).then(function() { + // Frame timing: + expect(Plots.transition.calls.argsFor(0)[4].duration).toEqual(50); + + // Transition timing: + expect(Plots.transition.calls.argsFor(0)[5].duration).toEqual(50); + + }).catch(fail).then(done); + }); + + it('limits the transition duration to <= frame duration (matching per-config)', function(done) { + Plotly.animate(gd, ['frame0', 'frame1'], { + transition: [{duration: 100000}, {duration: 123456}], + frame: [{duration: 50}, {duration: 40}] + }).then(function() { + // Frame timing: + expect(Plots.transition.calls.argsFor(0)[4].duration).toEqual(50); + expect(Plots.transition.calls.argsFor(1)[4].duration).toEqual(40); + + // Transition timing: + expect(Plots.transition.calls.argsFor(0)[5].duration).toEqual(50); + expect(Plots.transition.calls.argsFor(1)[5].duration).toEqual(40); + + }).catch(fail).then(done); + }); + }); }); diff --git a/test/jasmine/tests/transition_test.js b/test/jasmine/tests/transition_test.js index 9c9f1dcca44..76d261a480f 100644 --- a/test/jasmine/tests/transition_test.js +++ b/test/jasmine/tests/transition_test.js @@ -8,49 +8,6 @@ var fail = require('../assets/fail_test'); var delay = require('../assets/delay'); var mock = require('@mocks/animation'); - -describe('Plots.supplyAnimationTransitionDefaults', function() { - 'use strict'; - - it('supplies transition defaults', function() { - expect(Plots.supplyAnimationDefaults({})).toEqual({ - mode: 'afterall', - transition: { - duration: 500, - easing: 'cubic-in-out' - }, - frame: { - duration: 500, - redraw: true - } - }); - }); - - it('uses provided values', function() { - expect(Plots.supplyAnimationDefaults({ - mode: 'next', - transition: { - duration: 600, - easing: 'elastic-in-out' - }, - frame: { - duration: 700, - redraw: false - } - })).toEqual({ - mode: 'next', - transition: { - duration: 600, - easing: 'elastic-in-out' - }, - frame: { - duration: 700, - redraw: false - } - }); - }); -}); - function runTests(transitionDuration) { describe('Plots.transition (duration = ' + transitionDuration + ')', function() { 'use strict'; From 0f23eef5aba09532dd73a53defad08b6fb8b31ff Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 6 Sep 2016 11:58:13 -0600 Subject: [PATCH 43/45] jsDoc for animate, addFrames, and deleteFrames --- src/plot_api/plot_api.js | 54 ++++++++++++++++++++++--------- src/plots/animation_attributes.js | 5 ++- 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 93bf1da01e5..837a65fa8d6 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2456,12 +2456,31 @@ Plotly.relayout = function relayout(gd, astr, val) { }; /** - * Animate to a keyframe + * Animate to a frame, sequence of frame, frame group, or frame definition + * + * @param {string id or DOM element} gd + * the id or DOM element of the graph container div + * + * @param {string or object or array of strings or array of objects} frameOrGroupNameOrFrameList + * a single frame, array of frames, or group to which to animate. The intent is + * inferred by the type of the input. Valid inputs are: + * + * - string, e.g. 'groupname': animate all frames of a given `group` in the order + * in which they are defined via `Plotly.addFrames`. + * + * - array of strings, e.g. ['frame1', frame2']: a list of frames by name to which + * to animate in sequence + * + * - object: {data: ...}: a frame definition to which to animate. The frame is not + * and does not need to be added via `Plotly.addFrames`. It may contain any of + * the properties of a frame, including `data`, `layout`, and `traces`. The + * frame is used as provided and does not use the `baseframe` property. + * + * - array of objects, e.g. [{data: ...}, {data: ...}]: a list of frame objects, + * each following the same rules as a single `object`. * - * @param {string} name - * name of the keyframe to create * @param {object} animationOpts - * configuration for animation + * configuration for the animation */ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { gd = getGraphDiv(gd); @@ -2664,13 +2683,7 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { return frame; } - // Disambiguate what's been received. The possibilities are: - // - // - group: 'groupname': select frames by group name - // - frames ['frame1', frame2']: a list of frames - // - object: {data: ...}: a single frame itself - // - frames [{data: ...}, {data: ...}]: a list of frames - // + // Disambiguate what's sort of frames have been received var i, frame; var frameList = []; var allFrames = frameOrGroupNameOrFrameList === undefined || frameOrGroupNameOrFrameList === null; @@ -2729,15 +2742,23 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { }; /** - * Create new keyframes + * Register new frames + * + * @param {string id or DOM element} gd + * the id or DOM element of the graph container div * * @param {array of objects} frameList * list of frame definitions, in which each object includes any of: - * - name: {string} name of keyframe to add + * - name: {string} name of frame to add * - data: {array of objects} trace data * - layout {object} layout definition * - traces {array} trace indices - * - baseframe {string} name of keyframe from which this keyframe gets defaults + * - baseframe {string} name of frame from which this frame gets defaults + * + * @param {array of integers) indices + * an array of integer indices matching the respective frames in `frameList`. If not + * provided, an index will be provided in serial order. If already used, the frame + * will be overwritten. */ Plotly.addFrames = function(gd, frameList, indices) { gd = getGraphDiv(gd); @@ -2815,7 +2836,10 @@ Plotly.addFrames = function(gd, frameList, indices) { }; /** - * Delete keyframes + * Delete frame + * + * @param {string id or DOM element} gd + * the id or DOM element of the graph container div * * @param {array of integers} frameList * list of integer indices of frames to be deleted diff --git a/src/plots/animation_attributes.js b/src/plots/animation_attributes.js index e3dd6768def..a1999b4ba51 100644 --- a/src/plots/animation_attributes.js +++ b/src/plots/animation_attributes.js @@ -28,7 +28,10 @@ module.exports = { role: 'info', min: 0, dflt: 500, - description: 'The duration in milliseconds of each frame.' + description: [ + 'The duration in milliseconds of each frame. If greater than the frame', + 'duration, it will be limited to the frame duration.' + ].join(' ') }, redraw: { valType: 'boolean', From c85c3a2d5e05c063f656436fd2dc1ea9175db231 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 6 Sep 2016 12:20:22 -0600 Subject: [PATCH 44/45] Verify gd is a plot div for animate, addFrames, deleteFrames --- src/plot_api/plot_api.js | 16 ++++++++++++++++ src/plots/plots.js | 14 ++++++++++++-- test/jasmine/tests/animate_test.js | 7 +++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 837a65fa8d6..fd94e6688e4 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2484,6 +2484,12 @@ Plotly.relayout = function relayout(gd, astr, val) { */ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { gd = getGraphDiv(gd); + + if(!Lib.isPlotDiv(gd)) { + Lib.warn('This element is not a Plotly plot.', gd); + return Promise.reject(); + } + var trans = gd._transitionData; // This is the queue of frames that will be animated as soon as possible. They @@ -2763,6 +2769,11 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { Plotly.addFrames = function(gd, frameList, indices) { gd = getGraphDiv(gd); + if(!Lib.isPlotDiv(gd)) { + Lib.warn('This element is not a Plotly plot.', gd); + return Promise.reject(); + } + var i, frame, j, idx; var _frames = gd._transitionData._frames; var _hash = gd._transitionData._frameHash; @@ -2847,6 +2858,11 @@ Plotly.addFrames = function(gd, frameList, indices) { Plotly.deleteFrames = function(gd, frameList) { gd = getGraphDiv(gd); + if(!Lib.isPlotDiv(gd)) { + Lib.warn('This element is not a Plotly plot.', gd); + return Promise.reject(); + } + var i, idx; var _frames = gd._transitionData._frames; var ops = []; diff --git a/src/plots/plots.js b/src/plots/plots.js index 590452cab9c..b4948875f61 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -1342,8 +1342,18 @@ plots.computeFrame = function(gd, frameName) { /** * Transition to a set of new data and layout properties * - * @param {string id or DOM element} gd - * the id or DOM element of the graph container div + * @param {DOM element} gd + * the DOM element of the graph container div + * @param {Object[]} data + * an array of data objects following the normal Plotly data definition format + * @param {Object} layout + * a layout object, following normal Plotly layout format + * @param {Number[]} traceIndices + * indices of the corresponding traces specified in `data` + * @param {Object} frameOpts + * options for the frame (i.e. whether to redraw post-transition) + * @param {Object} transitionOpts + * options for the transition */ plots.transition = function(gd, data, layout, traceIndices, frameOpts, transitionOpts) { var i, traceIdx; diff --git a/test/jasmine/tests/animate_test.js b/test/jasmine/tests/animate_test.js index 110938cda24..7aa763042b7 100644 --- a/test/jasmine/tests/animate_test.js +++ b/test/jasmine/tests/animate_test.js @@ -94,6 +94,13 @@ describe('Test animate API', function() { destroyGraphDiv(); }); + it('throws an error if the div is not a plot', function(done) { + var gd2 = createGraphDiv(gd); + + // Then = fail, rejection = success + Plotly.animate(gd2).then(fail).catch(done); + }); + runTests(0); runTests(30); From 4e8818452cbbd57370c772af676dbd19a3d43b94 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 6 Sep 2016 12:46:27 -0600 Subject: [PATCH 45/45] Throw animate API errors whne gd is not a plot --- src/plot_api/plot_api.js | 12 ++++-------- test/jasmine/tests/animate_test.js | 13 +++++++++---- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index fd94e6688e4..c3a20b1458f 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2486,8 +2486,7 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { gd = getGraphDiv(gd); if(!Lib.isPlotDiv(gd)) { - Lib.warn('This element is not a Plotly plot.', gd); - return Promise.reject(); + throw new Error('This element is not a Plotly plot: ' + gd); } var trans = gd._transitionData; @@ -2770,8 +2769,7 @@ Plotly.addFrames = function(gd, frameList, indices) { gd = getGraphDiv(gd); if(!Lib.isPlotDiv(gd)) { - Lib.warn('This element is not a Plotly plot.', gd); - return Promise.reject(); + throw new Error('This element is not a Plotly plot: ' + gd); } var i, frame, j, idx; @@ -2780,8 +2778,7 @@ Plotly.addFrames = function(gd, frameList, indices) { if(!Array.isArray(frameList)) { - Lib.warn('addFrames failure: frameList must be an Array of frame definitions', frameList); - return Promise.reject(); + throw new Error('addFrames failure: frameList must be an Array of frame definitions' + frameList); } // Create a sorted list of insertions since we run into lots of problems if these @@ -2859,8 +2856,7 @@ Plotly.deleteFrames = function(gd, frameList) { gd = getGraphDiv(gd); if(!Lib.isPlotDiv(gd)) { - Lib.warn('This element is not a Plotly plot.', gd); - return Promise.reject(); + throw new Error('This element is not a Plotly plot: ' + gd); } var i, idx; diff --git a/test/jasmine/tests/animate_test.js b/test/jasmine/tests/animate_test.js index 7aa763042b7..eb6995c5ffa 100644 --- a/test/jasmine/tests/animate_test.js +++ b/test/jasmine/tests/animate_test.js @@ -94,11 +94,16 @@ describe('Test animate API', function() { destroyGraphDiv(); }); - it('throws an error if the div is not a plot', function(done) { - var gd2 = createGraphDiv(gd); + it('throws an error if gd is not a graph', function() { + var gd2 = document.createElement('div'); + gd2.id = 'invalidgd'; + document.body.appendChild(gd2); - // Then = fail, rejection = success - Plotly.animate(gd2).then(fail).catch(done); + expect(function() { + Plotly.addFrames(gd2, [{}]); + }).toThrow(new Error('This element is not a Plotly plot: [object HTMLDivElement]')); + + document.body.removeChild(gd); }); runTests(0);