diff --git a/src/components/colorscale/attributes.js b/src/components/colorscale/attributes.js index c00d178b820..f1dad9b9573 100644 --- a/src/components/colorscale/attributes.js +++ b/src/components/colorscale/attributes.js @@ -54,6 +54,8 @@ function code(s) { * most of these attributes already require a recalc, but the ones that do not * have editType *style* or *plot* unless you override (presumably with *calc*) * + * - anim {boolean) (dflt: undefined): is 'color' animatable? + * * @return {object} */ module.exports = function colorScaleAttrs(context, opts) { @@ -109,6 +111,10 @@ module.exports = function colorScaleAttrs(context, opts) { ' ' + minmaxFull + ' if set.' ].join('') }; + + if(opts.anim) { + attrs.color.anim = true; + } } attrs[auto] = { diff --git a/src/components/images/defaults.js b/src/components/images/defaults.js index dec20ac22fa..beef5bdf34e 100644 --- a/src/components/images/defaults.js +++ b/src/components/images/defaults.js @@ -52,6 +52,11 @@ function imageDefaults(imageIn, imageOut, fullLayout) { var axLetter = axLetters[i]; var axRef = Axes.coerceRef(imageIn, imageOut, gdMock, axLetter, 'paper'); + if(axRef !== 'paper') { + var ax = Axes.getFromId(gdMock, axRef); + ax._imgIndices.push(imageOut._index); + } + Axes.coercePosition(imageOut, gdMock, coerce, axRef, axLetter, 0); } diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index d8948fcb50e..252b1f80880 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2738,9 +2738,11 @@ exports.react = function(gd, data, layout, config) { var newFullData = gd._fullData; var newFullLayout = gd._fullLayout; var immutable = newFullLayout.datarevision === undefined; + var transition = newFullLayout.transition; - var restyleFlags = diffData(gd, oldFullData, newFullData, immutable); - var relayoutFlags = diffLayout(gd, oldFullLayout, newFullLayout, immutable); + var relayoutFlags = diffLayout(gd, oldFullLayout, newFullLayout, immutable, transition); + var newDataRevision = relayoutFlags.newDataRevision; + var restyleFlags = diffData(gd, oldFullData, newFullData, immutable, transition, newDataRevision); // TODO: how to translate this part of relayout to Plotly.react? // // Setting width or height to null must reset the graph's width / height @@ -2770,7 +2772,19 @@ exports.react = function(gd, data, layout, config) { seq.push(addFrames); } - if(restyleFlags.fullReplot || relayoutFlags.layoutReplot || configChanged) { + // Transition pathway, + // only used when 'transition' is set by user and + // when at least one animatable attribute has changed, + // N.B. config changed aren't animatable + if(newFullLayout.transition && !configChanged && (restyleFlags.anim || relayoutFlags.anim)) { + Plots.doCalcdata(gd); + subroutines.doAutoRangeAndConstraints(gd); + + seq.push(function() { + return Plots.transitionFromReact(gd, restyleFlags, relayoutFlags, oldFullLayout); + }); + } + else if(restyleFlags.fullReplot || relayoutFlags.layoutReplot || configChanged) { gd._fullLayout._skipDefaults = true; seq.push(exports.plot); } @@ -2823,8 +2837,10 @@ exports.react = function(gd, data, layout, config) { }; -function diffData(gd, oldFullData, newFullData, immutable) { - if(oldFullData.length !== newFullData.length) { +function diffData(gd, oldFullData, newFullData, immutable, transition, newDataRevision) { + var sameTraceLength = oldFullData.length === newFullData.length; + + if(!transition && !sameTraceLength) { return { fullReplot: true, calc: true @@ -2833,6 +2849,9 @@ function diffData(gd, oldFullData, newFullData, immutable) { var flags = editTypes.traceFlags(); flags.arrays = {}; + flags.nChanges = 0; + flags.nChangesAnim = 0; + var i, trace; function getTraceValObject(parts) { @@ -2843,31 +2862,41 @@ function diffData(gd, oldFullData, newFullData, immutable) { getValObject: getTraceValObject, flags: flags, immutable: immutable, + transition: transition, + newDataRevision: newDataRevision, gd: gd }; - var seenUIDs = {}; for(i = 0; i < oldFullData.length; i++) { - trace = newFullData[i]._fullInput; - if(Plots.hasMakesDataTransform(trace)) trace = newFullData[i]; - if(seenUIDs[trace.uid]) continue; - seenUIDs[trace.uid] = 1; + if(newFullData[i]) { + trace = newFullData[i]._fullInput; + if(Plots.hasMakesDataTransform(trace)) trace = newFullData[i]; + if(seenUIDs[trace.uid]) continue; + seenUIDs[trace.uid] = 1; - getDiffFlags(oldFullData[i]._fullInput, trace, [], diffOpts); + getDiffFlags(oldFullData[i]._fullInput, trace, [], diffOpts); + } } if(flags.calc || flags.plot) { flags.fullReplot = true; } + if(transition && flags.nChanges && flags.nChangesAnim) { + flags.anim = (flags.nChanges === flags.nChangesAnim) && sameTraceLength ? 'all' : 'some'; + } + return flags; } -function diffLayout(gd, oldFullLayout, newFullLayout, immutable) { +function diffLayout(gd, oldFullLayout, newFullLayout, immutable, transition) { var flags = editTypes.layoutFlags(); flags.arrays = {}; + flags.rangesAltered = {}; + flags.nChanges = 0; + flags.nChangesAnim = 0; function getLayoutValObject(parts) { return PlotSchema.getLayoutValObject(newFullLayout, parts); @@ -2877,6 +2906,7 @@ function diffLayout(gd, oldFullLayout, newFullLayout, immutable) { getValObject: getLayoutValObject, flags: flags, immutable: immutable, + transition: transition, gd: gd }; @@ -2886,11 +2916,15 @@ function diffLayout(gd, oldFullLayout, newFullLayout, immutable) { flags.layoutReplot = true; } + if(transition && flags.nChanges && flags.nChangesAnim) { + flags.anim = flags.nChanges === flags.nChangesAnim ? 'all' : 'some'; + } + return flags; } function getDiffFlags(oldContainer, newContainer, outerparts, opts) { - var valObject, key; + var valObject, key, astr; var getValObject = opts.getValObject; var flags = opts.flags; @@ -2905,6 +2939,25 @@ function getDiffFlags(oldContainer, newContainer, outerparts, opts) { return; } editTypes.update(flags, valObject); + + if(editType !== 'none') { + flags.nChanges++; + } + + // track animatable changes + if(opts.transition && valObject.anim) { + flags.nChangesAnim++; + } + + // track cartesian axes with altered ranges + if(AX_RANGE_RE.test(astr) || AX_AUTORANGE_RE.test(astr)) { + flags.rangesAltered[outerparts[0]] = 1; + } + + // track datarevision changes + if(key === 'datarevision') { + flags.newDataRevision = 1; + } } function valObjectCanBeDataArray(valObject) { @@ -2913,10 +2966,12 @@ function getDiffFlags(oldContainer, newContainer, outerparts, opts) { for(key in oldContainer) { // short-circuit based on previous calls or previous keys that already maximized the pathway - if(flags.calc) return; + if(flags.calc && !opts.transition) return; var oldVal = oldContainer[key]; var newVal = newContainer[key]; + var parts = outerparts.concat(key); + astr = parts.join('.'); if(key.charAt(0) === '_' || typeof oldVal === 'function' || oldVal === newVal) continue; @@ -2932,7 +2987,6 @@ function getDiffFlags(oldContainer, newContainer, outerparts, opts) { if(key === 'range' && newContainer.autorange) continue; if((key === 'zmin' || key === 'zmax') && newContainer.type === 'contourcarpet') continue; - var parts = outerparts.concat(key); valObject = getValObject(parts); // in case type changed, we may not even *have* a valObject. @@ -3003,6 +3057,11 @@ function getDiffFlags(oldContainer, newContainer, outerparts, opts) { if(immutable) { flags.calc = true; } + + // look for animatable attributes when the data changed + if(immutable || opts.newDataRevision) { + changed(); + } } else if(wasArray !== nowArray) { flags.calc = true; diff --git a/src/plots/animation_attributes.js b/src/plots/animation_attributes.js index c795bdd1d60..cc1ee758578 100644 --- a/src/plots/animation_attributes.js +++ b/src/plots/animation_attributes.js @@ -69,6 +69,7 @@ module.exports = { role: 'info', min: 0, dflt: 500, + editType: 'none', description: [ 'The duration of the transition, in milliseconds. If equal to zero,', 'updates are synchronous.' @@ -116,7 +117,19 @@ module.exports = { 'bounce-in-out' ], role: 'info', + editType: 'none', description: 'The easing function used for the transition' }, + ordering: { + valType: 'enumerated', + values: ['layout first', 'traces first'], + dflt: 'layout first', + role: 'info', + editType: 'none', + description: [ + 'Determines whether the figure\'s layout or traces smoothly transitions', + 'during updates that make both traces and layout change.' + ].join(' ') + } } }; diff --git a/src/plots/attributes.js b/src/plots/attributes.js index 40301a9f7b1..cbdf5fd3e8e 100644 --- a/src/plots/attributes.js +++ b/src/plots/attributes.js @@ -74,11 +74,18 @@ module.exports = { uid: { valType: 'string', role: 'info', - editType: 'plot' + editType: 'plot', + anim: true, + description: [ + 'Assign an id to this trace,', + 'Use this to provide object constancy between traces during animations', + 'and transitions.' + ].join(' ') }, ids: { valType: 'data_array', editType: 'calc', + anim: true, description: [ 'Assigns id labels to each datum.', 'These ids for object constancy of data points during animation.', diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index dbc400647ca..c531592fe22 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -132,6 +132,39 @@ axes.cleanPosition = function(pos, gd, axRef) { return cleanPos(pos); }; +axes.redrawComponents = function(gd, axIds) { + axIds = axIds ? axIds : axes.listIds(gd); + + var fullLayout = gd._fullLayout; + + function _redrawOneComp(moduleName, methodName, stashName, shortCircuit) { + var method = Registry.getComponentMethod(moduleName, methodName); + var stash = {}; + + for(var i = 0; i < axIds.length; i++) { + var ax = fullLayout[axes.id2name(axIds[i])]; + var indices = ax[stashName]; + + for(var j = 0; j < indices.length; j++) { + var ind = indices[j]; + + if(!stash[ind]) { + method(gd, ind); + stash[ind] = 1; + // once is enough for images (which doesn't use the `i` arg anyway) + if(shortCircuit) return; + } + } + } + } + + // annotations and shapes 'draw' method is slow, + // use the finer-grained 'drawOne' method instead + _redrawOneComp('annotations', 'drawOne', '_annIndices'); + _redrawOneComp('shapes', 'drawOne', '_shapeIndices'); + _redrawOneComp('images', 'draw', '_imgIndices', true); +}; + var getDataConversions = axes.getDataConversions = function(gd, trace, target, targetArray) { var ax; diff --git a/src/plots/cartesian/dragbox.js b/src/plots/cartesian/dragbox.js index 21157d0636f..8f44b934554 100644 --- a/src/plots/cartesian/dragbox.js +++ b/src/plots/cartesian/dragbox.js @@ -481,7 +481,7 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { // viewbox redraw at first updateSubplots(scrollViewBox); - ticksAndAnnotations(ns, ew); + ticksAndAnnotations(); // then replot after a delay to make sure // no more scrolling is coming @@ -513,7 +513,7 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { if(xActive) dragAxList(xaxes, dx); if(yActive) dragAxList(yaxes, dy); updateSubplots([xActive ? -dx : 0, yActive ? -dy : 0, pw, ph]); - ticksAndAnnotations(yActive, xActive); + ticksAndAnnotations(); return; } @@ -585,12 +585,12 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { } updateSubplots([x0, y0, pw - dx, ph - dy]); - ticksAndAnnotations(yActive, xActive); + ticksAndAnnotations(); } // Draw ticks and annotations (and other components) when ranges change. // Also records the ranges that have changed for use by update at the end. - function ticksAndAnnotations(ns, ew) { + function ticksAndAnnotations() { var activeAxIds = []; var i; @@ -618,25 +618,7 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { updates[ax._name + '.range[1]'] = ax.range[1]; } - function redrawObjs(objArray, method, shortCircuit) { - for(i = 0; i < objArray.length; i++) { - var obji = objArray[i]; - - if((ew && activeAxIds.indexOf(obji.xref) !== -1) || - (ns && activeAxIds.indexOf(obji.yref) !== -1)) { - method(gd, i); - // once is enough for images (which doesn't use the `i` arg anyway) - if(shortCircuit) return; - } - } - } - - // annotations and shapes 'draw' method is slow, - // use the finer-grained 'drawOne' method instead - - redrawObjs(gd._fullLayout.annotations || [], Registry.getComponentMethod('annotations', 'drawOne')); - redrawObjs(gd._fullLayout.shapes || [], Registry.getComponentMethod('shapes', 'drawOne')); - redrawObjs(gd._fullLayout.images || [], Registry.getComponentMethod('images', 'draw'), true); + Axes.redrawComponents(gd, activeAxIds); } function doubleClick() { diff --git a/src/plots/cartesian/layout_attributes.js b/src/plots/cartesian/layout_attributes.js index ae48118491e..c0f7f9b3a83 100644 --- a/src/plots/cartesian/layout_attributes.js +++ b/src/plots/cartesian/layout_attributes.js @@ -117,11 +117,12 @@ module.exports = { valType: 'info_array', role: 'info', items: [ - {valType: 'any', editType: 'axrange', impliedEdits: {'^autorange': false}}, - {valType: 'any', editType: 'axrange', impliedEdits: {'^autorange': false}} + {valType: 'any', editType: 'axrange', impliedEdits: {'^autorange': false}, anim: true}, + {valType: 'any', editType: 'axrange', impliedEdits: {'^autorange': false}, anim: true} ], editType: 'axrange', impliedEdits: {'autorange': false}, + anim: true, description: [ 'Sets the range of this axis.', 'If the axis `type` is *log*, then you must take the log of your', diff --git a/src/plots/cartesian/layout_defaults.js b/src/plots/cartesian/layout_defaults.js index 2a59a1bb428..8a3a05d940d 100644 --- a/src/plots/cartesian/layout_defaults.js +++ b/src/plots/cartesian/layout_defaults.js @@ -156,6 +156,7 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { axLayoutOut._traceIndices = traces.map(function(t) { return t._expandedIndex; }); axLayoutOut._annIndices = []; axLayoutOut._shapeIndices = []; + axLayoutOut._imgIndices = []; axLayoutOut._subplotsWith = []; axLayoutOut._counterAxes = []; diff --git a/src/plots/cartesian/transition_axes.js b/src/plots/cartesian/transition_axes.js index c646084839a..46bbdf704cb 100644 --- a/src/plots/cartesian/transition_axes.js +++ b/src/plots/cartesian/transition_axes.js @@ -13,147 +13,43 @@ var d3 = require('d3'); var Registry = require('../../registry'); var Drawing = require('../../components/drawing'); var Axes = require('./axes'); -var axisRegex = require('./constants').attrRegex; -module.exports = function transitionAxes(gd, newLayout, transitionOpts, makeOnCompleteCallback) { +/** + * transitionAxes + * + * transition axes from one set of ranges to another, using a svg + * transformations, similar to during panning. + * + * @param {DOM element | object} gd + * @param {array} edits : array of 'edits', each item with + * - plotinfo {object} subplot object + * - xr0 {array} initial x-range + * - xr1 {array} end x-range + * - yr0 {array} initial y-range + * - yr1 {array} end y-range + * @param {object} transitionOpts + * @param {function} makeOnCompleteCallback + */ +module.exports = function transitionAxes(gd, edits, 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 = ai.charAt(0); - var axisName = attrList[0]; - 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); - - function updateLayoutObjs() { - function redrawObjs(objArray, method, shortCircuit) { - for(var i = 0; i < objArray.length; i++) { - method(gd, i); - - // once is enough for images (which doesn't use the `i` arg anyway) - if(shortCircuit) return; - } - } - - redrawObjs(fullLayout.annotations || [], Registry.getComponentMethod('annotations', 'drawOne')); - redrawObjs(fullLayout.shapes || [], Registry.getComponentMethod('shapes', 'drawOne')); - redrawObjs(fullLayout.images || [], Registry.getComponentMethod('images', 'draw'), true); - } - if(!affectedSubplots.length) { - updateLayoutObjs(); - return false; - } - - function ticksAndAnnotations(xa, ya) { - var activeAxIds = [xa._id, ya._id]; - var i; - - Axes.drawOne(gd, xa, {skipTitle: true}); - Axes.drawOne(gd, ya, {skipTitle: true}); - - function redrawObjs(objArray, method, shortCircuit) { - 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); - } - - // once is enough for images (which doesn't use the `i` arg anyway) - if(shortCircuit) return; - } - } - - redrawObjs(fullLayout.annotations || [], Registry.getComponentMethod('annotations', 'drawOne')); - redrawObjs(fullLayout.shapes || [], Registry.getComponentMethod('shapes', 'drawOne')); - redrawObjs(fullLayout.images || [], Registry.getComponentMethod('images', 'draw'), true); + // special case for redraw:false Plotly.animate that relies on this + // to update axis-referenced layout components + if(edits.length === 0) { + Axes.redrawComponents(gd); + return; } function unsetSubplotTransform(subplot) { - var xa2 = subplot.xaxis; - var ya2 = subplot.yaxis; + var xa = subplot.xaxis; + var ya = subplot.yaxis; fullLayout._defs.select('#' + subplot.clipId + '> rect') .call(Drawing.setTranslate, 0, 0) .call(Drawing.setScale, 1, 1); subplot.plot - .call(Drawing.setTranslate, xa2._offset, ya2._offset) + .call(Drawing.setTranslate, xa._offset, ya._offset) .call(Drawing.setScale, 1, 1); var traceGroups = subplot.plot.selectAll('.scatterlayer .trace'); @@ -169,79 +65,71 @@ module.exports = function transitionAxes(gd, newLayout, transitionOpts, makeOnCo .call(Drawing.hideOutsideRangePoints, subplot); } - function updateSubplot(subplot, progress) { - var axis, r0, r1; - var xUpdate = updates[subplot.xaxis._id]; - var yUpdate = updates[subplot.yaxis._id]; + function updateSubplot(edit, progress) { + var plotinfo = edit.plotinfo; + var xa = plotinfo.xaxis; + var ya = plotinfo.yaxis; - var viewBox = []; + var xr0 = edit.xr0; + var xr1 = edit.xr1; + var xlen = xa._length; + var yr0 = edit.yr0; + var yr1 = edit.yr1; + var ylen = ya._length; - 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]; + var editX = !!xr1; + var editY = !!yr1; + var viewBox = []; - viewBox[2] = subplot.xaxis._length * ((1 - progress) + progress * dx2 / dx1); + if(editX) { + var dx0 = xr0[1] - xr0[0]; + var dx1 = xr1[1] - xr1[0]; + viewBox[0] = (xr0[0] * (1 - progress) + progress * xr1[0] - xr0[0]) / (xr0[1] - xr0[0]) * xlen; + viewBox[2] = xlen * ((1 - progress) + progress * dx1 / dx0); + xa.range[0] = xr0[0] * (1 - progress) + progress * xr1[0]; + xa.range[1] = xr0[1] * (1 - progress) + progress * xr1[1]; } else { viewBox[0] = 0; - viewBox[2] = subplot.xaxis._length; + viewBox[2] = xlen; } - 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); + if(editY) { + var dy0 = yr0[1] - yr0[0]; + var dy1 = yr1[1] - yr1[0]; + viewBox[1] = (yr0[1] * (1 - progress) + progress * yr1[1] - yr0[1]) / (yr0[0] - yr0[1]) * ylen; + viewBox[3] = ylen * ((1 - progress) + progress * dy1 / dy0); + ya.range[0] = yr0[0] * (1 - progress) + progress * yr1[0]; + ya.range[1] = yr0[1] * (1 - progress) + progress * yr1[1]; } else { viewBox[1] = 0; - viewBox[3] = subplot.yaxis._length; + viewBox[3] = ylen; } - ticksAndAnnotations(subplot.xaxis, subplot.yaxis); - - var xa2 = subplot.xaxis; - var ya2 = subplot.yaxis; - - var editX = !!xUpdate; - var editY = !!yUpdate; - - var xScaleFactor = editX ? xa2._length / viewBox[2] : 1; - var yScaleFactor = editY ? ya2._length / viewBox[3] : 1; + Axes.drawOne(gd, xa, {skipTitle: true}); + Axes.drawOne(gd, ya, {skipTitle: true}); + Axes.redrawComponents(gd, [xa._id, ya._id]); + var xScaleFactor = editX ? xlen / viewBox[2] : 1; + var yScaleFactor = editY ? ylen / viewBox[3] : 1; var clipDx = editX ? viewBox[0] : 0; var clipDy = editY ? viewBox[1] : 0; + var fracDx = editX ? (viewBox[0] / viewBox[2] * xlen) : 0; + var fracDy = editY ? (viewBox[1] / viewBox[3] * ylen) : 0; + var plotDx = xa._offset - fracDx; + var plotDy = ya._offset - fracDy; - var fracDx = editX ? (viewBox[0] / viewBox[2] * xa2._length) : 0; - var fracDy = editY ? (viewBox[1] / viewBox[3] * ya2._length) : 0; - - var plotDx = xa2._offset - fracDx; - var plotDy = ya2._offset - fracDy; - - subplot.clipRect + plotinfo.clipRect .call(Drawing.setTranslate, clipDx, clipDy) .call(Drawing.setScale, 1 / xScaleFactor, 1 / yScaleFactor); - subplot.plot + plotinfo.plot .call(Drawing.setTranslate, plotDx, plotDy) .call(Drawing.setScale, xScaleFactor, yScaleFactor); // apply an inverse scale to individual points to counteract // the scale of the trace group. - Drawing.setPointGroupScale(subplot.zoomScalePts, 1 / xScaleFactor, 1 / yScaleFactor); - Drawing.setTextPointsScale(subplot.zoomScaleTxt, 1 / xScaleFactor, 1 / yScaleFactor); + Drawing.setPointGroupScale(plotinfo.zoomScalePts, 1 / xScaleFactor, 1 / yScaleFactor); + Drawing.setTextPointsScale(plotinfo.zoomScaleTxt, 1 / xScaleFactor, 1 / yScaleFactor); } var onComplete; @@ -253,38 +141,35 @@ module.exports = function transitionAxes(gd, newLayout, transitionOpts, makeOnCo 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(); + for(var i = 0; i < edits.length; i++) { + var edit = edits[i]; + if(edit.xr1) aobj[edit.plotinfo.xaxis._name + '.range'] = edit.xr1.slice(); + if(edit.yr1) aobj[edit.plotinfo.yaxis._name + '.range'] = edit.yr1.slice(); } // Signal that this transition has completed: onComplete && onComplete(); return Registry.call('relayout', gd, aobj).then(function() { - for(var i = 0; i < affectedSubplots.length; i++) { - unsetSubplotTransform(affectedSubplots[i]); + for(var i = 0; i < edits.length; i++) { + unsetSubplotTransform(edits[i].plotinfo); } }); } 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(); + for(var i = 0; i < edits.length; i++) { + var edit = edits[i]; + if(edit.xr0) aobj[edit.plotinfo.xaxis._name + '.range'] = edit.xr0.slice(); + if(edit.yr0) aobj[edit.plotinfo.yaxis._name + '.range'] = edit.yr0.slice(); } return Registry.call('relayout', gd, aobj).then(function() { - for(var i = 0; i < affectedSubplots.length; i++) { - unsetSubplotTransform(affectedSubplots[i]); + for(var i = 0; i < edits.length; i++) { + unsetSubplotTransform(edits[i].plotinfo); } }); } @@ -304,8 +189,8 @@ module.exports = function transitionAxes(gd, newLayout, transitionOpts, makeOnCo 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); + for(var i = 0; i < edits.length; i++) { + updateSubplot(edits[i], progress); } if(t2 - t1 > transitionOpts.duration) { diff --git a/src/plots/gl3d/layout/axis_attributes.js b/src/plots/gl3d/layout/axis_attributes.js index 7264cf521fb..6699e73575a 100644 --- a/src/plots/gl3d/layout/axis_attributes.js +++ b/src/plots/gl3d/layout/axis_attributes.js @@ -77,7 +77,13 @@ module.exports = overrideAll({ }), autorange: axesAttrs.autorange, rangemode: axesAttrs.rangemode, - range: axesAttrs.range, + range: extendFlat({}, axesAttrs.range, { + items: [ + {valType: 'any', editType: 'plot', impliedEdits: {'^autorange': false}}, + {valType: 'any', editType: 'plot', impliedEdits: {'^autorange': false}} + ], + anim: false + }), // ticks tickmode: axesAttrs.tickmode, nticks: axesAttrs.nticks, diff --git a/src/plots/layout_attributes.js b/src/plots/layout_attributes.js index 521c32b7763..a86eea3eb8c 100644 --- a/src/plots/layout_attributes.js +++ b/src/plots/layout_attributes.js @@ -9,6 +9,7 @@ 'use strict'; var fontAttrs = require('./font_attributes'); +var animationAttrs = require('./animation_attributes'); var colorAttrs = require('../components/color/attributes'); var colorscaleAttrs = require('../components/colorscale/layout_attributes'); var padAttrs = require('./pad_attributes'); @@ -409,6 +410,7 @@ module.exports = { }, editType: 'modebar' }, + meta: { valType: 'data_array', editType: 'plot', @@ -420,6 +422,14 @@ module.exports = { 'item in question.' ].join(' ') }, + + transition: extendFlat({}, animationAttrs.transition, { + description: [ + 'Sets transition options used during Plotly.react updates.' + ].join(' '), + editType: 'none' + }), + _deprecated: { title: { valType: 'string', diff --git a/src/plots/plots.js b/src/plots/plots.js index f3a04690292..9fa2616a69e 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -513,7 +513,7 @@ plots.supplyDefaults = function(gd, opts) { plots.supplyDefaultsUpdateCalc = function(oldCalcdata, newFullData) { for(var i = 0; i < newFullData.length; i++) { var newTrace = newFullData[i]; - var cd0 = oldCalcdata[i][0]; + var cd0 = (oldCalcdata[i] || [])[0]; if(cd0 && cd0.trace) { var oldTrace = cd0.trace; if(oldTrace._hasCalcTransform) { @@ -563,7 +563,10 @@ function getTraceUids(oldFullData, newData) { } for(i = 0; i < len; i++) { - if(tryUid(newData[i].uid, i)) continue; + var newUid = newData[i].uid; + if(typeof newUid === 'number') newUid = String(newUid); + + if(tryUid(newUid, i)) continue; if(i < oldLen && tryUid(oldFullInput[i].uid, i)) continue; setUid(Lib.randstr(seenUids), i); } @@ -1457,6 +1460,13 @@ plots.supplyLayoutGlobalDefaults = function(layoutIn, layoutOut, formatObj) { coerce('meta'); + // do not include defaults in fullLayout when users do not set transition + if(Lib.isPlainObject(layoutIn.transition)) { + coerce('transition.duration'); + coerce('transition.easing'); + coerce('transition.ordering'); + } + Registry.getComponentMethod( 'calendars', 'handleDefaults' @@ -2288,10 +2298,9 @@ plots.extendLayout = function(destLayout, srcLayout) { }; /** - * Transition to a set of new data and layout properties + * Transition to a set of new data and layout properties from Plotly.animate * * @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 @@ -2304,17 +2313,15 @@ plots.extendLayout = function(destLayout, srcLayout) { * options for the transition */ plots.transition = function(gd, data, layout, traces, frameOpts, transitionOpts) { - var i, traceIdx; - - var dataLength = Array.isArray(data) ? data.length : 0; - var traceIndices = traces.slice(0, dataLength); - + var opts = {redraw: frameOpts.redraw}; var transitionedTraces = []; + var axEdits = []; - function prepareTransitions() { - var i; + opts.prepareFn = function() { + var dataLength = Array.isArray(data) ? data.length : 0; + var traceIndices = traces.slice(0, dataLength); - for(i = 0; i < traceIndices.length; i++) { + for(var i = 0; i < traceIndices.length; i++) { var traceIdx = traceIndices[i]; var trace = gd._fullData[traceIdx]; var module = trace._module; @@ -2360,8 +2367,213 @@ plots.transition = function(gd, data, layout, traces, frameOpts, transitionOpts) plots.supplyDefaults(gd); plots.doCalcdata(gd); + var newLayout = Lib.expandObjectPaths(layout); + + if(newLayout) { + var subplots = gd._fullLayout._plots; + + for(var k in subplots) { + var plotinfo = subplots[k]; + var xa = plotinfo.xaxis; + var ya = plotinfo.yaxis; + var xr0 = xa.range.slice(); + var yr0 = ya.range.slice(); + + var xr1; + if(Array.isArray(newLayout[xa._name + '.range'])) { + xr1 = newLayout[xa._name + '.range'].slice(); + } else if(Array.isArray((newLayout[xa._name] || {}).range)) { + xr1 = newLayout[xa._name].range.slice(); + } + + var yr1; + if(Array.isArray(newLayout[ya._name + '.range'])) { + yr1 = newLayout[ya._name + '.range'].slice(); + } else if(Array.isArray((newLayout[ya._name] || {}).range)) { + yr1 = newLayout[ya._name].range.slice(); + } + + var editX; + if(xr0 && xr1 && (xr0[0] !== xr1[0] || xr0[1] !== xr1[1])) { + editX = {xr0: xr0, xr1: xr1}; + } + + var editY; + if(yr0 && yr1 && (yr0[0] !== yr1[0] || yr0[1] !== yr1[1])) { + editY = {yr0: yr0, yr1: yr1}; + } + + if(editX || editY) { + axEdits.push(Lib.extendFlat({plotinfo: plotinfo}, editX, editY)); + } + } + } + return Promise.resolve(); - } + }; + + opts.runFn = function(makeCallback) { + var traceTransitionOpts; + var basePlotModules = gd._fullLayout._basePlotModules; + var hasAxisTransition = axEdits.length; + var i; + + if(layout) { + for(i = 0; i < basePlotModules.length; i++) { + if(basePlotModules[i].transitionAxes) { + basePlotModules[i].transitionAxes(gd, axEdits, transitionOpts, makeCallback); + } + } + } + + // 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; + // This means do not transition traces, + // this happens on layout-only (e.g. axis range) animations + transitionedTraces = null; + } else { + traceTransitionOpts = transitionOpts; + } + + for(i = 0; i < basePlotModules.length; i++) { + // 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[i].plot(gd, transitionedTraces, traceTransitionOpts, makeCallback); + } + }; + + return _transition(gd, transitionOpts, opts); +}; + +/** + * Transition to a set of new data and layout properties from Plotly.react + * + * @param {DOM element} gd + * @param {object} restyleFlags + * - anim {'all'|'some'} + * @param {object} relayoutFlags + * - anim {'all'|'some'} + * @param {object} oldFullLayout : old (pre Plotly.react) fullLayout + */ +plots.transitionFromReact = function(gd, restyleFlags, relayoutFlags, oldFullLayout) { + var fullLayout = gd._fullLayout; + var transitionOpts = fullLayout.transition; + var opts = {}; + var axEdits = []; + + opts.prepareFn = function() { + var subplots = fullLayout._plots; + + // no need to redraw at end of transition, + // if all changes are animatable + opts.redraw = false; + if(restyleFlags.anim === 'some') opts.redraw = true; + if(relayoutFlags.anim === 'some') opts.redraw = true; + + for(var k in subplots) { + var plotinfo = subplots[k]; + var xa = plotinfo.xaxis; + var ya = plotinfo.yaxis; + var xr0 = oldFullLayout[xa._name].range.slice(); + var yr0 = oldFullLayout[ya._name].range.slice(); + var xr1 = xa.range.slice(); + var yr1 = ya.range.slice(); + + xa.setScale(); + ya.setScale(); + + var editX; + if(xr0[0] !== xr1[0] || xr0[1] !== xr1[1]) { + editX = {xr0: xr0, xr1: xr1}; + } + + var editY; + if(yr0[0] !== yr1[0] || yr0[1] !== yr1[1]) { + editY = {yr0: yr0, yr1: yr1}; + } + + if(editX || editY) { + axEdits.push(Lib.extendFlat({plotinfo: plotinfo}, editX, editY)); + } + } + + return Promise.resolve(); + }; + + opts.runFn = function(makeCallback) { + var fullData = gd._fullData; + var fullLayout = gd._fullLayout; + var basePlotModules = fullLayout._basePlotModules; + + var axisTransitionOpts; + var traceTransitionOpts; + var transitionedTraces; + + var allTraceIndices = []; + for(var i = 0; i < fullData.length; i++) { + allTraceIndices.push(i); + } + + function transitionAxes() { + for(var j = 0; j < basePlotModules.length; j++) { + if(basePlotModules[j].transitionAxes) { + basePlotModules[j].transitionAxes(gd, axEdits, axisTransitionOpts, makeCallback); + } + } + } + + function transitionTraces() { + for(var j = 0; j < basePlotModules.length; j++) { + basePlotModules[j].plot(gd, transitionedTraces, traceTransitionOpts, makeCallback); + } + } + + if(axEdits.length && restyleFlags.anim) { + if(transitionOpts.ordering === 'traces first') { + axisTransitionOpts = Lib.extendFlat({}, transitionOpts, {duration: 0}); + transitionedTraces = allTraceIndices; + traceTransitionOpts = transitionOpts; + transitionTraces(); + setTimeout(transitionAxes, transitionOpts.duration); + } else { + axisTransitionOpts = transitionOpts; + transitionedTraces = null; + traceTransitionOpts = Lib.extendFlat({}, transitionOpts, {duration: 0}); + transitionAxes(); + transitionTraces(); + } + } else if(axEdits.length) { + axisTransitionOpts = transitionOpts; + transitionAxes(); + } else if(restyleFlags.anim) { + transitionedTraces = allTraceIndices; + traceTransitionOpts = transitionOpts; + transitionTraces(); + } + }; + + return _transition(gd, transitionOpts, opts); +}; + +/** + * trace/layout transition wrapper that works + * for transitions initiated by Plotly.animate and Plotly.react. + * + * @param {DOM element} gd + * @param {object} transitionOpts + * @param {object} opts + * - redraw {boolean} + * - prepareFn {function} *should return a Promise* + * - runFn {function} ran inside executeTransitions + */ +function _transition(gd, transitionOpts, opts) { + var aborted = false; function executeCallbacks(list) { var p = Promise.resolve(); @@ -2379,8 +2591,6 @@ plots.transition = function(gd, data, layout, traces, frameOpts, transitionOpts) } } - var aborted = false; - function executeTransitions() { gd.emit('plotly_transitioning', []); @@ -2395,7 +2605,6 @@ plots.transition = function(gd, data, layout, traces, frameOpts, transitionOpts) gd._transitioningWithDuration = true; } - // If another transition is triggered, this callback will be executed simply because it's // in the interruptCallbacks queue. If this transition completes, it will instead flush // that queue and forget about this callback. @@ -2403,7 +2612,7 @@ plots.transition = function(gd, data, layout, traces, frameOpts, transitionOpts) aborted = true; }); - if(frameOpts.redraw) { + if(opts.redraw) { gd._transitionData._interruptCallbacks.push(function() { return Registry.call('redraw', gd); }); @@ -2429,44 +2638,10 @@ plots.transition = function(gd, data, layout, traces, frameOpts, transitionOpts) }; } - 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; - // This means do not transition traces, - // this happens on layout-only (e.g. axis range) animations - transitionedTraces = null; - } 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); - } + opts.runFn(makeCallback); // If nothing else creates a callback, then this will trigger the completion in the next tick: setTimeout(makeCallback()); - }); } @@ -2479,7 +2654,7 @@ plots.transition = function(gd, data, layout, traces, frameOpts, transitionOpts) flushCallbacks(gd._transitionData._interruptCallbacks); return Promise.resolve().then(function() { - if(frameOpts.redraw) { + if(opts.redraw) { return Registry.call('redraw', gd); } }).then(function() { @@ -2505,15 +2680,13 @@ plots.transition = function(gd, data, layout, traces, frameOpts, transitionOpts) 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; - } - - var seq = [plots.previousPromises, interruptPreviousTransitions, prepareTransitions, plots.rehover, executeTransitions]; + var seq = [ + plots.previousPromises, + interruptPreviousTransitions, + opts.prepareFn, + plots.rehover, + executeTransitions + ]; var transitionStarting = Lib.syncOrAsync(seq, gd); @@ -2521,10 +2694,8 @@ plots.transition = function(gd, data, layout, traces, frameOpts, transitionOpts) transitionStarting = Promise.resolve(); } - return transitionStarting.then(function() { - return gd; - }); -}; + return transitionStarting.then(function() { return gd; }); +} plots.doCalcdata = function(gd, traces) { var axList = axisIDs.list(gd); diff --git a/src/traces/scatter/attributes.js b/src/traces/scatter/attributes.js index f785b09af46..86f8161f82e 100644 --- a/src/traces/scatter/attributes.js +++ b/src/traces/scatter/attributes.js @@ -22,6 +22,7 @@ module.exports = { x: { valType: 'data_array', editType: 'calc+clearAxisTypes', + anim: true, description: 'Sets the x coordinates.' }, x0: { @@ -29,6 +30,7 @@ module.exports = { dflt: 0, role: 'info', editType: 'calc+clearAxisTypes', + anim: true, description: [ 'Alternate to `x`.', 'Builds a linear space of x coordinates.', @@ -41,6 +43,7 @@ module.exports = { dflt: 1, role: 'info', editType: 'calc', + anim: true, description: [ 'Sets the x coordinate step.', 'See `x0` for more info.' @@ -49,6 +52,7 @@ module.exports = { y: { valType: 'data_array', editType: 'calc+clearAxisTypes', + anim: true, description: 'Sets the y coordinates.' }, y0: { @@ -56,6 +60,7 @@ module.exports = { dflt: 0, role: 'info', editType: 'calc+clearAxisTypes', + anim: true, description: [ 'Alternate to `y`.', 'Builds a linear space of y coordinates.', @@ -68,6 +73,7 @@ module.exports = { dflt: 1, role: 'info', editType: 'calc', + anim: true, description: [ 'Sets the y coordinate step.', 'See `y0` for more info.' @@ -212,6 +218,7 @@ module.exports = { valType: 'color', role: 'style', editType: 'style', + anim: true, description: 'Sets the line color.' }, width: { @@ -220,6 +227,7 @@ module.exports = { dflt: 2, role: 'style', editType: 'style', + anim: true, description: 'Sets the line width (in px).' }, shape: { @@ -318,6 +326,7 @@ module.exports = { valType: 'color', role: 'style', editType: 'style', + anim: true, description: [ 'Sets the fill color.', 'Defaults to a half-transparent variant of the line color,', @@ -347,6 +356,7 @@ module.exports = { arrayOk: true, role: 'style', editType: 'style', + anim: true, description: 'Sets the marker opacity.' }, size: { @@ -356,6 +366,7 @@ module.exports = { arrayOk: true, role: 'style', editType: 'calc', + anim: true, description: 'Sets the marker size (in px).' }, maxdisplayed: { @@ -413,11 +424,12 @@ module.exports = { arrayOk: true, role: 'style', editType: 'style', + anim: true, description: 'Sets the width (in px) of the lines bounding the marker points.' }, editType: 'calc' }, - colorAttributes('marker.line') + colorAttributes('marker.line', {anim: true}) ), gradient: { type: { @@ -446,7 +458,7 @@ module.exports = { }, editType: 'calc' }, - colorAttributes('marker') + colorAttributes('marker', {anim: true}) ), selected: { marker: { diff --git a/test/jasmine/bundle_tests/plotschema_test.js b/test/jasmine/bundle_tests/plotschema_test.js index 8fb836176e9..e9c090b1ea9 100644 --- a/test/jasmine/bundle_tests/plotschema_test.js +++ b/test/jasmine/bundle_tests/plotschema_test.js @@ -117,7 +117,7 @@ describe('plot schema', function() { .concat(valObject.otherOpts) .concat([ 'valType', 'description', 'role', - 'editType', 'impliedEdits', + 'editType', 'impliedEdits', 'anim', '_compareAsJSON', '_noTemplating' ]); diff --git a/test/jasmine/tests/annotations_test.js b/test/jasmine/tests/annotations_test.js index 65b46325dd1..d6c11e6c6e7 100644 --- a/test/jasmine/tests/annotations_test.js +++ b/test/jasmine/tests/annotations_test.js @@ -1603,7 +1603,7 @@ describe('animating annotations', function() { { annotations: [{text: 'hello'}], shapes: [{fillcolor: 'rgb(170, 170, 170)'}], - images: [{source: img1}] + images: [{source: img1, xref: 'x', yref: 'y'}] } ).then(function() { assertAnnotations(['hello']); diff --git a/test/jasmine/tests/plots_test.js b/test/jasmine/tests/plots_test.js index 140daf9c2c0..367e077b482 100644 --- a/test/jasmine/tests/plots_test.js +++ b/test/jasmine/tests/plots_test.js @@ -153,6 +153,26 @@ describe('Test Plots', function() { testSanitizeMarginsHasBeenCalledOnlyOnce(gd); }); + + it('should accept trace uids as non-empty strings or numbers', function() { + var gd = { + data: [{}, {uid: false}, {uid: 'my-id'}, {uid: ''}, {uid: 0}, {uid: 2}] + }; + supplyAllDefaults(gd); + + var traceUids = gd._fullLayout._traceUids; + expect(traceUids.length).toBe(6, '# of trace uids'); + expect(traceUids[2]).toBe('my-id'); + expect(traceUids[4]).toBe('0'); + expect(traceUids[5]).toBe('2'); + + var indicesOfRandomUid = [0, 1, 3]; + indicesOfRandomUid.forEach(function(ind) { + var msg = 'fullData[' + ind + '].uid'; + expect(typeof traceUids[ind]).toBe('string', msg + 'is a string'); + expect(traceUids[ind].length).toBe(6, msg + 'is of length 6'); + }); + }); }); describe('Plots.supplyLayoutGlobalDefaults should', function() { diff --git a/test/jasmine/tests/transition_test.js b/test/jasmine/tests/transition_test.js index 60095b95815..f3cc0b3827b 100644 --- a/test/jasmine/tests/transition_test.js +++ b/test/jasmine/tests/transition_test.js @@ -2,6 +2,8 @@ var Plotly = require('@lib/index'); var Lib = require('@src/lib'); var Plots = Plotly.Plots; var plotApiHelpers = require('@src/plot_api/helpers'); +var Registry = require('@src/registry'); +var Drawing = require('@src/components/drawing'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); @@ -266,3 +268,725 @@ for(var i = 0; i < 2; i++) { // And of course, remember to put the async loop in a closure: runTests(duration); } + +describe('Plotly.react transitions:', function() { + var gd; + var methods; + + beforeEach(function() { + gd = createGraphDiv(); + methods = [ + [Plots, 'transitionFromReact'], + [Registry, 'call'] + ]; + }); + + afterEach(function() { + Plotly.purge(gd); + destroyGraphDiv(); + }); + + function addSpies() { + methods.forEach(function(m) { + spyOn(m[0], m[1]).and.callThrough(); + }); + } + + function resetSpyCounters() { + methods.forEach(function(m) { + m[0][m[1]].calls.reset(); + }); + } + + function assertSpies(msg, exps) { + exps.forEach(function(exp) { + var calls = exp[0][exp[1]].calls; + var cnt = calls.count(); + + if(Array.isArray(exp[2])) { + expect(cnt).toBe(exp[2].length, msg); + + var allArgs = calls.allArgs(); + allArgs.forEach(function(args, i) { + args.forEach(function(a, j) { + var e = exp[2][i][j]; + if(Lib.isPlainObject(a) || Array.isArray(a)) { + expect(a).toEqual(e, msg); + } else if(typeof a === 'function') { + expect('function').toBe(e, msg); + } else { + expect(a).toBe(e, msg); + } + }); + }); + } else if(typeof exp[2] === 'number') { + expect(cnt).toBe(exp[2], msg); + } else { + fail('wrong arguments for assertSpies'); + } + }); + resetSpyCounters(); + } + + it('should go through transition pathway when *transition* is set in layout', function(done) { + addSpies(); + + var data = [{y: [1, 2, 1]}]; + var layout = {}; + + Plotly.react(gd, data, layout) + .then(function() { + assertSpies('first draw', [ + [Plots, 'transitionFromReact', 0] + ]); + }) + .then(function() { + data[0].marker = {color: 'red'}; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('no *transition* set', [ + [Plots, 'transitionFromReact', 0] + ]); + }) + .then(function() { + layout.transition = {duration: 10}; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('with *transition* set, no changes', [ + [Plots, 'transitionFromReact', 0] + ]); + }) + .then(function() { + data[0].marker = {color: 'blue'}; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('with *transition* set and changes', [ + [Plots, 'transitionFromReact', 1], + ]); + }) + .catch(failTest) + .then(done); + }); + + it('should go through transition pathway only when there are animatable changes', function(done) { + addSpies(); + + var data = [{y: [1, 2, 1]}]; + var layout = {transition: {duration: 10}}; + + Plotly.react(gd, data, layout) + .then(function() { + assertSpies('first draw', [ + [Plots, 'transitionFromReact', 0] + ]); + }) + .then(function() { + data[0].marker = {color: 'red'}; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('animatable trace change', [ + [Plots, 'transitionFromReact', 1] + ]); + }) + .then(function() { + data[0].name = 'TRACE'; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('non-animatable trace change', [ + [Plots, 'transitionFromReact', 0] + ]); + }) + .then(function() { + layout.xaxis = {range: [-1, 10]}; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('animatable layout change', [ + [Plots, 'transitionFromReact', 1] + ]); + }) + .then(function() { + layout.title = 'FIGURE'; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('non-animatable layout change', [ + [Plots, 'transitionFromReact', 0] + ]); + }) + .then(function() { + data[0].marker = {color: 'black'}; + layout.xaxis = {range: [-10, 20]}; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('animatable trace & layout change', [ + [Plots, 'transitionFromReact', 1] + ]); + }) + .catch(failTest) + .then(done); + }); + + it('should not try to transition when the *config* has changed', function(done) { + addSpies(); + + var data = [{y: [1, 2, 1]}]; + var layout = {transition: {duration: 10}}; + var config = {scrollZoom: true}; + + Plotly.react(gd, data, layout, config) + .then(function() { + assertSpies('first draw', [ + [Plots, 'transitionFromReact', 0] + ]); + }) + .then(function() { + data[0].marker = {color: 'red'}; + config.scrollZoom = false; + return Plotly.react(gd, data, layout, config); + }) + .then(function() { + assertSpies('on config change', [ + [Plots, 'transitionFromReact', 0] + ]); + }) + .then(function() { + data[0].marker = {color: 'blue'}; + return Plotly.react(gd, data, layout, config); + }) + .then(function() { + assertSpies('no config change', [ + [Plots, 'transitionFromReact', 1] + ]); + }) + .catch(failTest) + .then(done); + }); + + it('should only *redraw* at end of transition when necessary', function(done) { + addSpies(); + + var data = [{ + y: [1, 2, 1], + marker: {color: 'blue'} + }]; + var layout = { + transition: {duration: 10}, + xaxis: {range: [0, 3]}, + yaxis: {range: [0, 3]} + }; + + Plotly.react(gd, data, layout) + .then(function() { + assertSpies('first draw', [ + [Plots, 'transitionFromReact', 0], + [Registry, 'call', 0] + ]); + }) + .then(function() { + data[0].marker.color = 'red'; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('redraw NOT required', [ + [Plots, 'transitionFromReact', 1], + [Registry, 'call', 0] + ]); + }) + .then(function() { + data[0].marker = {color: 'black'}; + // 'name' is NOT anim:true + data[0].name = 'TRACE'; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('redraw required', [ + [Plots, 'transitionFromReact', 1], + [Registry, 'call', [['redraw', gd]]] + ]); + }) + .catch(failTest) + .then(done); + }); + + it('should only transition the layout when both traces and layout have animatable changes by default', function(done) { + var data = [{y: [1, 2, 1]}]; + var layout = { + transition: {duration: 10}, + xaxis: {range: [0, 3]}, + yaxis: {range: [0, 3]} + }; + + Plotly.react(gd, data, layout) + .then(function() { + methods.push([gd._fullLayout._basePlotModules[0], 'plot']); + methods.push([gd._fullLayout._basePlotModules[0], 'transitionAxes']); + addSpies(); + }) + .then(function() { + data[0].marker = {color: 'red'}; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('just trace transition', [ + [Plots, 'transitionFromReact', 1], + [gd._fullLayout._basePlotModules[0], 'plot', 1], + [gd._fullLayout._basePlotModules[0], 'transitionAxes', 0] + ]); + }) + .then(function() { + layout.xaxis.range = [-2, 2]; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('just layout transition', [ + [Plots, 'transitionFromReact', 1], + [gd._fullLayout._basePlotModules[0], 'transitionAxes', 1], + // one _module.plot call from the relayout at end of axis transition + [Registry, 'call', [['relayout', gd, {'xaxis.range': [-2, 2]}]]], + [gd._fullLayout._basePlotModules[0], 'plot', 1], + ]); + }) + .then(function() { + data[0].marker.color = 'black'; + layout.xaxis.range = [-1, 1]; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('both trace and layout transitions', [ + [Plots, 'transitionFromReact', 1], + [gd._fullLayout._basePlotModules[0], 'transitionAxes', 1], + [Registry, 'call', [['relayout', gd, {'xaxis.range': [-1, 1]}]]], + [gd._fullLayout._basePlotModules[0], 'plot', [ + // one instantaneous transition options to halt + // other trace transitions (if any) + [gd, null, {duration: 0, easing: 'cubic-in-out', ordering: 'layout first'}, 'function'], + // one _module.plot call from the relayout at end of axis transition + [gd] + ]], + ]); + }) + .then(function() { + data[0].marker.color = 'red'; + layout.xaxis.range = [-2, 2]; + layout.transition.ordering = 'traces first'; + return Plotly.react(gd, data, layout); + }) + .then(delay(20)) + .then(function() { + assertSpies('both trace and layout transitions under *ordering:traces first*', [ + [Plots, 'transitionFromReact', 1], + [gd._fullLayout._basePlotModules[0], 'plot', [ + // one smooth transition + [gd, [0], {duration: 10, easing: 'cubic-in-out', ordering: 'traces first'}, 'function'], + // one by relayout call at the end of instantaneous axis transition + [gd] + ]], + [gd._fullLayout._basePlotModules[0], 'transitionAxes', 1], + [Registry, 'call', [['relayout', gd, {'xaxis.range': [-2, 2]}]]] + ]); + }) + .catch(failTest) + .then(done); + }); + + it('should transition data coordinates with and without *datarevision*', function(done) { + addSpies(); + + var y0 = [1, 2, 1]; + var y1 = [2, 1, 1]; + var i = 0; + + var data = [{ y: y0 }]; + var layout = { + transition: {duration: 10}, + xaxis: {range: [0, 3]}, + yaxis: {range: [0, 3]} + }; + + function dataArrayToggle() { + i++; + return i % 2 ? y1 : y0; + } + + Plotly.react(gd, data, layout) + .then(function() { + assertSpies('first draw', [ + [Plots, 'transitionFromReact', 0] + ]); + }) + .then(function() { + data[0].y = dataArrayToggle(); + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('picks data_array changes with datarevision unset', [ + [Plots, 'transitionFromReact', 1] + ]); + }) + .then(function() { + data[0].y = dataArrayToggle(); + layout.datarevision = '1'; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('picks up datarevision changes', [ + [Plots, 'transitionFromReact', 1] + ]); + }) + .then(function() { + data[0].y = dataArrayToggle(); + layout.datarevision = '1'; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('ignores data_array changes when datarevision is same', [ + [Plots, 'transitionFromReact', 0] + ]); + }) + .then(function() { + data[0].y = dataArrayToggle(); + layout.datarevision = '2'; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('picks up datarevision changes (take 2)', [ + [Plots, 'transitionFromReact', 1] + ]); + }) + .catch(failTest) + .then(done); + }); + + it('should transition layout when one or more axis auto-ranged value changed', function(done) { + var data = [{y: [1, 2, 1]}]; + var layout = {transition: {duration: 10}}; + + function assertAxAutorange(msg, exp) { + expect(gd.layout.xaxis.autorange).toBe(exp, msg); + expect(gd.layout.yaxis.autorange).toBe(exp, msg); + expect(gd._fullLayout.xaxis.autorange).toBe(exp, msg); + expect(gd._fullLayout.yaxis.autorange).toBe(exp, msg); + } + + Plotly.react(gd, data, layout) + .then(function() { + methods.push([gd._fullLayout._basePlotModules[0], 'plot']); + methods.push([gd._fullLayout._basePlotModules[0], 'transitionAxes']); + addSpies(); + assertAxAutorange('axes are autorange:true by default', true); + }) + .then(function() { + // N.B. marker.size can expand axis range + data[0].marker = {size: 30}; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('must transition autoranged axes, not the traces', [ + [Plots, 'transitionFromReact', 1], + [gd._fullLayout._basePlotModules[0], 'transitionAxes', 1], + [gd._fullLayout._basePlotModules[0], 'plot', [ + // one instantaneous transition options to halt + // other trace transitions (if any) + [gd, null, {duration: 0, easing: 'cubic-in-out', ordering: 'layout first'}, 'function'], + // one _module.plot call from the relayout at end of axis transition + [gd] + ]], + ]); + assertAxAutorange('axes are now autorange:false', false); + }) + .then(function() { + data[0].marker = {size: 10}; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('transition just traces, as now axis ranges are set', [ + [Plots, 'transitionFromReact', 1], + [gd._fullLayout._basePlotModules[0], 'transitionAxes', 0], + [gd._fullLayout._basePlotModules[0], 'plot', [ + // called from Plots.transitionFromReact + [gd, [0], {duration: 10, easing: 'cubic-in-out', ordering: 'layout first'}, 'function'], + ]], + ]); + assertAxAutorange('axes are still autorange:false', false); + }) + .catch(failTest) + .then(done); + }); + + it('should not transition layout when axis auto-ranged value do not changed', function(done) { + var data = [{y: [1, 2, 1]}]; + var layout = {transition: {duration: 10}}; + + function assertAxAutorange(msg, exp) { + expect(gd.layout.yaxis.autorange).toBe(exp, msg); + expect(gd._fullLayout.yaxis.autorange).toBe(exp, msg); + } + + Plotly.react(gd, data, layout) + .then(function() { + methods.push([gd._fullLayout._basePlotModules[0], 'plot']); + methods.push([gd._fullLayout._basePlotModules[0], 'transitionAxes']); + addSpies(); + assertAxAutorange('y-axis is autorange:true by default', true); + }) + .then(function() { + // N.B. different coordinate, but same auto-range value + data[0].y = [2, 1, 2]; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('do not transition autoranged axes, just the traces', [ + [Plots, 'transitionFromReact', 1], + [gd._fullLayout._basePlotModules[0], 'transitionAxes', 0], + [gd._fullLayout._basePlotModules[0], 'plot', 1] + ]); + assertAxAutorange('y-axis is still autorange:true', true); + }) + .then(function() { + // N.B. different coordinates with different auto-range value + data[0].y = [20, 10, 20]; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('both trace and layout transitions', [ + [Plots, 'transitionFromReact', 1], + [gd._fullLayout._basePlotModules[0], 'transitionAxes', 1], + [Registry, 'call', [ + // xaxis call to _storeDirectGUIEdit from doAutoRange + ['_storeDirectGUIEdit', gd.layout, gd._fullLayout._preGUI, { + 'xaxis.range': [-0.12852664576802508, 2.128526645768025], + 'xaxis.autorange': true + }], + // yaxis call to _storeDirectGUIEdit from doAutoRange + ['_storeDirectGUIEdit', gd.layout, gd._fullLayout._preGUI, { + 'yaxis.range': [9.26751592356688, 20.73248407643312], + 'yaxis.autorange': true + }], + ['relayout', gd, { + 'yaxis.range': [9.26751592356688, 20.73248407643312] + }], + // xaxis call #2 to _storeDirectGUIEdit from doAutoRange, + // as this axis is still autorange:true + ['_storeDirectGUIEdit', gd.layout, gd._fullLayout._preGUI, { + 'xaxis.range': [-0.12852664576802508, 2.128526645768025], + 'xaxis.autorange': true + }], + ]], + [gd._fullLayout._basePlotModules[0], 'plot', [ + // one instantaneous transition options to halt + // other trace transitions (if any) + [gd, null, {duration: 0, easing: 'cubic-in-out', ordering: 'layout first'}, 'function'], + // one _module.plot call from the relayout at end of axis transition + [gd] + ]] + ]); + assertAxAutorange('y-axis is now autorange:false', false); + }) + .catch(failTest) + .then(done); + }); + + it('should emit transition events', function(done) { + var events = ['transitioning', 'transitioned', 'react']; + var store = {}; + + var data = [{y: [1, 2, 1]}]; + var layout = {transition: {duration: 10}}; + + Plotly.react(gd, data, layout) + .then(function() { + events.forEach(function(k) { + store[k] = jasmine.createSpy(k); + gd.on('plotly_' + k, store[k]); + }); + }) + .then(function() { + data[0].marker = {color: 'red'}; + return Plotly.react(gd, data, layout); + }) + .then(function() { + for(var k in store) { + expect(store[k]).toHaveBeenCalledTimes(1); + } + }) + .catch(failTest) + .then(done); + }); + + it('should preserve trace object-constancy (out-of-order case)', function(done) { + var data1 = [{ + uid: 1, + x: [5, 6, 7], + y: [5, 6, 7], + marker: {color: 'blue', size: 10} + }, { + uid: 2, + x: [1, 2, 3], + y: [1, 2, 3], + marker: {color: 'red', size: 10} + }]; + + var data2 = [{ + uid: 2, + x: [5, 6, 7], + y: [5, 6, 7], + marker: {color: 'yellow', size: 10} + }, { + uid: 1, + x: [1, 2, 3], + y: [1, 2, 3], + marker: {color: 'green', size: 10} + }]; + + var layout = { + xaxis: {range: [-1, 8]}, + yaxis: {range: [-1, 8]}, + showlegend: false, + transition: {duration: 10} + }; + + var traceNodes; + + function _assertTraceNodes(msg, traceNodesOrdered, ptsXY) { + var traceNodesNew = gd.querySelectorAll('.scatterlayer > .trace'); + expect(traceNodesNew[0]).toBe(traceNodesOrdered[0], 'same trace node 0 - ' + msg); + expect(traceNodesNew[1]).toBe(traceNodesOrdered[1], 'same trace node 1 - ' + msg); + + var pt0 = traceNodes[0].querySelector('.points > path'); + var pt0XY = Drawing.getTranslate(pt0); + expect(pt0XY.x).toBeCloseTo(ptsXY[0][0], 1, 'pt0 x - ' + msg); + expect(pt0XY.y).toBeCloseTo(ptsXY[0][1], 1, 'pt0 y - ' + msg); + + var pt1 = traceNodes[1].querySelector('.points > path'); + var pt1XY = Drawing.getTranslate(pt1); + expect(pt1XY.x).toBeCloseTo(ptsXY[1][0], 1, 'pt1 x - ' + msg); + expect(pt1XY.y).toBeCloseTo(ptsXY[1][1], 1, 'pt1 y - ' + msg); + } + + Plotly.react(gd, data1, layout) + .then(function() { + methods.push([gd._fullLayout._basePlotModules[0], 'plot']); + methods.push([gd._fullLayout._basePlotModules[0], 'transitionAxes']); + addSpies(); + + traceNodes = gd.querySelectorAll('.scatterlayer > .trace'); + _assertTraceNodes('base', traceNodes, [[360, 90], [120, 210]]); + }) + .then(function() { return Plotly.react(gd, data2, layout); }) + .then(function() { + var msg = 'transition into data2'; + assertSpies(msg, [ + [Plots, 'transitionFromReact', 1], + [gd._fullLayout._basePlotModules[0], 'plot', 1], + [gd._fullLayout._basePlotModules[0], 'transitionAxes', 0] + ]); + // N.B. order is reversed, but the nodes are the *same* + _assertTraceNodes(msg, [traceNodes[1], traceNodes[0]], [[120, 210], [360, 90]]); + }) + .then(function() { return Plotly.react(gd, data1, layout); }) + .then(function() { + var msg = 'transition back to data1'; + assertSpies(msg, [ + [Plots, 'transitionFromReact', 1], + [gd._fullLayout._basePlotModules[0], 'plot', 1], + [gd._fullLayout._basePlotModules[0], 'transitionAxes', 0] + ]); + _assertTraceNodes(msg, traceNodes, [[360, 90], [120, 210]]); + }) + .catch(failTest) + .then(done); + }); + + it('should preserve trace object-constancy (# of traces mismatch case)', function(done) { + var data1 = [{ + uid: 1, + x: [5, 6, 7], + y: [5, 6, 7], + marker: {color: 'blue', size: 10} + }, { + uid: 2, + x: [1, 2, 3], + y: [1, 2, 3], + marker: {color: 'red', size: 10} + }]; + + var data2 = [{ + uid: 1, + x: [1, 2, 3], + y: [1, 2, 3], + marker: {color: 'blue', size: 10} + }]; + + var layout = { + xaxis: {range: [-1, 8]}, + yaxis: {range: [-1, 8]}, + showlegend: false, + transition: {duration: 10} + }; + + var traceNodes; + + function _assertTraceNodes(msg, traceNodesOrdered, ptsXY) { + var traceNodesNew = gd.querySelectorAll('.scatterlayer > .trace'); + expect(traceNodesNew.length).toBe(traceNodesOrdered.length, 'same # of traces - ' + msg); + + for(var i = 0; i < traceNodesNew.length; i++) { + var node = traceNodesOrdered[i]; + + expect(traceNodesNew[i]).toBe(node, 'same trace node ' + i + ' - ' + msg); + + var pt0 = node.querySelector('.points > path'); + var pt0XY = Drawing.getTranslate(pt0); + expect(pt0XY.x).toBeCloseTo(ptsXY[i][0], 1, 'pt' + i + ' x - ' + msg); + expect(pt0XY.y).toBeCloseTo(ptsXY[i][1], 1, 'pt' + i + ' y - ' + msg); + } + } + + Plotly.react(gd, data1, layout) + .then(function() { + methods.push([gd._fullLayout._basePlotModules[0], 'plot']); + methods.push([gd._fullLayout._basePlotModules[0], 'transitionAxes']); + addSpies(); + + traceNodes = gd.querySelectorAll('.scatterlayer > .trace'); + _assertTraceNodes('base', traceNodes, [[360, 90], [120, 210]]); + }) + .then(function() { return Plotly.react(gd, data2, layout); }) + .then(function() { + var msg = 'transition into data2'; + assertSpies(msg, [ + [Plots, 'transitionFromReact', 1], + [gd._fullLayout._basePlotModules[0], 'plot', 2], + [Registry, 'call', 1], + [gd._fullLayout._basePlotModules[0], 'transitionAxes', 0] + ]); + + // N.B. traceNodes[1] is gone, but traceNodes[0] is the same + _assertTraceNodes(msg, [traceNodes[0]], [[120, 210]]); + }) + .then(function() { return Plotly.react(gd, data1, layout); }) + .then(function() { + var msg = 'transition back to data1'; + assertSpies(msg, [ + [Plots, 'transitionFromReact', 1], + [Registry, 'call', 1], + [gd._fullLayout._basePlotModules[0], 'plot', 2], + [gd._fullLayout._basePlotModules[0], 'transitionAxes', 0] + ]); + + // N.B. we have a "new" traceNodes[1] here, + // the old one get removed from the DOM when transitioning into data2 + var traceNodesNew = gd.querySelectorAll('.scatterlayer > .trace'); + _assertTraceNodes(msg, [traceNodes[0], traceNodesNew[1]], [[360, 90], [120, 210]]); + }) + .catch(failTest) + .then(done); + }); +});