From 0d34b05aa6e9c77e7010ecdb1c099323a90f462c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Mon, 5 Nov 2018 17:02:51 -0500 Subject: [PATCH 01/20] add `anim:true` info to valObjects of animatable scatter attributes --- src/components/colorscale/attributes.js | 6 ++++++ src/plots/attributes.js | 1 + src/traces/scatter/attributes.js | 16 ++++++++++++++-- test/jasmine/bundle_tests/plotschema_test.js | 2 +- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/components/colorscale/attributes.js b/src/components/colorscale/attributes.js index a0df770a15b..cdf220827bd 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/plots/attributes.js b/src/plots/attributes.js index 653176fd260..9ffd6a45abc 100644 --- a/src/plots/attributes.js +++ b/src/plots/attributes.js @@ -79,6 +79,7 @@ module.exports = { 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/traces/scatter/attributes.js b/src/traces/scatter/attributes.js index b865858ad66..dae39e2ef70 100644 --- a/src/traces/scatter/attributes.js +++ b/src/traces/scatter/attributes.js @@ -21,6 +21,7 @@ module.exports = { x: { valType: 'data_array', editType: 'calc+clearAxisTypes', + anim: true, description: 'Sets the x coordinates.' }, x0: { @@ -28,6 +29,7 @@ module.exports = { dflt: 0, role: 'info', editType: 'calc+clearAxisTypes', + anim: true, description: [ 'Alternate to `x`.', 'Builds a linear space of x coordinates.', @@ -40,6 +42,7 @@ module.exports = { dflt: 1, role: 'info', editType: 'calc', + anim: true, description: [ 'Sets the x coordinate step.', 'See `x0` for more info.' @@ -48,6 +51,7 @@ module.exports = { y: { valType: 'data_array', editType: 'calc+clearAxisTypes', + anim: true, description: 'Sets the y coordinates.' }, y0: { @@ -55,6 +59,7 @@ module.exports = { dflt: 0, role: 'info', editType: 'calc+clearAxisTypes', + anim: true, description: [ 'Alternate to `y`.', 'Builds a linear space of y coordinates.', @@ -67,6 +72,7 @@ module.exports = { dflt: 1, role: 'info', editType: 'calc', + anim: true, description: [ 'Sets the y coordinate step.', 'See `y0` for more info.' @@ -208,6 +214,7 @@ module.exports = { valType: 'color', role: 'style', editType: 'style', + anim: true, description: 'Sets the line color.' }, width: { @@ -216,6 +223,7 @@ module.exports = { dflt: 2, role: 'style', editType: 'style', + anim: true, description: 'Sets the line width (in px).' }, shape: { @@ -314,6 +322,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,', @@ -343,6 +352,7 @@ module.exports = { arrayOk: true, role: 'style', editType: 'style', + anim: true, description: 'Sets the marker opacity.' }, size: { @@ -352,6 +362,7 @@ module.exports = { arrayOk: true, role: 'style', editType: 'calc', + anim: true, description: 'Sets the marker size (in px).' }, maxdisplayed: { @@ -409,11 +420,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: { @@ -442,7 +454,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 825c537ecc3..80f3b029d79 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' ]); From 1087b8e2583b45dc8cb886764f9af67bd4c11568 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Mon, 5 Nov 2018 17:03:23 -0500 Subject: [PATCH 02/20] add `anim:true` to cartesian axis range attribute declarations --- src/plots/cartesian/layout_attributes.js | 5 +++-- src/plots/gl3d/layout/axis_attributes.js | 9 +++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/plots/cartesian/layout_attributes.js b/src/plots/cartesian/layout_attributes.js index 404f023eb71..b08340ae08b 100644 --- a/src/plots/cartesian/layout_attributes.js +++ b/src/plots/cartesian/layout_attributes.js @@ -107,11 +107,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/gl3d/layout/axis_attributes.js b/src/plots/gl3d/layout/axis_attributes.js index 78309a1deb5..c5fd116b4ab 100644 --- a/src/plots/gl3d/layout/axis_attributes.js +++ b/src/plots/gl3d/layout/axis_attributes.js @@ -13,7 +13,6 @@ var axesAttrs = require('../../cartesian/layout_attributes'); var extendFlat = require('../../../lib/extend').extendFlat; var overrideAll = require('../../../plot_api/edit_types').overrideAll; - module.exports = overrideAll({ visible: axesAttrs.visible, showspikes: { @@ -77,7 +76,13 @@ module.exports = overrideAll({ type: axesAttrs.type, 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, From 78c38406251b3cdabef3d6a927179afb875fa6b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Mon, 5 Nov 2018 17:04:43 -0500 Subject: [PATCH 03/20] add layout.transition attribute ... which will be used to set transition options during Plotly.react updates. --- src/plots/animation_attributes.js | 2 ++ src/plots/layout_attributes.js | 11 ++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/plots/animation_attributes.js b/src/plots/animation_attributes.js index 3dd8645e6ad..c5c305ad72f 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,6 +117,7 @@ module.exports = { 'bounce-in-out' ], role: 'info', + editType: 'none', description: 'The easing function used for the transition' }, } diff --git a/src/plots/layout_attributes.js b/src/plots/layout_attributes.js index 103a0b234f7..6aee748cbe4 100644 --- a/src/plots/layout_attributes.js +++ b/src/plots/layout_attributes.js @@ -9,7 +9,9 @@ 'use strict'; var fontAttrs = require('./font_attributes'); +var animationAttrs = require('./animation_attributes'); var colorAttrs = require('../components/color/attributes'); +var extendFlat = require('../lib/extend').extendFlat; var globalFont = fontAttrs({ editType: 'calc', @@ -253,5 +255,12 @@ module.exports = { description: 'Sets the color of the active or hovered on icons in the modebar.' }, editType: 'modebar' - } + }, + + transition: extendFlat({}, animationAttrs.transition, { + description: [ + 'Sets transition options used during Plotly.react updates.' + ].join(' '), + editType: 'none' + }) }; From 5010de0a041e1635e25697daa53f83265277639f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Mon, 5 Nov 2018 17:12:11 -0500 Subject: [PATCH 04/20] track axes w/ autorange:true / altered ranges during react diffing - to (in next commit) efficiently compute axis range transitions - potentially target axis range react update (like we currently do in relayout) --- src/plot_api/plot_api.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index dfc66dd9868..0ca0af5a52c 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2466,6 +2466,8 @@ function diffData(gd, oldFullData, newFullData, immutable) { function diffLayout(gd, oldFullLayout, newFullLayout, immutable) { var flags = editTypes.layoutFlags(); flags.arrays = {}; + flags.rangesAltered = {}; + flags.autorangedAxes = {}; function getLayoutValObject(parts) { return PlotSchema.getLayoutValObject(newFullLayout, parts); @@ -2503,6 +2505,11 @@ function getDiffFlags(oldContainer, newContainer, outerparts, opts) { return; } editTypes.update(flags, valObject); + + // track cartesian axes with altered ranges + if(AX_RANGE_RE.test(astr) || AX_AUTORANGE_RE.test(astr)) { + flags.rangesAltered[outerparts[0]] = 1; + } } function valObjectCanBeDataArray(valObject) { @@ -2515,6 +2522,13 @@ function getDiffFlags(oldContainer, newContainer, outerparts, opts) { var oldVal = oldContainer[key]; var newVal = newContainer[key]; + var parts = outerparts.concat(key); + astr = parts.join('.'); + + // track auto-ranged cartesian axes, changed or not + if(AX_AUTORANGE_RE.test(astr) && newVal === true) { + flags.autorangedAxes[outerparts[0]] = 1; + } if(key.charAt(0) === '_' || typeof oldVal === 'function' || oldVal === newVal) continue; @@ -2530,7 +2544,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. From 1cb0d5f6712d2b46ff6e4ebe3352392fde3d7cc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Mon, 5 Nov 2018 17:17:32 -0500 Subject: [PATCH 05/20] first cut Plotly.react transitions - temporarily add Plots.transitions2 & Cartesian.transitionAxes2 - call Plots.transition2 from Plotly.react when at one animatable attribute has changed AND 'layout.transition` is set by user - 'redraw' after transition iff not all changed attributer are animatable - handle simultaneous trace + layout updates the same way as Plotly.animate - special handling for 'datarevision' diff'ing --- src/plot_api/plot_api.js | 51 +++++- src/plots/cartesian/index.js | 3 +- src/plots/cartesian/transition_axes.js | 187 +++++++++++++++++++++- src/plots/plots.js | 209 +++++++++++++++++++++++++ 4 files changed, 440 insertions(+), 10 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 0ca0af5a52c..c347f296de9 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2336,9 +2336,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 @@ -2368,7 +2370,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.transition2(gd, restyleFlags, relayoutFlags, oldFullLayout); + }); + } + else if(restyleFlags.fullReplot || relayoutFlags.layoutReplot || configChanged) { gd._fullLayout._skipDefaults = true; seq.push(exports.plot); } @@ -2421,7 +2435,7 @@ exports.react = function(gd, data, layout, config) { }; -function diffData(gd, oldFullData, newFullData, immutable) { +function diffData(gd, oldFullData, newFullData, immutable, transition, newDataRevision) { if(oldFullData.length !== newFullData.length) { return { fullReplot: true, @@ -2441,10 +2455,11 @@ 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++) { @@ -2463,7 +2478,7 @@ function diffData(gd, oldFullData, newFullData, immutable) { return flags; } -function diffLayout(gd, oldFullLayout, newFullLayout, immutable) { +function diffLayout(gd, oldFullLayout, newFullLayout, immutable, transition) { var flags = editTypes.layoutFlags(); flags.arrays = {}; flags.rangesAltered = {}; @@ -2477,6 +2492,7 @@ function diffLayout(gd, oldFullLayout, newFullLayout, immutable) { getValObject: getLayoutValObject, flags: flags, immutable: immutable, + transition: transition, gd: gd }; @@ -2490,7 +2506,7 @@ function diffLayout(gd, oldFullLayout, newFullLayout, immutable) { } function getDiffFlags(oldContainer, newContainer, outerparts, opts) { - var valObject, key; + var valObject, key, astr; var getValObject = opts.getValObject; var flags = opts.flags; @@ -2506,10 +2522,24 @@ function getDiffFlags(oldContainer, newContainer, outerparts, opts) { } editTypes.update(flags, valObject); + // track animatable changes + if(opts.transition) { + if(flags.anim === 'all' && !valObject.anim) { + flags.anim = 'some'; + } else if(!flags.anim && valObject.anim) { + flags.anim = 'all'; + } + } + // 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) { @@ -2518,7 +2548,7 @@ 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]; @@ -2614,6 +2644,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/cartesian/index.js b/src/plots/cartesian/index.js index 255b2a5d837..8f9b929a0b4 100644 --- a/src/plots/cartesian/index.js +++ b/src/plots/cartesian/index.js @@ -45,7 +45,8 @@ exports.layoutAttributes = require('./layout_attributes'); exports.supplyLayoutDefaults = require('./layout_defaults'); -exports.transitionAxes = require('./transition_axes'); +exports.transitionAxes = require('./transition_axes').transitionAxes; +exports.transitionAxes2 = require('./transition_axes').transitionAxes2; exports.finalizeSubplots = function(layoutIn, layoutOut) { var subplots = layoutOut._subplots; diff --git a/src/plots/cartesian/transition_axes.js b/src/plots/cartesian/transition_axes.js index 4a21331ba97..c66135146c5 100644 --- a/src/plots/cartesian/transition_axes.js +++ b/src/plots/cartesian/transition_axes.js @@ -15,7 +15,7 @@ var Drawing = require('../../components/drawing'); var Axes = require('./axes'); var axisRegex = require('./constants').attrRegex; -module.exports = function transitionAxes(gd, newLayout, transitionOpts, makeOnCompleteCallback) { +function transitionAxes(gd, newLayout, transitionOpts, makeOnCompleteCallback) { var fullLayout = gd._fullLayout; var axes = []; @@ -323,4 +323,189 @@ module.exports = function transitionAxes(gd, newLayout, transitionOpts, makeOnCo raf = window.requestAnimationFrame(doFrame); return Promise.resolve(); +} + +function transitionAxes2(gd, edits, transitionOpts, makeOnCompleteCallback) { + var fullLayout = gd._fullLayout; + + function ticksAndAnnotations(xa, ya) { + var activeAxIds = [xa._id, ya._id]; + var i; + + for(i = 0; i < activeAxIds.length; i++) { + Axes.doTicksSingle(gd, activeAxIds[i], 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); + } + + function unsetSubplotTransform(plotinfo) { + var xa = plotinfo.xaxis; + var ya = plotinfo.yaxis; + + fullLayout._defs.select('#' + plotinfo.clipId + '> rect') + .call(Drawing.setTranslate, 0, 0) + .call(Drawing.setScale, 1, 1); + + plotinfo.plot + .call(Drawing.setTranslate, xa._offset, ya._offset) + .call(Drawing.setScale, 1, 1); + + var traceGroups = plotinfo.plot.selectAll('.scatterlayer .trace'); + + // This is specifically directed at scatter traces, applying an inverse + // scale to individual points to counteract the scale of the trace + // as a whole: + traceGroups.selectAll('.point') + .call(Drawing.setPointGroupScale, 1, 1); + traceGroups.selectAll('.textpoint') + .call(Drawing.setTextPointsScale, 1, 1); + traceGroups + .call(Drawing.hideOutsideRangePoints, plotinfo); + } + + function updateSubplot(edit, progress) { + var plotinfo = edit.plotinfo; + var xa1 = plotinfo.xaxis; + var ya1 = plotinfo.yaxis; + + var xr0 = edit.xr0; + var xr1 = edit.xr1; + var xlen = xa1._length; + var yr0 = edit.yr0; + var yr1 = edit.yr1; + var ylen = ya1._length; + + var editX = xr0[0] !== xr1[0] || xr0[1] !== xr1[1]; + var editY = yr0[0] !== yr1[0] || yr0[1] !== yr1[1]; + var viewBox = []; + + 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); + xa1.range[0] = xr0[0] * (1 - progress) + progress * xr1[0]; + xa1.range[1] = xr0[1] * (1 - progress) + progress * xr1[1]; + } else { + viewBox[0] = 0; + viewBox[2] = xlen; + } + + 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); + ya1.range[0] = yr0[0] * (1 - progress) + progress * yr1[0]; + ya1.range[1] = yr0[1] * (1 - progress) + progress * yr1[1]; + } else { + viewBox[1] = 0; + viewBox[3] = ylen; + } + + ticksAndAnnotations(plotinfo.xaxis, plotinfo.yaxis); + + 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 = xa1._offset - fracDx; + var plotDy = ya1._offset - fracDy; + + plotinfo.clipRect + .call(Drawing.setTranslate, clipDx, clipDy) + .call(Drawing.setScale, 1 / xScaleFactor, 1 / yScaleFactor); + + 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(plotinfo.zoomScalePts, 1 / xScaleFactor, 1 / yScaleFactor); + Drawing.setTextPointsScale(plotinfo.zoomScaleTxt, 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 = {}; + var k; + + for(k in edits) { + var edit = edits[k]; + aobj[edit.plotinfo.xaxis._name + '.range'] = edit.xr1.slice(); + 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(k in edits) { + unsetSubplotTransform(edits[k].plotinfo); + } + }); + } + + var t1, t2, raf; + var easeFn = d3.ease(transitionOpts.easing); + + gd._transitionData._interruptCallbacks.push(function() { + window.cancelAnimationFrame(raf); + raf = null; + return transitionComplete(); + }); + + function doFrame() { + t2 = Date.now(); + + var tInterp = Math.min(1, (t2 - t1) / transitionOpts.duration); + var progress = easeFn(tInterp); + + for(var k in edits) { + updateSubplot(edits[k], 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(); +} + +module.exports = { + transitionAxes: transitionAxes, + transitionAxes2: transitionAxes2 }; diff --git a/src/plots/plots.js b/src/plots/plots.js index a6d222da003..b16400d0d54 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -1371,6 +1371,12 @@ plots.supplyLayoutGlobalDefaults = function(layoutIn, layoutOut, formatObj) { coerce('modebar.color', Color.addOpacity(modebarDefaultColor, 0.3)); coerce('modebar.activecolor', Color.addOpacity(modebarDefaultColor, 0.7)); + // do not include defaults in fullLayout when users do not set transition + if(Lib.isPlainObject(layoutIn.transition)) { + coerce('transition.duration'); + coerce('transition.easing'); + } + Registry.getComponentMethod( 'calendars', 'handleDefaults' @@ -2427,6 +2433,209 @@ plots.transition = function(gd, data, layout, traces, frameOpts, transitionOpts) }); }; +/** + * Transition used in Plotly.react + */ +plots.transition2 = function(gd, restyleFlags, relayoutFlags, oldFullLayout) { + var aborted = false; + var redraw = true; + var edits; + + function prepareTransitions() { + var fullLayout = gd._fullLayout; + var subplots = fullLayout._plots; + var rangesAltered = relayoutFlags.rangesAltered; + var autorangedAxes = relayoutFlags.autorangedAxes; + + // no need to redraw at end of transition, + // if all changes are animatable + redraw = false; + if(restyleFlags.anim === 'some') redraw = true; + if(relayoutFlags.anim === 'some') redraw = true; + + edits = {}; + for(var k in subplots) { + var plotinfo = subplots[k]; + var xa = plotinfo.xaxis; + var ya = plotinfo.yaxis; + + if(rangesAltered[xa._name] || rangesAltered[ya._name] || + autorangedAxes[xa._name] || autorangedAxes[ya._name] + ) { + edits[k] = { + plotinfo: plotinfo, + xr0: oldFullLayout[xa._name].range.slice(), + yr0: oldFullLayout[ya._name].range.slice(), + xr1: xa.range.slice(), + yr1: ya.range.slice() + }; + } + } + + 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(); + } + } + + function executeTransitions() { + gd.emit('plotly_transitioning', []); + + return new Promise(function(resolve) { + var fullData = gd._fullData; + var fullLayout = gd._fullLayout; + var transitionOpts = fullLayout.transition; + var basePlotModules = fullLayout._basePlotModules; + var i; + + // 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; + } + + // 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. + gd._transitionData._interruptCallbacks.push(function() { + aborted = true; + }); + + if(redraw) { + gd._transitionData._interruptCallbacks.push(function() { + return Registry.call('redraw', gd); + }); + } + + // Emit this and make sure it happens last: + gd._transitionData._interruptCallbacks.push(function() { + gd.emit('plotly_transitioninterrupted', []); + }); + + // 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); + } + }; + } + + // Here handle the exception that we refuse to animate traces and axes at the same + // time. In other words, if there's an axis transition, then set the data transition + // to instantaneous. + var traceTransitionOpts; + var transitionedTraces = []; + + if(Object.keys(edits).length) { + for(i = 0; i < basePlotModules.length; i++) { + if(basePlotModules[i].transitionAxes2) { + basePlotModules[i].transitionAxes2(gd, edits, transitionOpts, makeCallback); + } + } + + 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 < fullData.length; i++) { + transitionedTraces.push(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. + for(i = 0; i < basePlotModules.length; i++) { + basePlotModules[i].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) { + // This a simple workaround for tests which purge the graph before animations + // have completed. That's not a very common case, so this is the simplest + // fix. + if(!gd._transitionData) return; + + flushCallbacks(gd._transitionData._interruptCallbacks); + + return Promise.resolve().then(function() { + if(redraw) { + return Registry.call('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() { + // Fail-safe against purged plot: + if(!gd._transitionData) return; + + // 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; + + return executeCallbacks(gd._transitionData._interruptCallbacks); + } + + var seq = [ + plots.previousPromises, + interruptPreviousTransitions, + prepareTransitions, + plots.rehover, + executeTransitions + ]; + + var transitionStarting = Lib.syncOrAsync(seq, gd); + + if(!transitionStarting || !transitionStarting.then) { + transitionStarting = Promise.resolve(); + } + + return transitionStarting.then(function() { + return gd; + }); +}; + plots.doCalcdata = function(gd, traces) { var axList = axisIDs.list(gd), fullData = gd._fullData, From 209d7b449cb47658bce45c47e426195716f4f022 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Wed, 7 Nov 2018 16:40:53 -0500 Subject: [PATCH 06/20] fixup react 'anim' flag logic --- src/plot_api/plot_api.js | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index c347f296de9..f4237281766 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2445,6 +2445,9 @@ function diffData(gd, oldFullData, newFullData, immutable, transition, newDataRe var flags = editTypes.traceFlags(); flags.arrays = {}; + flags.nChanges = 0; + flags.nChangesAnim = 0; + var i, trace; function getTraceValObject(parts) { @@ -2456,6 +2459,8 @@ function diffData(gd, oldFullData, newFullData, immutable, transition, newDataRe flags: flags, immutable: immutable, transition: transition, + nChanges: 0, + nChangesAnim: 0, newDataRevision: newDataRevision, gd: gd }; @@ -2475,6 +2480,10 @@ function diffData(gd, oldFullData, newFullData, immutable, transition, newDataRe flags.fullReplot = true; } + if(transition && flags.nChanges && flags.nChangesAnim) { + flags.anim = flags.nChanges === flags.nChangesAnim ? 'all' : 'some'; + } + return flags; } @@ -2483,6 +2492,8 @@ function diffLayout(gd, oldFullLayout, newFullLayout, immutable, transition) { flags.arrays = {}; flags.rangesAltered = {}; flags.autorangedAxes = {}; + flags.nChanges = 0; + flags.nChangesAnim = 0; function getLayoutValObject(parts) { return PlotSchema.getLayoutValObject(newFullLayout, parts); @@ -2502,6 +2513,10 @@ function diffLayout(gd, oldFullLayout, newFullLayout, immutable, transition) { flags.layoutReplot = true; } + if(transition && flags.nChanges && flags.nChangesAnim) { + flags.anim = flags.nChanges === flags.nChangesAnim ? 'all' : 'some'; + } + return flags; } @@ -2521,14 +2536,11 @@ function getDiffFlags(oldContainer, newContainer, outerparts, opts) { return; } editTypes.update(flags, valObject); + flags.nChanges++; // track animatable changes - if(opts.transition) { - if(flags.anim === 'all' && !valObject.anim) { - flags.anim = 'some'; - } else if(!flags.anim && valObject.anim) { - flags.anim = 'all'; - } + if(opts.transition && valObject.anim) { + flags.nChangesAnim++; } // track cartesian axes with altered ranges From 472d0d1e721421f873547bc446b45b7a18d644e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Wed, 7 Nov 2018 16:41:15 -0500 Subject: [PATCH 07/20] no need to call _module.plot during layout-only transitions --- src/plots/plots.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/plots/plots.js b/src/plots/plots.js index b16400d0d54..58340a96a70 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -2573,8 +2573,10 @@ plots.transition2 = function(gd, restyleFlags, relayoutFlags, oldFullLayout) { // 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. - for(i = 0; i < basePlotModules.length; i++) { - basePlotModules[i].plot(gd, transitionedTraces, traceTransitionOpts, makeCallback); + if(restyleFlags.anim) { + for(i = 0; i < basePlotModules.length; i++) { + basePlotModules[i].plot(gd, transitionedTraces, traceTransitionOpts, makeCallback); + } } // If nothing else creates a callback, then this will trigger the completion in the next tick: From badfeda61f7b3183239fcd5b458bf55d45a7d08d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Wed, 7 Nov 2018 16:41:28 -0500 Subject: [PATCH 08/20] add react+transitions tests --- test/jasmine/tests/transition_test.js | 459 ++++++++++++++++++++++++++ 1 file changed, 459 insertions(+) diff --git a/test/jasmine/tests/transition_test.js b/test/jasmine/tests/transition_test.js index 60095b95815..ffce5e9ef8e 100644 --- a/test/jasmine/tests/transition_test.js +++ b/test/jasmine/tests/transition_test.js @@ -2,6 +2,7 @@ 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 createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); @@ -266,3 +267,461 @@ 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, 'transition2'], + [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, 'transition2', 0] + ]); + }) + .then(function() { + data[0].marker = {color: 'red'}; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('no *transition* set', [ + [Plots, 'transition2', 0] + ]); + }) + .then(function() { + layout.transition = {duration: 10}; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('with *transition* set, no changes', [ + [Plots, 'transition2', 0] + ]); + }) + .then(function() { + data[0].marker = {color: 'blue'}; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('with *transition* set and changes', [ + [Plots, 'transition2', 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, 'transition2', 0] + ]); + }) + .then(function() { + data[0].marker = {color: 'red'}; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('animatable trace change', [ + [Plots, 'transition2', 1] + ]); + }) + .then(function() { + data[0].name = 'TRACE'; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('non-animatable trace change', [ + [Plots, 'transition2', 0] + ]); + }) + .then(function() { + layout.xaxis = {range: [-1, 10]}; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('animatable layout change', [ + [Plots, 'transition2', 1] + ]); + }) + .then(function() { + layout.title = 'FIGURE'; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('non-animatable layout change', [ + [Plots, 'transition2', 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, 'transition2', 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, 'transition2', 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, 'transition2', 0] + ]); + }) + .then(function() { + data[0].marker = {color: 'blue'}; + return Plotly.react(gd, data, layout, config); + }) + .then(function() { + assertSpies('no config change', [ + [Plots, 'transition2', 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, 'transition2', 0], + [Registry, 'call', 0] + ]); + }) + .then(function() { + data[0].marker.color = 'red'; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('redraw NOT required', [ + [Plots, 'transition2', 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, 'transition2', 1], + [Registry, 'call', [['redraw', gd]]] + ]); + }) + .catch(failTest) + .then(done); + }); + + it('should only transition the layout when both traces and layout have animatable changes', 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], 'transitionAxes2']); + addSpies(); + }) + .then(function() { + data[0].marker = {color: 'red'}; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('just trace transition', [ + [Plots, 'transition2', 1], + [gd._fullLayout._basePlotModules[0], 'plot', 1], + [gd._fullLayout._basePlotModules[0], 'transitionAxes2', 0] + ]); + }) + .then(function() { + layout.xaxis.range = [-2, 2]; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('just layout transition', [ + [Plots, 'transition2', 1], + [gd._fullLayout._basePlotModules[0], 'transitionAxes2', 1], + // one _module.plot call from the relayout at end of axis transition + [Registry, 'call', [['relayout', gd, {'xaxis.range': [-2, 2], 'yaxis.range': [0, 3]}]]], + [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, 'transition2', 1], + [gd._fullLayout._basePlotModules[0], 'transitionAxes2', 1], + [Registry, 'call', [['relayout', gd, {'xaxis.range': [-1, 1], 'yaxis.range': [0, 3]}]]], + [gd._fullLayout._basePlotModules[0], 'plot', [ + // one instantaneous transition options to halt + // other trace transitions (if any) + [gd, null, {duration: 0, easing: 'cubic-in-out'}, 'function'], + // one _module.plot call from the relayout at end of axis transition + [gd] + ]], + ]); + }) + .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, 'transition2', 0] + ]); + }) + .then(function() { + data[0].y = dataArrayToggle(); + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('picks data_array changes with datarevision unset', [ + [Plots, 'transition2', 1] + ]); + }) + .then(function() { + data[0].y = dataArrayToggle(); + layout.datarevision = '1'; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('picks up datarevision changes', [ + [Plots, 'transition2', 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, 'transition2', 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, 'transition2', 1] + ]); + }) + .catch(failTest) + .then(done); + }); + + it('should transition layout when one or more axes has *autorange:true*', 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], 'transitionAxes2']); + addSpies(); + assertAxAutorange('axes are autorange:true by default', true); + }) + .then(function() { + data[0].marker = {size: 30}; + return Plotly.react(gd, data, layout); + }) + .then(function() { + assertSpies('must transition autoranged axes, not the traces', [ + [Plots, 'transition2', 1], + [gd._fullLayout._basePlotModules[0], 'transitionAxes2', 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'}, '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, 'transition2', 1], + [gd._fullLayout._basePlotModules[0], 'transitionAxes2', 0], + [gd._fullLayout._basePlotModules[0], 'plot', [ + // called from Plots.transition2 + [gd, [0], {duration: 10, easing: 'cubic-in-out'}, 'function'], + ]], + ]); + assertAxAutorange('axes are still 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); + }); +}); From 3c51bf3c9dd57096382490325f04b25c2fdf9695 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Wed, 9 Jan 2019 14:42:02 -0500 Subject: [PATCH 09/20] adapt Axes.transition2 to new axes draw routines --- src/plots/cartesian/transition_axes.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/plots/cartesian/transition_axes.js b/src/plots/cartesian/transition_axes.js index 50f4ef4ba8d..c66f3a3f190 100644 --- a/src/plots/cartesian/transition_axes.js +++ b/src/plots/cartesian/transition_axes.js @@ -329,9 +329,8 @@ function transitionAxes2(gd, edits, transitionOpts, makeOnCompleteCallback) { var activeAxIds = [xa._id, ya._id]; var i; - for(i = 0; i < activeAxIds.length; i++) { - Axes.doTicksSingle(gd, activeAxIds[i], true); - } + Axes.drawOne(gd, xa, {skipTitle: true}); + Axes.drawOne(gd, ya, {skipTitle: true}); function redrawObjs(objArray, method, shortCircuit) { for(i = 0; i < objArray.length; i++) { From 19e2bb2da87d44d37610cada25b1dadddd7b02be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Wed, 9 Jan 2019 15:34:17 -0500 Subject: [PATCH 10/20] lint --- src/plot_api/plot_api.js | 2 -- src/plots/plots.js | 6 +++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 29fbc0270c7..93446ac07e0 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2842,8 +2842,6 @@ function diffData(gd, oldFullData, newFullData, immutable, transition, newDataRe flags: flags, immutable: immutable, transition: transition, - nChanges: 0, - nChangesAnim: 0, newDataRevision: newDataRevision, gd: gd }; diff --git a/src/plots/plots.js b/src/plots/plots.js index e5ba37ca1ce..8ba37ea63a0 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -2645,7 +2645,7 @@ plots.transition2 = function(gd, restyleFlags, relayoutFlags, oldFullLayout) { // time. In other words, if there's an axis transition, then set the data transition // to instantaneous. var traceTransitionOpts; - var transitionedTraces = []; + var transitionedTraces; if(Object.keys(edits).length) { for(i = 0; i < basePlotModules.length; i++) { @@ -2654,13 +2654,13 @@ plots.transition2 = function(gd, restyleFlags, relayoutFlags, oldFullLayout) { } } - traceTransitionOpts = Lib.extendFlat({}, transitionOpts); - traceTransitionOpts.duration = 0; // This means do not transition traces, // this happens on layout-only (e.g. axis range) animations + traceTransitionOpts = Lib.extendFlat({}, transitionOpts, {duration: 0}); transitionedTraces = null; } else { traceTransitionOpts = transitionOpts; + transitionedTraces = []; for(i = 0; i < fullData.length; i++) { transitionedTraces.push(i); } From c087b0abe30b16344aa62f1b91027bbb52c610b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Wed, 9 Jan 2019 15:52:08 -0500 Subject: [PATCH 11/20] improved transitionAxes yes/no logic - use old vs new (after subroutines.doAutoRangeAndConstraints) range values to determine if we call transitionAxes or not, instead of "just" calling transitionAxes for all subplots with altered *and* autoranged axes. --- src/plot_api/plot_api.js | 6 --- src/plots/plots.js | 21 +++++--- test/jasmine/tests/transition_test.js | 73 ++++++++++++++++++++++++++- 3 files changed, 85 insertions(+), 15 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 93446ac07e0..4a8c9aadf0f 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2872,7 +2872,6 @@ function diffLayout(gd, oldFullLayout, newFullLayout, immutable, transition) { var flags = editTypes.layoutFlags(); flags.arrays = {}; flags.rangesAltered = {}; - flags.autorangedAxes = {}; flags.nChanges = 0; flags.nChangesAnim = 0; @@ -2948,11 +2947,6 @@ function getDiffFlags(oldContainer, newContainer, outerparts, opts) { var parts = outerparts.concat(key); astr = parts.join('.'); - // track auto-ranged cartesian axes, changed or not - if(AX_AUTORANGE_RE.test(astr) && newVal === true) { - flags.autorangedAxes[outerparts[0]] = 1; - } - if(key.charAt(0) === '_' || typeof oldVal === 'function' || oldVal === newVal) continue; // FIXME: ax.tick0 and dtick get filled in during plotting (except for geo subplots), diff --git a/src/plots/plots.js b/src/plots/plots.js index 8ba37ea63a0..6cc89747f6a 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -2541,8 +2541,6 @@ plots.transition2 = function(gd, restyleFlags, relayoutFlags, oldFullLayout) { function prepareTransitions() { var fullLayout = gd._fullLayout; var subplots = fullLayout._plots; - var rangesAltered = relayoutFlags.rangesAltered; - var autorangedAxes = relayoutFlags.autorangedAxes; // no need to redraw at end of transition, // if all changes are animatable @@ -2555,16 +2553,23 @@ plots.transition2 = function(gd, restyleFlags, relayoutFlags, oldFullLayout) { 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(); - if(rangesAltered[xa._name] || rangesAltered[ya._name] || - autorangedAxes[xa._name] || autorangedAxes[ya._name] + xa.setScale(); + ya.setScale(); + + if(xr0[0] !== xr1[0] || xr0[1] !== xr1[1] || + yr0[0] !== yr1[0] || yr0[1] !== yr1[1] ) { edits[k] = { plotinfo: plotinfo, - xr0: oldFullLayout[xa._name].range.slice(), - yr0: oldFullLayout[ya._name].range.slice(), - xr1: xa.range.slice(), - yr1: ya.range.slice() + xr0: xr0, + yr0: yr0, + xr1: xr1, + yr1: yr1 }; } } diff --git a/test/jasmine/tests/transition_test.js b/test/jasmine/tests/transition_test.js index ffce5e9ef8e..6e79ac62e8e 100644 --- a/test/jasmine/tests/transition_test.js +++ b/test/jasmine/tests/transition_test.js @@ -643,7 +643,7 @@ describe('Plotly.react transitions:', function() { .then(done); }); - it('should transition layout when one or more axes has *autorange:true*', function(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}}; @@ -662,6 +662,7 @@ describe('Plotly.react transitions:', function() { 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); }) @@ -698,6 +699,76 @@ describe('Plotly.react transitions:', function() { .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.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], 'transitionAxes2']); + addSpies(); + assertAxAutorange('axes are 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, 'transition2', 1], + [gd._fullLayout._basePlotModules[0], 'transitionAxes2', 0], + [gd._fullLayout._basePlotModules[0], 'plot', 1] + ]); + assertAxAutorange('axes are 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, 'transition2', 1], + [gd._fullLayout._basePlotModules[0], 'transitionAxes2', 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, { + 'xaxis.range': [-0.12852664576802508, 2.128526645768025], + 'yaxis.range': [9.26751592356688, 20.73248407643312] + }]] + ], + [gd._fullLayout._basePlotModules[0], 'plot', [ + // one instantaneous transition options to halt + // other trace transitions (if any) + [gd, null, {duration: 0, easing: 'cubic-in-out'}, 'function'], + // one _module.plot call from the relayout at end of axis transition + [gd] + ]], + ]); + assertAxAutorange('axes are now autorange:false', false); + }) + .catch(failTest) + .then(done); + }); + it('should emit transition events', function(done) { var events = ['transitioning', 'transitioned', 'react']; var store = {}; From 4ef048f36c9fe043ed4d2cc37f5109033b1d9f7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Thu, 10 Jan 2019 15:29:58 -0500 Subject: [PATCH 12/20] make trace `uid` accept numbers --- src/plots/plots.js | 5 ++++- test/jasmine/tests/plots_test.js | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/plots/plots.js b/src/plots/plots.js index 6cc89747f6a..04648fbc465 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -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); } 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() { From 61ee4e97610105b08ab640a2197059d31eed3a2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Thu, 10 Jan 2019 16:56:55 -0500 Subject: [PATCH 13/20] make trace uid an `anim:true` attr + improve description --- src/plots/attributes.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/plots/attributes.js b/src/plots/attributes.js index 4864109e8b1..cbdf5fd3e8e 100644 --- a/src/plots/attributes.js +++ b/src/plots/attributes.js @@ -74,7 +74,13 @@ 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', From 73284f434fba9bc4e9265094dc61747ee5e0bf32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Thu, 10 Jan 2019 16:58:50 -0500 Subject: [PATCH 14/20] add test for trace object-constancy out-of-order case - making uid anim:true and allowing uid to be numbers was enough to fix this case. --- test/jasmine/tests/transition_test.js | 85 +++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/test/jasmine/tests/transition_test.js b/test/jasmine/tests/transition_test.js index 6e79ac62e8e..e1608898ca1 100644 --- a/test/jasmine/tests/transition_test.js +++ b/test/jasmine/tests/transition_test.js @@ -3,6 +3,7 @@ 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'); @@ -795,4 +796,88 @@ describe('Plotly.react transitions:', function() { .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], 'transitionAxes2']); + 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, 'transition2', 1], + [gd._fullLayout._basePlotModules[0], 'plot', 1], + [gd._fullLayout._basePlotModules[0], 'transitionAxes2', 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, 'transition2', 1], + [gd._fullLayout._basePlotModules[0], 'plot', 1], + [gd._fullLayout._basePlotModules[0], 'transitionAxes2', 0] + ]); + _assertTraceNodes(msg, traceNodes, [[360, 90], [120, 210]]); + }) + .catch(failTest) + .then(done); + }); }); From 33a9530e97cb36296d8be49429a22d5f17b55a3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Thu, 10 Jan 2019 17:01:36 -0500 Subject: [PATCH 15/20] allow react-transition to diff when old/new fullData lengths mismatch - should still go through calc (and hence a redraw after the transition) but Scatter.plot can handle this case fine. --- src/plot_api/plot_api.js | 14 +++-- src/plots/plots.js | 2 +- test/jasmine/tests/transition_test.js | 86 +++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 7 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 4a8c9aadf0f..614420759bf 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2819,7 +2819,7 @@ exports.react = function(gd, data, layout, config) { }; function diffData(gd, oldFullData, newFullData, immutable, transition, newDataRevision) { - if(oldFullData.length !== newFullData.length) { + if(!transition && oldFullData.length !== newFullData.length) { return { fullReplot: true, calc: true @@ -2849,12 +2849,14 @@ function diffData(gd, oldFullData, newFullData, immutable, transition, newDataRe 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) { diff --git a/src/plots/plots.js b/src/plots/plots.js index 04648fbc465..a614a806dfd 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) { diff --git a/test/jasmine/tests/transition_test.js b/test/jasmine/tests/transition_test.js index e1608898ca1..c7c54d95abf 100644 --- a/test/jasmine/tests/transition_test.js +++ b/test/jasmine/tests/transition_test.js @@ -880,4 +880,90 @@ describe('Plotly.react transitions:', function() { .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], 'transitionAxes2']); + 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, 'transition2', 1], + [Registry, 'call', 1], + [gd._fullLayout._basePlotModules[0], 'plot', 2], + [gd._fullLayout._basePlotModules[0], 'transitionAxes2', 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, 'transition2', 1], + [Registry, 'call', 1], + [gd._fullLayout._basePlotModules[0], 'plot', 2], + [gd._fullLayout._basePlotModules[0], 'transitionAxes2', 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); + }); }); From fd121f48e58c8603c5e5d26302ba32c1a8c22104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Mon, 14 Jan 2019 17:20:54 -0500 Subject: [PATCH 16/20] don't count editType:'none' edits as "changes", ... but make sure to count traces deletion as a "non-animatable" change (to make data-mismatch test still passes). --- src/plot_api/plot_api.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 614420759bf..9a13c7b6613 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2819,7 +2819,9 @@ exports.react = function(gd, data, layout, config) { }; function diffData(gd, oldFullData, newFullData, immutable, transition, newDataRevision) { - if(!transition && oldFullData.length !== newFullData.length) { + var sameTraceLength = oldFullData.length === newFullData.length; + + if(!transition && !sameTraceLength) { return { fullReplot: true, calc: true @@ -2864,7 +2866,7 @@ function diffData(gd, oldFullData, newFullData, immutable, transition, newDataRe } if(transition && flags.nChanges && flags.nChangesAnim) { - flags.anim = flags.nChanges === flags.nChangesAnim ? 'all' : 'some'; + flags.anim = flags.nChanges === flags.nChangesAnim && sameTraceLength ? 'all' : 'some'; } return flags; @@ -2918,7 +2920,10 @@ function getDiffFlags(oldContainer, newContainer, outerparts, opts) { return; } editTypes.update(flags, valObject); - flags.nChanges++; + + if(editType !== 'none') { + flags.nChanges++; + } // track animatable changes if(opts.transition && valObject.anim) { From eb7ba351271c15818feb68a535eea7b6db4f689f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Mon, 14 Jan 2019 17:23:51 -0500 Subject: [PATCH 17/20] add Axes.redrawComponents - to DRY up pan/scroll and axes transition (next commit) annotations/shapes/images redraw calls. - add ax._imgIndices stash (similar to _annIndices/_shapeIndices), to make redrawComponents loop just once over the axis list. --- src/components/images/defaults.js | 5 ++++ src/plots/cartesian/axes.js | 33 ++++++++++++++++++++++++++ src/plots/cartesian/dragbox.js | 28 ++++------------------ src/plots/cartesian/layout_defaults.js | 1 + test/jasmine/tests/annotations_test.js | 2 +- 5 files changed, 45 insertions(+), 24 deletions(-) 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/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 e8110756b0c..547dfbf21d2 100644 --- a/src/plots/cartesian/dragbox.js +++ b/src/plots/cartesian/dragbox.js @@ -490,7 +490,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 @@ -522,7 +522,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; } @@ -594,12 +594,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; @@ -627,25 +627,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_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/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']); From 1ed4e425232a8a88c6891683269c0dee83f7d79f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Mon, 14 Jan 2019 17:36:32 -0500 Subject: [PATCH 18/20] merge Plotly.animate and Plotly.react transition pathways - Plots.transition2 becomes Plots.transitionFromReact - Plots.transition and Plots.transitionFromReact are now simple wrappers around a more general _transition() internal method - transitionAxes2 is now merged into transitionAxes, the "find which axis to edit" logic in now in Plots.transition - transitionAxes now uses Axes.redrawComponents and support xa-only and ya-only edits (new react+transition tests were adapted for this) --- src/plot_api/plot_api.js | 2 +- src/plots/cartesian/index.js | 3 +- src/plots/cartesian/transition_axes.js | 429 ++++--------------------- src/plots/plots.js | 395 ++++++++++------------- test/jasmine/tests/transition_test.js | 127 ++++---- 5 files changed, 300 insertions(+), 656 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 9a13c7b6613..0e868104439 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2762,7 +2762,7 @@ exports.react = function(gd, data, layout, config) { subroutines.doAutoRangeAndConstraints(gd); seq.push(function() { - return Plots.transition2(gd, restyleFlags, relayoutFlags, oldFullLayout); + return Plots.transitionFromReact(gd, restyleFlags, relayoutFlags, oldFullLayout); }); } else if(restyleFlags.fullReplot || relayoutFlags.layoutReplot || configChanged) { diff --git a/src/plots/cartesian/index.js b/src/plots/cartesian/index.js index 3cb51d7fea0..430e01dc4ea 100644 --- a/src/plots/cartesian/index.js +++ b/src/plots/cartesian/index.js @@ -45,8 +45,7 @@ exports.layoutAttributes = require('./layout_attributes'); exports.supplyLayoutDefaults = require('./layout_defaults'); -exports.transitionAxes = require('./transition_axes').transitionAxes; -exports.transitionAxes2 = require('./transition_axes').transitionAxes2; +exports.transitionAxes = require('./transition_axes'); exports.finalizeSubplots = function(layoutIn, layoutOut) { var subplots = layoutOut._subplots; diff --git a/src/plots/cartesian/transition_axes.js b/src/plots/cartesian/transition_axes.js index c66f3a3f190..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; -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,227 +65,20 @@ function transitionAxes(gd, newLayout, transitionOpts, makeOnCompleteCallback) { .call(Drawing.hideOutsideRangePoints, subplot); } - 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.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; - - var clipDx = editX ? viewBox[0] : 0; - var clipDy = editY ? viewBox[1] : 0; - - 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 - .call(Drawing.setTranslate, clipDx, clipDy) - .call(Drawing.setScale, 1 / xScaleFactor, 1 / yScaleFactor); - - subplot.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); - } - - 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 Registry.call('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 Registry.call('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(); -} - -function transitionAxes2(gd, edits, transitionOpts, makeOnCompleteCallback) { - var fullLayout = gd._fullLayout; - - 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); - } - - function unsetSubplotTransform(plotinfo) { - var xa = plotinfo.xaxis; - var ya = plotinfo.yaxis; - - fullLayout._defs.select('#' + plotinfo.clipId + '> rect') - .call(Drawing.setTranslate, 0, 0) - .call(Drawing.setScale, 1, 1); - - plotinfo.plot - .call(Drawing.setTranslate, xa._offset, ya._offset) - .call(Drawing.setScale, 1, 1); - - var traceGroups = plotinfo.plot.selectAll('.scatterlayer .trace'); - - // This is specifically directed at scatter traces, applying an inverse - // scale to individual points to counteract the scale of the trace - // as a whole: - traceGroups.selectAll('.point') - .call(Drawing.setPointGroupScale, 1, 1); - traceGroups.selectAll('.textpoint') - .call(Drawing.setTextPointsScale, 1, 1); - traceGroups - .call(Drawing.hideOutsideRangePoints, plotinfo); - } - function updateSubplot(edit, progress) { var plotinfo = edit.plotinfo; - var xa1 = plotinfo.xaxis; - var ya1 = plotinfo.yaxis; + var xa = plotinfo.xaxis; + var ya = plotinfo.yaxis; var xr0 = edit.xr0; var xr1 = edit.xr1; - var xlen = xa1._length; + var xlen = xa._length; var yr0 = edit.yr0; var yr1 = edit.yr1; - var ylen = ya1._length; + var ylen = ya._length; - var editX = xr0[0] !== xr1[0] || xr0[1] !== xr1[1]; - var editY = yr0[0] !== yr1[0] || yr0[1] !== yr1[1]; + var editX = !!xr1; + var editY = !!yr1; var viewBox = []; if(editX) { @@ -397,8 +86,8 @@ function transitionAxes2(gd, edits, transitionOpts, makeOnCompleteCallback) { 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); - xa1.range[0] = xr0[0] * (1 - progress) + progress * xr1[0]; - xa1.range[1] = xr0[1] * (1 - progress) + progress * xr1[1]; + 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] = xlen; @@ -409,14 +98,16 @@ function transitionAxes2(gd, edits, transitionOpts, makeOnCompleteCallback) { 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); - ya1.range[0] = yr0[0] * (1 - progress) + progress * yr1[0]; - ya1.range[1] = yr0[1] * (1 - progress) + progress * yr1[1]; + 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] = ylen; } - ticksAndAnnotations(plotinfo.xaxis, plotinfo.yaxis); + 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; @@ -424,8 +115,8 @@ function transitionAxes2(gd, edits, transitionOpts, makeOnCompleteCallback) { 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 = xa1._offset - fracDx; - var plotDy = ya1._offset - fracDy; + var plotDx = xa._offset - fracDx; + var plotDy = ya._offset - fracDy; plotinfo.clipRect .call(Drawing.setTranslate, clipDx, clipDy) @@ -450,20 +141,35 @@ function transitionAxes2(gd, edits, transitionOpts, makeOnCompleteCallback) { function transitionComplete() { var aobj = {}; - var k; - for(k in edits) { - var edit = edits[k]; - aobj[edit.plotinfo.xaxis._name + '.range'] = edit.xr1.slice(); - aobj[edit.plotinfo.yaxis._name + '.range'] = edit.yr1.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(k in edits) { - unsetSubplotTransform(edits[k].plotinfo); + for(var i = 0; i < edits.length; i++) { + unsetSubplotTransform(edits[i].plotinfo); + } + }); + } + + function transitionInterrupt() { + var aobj = {}; + + 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 < edits.length; i++) { + unsetSubplotTransform(edits[i].plotinfo); } }); } @@ -474,7 +180,7 @@ function transitionAxes2(gd, edits, transitionOpts, makeOnCompleteCallback) { gd._transitionData._interruptCallbacks.push(function() { window.cancelAnimationFrame(raf); raf = null; - return transitionComplete(); + return transitionInterrupt(); }); function doFrame() { @@ -483,8 +189,8 @@ function transitionAxes2(gd, edits, transitionOpts, makeOnCompleteCallback) { var tInterp = Math.min(1, (t2 - t1) / transitionOpts.duration); var progress = easeFn(tInterp); - for(var k in edits) { - updateSubplot(edits[k], progress); + for(var i = 0; i < edits.length; i++) { + updateSubplot(edits[i], progress); } if(t2 - t1 > transitionOpts.duration) { @@ -499,9 +205,4 @@ function transitionAxes2(gd, edits, transitionOpts, makeOnCompleteCallback) { raf = window.requestAnimationFrame(doFrame); return Promise.resolve(); -} - -module.exports = { - transitionAxes: transitionAxes, - transitionAxes2: transitionAxes2 }; diff --git a/src/plots/plots.js b/src/plots/plots.js index a614a806dfd..57f29934c57 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -2295,10 +2295,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 @@ -2311,17 +2310,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; @@ -2367,191 +2364,115 @@ plots.transition = function(gd, data, layout, traces, frameOpts, transitionOpts) plots.supplyDefaults(gd); plots.doCalcdata(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() { - gd.emit('plotly_transitioning', []); - - 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; - } - + var newLayout = Lib.expandObjectPaths(layout); - // 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. - gd._transitionData._interruptCallbacks.push(function() { - aborted = true; - }); + if(newLayout) { + var subplots = gd._fullLayout._plots; - if(frameOpts.redraw) { - gd._transitionData._interruptCallbacks.push(function() { - return Registry.call('redraw', gd); - }); - } + 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(); - // Emit this and make sure it happens last: - gd._transitionData._interruptCallbacks.push(function() { - gd.emit('plotly_transitioninterrupted', []); - }); - - // 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 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 traceTransitionOpts; - var j; - var basePlotModules = gd._fullLayout._basePlotModules; - var hasAxisTransition = false; + 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(); + } - 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; - } + var editX; + if(xr0 && xr1 && (xr0[0] !== xr1[0] || xr0[1] !== xr1[1])) { + editX = {xr0: xr0, xr1: xr1}; } - } - // 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; - } + var editY; + if(yr0 && yr1 && (yr0[0] !== yr1[0] || yr0[1] !== yr1[1])) { + editY = {yr0: yr0, yr1: yr1}; + } - 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(editX || editY) { + axEdits.push(Lib.extendFlat({plotinfo: plotinfo}, editX, editY)); + } } + } - // If nothing else creates a callback, then this will trigger the completion in the next tick: - setTimeout(makeCallback()); - - }); - } - - function completeTransition(callback) { - // This a simple workaround for tests which purge the graph before animations - // have completed. That's not a very common case, so this is the simplest - // fix. - if(!gd._transitionData) return; + return Promise.resolve(); + }; - flushCallbacks(gd._transitionData._interruptCallbacks); + opts.runFn = function(makeCallback) { + var traceTransitionOpts; + var basePlotModules = gd._fullLayout._basePlotModules; + var hasAxisTransition = axEdits.length; + var i; - return Promise.resolve().then(function() { - if(frameOpts.redraw) { - return Registry.call('redraw', gd); + if(layout) { + for(i = 0; i < basePlotModules.length; i++) { + if(basePlotModules[i].transitionAxes) { + basePlotModules[i].transitionAxes(gd, axEdits, transitionOpts, makeCallback); + } } - }).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() { - // Fail-safe against purged plot: - if(!gd._transitionData) return; - - // 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; - - 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 transitionStarting = Lib.syncOrAsync(seq, gd); + // 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; + } - if(!transitionStarting || !transitionStarting.then) { - transitionStarting = Promise.resolve(); - } + 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 transitionStarting.then(function() { - return gd; - }); + return _transition(gd, transitionOpts, opts); }; /** - * Transition used in Plotly.react + * 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.transition2 = function(gd, restyleFlags, relayoutFlags, oldFullLayout) { - var aborted = false; - var redraw = true; - var edits; +plots.transitionFromReact = function(gd, restyleFlags, relayoutFlags, oldFullLayout) { + var fullLayout = gd._fullLayout; + var transitionOpts = fullLayout.transition; + var opts = {}; + var axEdits = []; - function prepareTransitions() { - var fullLayout = gd._fullLayout; + opts.prepareFn = function() { var subplots = fullLayout._plots; // no need to redraw at end of transition, // if all changes are animatable - redraw = false; - if(restyleFlags.anim === 'some') redraw = true; - if(relayoutFlags.anim === 'some') redraw = true; + opts.redraw = false; + if(restyleFlags.anim === 'some') opts.redraw = true; + if(relayoutFlags.anim === 'some') opts.redraw = true; - edits = {}; for(var k in subplots) { var plotinfo = subplots[k]; var xa = plotinfo.xaxis; @@ -2564,21 +2485,82 @@ plots.transition2 = function(gd, restyleFlags, relayoutFlags, oldFullLayout) { xa.setScale(); ya.setScale(); - if(xr0[0] !== xr1[0] || xr0[1] !== xr1[1] || - yr0[0] !== yr1[0] || yr0[1] !== yr1[1] - ) { - edits[k] = { - plotinfo: plotinfo, - xr0: xr0, - yr0: yr0, - xr1: xr1, - yr1: yr1 - }; + 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 i; + + // Here handle the exception that we refuse to animate traces and axes at the same + // time. In other words, if there's an axis transition, then set the data transition + // to instantaneous. + var traceTransitionOpts; + var transitionedTraces; + + if(axEdits.length) { + for(i = 0; i < basePlotModules.length; i++) { + if(basePlotModules[i].transitionAxes2) { + basePlotModules[i].transitionAxes2(gd, axEdits, transitionOpts, makeCallback); + } + } + + // This means do not transition traces, + // this happens on layout-only (e.g. axis range) animations + traceTransitionOpts = Lib.extendFlat({}, transitionOpts, {duration: 0}); + transitionedTraces = null; + } else { + traceTransitionOpts = transitionOpts; + transitionedTraces = []; + for(i = 0; i < fullData.length; i++) { + transitionedTraces.push(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. + if(restyleFlags.anim) { + for(i = 0; i < basePlotModules.length; i++) { + basePlotModules[i].plot(gd, transitionedTraces, traceTransitionOpts, makeCallback); + } + } + }; + + 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(); @@ -2600,12 +2582,6 @@ plots.transition2 = function(gd, restyleFlags, relayoutFlags, oldFullLayout) { gd.emit('plotly_transitioning', []); return new Promise(function(resolve) { - var fullData = gd._fullData; - var fullLayout = gd._fullLayout; - var transitionOpts = fullLayout.transition; - var basePlotModules = fullLayout._basePlotModules; - var i; - // This flag is used to disabled things like autorange: gd._transitioning = true; @@ -2623,7 +2599,7 @@ plots.transition2 = function(gd, restyleFlags, relayoutFlags, oldFullLayout) { aborted = true; }); - if(redraw) { + if(opts.redraw) { gd._transitionData._interruptCallbacks.push(function() { return Registry.call('redraw', gd); }); @@ -2649,40 +2625,7 @@ plots.transition2 = function(gd, restyleFlags, relayoutFlags, oldFullLayout) { }; } - // Here handle the exception that we refuse to animate traces and axes at the same - // time. In other words, if there's an axis transition, then set the data transition - // to instantaneous. - var traceTransitionOpts; - var transitionedTraces; - - if(Object.keys(edits).length) { - for(i = 0; i < basePlotModules.length; i++) { - if(basePlotModules[i].transitionAxes2) { - basePlotModules[i].transitionAxes2(gd, edits, transitionOpts, makeCallback); - } - } - - // This means do not transition traces, - // this happens on layout-only (e.g. axis range) animations - traceTransitionOpts = Lib.extendFlat({}, transitionOpts, {duration: 0}); - transitionedTraces = null; - } else { - traceTransitionOpts = transitionOpts; - transitionedTraces = []; - for(i = 0; i < fullData.length; i++) { - transitionedTraces.push(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. - if(restyleFlags.anim) { - for(i = 0; i < basePlotModules.length; i++) { - basePlotModules[i].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()); @@ -2698,7 +2641,7 @@ plots.transition2 = function(gd, restyleFlags, relayoutFlags, oldFullLayout) { flushCallbacks(gd._transitionData._interruptCallbacks); return Promise.resolve().then(function() { - if(redraw) { + if(opts.redraw) { return Registry.call('redraw', gd); } }).then(function() { @@ -2727,7 +2670,7 @@ plots.transition2 = function(gd, restyleFlags, relayoutFlags, oldFullLayout) { var seq = [ plots.previousPromises, interruptPreviousTransitions, - prepareTransitions, + opts.prepareFn, plots.rehover, executeTransitions ]; @@ -2738,10 +2681,8 @@ plots.transition2 = function(gd, restyleFlags, relayoutFlags, oldFullLayout) { 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/test/jasmine/tests/transition_test.js b/test/jasmine/tests/transition_test.js index c7c54d95abf..1807a685376 100644 --- a/test/jasmine/tests/transition_test.js +++ b/test/jasmine/tests/transition_test.js @@ -276,7 +276,7 @@ describe('Plotly.react transitions:', function() { beforeEach(function() { gd = createGraphDiv(); methods = [ - [Plots, 'transition2'], + [Plots, 'transitionFromReact'], [Registry, 'call'] ]; }); @@ -337,7 +337,7 @@ describe('Plotly.react transitions:', function() { Plotly.react(gd, data, layout) .then(function() { assertSpies('first draw', [ - [Plots, 'transition2', 0] + [Plots, 'transitionFromReact', 0] ]); }) .then(function() { @@ -346,7 +346,7 @@ describe('Plotly.react transitions:', function() { }) .then(function() { assertSpies('no *transition* set', [ - [Plots, 'transition2', 0] + [Plots, 'transitionFromReact', 0] ]); }) .then(function() { @@ -355,7 +355,7 @@ describe('Plotly.react transitions:', function() { }) .then(function() { assertSpies('with *transition* set, no changes', [ - [Plots, 'transition2', 0] + [Plots, 'transitionFromReact', 0] ]); }) .then(function() { @@ -364,7 +364,7 @@ describe('Plotly.react transitions:', function() { }) .then(function() { assertSpies('with *transition* set and changes', [ - [Plots, 'transition2', 1], + [Plots, 'transitionFromReact', 1], ]); }) .catch(failTest) @@ -380,7 +380,7 @@ describe('Plotly.react transitions:', function() { Plotly.react(gd, data, layout) .then(function() { assertSpies('first draw', [ - [Plots, 'transition2', 0] + [Plots, 'transitionFromReact', 0] ]); }) .then(function() { @@ -389,7 +389,7 @@ describe('Plotly.react transitions:', function() { }) .then(function() { assertSpies('animatable trace change', [ - [Plots, 'transition2', 1] + [Plots, 'transitionFromReact', 1] ]); }) .then(function() { @@ -398,7 +398,7 @@ describe('Plotly.react transitions:', function() { }) .then(function() { assertSpies('non-animatable trace change', [ - [Plots, 'transition2', 0] + [Plots, 'transitionFromReact', 0] ]); }) .then(function() { @@ -407,7 +407,7 @@ describe('Plotly.react transitions:', function() { }) .then(function() { assertSpies('animatable layout change', [ - [Plots, 'transition2', 1] + [Plots, 'transitionFromReact', 1] ]); }) .then(function() { @@ -416,7 +416,7 @@ describe('Plotly.react transitions:', function() { }) .then(function() { assertSpies('non-animatable layout change', [ - [Plots, 'transition2', 0] + [Plots, 'transitionFromReact', 0] ]); }) .then(function() { @@ -426,7 +426,7 @@ describe('Plotly.react transitions:', function() { }) .then(function() { assertSpies('animatable trace & layout change', [ - [Plots, 'transition2', 1] + [Plots, 'transitionFromReact', 1] ]); }) .catch(failTest) @@ -443,7 +443,7 @@ describe('Plotly.react transitions:', function() { Plotly.react(gd, data, layout, config) .then(function() { assertSpies('first draw', [ - [Plots, 'transition2', 0] + [Plots, 'transitionFromReact', 0] ]); }) .then(function() { @@ -453,7 +453,7 @@ describe('Plotly.react transitions:', function() { }) .then(function() { assertSpies('on config change', [ - [Plots, 'transition2', 0] + [Plots, 'transitionFromReact', 0] ]); }) .then(function() { @@ -462,7 +462,7 @@ describe('Plotly.react transitions:', function() { }) .then(function() { assertSpies('no config change', [ - [Plots, 'transition2', 1] + [Plots, 'transitionFromReact', 1] ]); }) .catch(failTest) @@ -485,7 +485,7 @@ describe('Plotly.react transitions:', function() { Plotly.react(gd, data, layout) .then(function() { assertSpies('first draw', [ - [Plots, 'transition2', 0], + [Plots, 'transitionFromReact', 0], [Registry, 'call', 0] ]); }) @@ -495,7 +495,7 @@ describe('Plotly.react transitions:', function() { }) .then(function() { assertSpies('redraw NOT required', [ - [Plots, 'transition2', 1], + [Plots, 'transitionFromReact', 1], [Registry, 'call', 0] ]); }) @@ -507,7 +507,7 @@ describe('Plotly.react transitions:', function() { }) .then(function() { assertSpies('redraw required', [ - [Plots, 'transition2', 1], + [Plots, 'transitionFromReact', 1], [Registry, 'call', [['redraw', gd]]] ]); }) @@ -526,7 +526,7 @@ describe('Plotly.react transitions:', function() { Plotly.react(gd, data, layout) .then(function() { methods.push([gd._fullLayout._basePlotModules[0], 'plot']); - methods.push([gd._fullLayout._basePlotModules[0], 'transitionAxes2']); + methods.push([gd._fullLayout._basePlotModules[0], 'transitionAxes']); addSpies(); }) .then(function() { @@ -535,9 +535,9 @@ describe('Plotly.react transitions:', function() { }) .then(function() { assertSpies('just trace transition', [ - [Plots, 'transition2', 1], + [Plots, 'transitionFromReact', 1], [gd._fullLayout._basePlotModules[0], 'plot', 1], - [gd._fullLayout._basePlotModules[0], 'transitionAxes2', 0] + [gd._fullLayout._basePlotModules[0], 'transitionAxes', 0] ]); }) .then(function() { @@ -546,10 +546,10 @@ describe('Plotly.react transitions:', function() { }) .then(function() { assertSpies('just layout transition', [ - [Plots, 'transition2', 1], - [gd._fullLayout._basePlotModules[0], 'transitionAxes2', 1], + [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], 'yaxis.range': [0, 3]}]]], + [Registry, 'call', [['relayout', gd, {'xaxis.range': [-2, 2]}]]], [gd._fullLayout._basePlotModules[0], 'plot', 1], ]); }) @@ -560,9 +560,9 @@ describe('Plotly.react transitions:', function() { }) .then(function() { assertSpies('both trace and layout transitions', [ - [Plots, 'transition2', 1], - [gd._fullLayout._basePlotModules[0], 'transitionAxes2', 1], - [Registry, 'call', [['relayout', gd, {'xaxis.range': [-1, 1], 'yaxis.range': [0, 3]}]]], + [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) @@ -598,7 +598,7 @@ describe('Plotly.react transitions:', function() { Plotly.react(gd, data, layout) .then(function() { assertSpies('first draw', [ - [Plots, 'transition2', 0] + [Plots, 'transitionFromReact', 0] ]); }) .then(function() { @@ -607,7 +607,7 @@ describe('Plotly.react transitions:', function() { }) .then(function() { assertSpies('picks data_array changes with datarevision unset', [ - [Plots, 'transition2', 1] + [Plots, 'transitionFromReact', 1] ]); }) .then(function() { @@ -617,7 +617,7 @@ describe('Plotly.react transitions:', function() { }) .then(function() { assertSpies('picks up datarevision changes', [ - [Plots, 'transition2', 1] + [Plots, 'transitionFromReact', 1] ]); }) .then(function() { @@ -627,7 +627,7 @@ describe('Plotly.react transitions:', function() { }) .then(function() { assertSpies('ignores data_array changes when datarevision is same', [ - [Plots, 'transition2', 0] + [Plots, 'transitionFromReact', 0] ]); }) .then(function() { @@ -637,7 +637,7 @@ describe('Plotly.react transitions:', function() { }) .then(function() { assertSpies('picks up datarevision changes (take 2)', [ - [Plots, 'transition2', 1] + [Plots, 'transitionFromReact', 1] ]); }) .catch(failTest) @@ -658,7 +658,7 @@ describe('Plotly.react transitions:', function() { Plotly.react(gd, data, layout) .then(function() { methods.push([gd._fullLayout._basePlotModules[0], 'plot']); - methods.push([gd._fullLayout._basePlotModules[0], 'transitionAxes2']); + methods.push([gd._fullLayout._basePlotModules[0], 'transitionAxes']); addSpies(); assertAxAutorange('axes are autorange:true by default', true); }) @@ -669,8 +669,8 @@ describe('Plotly.react transitions:', function() { }) .then(function() { assertSpies('must transition autoranged axes, not the traces', [ - [Plots, 'transition2', 1], - [gd._fullLayout._basePlotModules[0], 'transitionAxes2', 1], + [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) @@ -687,11 +687,11 @@ describe('Plotly.react transitions:', function() { }) .then(function() { assertSpies('transition just traces, as now axis ranges are set', [ - [Plots, 'transition2', 1], - [gd._fullLayout._basePlotModules[0], 'transitionAxes2', 0], + [Plots, 'transitionFromReact', 1], + [gd._fullLayout._basePlotModules[0], 'transitionAxes', 0], [gd._fullLayout._basePlotModules[0], 'plot', [ - // called from Plots.transition2 [gd, [0], {duration: 10, easing: 'cubic-in-out'}, 'function'], + // called from Plots.transitionFromReact ]], ]); assertAxAutorange('axes are still autorange:false', false); @@ -705,18 +705,16 @@ describe('Plotly.react transitions:', function() { 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], 'transitionAxes2']); + methods.push([gd._fullLayout._basePlotModules[0], 'transitionAxes']); addSpies(); - assertAxAutorange('axes are autorange:true by default', true); + assertAxAutorange('y-axis is autorange:true by default', true); }) .then(function() { // N.B. different coordinate, but same auto-range value @@ -725,11 +723,11 @@ describe('Plotly.react transitions:', function() { }) .then(function() { assertSpies('do not transition autoranged axes, just the traces', [ - [Plots, 'transition2', 1], - [gd._fullLayout._basePlotModules[0], 'transitionAxes2', 0], + [Plots, 'transitionFromReact', 1], + [gd._fullLayout._basePlotModules[0], 'transitionAxes', 0], [gd._fullLayout._basePlotModules[0], 'plot', 1] ]); - assertAxAutorange('axes are still autorange:true', true); + assertAxAutorange('y-axis is still autorange:true', true); }) .then(function() { // N.B. different coordinates with different auto-range value @@ -738,8 +736,8 @@ describe('Plotly.react transitions:', function() { }) .then(function() { assertSpies('both trace and layout transitions', [ - [Plots, 'transition2', 1], - [gd._fullLayout._basePlotModules[0], 'transitionAxes2', 1], + [Plots, 'transitionFromReact', 1], + [gd._fullLayout._basePlotModules[0], 'transitionAxes', 1], [Registry, 'call', [ // xaxis call to _storeDirectGUIEdit from doAutoRange ['_storeDirectGUIEdit', gd.layout, gd._fullLayout._preGUI, { @@ -752,19 +750,24 @@ describe('Plotly.react transitions:', function() { 'yaxis.autorange': true }], ['relayout', gd, { - 'xaxis.range': [-0.12852664576802508, 2.128526645768025], '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'}, 'function'], // one _module.plot call from the relayout at end of axis transition [gd] - ]], + ]] ]); - assertAxAutorange('axes are now autorange:false', false); + assertAxAutorange('y-axis is now autorange:false', false); }) .catch(failTest) .then(done); @@ -850,7 +853,7 @@ describe('Plotly.react transitions:', function() { Plotly.react(gd, data1, layout) .then(function() { methods.push([gd._fullLayout._basePlotModules[0], 'plot']); - methods.push([gd._fullLayout._basePlotModules[0], 'transitionAxes2']); + methods.push([gd._fullLayout._basePlotModules[0], 'transitionAxes']); addSpies(); traceNodes = gd.querySelectorAll('.scatterlayer > .trace'); @@ -860,9 +863,9 @@ describe('Plotly.react transitions:', function() { .then(function() { var msg = 'transition into data2'; assertSpies(msg, [ - [Plots, 'transition2', 1], + [Plots, 'transitionFromReact', 1], [gd._fullLayout._basePlotModules[0], 'plot', 1], - [gd._fullLayout._basePlotModules[0], 'transitionAxes2', 0] + [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]]); @@ -871,9 +874,9 @@ describe('Plotly.react transitions:', function() { .then(function() { var msg = 'transition back to data1'; assertSpies(msg, [ - [Plots, 'transition2', 1], + [Plots, 'transitionFromReact', 1], [gd._fullLayout._basePlotModules[0], 'plot', 1], - [gd._fullLayout._basePlotModules[0], 'transitionAxes2', 0] + [gd._fullLayout._basePlotModules[0], 'transitionAxes', 0] ]); _assertTraceNodes(msg, traceNodes, [[360, 90], [120, 210]]); }) @@ -929,7 +932,7 @@ describe('Plotly.react transitions:', function() { Plotly.react(gd, data1, layout) .then(function() { methods.push([gd._fullLayout._basePlotModules[0], 'plot']); - methods.push([gd._fullLayout._basePlotModules[0], 'transitionAxes2']); + methods.push([gd._fullLayout._basePlotModules[0], 'transitionAxes']); addSpies(); traceNodes = gd.querySelectorAll('.scatterlayer > .trace'); @@ -939,10 +942,10 @@ describe('Plotly.react transitions:', function() { .then(function() { var msg = 'transition into data2'; assertSpies(msg, [ - [Plots, 'transition2', 1], - [Registry, 'call', 1], + [Plots, 'transitionFromReact', 1], [gd._fullLayout._basePlotModules[0], 'plot', 2], - [gd._fullLayout._basePlotModules[0], 'transitionAxes2', 0] + [Registry, 'call', 1], + [gd._fullLayout._basePlotModules[0], 'transitionAxes', 0] ]); // N.B. traceNodes[1] is gone, but traceNodes[0] is the same @@ -952,10 +955,10 @@ describe('Plotly.react transitions:', function() { .then(function() { var msg = 'transition back to data1'; assertSpies(msg, [ - [Plots, 'transition2', 1], + [Plots, 'transitionFromReact', 1], [Registry, 'call', 1], [gd._fullLayout._basePlotModules[0], 'plot', 2], - [gd._fullLayout._basePlotModules[0], 'transitionAxes2', 0] + [gd._fullLayout._basePlotModules[0], 'transitionAxes', 0] ]); // N.B. we have a "new" traceNodes[1] here, From 8ab38be3d84a23902430e979a9a9123018642da4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Mon, 14 Jan 2019 17:37:57 -0500 Subject: [PATCH 19/20] add transition.ordering attr - with values 'layout first' and 'traces first' which determines whether the figure's layout OR traces are smoothly transitions during Plotly.react calls that generate a data AND layout diff. --- src/plots/animation_attributes.js | 11 +++++ src/plots/plots.js | 58 ++++++++++++++++----------- test/jasmine/tests/transition_test.js | 30 +++++++++++--- 3 files changed, 71 insertions(+), 28 deletions(-) diff --git a/src/plots/animation_attributes.js b/src/plots/animation_attributes.js index cf53ce0d437..cc1ee758578 100644 --- a/src/plots/animation_attributes.js +++ b/src/plots/animation_attributes.js @@ -120,5 +120,16 @@ module.exports = { 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/plots.js b/src/plots/plots.js index 57f29934c57..c59afc8e3cd 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -1462,6 +1462,7 @@ plots.supplyLayoutGlobalDefaults = function(layoutIn, layoutOut, formatObj) { if(Lib.isPlainObject(layoutIn.transition)) { coerce('transition.duration'); coerce('transition.easing'); + coerce('transition.ordering'); } Registry.getComponentMethod( @@ -2509,39 +2510,50 @@ plots.transitionFromReact = function(gd, restyleFlags, relayoutFlags, oldFullLay var basePlotModules = fullLayout._basePlotModules; var i; - // Here handle the exception that we refuse to animate traces and axes at the same - // time. In other words, if there's an axis transition, then set the data transition - // to instantaneous. + var axisTransitionOpts; var traceTransitionOpts; var transitionedTraces; - if(axEdits.length) { - for(i = 0; i < basePlotModules.length; i++) { - if(basePlotModules[i].transitionAxes2) { - basePlotModules[i].transitionAxes2(gd, axEdits, transitionOpts, makeCallback); + var allTraceIndices = []; + for(i = 0; i < fullData.length; i++) { + allTraceIndices.push(i); + } + + function transitionAxes() { + for(var i = 0; i < basePlotModules.length; i++) { + if(basePlotModules[i].transitionAxes) { + basePlotModules[i].transitionAxes(gd, axEdits, axisTransitionOpts, makeCallback); } } + } - // This means do not transition traces, - // this happens on layout-only (e.g. axis range) animations - traceTransitionOpts = Lib.extendFlat({}, transitionOpts, {duration: 0}); - transitionedTraces = null; - } else { - traceTransitionOpts = transitionOpts; - transitionedTraces = []; - for(i = 0; i < fullData.length; i++) { - transitionedTraces.push(i); + function transitionTraces() { + for(var i = 0; i < basePlotModules.length; i++) { + basePlotModules[i].plot(gd, transitionedTraces, traceTransitionOpts, makeCallback); } } - // 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. - if(restyleFlags.anim) { - for(i = 0; i < basePlotModules.length; i++) { - basePlotModules[i].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(); } }; diff --git a/test/jasmine/tests/transition_test.js b/test/jasmine/tests/transition_test.js index 1807a685376..f3cc0b3827b 100644 --- a/test/jasmine/tests/transition_test.js +++ b/test/jasmine/tests/transition_test.js @@ -515,7 +515,7 @@ describe('Plotly.react transitions:', function() { .then(done); }); - it('should only transition the layout when both traces and layout have animatable changes', function(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}, @@ -566,12 +566,32 @@ describe('Plotly.react transitions:', function() { [gd._fullLayout._basePlotModules[0], 'plot', [ // one instantaneous transition options to halt // other trace transitions (if any) - [gd, null, {duration: 0, easing: 'cubic-in-out'}, 'function'], + [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); }); @@ -674,7 +694,7 @@ describe('Plotly.react transitions:', function() { [gd._fullLayout._basePlotModules[0], 'plot', [ // one instantaneous transition options to halt // other trace transitions (if any) - [gd, null, {duration: 0, easing: 'cubic-in-out'}, 'function'], + [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] ]], @@ -690,8 +710,8 @@ describe('Plotly.react transitions:', function() { [Plots, 'transitionFromReact', 1], [gd._fullLayout._basePlotModules[0], 'transitionAxes', 0], [gd._fullLayout._basePlotModules[0], 'plot', [ - [gd, [0], {duration: 10, easing: 'cubic-in-out'}, 'function'], // called from Plots.transitionFromReact + [gd, [0], {duration: 10, easing: 'cubic-in-out', ordering: 'layout first'}, 'function'], ]], ]); assertAxAutorange('axes are still autorange:false', false); @@ -762,7 +782,7 @@ describe('Plotly.react transitions:', function() { [gd._fullLayout._basePlotModules[0], 'plot', [ // one instantaneous transition options to halt // other trace transitions (if any) - [gd, null, {duration: 0, easing: 'cubic-in-out'}, 'function'], + [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] ]] From ec4d8b9250c1a0b598bc960cbc777d2d996e2ee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Mon, 21 Jan 2019 21:19:00 -0500 Subject: [PATCH 20/20] some linting --- src/plot_api/plot_api.js | 2 +- src/plots/plots.js | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 9a209234f1d..252b1f80880 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2885,7 +2885,7 @@ function diffData(gd, oldFullData, newFullData, immutable, transition, newDataRe } if(transition && flags.nChanges && flags.nChangesAnim) { - flags.anim = flags.nChanges === flags.nChangesAnim && sameTraceLength ? 'all' : 'some'; + flags.anim = (flags.nChanges === flags.nChangesAnim) && sameTraceLength ? 'all' : 'some'; } return flags; diff --git a/src/plots/plots.js b/src/plots/plots.js index 4c3cf20539a..e714af0f65b 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -2510,28 +2510,27 @@ plots.transitionFromReact = function(gd, restyleFlags, relayoutFlags, oldFullLay var fullData = gd._fullData; var fullLayout = gd._fullLayout; var basePlotModules = fullLayout._basePlotModules; - var i; var axisTransitionOpts; var traceTransitionOpts; var transitionedTraces; var allTraceIndices = []; - for(i = 0; i < fullData.length; i++) { + for(var i = 0; i < fullData.length; i++) { allTraceIndices.push(i); } function transitionAxes() { - for(var i = 0; i < basePlotModules.length; i++) { - if(basePlotModules[i].transitionAxes) { - basePlotModules[i].transitionAxes(gd, axEdits, axisTransitionOpts, makeCallback); + for(var j = 0; j < basePlotModules.length; j++) { + if(basePlotModules[j].transitionAxes) { + basePlotModules[j].transitionAxes(gd, axEdits, axisTransitionOpts, makeCallback); } } } function transitionTraces() { - for(var i = 0; i < basePlotModules.length; i++) { - basePlotModules[i].plot(gd, transitionedTraces, traceTransitionOpts, makeCallback); + for(var j = 0; j < basePlotModules.length; j++) { + basePlotModules[j].plot(gd, transitionedTraces, traceTransitionOpts, makeCallback); } }