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 e906f2d792e..3997fd40f31 100644 --- a/src/components/drawing/index.js +++ b/src/components/drawing/index.js @@ -46,18 +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) { - s.each(function(d) { - // 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 + ')'); +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); + + 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 p.remove(); + } + else sel.remove(); +}; + +drawing.translatePoints = function(s, xa, ya, trace) { + s.each(function(d) { + var sel = d3.select(this); + drawing.translatePoint(d, sel, xa, ya, trace); }); }; @@ -80,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) { @@ -175,18 +193,13 @@ drawing.symbolNumber = function(v) { return Math.floor(Math.max(v, 0)); }; -drawing.pointStyle = function(s, trace) { - if(!s.size()) return; - - var marker = trace.marker, - markerLine = marker.line; - +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); - s.attr('d', function(d) { + sel.attr('d', function(d) { var r; // handle multi-trace graph edit case @@ -212,54 +225,75 @@ drawing.pointStyle = function(s, trace) { return (d.mo + 1 || marker.opacity + 1) - 1; }); } + + // '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.'); - 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)'; - } + singlePointStyle(d, sel, trace, markerScale, lineScale, marker, markerLine); - 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.pointStyle = function(s, trace) { + if(!s.size()) return; + + // 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.'); + + s.each(function(d) { + drawing.singlePointStyle(d, d3.select(this), trace, markerScale, lineScale); }); }; diff --git a/src/components/errorbars/plot.js b/src/components/errorbars/plot.js index 3f44ed58df1..07fdeafc3ec 100644 --- a/src/components/errorbars/plot.js +++ b/src/components/errorbars/plot.js @@ -12,14 +12,15 @@ var d3 = require('d3'); var isNumeric = require('fast-isnumeric'); -var Lib = require('../../lib'); var subTypes = require('../../traces/scatter/subtypes'); - -module.exports = function plot(traces, plotinfo) { +module.exports = function plot(traces, plotinfo, transitionOpts) { + var isNew; var xa = plotinfo.x(), ya = plotinfo.y(); + var hasAnimation = transitionOpts && transitionOpts.duration > 0; + traces.each(function(d) { var trace = d[0].trace, // || {} is in case the trace (specifically scatterternary) @@ -29,6 +30,12 @@ module.exports = function plot(traces, plotinfo) { xObj = trace.error_x || {}, yObj = trace.error_y || {}; + var keyFunc; + + if(trace.ids) { + keyFunc = function(d) {return d.id;}; + } + var sparse = ( subTypes.hasMarkers(trace) && trace.marker.maxdisplayed > 0 @@ -37,11 +44,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.exit().remove(); - errorbars.enter().append('g') + errorbars.style('opacity', 1); + + var enter = errorbars.enter().append('g') .classed('errorbar', true); + if(hasAnimation) { + enter.style('opacity', 0).transition() + .duration(transitionOpts.duration) + .style('opacity', 1); + } + errorbars.each(function(d) { var errorbar = d3.select(this); var coords = errorCoords(d, xa, ya); @@ -59,11 +76,24 @@ 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(transitionOpts.duration) + .ease(transitionOpts.easing); + } + + yerror.attr('d', path); } if(xObj.visible && isNumeric(coords.y) && @@ -77,9 +107,21 @@ 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(transitionOpts.duration) + .ease(transitionOpts.easing); + } + + xerror.attr('d', path); } }); }); 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/core.js b/src/core.js index e264484ad40..143ae88bdaf 100644 --- a/src/core.js +++ b/src/core.js @@ -43,6 +43,9 @@ 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.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..cb1cbd88ac8 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. * diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 15d51de7604..c3a20b1458f 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'); @@ -106,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; @@ -157,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++) { @@ -234,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]); @@ -851,59 +853,6 @@ Plotly.newPlot = function(gd, data, layout, config) { return Plotly.plot(gd, data, layout, config); }; -function doCalcdata(gd) { - var axList = Plotly.Axes.list(gd), - fullData = gd._fullData, - fullLayout = gd._fullLayout, - i; - - var calcdata = gd.calcdata = new Array(fullData.length); - - // 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++) { - var trace = fullData[i], - _module = trace._module, - cd = []; - - 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. * @@ -2506,6 +2455,434 @@ Plotly.relayout = function relayout(gd, astr, val) { }); }; +/** + * 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 {object} animationOpts + * configuration for the animation + */ +Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { + gd = getGraphDiv(gd); + + if(!Lib.isPlotDiv(gd)) { + throw new Error('This element is not a Plotly plot: ' + gd); + } + + var trans = gd._transitionData; + + // 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 = []; + } + + 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, + // 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 getTransitionOpts(i) { + if(Array.isArray(transitionOpts)) { + if(i >= transitionOpts.length) { + return transitionOpts[0]; + } else { + return transitionOpts[i]; + } + } else { + return transitionOpts; + } + } + + 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) { + return; + } + + while(trans._frameQueue.length) { + var next = trans._frameQueue.pop(); + if(next.onInterrupt) { + next.onInterrupt(); + } + } + + gd.emit('plotly_animationinterrupted', []); + } + + function queueFrames(frameList) { + if(frameList.length === 0) return; + + 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; + } + + 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: frameOpts, + transitionOpts: transitionOpts, + }; + + 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; + } + + 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 && 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(); + + if(newFrame) { + 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 + ); + } else { + // If there are no more frames, then stop the RAF loop: + stopAnimationLoop(); + } + } + + function beginAnimationLoop() { + gd.emit('plotly_animating'); + + // 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() { + // 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(); + } + }; + + doFrame(); + } + + // 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(configCounter >= transitionOpts.length) { + frame.transitionOpts = transitionOpts[configCounter]; + } else { + frame.transitionOpts = transitionOpts[0]; + } + } else { + frame.transitionOpts = transitionOpts; + } + configCounter++; + return frame; + } + + // Disambiguate what's sort of frames have been received + var i, frame; + var frameList = []; + var allFrames = frameOrGroupNameOrFrameList === undefined || frameOrGroupNameOrFrameList === null; + var isFrameArray = Array.isArray(frameOrGroupNameOrFrameList); + var isSingleFrame = !allFrames && !isFrameArray && Lib.isPlainObject(frameOrGroupNameOrFrameList); + + 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 === frameOrGroupNameOrFrameList) { + frameList.push(setTransitionConfig({name: frame.name})); + } + } + } 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(frameList[i].name && !trans._frameHash[frameList[i].name]) { + Lib.warn('animate failure: frame not found: "' + frameList[i].name + '"'); + reject(); + return; + } + } + + // 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(); + } + + 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(); + } + }); +}; + +/** + * 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 frame to add + * - data: {array of objects} trace data + * - layout {object} layout definition + * - traces {array} trace indices + * - 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); + + if(!Lib.isPlotDiv(gd)) { + throw new Error('This element is not a Plotly plot: ' + gd); + } + + var i, frame, j, idx; + var _frames = gd._transitionData._frames; + var _hash = gd._transitionData._frameHash; + + + if(!Array.isArray(frameList)) { + 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 + // 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: Plots.supplyFrameDefaults(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._transitionData._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 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 + */ +Plotly.deleteFrames = function(gd, frameList) { + gd = getGraphDiv(gd); + + if(!Lib.isPlotDiv(gd)) { + throw new Error('This element is not a Plotly plot: ' + gd); + } + + var i, idx; + var _frames = gd._transitionData._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/animation_attributes.js b/src/plots/animation_attributes.js new file mode 100644 index 00000000000..a1999b4ba51 --- /dev/null +++ b/src/plots/animation_attributes.js @@ -0,0 +1,104 @@ +/** +* 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 = { + mode: { + valType: 'enumerated', + dflt: 'afterall', + values: ['immediate', 'next', 'afterall'], + description: [ + '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: { + duration: { + valType: 'number', + role: 'info', + min: 0, + dflt: 500, + 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', + 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/dragbox.js b/src/plots/cartesian/dragbox.js index eb7ad269d1d..85058a998d7 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; @@ -202,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), @@ -364,8 +390,16 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { 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 || @@ -433,6 +467,13 @@ 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) { for(var i = 0; i < axList.length; i++) { var axi = axList[i]; @@ -533,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 = {}; @@ -606,6 +649,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,9 +658,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/index.js b/src/plots/cartesian/index.js index 56f827f0ec6..156d89ca120 100644 --- a/src/plots/cartesian/index.js +++ b/src/plots/cartesian/index.js @@ -25,61 +25,88 @@ exports.attrRegex = constants.attrRegex; exports.attributes = require('./attributes'); -exports.plot = function(gd) { +exports.transitionAxes = require('./transition_axes'); + +exports.plot = function(gd, traces, transitionOpts, makeOnCompleteCallback) { + 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]; - for(var i = 0; i < cdSubplot.length; i++) { - var cd = cdSubplot[i]; - var trace = cd[0].trace; + // Get all calcdata for this subplot: + cdSubplot = []; + var pcd; + for(j = 0; j < calcdata.length; j++) { + cd = calcdata[j]; + trace = cd[0].trace; - if((trace._module === _module) && (trace.visible === true)) { - cdModule.push(cd); + // 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) { + // 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); + } + + // 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, makeOnCompleteCallback); } } }; 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/src/plots/cartesian/transition_axes.js b/src/plots/cartesian/transition_axes.js new file mode 100644 index 00000000000..e1ffe9a2167 --- /dev/null +++ b/src/plots/cartesian/transition_axes.js @@ -0,0 +1,310 @@ +/** +* 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 Registry = require('../../registry'); +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, transitionOpts, makeOnCompleteCallback) { + 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 axisLetter = match[1]; + var axisName = axisLetter + 'axis'; + axis = fullLayout[axisName]; + 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.axisName = axisName; + update.length = axis._length; + + axes.push(axisLetter); + + updates[axisLetter] = 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; + + // 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 { + 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, method) { + for(i = 0; i < objArray.length; i++) { + var obji = objArray[i]; + + if((activeAxIds.indexOf(obji.xref) !== -1) || + (activeAxIds.indexOf(obji.yref) !== -1)) { + method(gd, i); + } + } + } + + // 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) { + var xa2 = subplot.x(); + var ya2 = subplot.y(); + + fullLayout._defs.selectAll('#' + subplot.clipId) + .call(Lib.setTranslate, 0, 0) + .call(Lib.setScale, 1, 1); + + subplot.plot + .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, 1); + + } + + 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 = 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; + 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 = 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; + 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); + + } + + var onComplete; + if(makeOnCompleteCallback) { + // This module makes the choice whether or not it notifies Plotly.transition + // about completion: + onComplete = makeOnCompleteCallback(); + } + + function transitionComplete() { + var aobj = {}; + for(var i = 0; i < updatedAxisIds.length; i++) { + var axi = gd._fullLayout[updates[updatedAxisIds[i]].axisName]; + var to = updates[updatedAxisIds[i]].to; + aobj[axi._name + '.range[0]'] = to[0]; + aobj[axi._name + '.range[1]'] = to[1]; + + axi.range = to.slice(); + } + + // Signal that this transition has completed: + onComplete && onComplete(); + + return Plotly.relayout(gd, aobj).then(function() { + for(var i = 0; i < affectedSubplots.length; i++) { + unsetSubplotTransform(affectedSubplots[i]); + } + }); + } + + function transitionInterrupt() { + var aobj = {}; + for(var i = 0; i < updatedAxisIds.length; i++) { + var axi = gd._fullLayout[updatedAxisIds[i] + 'axis']; + 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, aobj).then(function() { + for(var i = 0; i < affectedSubplots.length; i++) { + unsetSubplotTransform(affectedSubplots[i]); + } + }); + } + + var t1, t2, raf; + var easeFn = d3.ease(transitionOpts.easing); + + gd._transitionData._interruptCallbacks.push(function() { + window.cancelAnimationFrame(raf); + raf = null; + return transitionInterrupt(); + }); + + function doFrame() { + t2 = Date.now(); + + 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 > transitionOpts.duration) { + transitionComplete(); + raf = window.cancelAnimationFrame(doFrame); + } else { + raf = window.requestAnimationFrame(doFrame); + } + } + + t1 = Date.now(); + raf = window.requestAnimationFrame(doFrame); + + return Promise.resolve(); +}; diff --git a/src/plots/frame_attributes.js b/src/plots/frame_attributes.js new file mode 100644 index 00000000000..8dc2f57712a --- /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.' + ].join(' ') + }, + layout: { + valType: 'any', + description: [ + 'Layout properties which this frame modifies. The format is identical', + 'to the normal layout definition.' + ].join(' ') + } +}; diff --git a/src/plots/plots.js b/src/plots/plots.js index 107dc0b2655..b4948875f61 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -16,8 +16,9 @@ var Plotly = require('../plotly'); var Registry = require('../registry'); var Lib = require('../lib'); var Color = require('../components/color'); - var plots = module.exports = {}; +var animationAttrs = require('./animation_attributes'); +var frameAttrs = require('./frame_attributes'); // Expose registry methods on Plots for backward-compatibility Lib.extendFlat(plots, Registry); @@ -33,6 +34,7 @@ plots.fontWeight = 'normal'; var subplotsRegistry = plots.subplotsRegistry; var transformsRegistry = plots.transformsRegistry; +var ErrorBars = require('../components/errorbars'); /** * Find subplot ids in data. @@ -419,6 +421,36 @@ plots.supplyDefaults = function(gd) { (gd.calcdata[i][0] || {}).trace = trace; } } + + // 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._transitionData) { + gd._transitionData = {}; + } + + if(!gd._transitionData._frames) { + gd._transitionData._frames = []; + } + + if(!gd._transitionData._frameHash) { + gd._transitionData._frameHash = {}; + } + + if(!gd._transitionData._counter) { + gd._transitionData._counter = 0; + } + + if(!gd._transitionData._interruptCallbacks) { + gd._transitionData._interruptCallbacks = []; + } }; // helper function to be bound to fullLayout to check @@ -461,12 +493,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(); } @@ -576,6 +613,81 @@ plots.supplyDataDefaults = function(dataIn, dataOut, layout) { } }; +plots.supplyAnimationDefaults = function(opts) { + opts = opts || {}; + var i; + var optsOut = {}; + + function coerce(attr, dflt) { + return Lib.coerce(opts || {}, optsOut, animationAttrs, attr, dflt); + } + + coerce('mode'); + + 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.supplyAnimationFrameDefaults = function(opts) { + var optsOut = {}; + + function coerce(attr, dflt) { + return Lib.coerce(opts || {}, optsOut, animationAttrs.frame, attr, dflt); + } + + 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 = {}; + + 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]; @@ -788,6 +900,10 @@ plots.purge = function(gd) { // remove modebar if(fullLayout._modeBar) fullLayout._modeBar.destroy(); + if(gd._transitionData && gd._transitionData._animationRaf) { + window.cancelAnimationFrame(gd._transitionData._animationRaf); + } + // data and layout delete gd.data; delete gd.layout; @@ -817,6 +933,8 @@ plots.purge = function(gd) { delete gd.numboxes; delete gd._hoverTimer; delete gd._lastHoverTime; + delete gd._transitionData; + delete gd._transitioning; // remove all event listeners if(gd.removeAllListeners) gd.removeAllListeners(); @@ -1081,3 +1199,460 @@ 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._transitionData._frames; + var _hash = gd._transitionData._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 {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) { + 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.traces; + + 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.traces) { + result.traces = []; + } + + 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.traces.indexOf(traceIndex); + if(destIndex === -1) { + destIndex = result.data.length; + result.traces[destIndex] = traceIndex; + } + + copy = Lib.extendDeepNoArrays({}, framePtr.data[i]); + expandedObj = Lib.expandObjectPaths(copy); + result.data[destIndex] = Lib.extendDeepNoArrays(result.data[destIndex] || {}, expandedObj); + } + } + } + + return result; +}; + +/** + * Transition to a set of new data and layout properties + * + * @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; + + 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); + } + + // 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 + // 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); + + // 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); + + 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.duration > 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 = gd._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.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, 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(frameOpts.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; + } +}; diff --git a/src/traces/scatter/attributes.js b/src/traces/scatter/attributes.js index 28db0d1343e..d9f83dd78ce 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(' ') }, + ids: { + 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..cb889ab396e 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.ids) { + cd[i].id = String(trace.ids[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..115ef4c757e 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('ids'); 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..ef049853b8d 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -15,80 +15,197 @@ 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, transitionOpts, makeOnCompleteCallback) { + var i, uids, selection, join, onComplete; -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 = !transitionOpts; + var hasTransition = !!transitionOpts && transitionOpts.duration > 0; - // 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); + // 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; + }); + + 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(); } - else ownFillEl3 = null; + + var transition = d3.transition() + .duration(transitionOpts.duration) + .ease(transitionOpts.easing) + .each('end', function() { + onComplete && onComplete(); + }) + .each('interrupt', function() { + onComplete && 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, transitionOpts); + }); + }); + } else { + scatterlayer.selectAll('g.trace').each(function(d, i) { + plotOne(gd, i, plotinfo, d, cdscatter, this, transitionOpts); + }); + } + + 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, transitionOpts) { + 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 = !!transitionOpts && transitionOpts.duration > 0; + + function transition(selection) { + return hasTransition ? selection.transition() : selection; + } + + 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, transitionOpts); + + 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; + } - // now make a new nexttonext for next time - nexttonext = tr.append('path').classed('js-fill', true); + 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 +237,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]; + } + + var lineSegments = segments.filter(function(s) { + return s.length > 1; + }); - for(i = 0; i < segments.length; i++) { - var pts = segments[i]; + var makeUpdate = function(isEnter) { + return function(pts) { thispath = pathfn(pts); thisrevpath = revpathfn(pts); if(!fullpath) { @@ -160,13 +283,42 @@ 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 { + var sel = transition(el); + sel.attr('d', thispath); + Drawing.singleLineStyle(cdscatter, sel); + } } - } + }; + }; + + 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 +331,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 +356,146 @@ 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); + function keyFunc(d) { + return d.id; + } + + // Returns a function if the trace is keyed, otherwise returns undefined + function getKeyFunc(trace) { + if(trace.ids) { + 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)); + + var enter = join.enter().append('path') + .classed('point', true); + + enter.call(Drawing.pointStyle, trace) + .call(Drawing.translatePoints, xa, ya, trace); + + if(hasTransition) { + enter.style('opacity', 0).transition() + .style('opacity', 1); } - 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); + + join.each(function(d) { + var sel = transition(d3.select(this)); + Drawing.translatePoint(d, sel, xa, ya); + Drawing.singlePointStyle(d, sel, trace); + }); + + if(hasTransition) { + join.exit().transition() + .style('opacity', 0) + .remove(); + } else { + join.exit().remove(); } } - }); -}; + if(showText) { + selection = s.selectAll('g'); + + 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); -function selectMarkers(gd, plotinfo, cdscatter) { + 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..91da6fda453 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, + id: di.id }); di.dim = 0; } diff --git a/test/image/baselines/animation.png b/test/image/baselines/animation.png new file mode 100644 index 00000000000..627670db86e Binary files /dev/null and b/test/image/baselines/animation.png differ diff --git a/test/image/mocks/animation.json b/test/image/mocks/animation.json new file mode 100644 index 00000000000..cfb411ac943 --- /dev/null +++ b/test/image/mocks/animation.json @@ -0,0 +1,89 @@ +{ + "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", + "group": "even-frames", + "data": [ + {"y": [0.5, 1.5, 7.5]}, + {"y": [4.25, 2.25, 3.05]} + ], + "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", + "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", + "traces": [0, 1], + "layout": { } + }, { + "name": "frame3", + "group": "odd-frames", + "data": [ + {"y": [5.1, 0.25, 5]}, + {"y": [7, 2.9, 6]} + ], + "baseframe": "base", + "traces": [0, 1], + "layout": { + "xaxis": { + "range": [-1, 4] + }, + "yaxis": { + "range": [-5, 15] + } + } + }] +} 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/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/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..eb6995c5ffa --- /dev/null +++ b/test/jasmine/tests/animate_test.js @@ -0,0 +1,440 @@ +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'); +var fail = require('../assets/fail_test'); +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'; + + var gd, mockCopy; + + function verifyQueueEmpty(gd) { + expect(gd._transitionData._frameQueue.length).toEqual(0); + } + + function verifyFrameTransitionOrder(gd, expectedFrames) { + var calls = Plots.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(); + + mockCopy = Lib.extendDeep({}, mock); + + spyOn(Plots, 'transition').and.callFake(function() { + // 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() { + 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(); + }); + + it('throws an error if gd is not a graph', function() { + var gd2 = document.createElement('div'); + gd2.id = 'invalidgd'; + document.body.appendChild(gd2); + + expect(function() { + Plotly.addFrames(gd2, [{}]); + }).toThrow(new Error('This element is not a Plotly plot: [object HTMLDivElement]')); + + document.body.removeChild(gd); + }); + + runTests(0); + runTests(30); + + function runTests(duration) { + describe('With duration = ' + duration, function() { + var animOpts; + + beforeEach(function() { + animOpts = {frame: {duration: duration}, transition: {duration: duration * 0.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(); + + var args = Plots.transition.calls.mostRecent().args; + + // was called with gd, data, layout, traceIndices, transitionConfig: + expect(args.length).toEqual(6); + + // data has two traces: + expect(args[1].length).toEqual(2); + + // Verify frame config has been passed: + expect(args[4].duration).toEqual(1.5678); + + // Verify transition config has been passed: + expect(args[5].duration).toEqual(1.2345); + + // 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); + }); + + it('rejects if a frame is not found', function(done) { + Plotly.animate(gd, ['foobar'], animOpts).then(fail).then(done, 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('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 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 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 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 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 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 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('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 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('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('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('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); + }); + + it('emits plotly_animated before the promise is resolved', function(done) { + var animated = false; + gd.on('plotly_animated', function() { + animated = true; + }); + + Plotly.animate(gd, ['frame0'], animOpts).then(function() { + expect(animated).toBe(true); + }).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++; + } + }); + + 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); + }); + }); + } + + // 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 animOpts; + + beforeEach(function() { + animOpts = {frame: {duration: 30}}; + }); + + 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'], animOpts); + + Plotly.animate(gd, ['frame2'], Lib.extendFlat(animOpts, {mode: 'immediate'})).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(Plots.transition.calls.count()).toEqual(4); + expect(starts).toEqual(1); + }); + + 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'], {frame: {duration: 50}}); + Plotly.animate(gd, [], {mode: 'immediate'}).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', 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', animOpts); + Plotly.animate(gd, 'odd-frames', Lib.extendFlat(animOpts, {mode: 'immediate'})).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', 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); + }); + + it('rejects when an animation is interrupted', function(done) { + var interrupted = false; + Plotly.animate(gd, ['frame0', 'frame1'], animOpts).then(fail, function() { + interrupted = true; + }); + + 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); + }); + }); + + 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/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..9010838ee5a --- /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'}}], traces: [0]}; + expect(computed).toEqual(expected); + }); + + it('leaves the frame unaffected', function() { + computeFrame(gd, 'frame1'); + expect(gd._transitionData._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 = [ + {traces: [0], data: [{marker: {size: 0}}]}, + {traces: [0], data: [{marker: {size: 1}}]}, + {traces: [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}], + traces: [2] + }, { + name: 'frame1', + data: [{'marker.size': 1}], + traces: [8] + }, { + name: 'frame2', + data: [{'marker.size': 2}], + traces: [2] + }, { + name: 'frame3', + data: [{'marker.size': 3}, {'marker.size': 4}], + traces: [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({ + traces: [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._transitionData._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({ + traces: [2], + data: [{marker: {size: 0}}] + }); + + expect(gd._transitionData._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({ + traces: [2, 8], + data: [ + {marker: {size: 0}}, + {marker: {size: 1}} + ] + }); + + expect(gd._transitionData._frames).toEqual(frames); + }); + + 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({ + traces: [2, 8, 0, 1], + data: [ + {marker: {size: 7}}, + {marker: {size: 4}}, + {marker: {size: 5}}, + {marker: {size: 6}} + ] + }); + + expect(gd._transitionData._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._transitionData._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..fed2b0cdba5 --- /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._transitionData._frames; + h = gd._transitionData._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._transitionData._frames).toEqual([]); + }); + + it('creates an empty lookup table for frames', function() { + expect(gd._transitionData._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', data: [1]}, {name: 'frame7', data: [2]}, {name: 'frame10', data: [3]}], [5, 7, undefined]); + }).then(function() { + 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); + }); + + 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', data: [3]}, {name: 'frame7', data: [2]}, {name: 'frame5', data: [1]}], [undefined, 7, 5]); + }).then(function() { + 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); + }); + + 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', 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'}]); + }).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', data: ['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/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(); }); }); }); diff --git a/test/jasmine/tests/scatter_test.js b/test/jasmine/tests/scatter_test.js index cfc6e132353..8c948779459 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) { @@ -236,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], diff --git a/test/jasmine/tests/select_test.js b/test/jasmine/tests/select_test.js index 885d993afb3..f2be564a41d 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, + id: undefined }, { curveNumber: 0, pointNumber: 1, x: 0.004, - y: 12.5 + y: 12.5, + id: 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, + id: undefined }, { curveNumber: 0, pointNumber: 1, x: 0.004, - y: 12.5 + y: 12.5, + id: 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, + id: 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, + 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 new file mode 100644 index 00000000000..76d261a480f --- /dev/null +++ b/test/jasmine/tests/transition_test.js @@ -0,0 +1,105 @@ +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'); +var fail = require('../assets/fail_test'); +var delay = require('../assets/delay'); +var mock = require('@mocks/animation'); + +function runTests(transitionDuration) { + describe('Plots.transition (duration = ' + transitionDuration + ')', 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() { + Plotly.purge(gd); + destroyGraphDiv(); + }); + + 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, {redraw: true}, {duration: transitionDuration, easing: 'cubic-in-out'}) + .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; + gd.on('plotly_transitioning', function() { beginTransitionCnt++; }); + + 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); + }).catch(fail).then(done); + }); + + it('emits plotly_transitioned on transition end', function(done) { + var trEndCnt = 0; + gd.on('plotly_transitioned', function() { trEndCnt++; }); + + 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); + }).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 Plots.transition(gd, [{x: [1, 2]}], null, null, {redraw: true}, {duration: transitionDuration, easing: 'cubic-in-out'}); + } + + 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); +}