Skip to content

Commit ab193b2

Browse files
authored
Merge pull request #1108 from plotly/fix-layout-animation
Fix race condition in animation resolution
2 parents 2ac3dd7 + c100d6c commit ab193b2

File tree

3 files changed

+115
-21
lines changed

3 files changed

+115
-21
lines changed

src/plot_api/plot_api.js

+21-3
Original file line numberDiff line numberDiff line change
@@ -2214,6 +2214,20 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) {
22142214
}
22152215
}
22162216

2217+
// Execute a callback after the wrapper function has been called n times.
2218+
// This is used to defer the resolution until a transition has resovled *and*
2219+
// the frame has completed. If it's not done this way, then we get a race
2220+
// condition in which the animation might resolve before a transition is complete
2221+
// or vice versa.
2222+
function callbackOnNthTime(cb, n) {
2223+
var cnt = 0;
2224+
return function() {
2225+
if(cb && ++cnt === n) {
2226+
return cb();
2227+
}
2228+
};
2229+
}
2230+
22172231
return new Promise(function(resolve, reject) {
22182232
function discardExistingFrames() {
22192233
if(trans._frameQueue.length === 0) {
@@ -2264,7 +2278,7 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) {
22642278
// loop (which may exist as a result of a *different* .animate call)
22652279
// still resolves or rejecdts this .animate call's promise. once it's
22662280
// complete.
2267-
nextFrame.onComplete = resolve;
2281+
nextFrame.onComplete = callbackOnNthTime(resolve, 2);
22682282
nextFrame.onInterrupt = reject;
22692283
}
22702284

@@ -2302,7 +2316,6 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) {
23022316
// Execute the callback and unset it to ensure it doesn't
23032317
// accidentally get called twice
23042318
trans._currentFrame.onComplete();
2305-
trans._currentFrame.onComplete = null;
23062319
}
23072320

23082321
var newFrame = trans._currentFrame = trans._frameQueue.shift();
@@ -2322,7 +2335,12 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) {
23222335
helpers.coerceTraceIndices(gd, newFrame.frame.traces),
23232336
newFrame.frameOpts,
23242337
newFrame.transitionOpts
2325-
);
2338+
).then(function() {
2339+
if(newFrame.onComplete) {
2340+
newFrame.onComplete();
2341+
}
2342+
2343+
});
23262344

23272345
gd.emit('plotly_animatingframe', {
23282346
name: newFrame.name,

src/plots/plots.js

+19-6
Original file line numberDiff line numberDiff line change
@@ -1104,8 +1104,16 @@ plots.purge = function(gd) {
11041104
// remove modebar
11051105
if(fullLayout._modeBar) fullLayout._modeBar.destroy();
11061106

1107-
if(gd._transitionData && gd._transitionData._animationRaf) {
1108-
window.cancelAnimationFrame(gd._transitionData._animationRaf);
1107+
if(gd._transitionData) {
1108+
// Ensure any dangling callbacks are simply dropped if the plot is purged.
1109+
// This is more or less only actually important for testing.
1110+
if(gd._transitionData._interruptCallbacks) {
1111+
gd._transitionData._interruptCallbacks.length = 0;
1112+
}
1113+
1114+
if(gd._transitionData._animationRaf) {
1115+
window.cancelAnimationFrame(gd._transitionData._animationRaf);
1116+
}
11091117
}
11101118

11111119
// data and layout
@@ -1714,6 +1722,9 @@ plots.transition = function(gd, data, layout, traces, frameOpts, transitionOpts)
17141722
var aborted = false;
17151723

17161724
function executeTransitions() {
1725+
1726+
gd.emit('plotly_transitioning', []);
1727+
17171728
return new Promise(function(resolve) {
17181729
// This flag is used to disabled things like autorange:
17191730
gd._transitioning = true;
@@ -1798,7 +1809,9 @@ plots.transition = function(gd, data, layout, traces, frameOpts, transitionOpts)
17981809
}
17991810

18001811
function completeTransition(callback) {
1801-
// Fail-safe against purged plot:
1812+
// This a simple workaround for tests which purge the graph before animations
1813+
// have completed. That's not a very common case, so this is the simplest
1814+
// fix.
18021815
if(!gd._transitionData) return;
18031816

18041817
flushCallbacks(gd._transitionData._interruptCallbacks);
@@ -1848,13 +1861,13 @@ plots.transition = function(gd, data, layout, traces, frameOpts, transitionOpts)
18481861

18491862
var seq = [plots.previousPromises, interruptPreviousTransitions, prepareTransitions, executeTransitions];
18501863

1851-
18521864
var transitionStarting = Lib.syncOrAsync(seq, gd);
18531865

1854-
if(!transitionStarting || !transitionStarting.then) transitionStarting = Promise.resolve();
1866+
if(!transitionStarting || !transitionStarting.then) {
1867+
transitionStarting = Promise.resolve();
1868+
}
18551869

18561870
return transitionStarting.then(function() {
1857-
gd.emit('plotly_transitioning', []);
18581871
return gd;
18591872
});
18601873
};

test/jasmine/tests/animate_test.js

+75-12
Original file line numberDiff line numberDiff line change
@@ -591,32 +591,84 @@ describe('Test animate API', function() {
591591
describe('Animate API details', function() {
592592
'use strict';
593593

594-
var gd, mockCopy;
594+
var gd;
595+
var dur = 30;
596+
var mockCopy;
595597

596598
beforeEach(function(done) {
597599
gd = createGraphDiv();
598-
599600
mockCopy = Lib.extendDeep({}, mock);
600-
601-
Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(function() {
602-
return Plotly.addFrames(gd, mockCopy.frames);
603-
}).then(done);
601+
Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(done);
604602
});
605603

606604
afterEach(function() {
607605
Plotly.purge(gd);
608606
destroyGraphDiv();
609607
});
610608

611-
it('null frames should not break everything', function(done) {
612-
gd._transitionData._frames.push(null);
609+
it('redraws after a layout animation', function(done) {
610+
var redraws = 0;
611+
gd.on('plotly_redraw', function() {redraws++;});
613612

614-
Plotly.animate(gd, null, {
615-
frame: {duration: 0},
616-
transition: {duration: 0}
613+
Plotly.animate(gd,
614+
{layout: {'xaxis.range': [0, 1]}},
615+
{frame: {redraw: true, duration: dur}, transition: {duration: dur}}
616+
).then(function() {
617+
expect(redraws).toBe(1);
617618
}).catch(fail).then(done);
618619
});
619620

621+
it('forces a relayout after layout animations', function(done) {
622+
var relayouts = 0;
623+
var restyles = 0;
624+
var redraws = 0;
625+
gd.on('plotly_relayout', function() {relayouts++;});
626+
gd.on('plotly_restyle', function() {restyles++;});
627+
gd.on('plotly_redraw', function() {redraws++;});
628+
629+
Plotly.animate(gd,
630+
{layout: {'xaxis.range': [0, 1]}},
631+
{frame: {redraw: false, duration: dur}, transition: {duration: dur}}
632+
).then(function() {
633+
expect(relayouts).toBe(1);
634+
expect(restyles).toBe(0);
635+
expect(redraws).toBe(0);
636+
}).catch(fail).then(done);
637+
});
638+
639+
it('triggers plotly_animated after a single layout animation', function(done) {
640+
var animateds = 0;
641+
gd.on('plotly_animated', function() {animateds++;});
642+
643+
Plotly.animate(gd, [
644+
{layout: {'xaxis.range': [0, 1]}},
645+
], {frame: {redraw: false, duration: dur}, transition: {duration: dur}}
646+
).then(function() {
647+
// Wait a bit just to be sure:
648+
setTimeout(function() {
649+
expect(animateds).toBe(1);
650+
done();
651+
}, dur);
652+
});
653+
});
654+
655+
it('triggers plotly_animated after a multi-step layout animation', function(done) {
656+
var animateds = 0;
657+
gd.on('plotly_animated', function() {animateds++;});
658+
659+
Plotly.animate(gd, [
660+
{layout: {'xaxis.range': [0, 1]}},
661+
{layout: {'xaxis.range': [2, 4]}},
662+
], {frame: {redraw: false, duration: dur}, transition: {duration: dur}}
663+
).then(function() {
664+
// Wait a bit just to be sure:
665+
setTimeout(function() {
666+
expect(animateds).toBe(1);
667+
done();
668+
}, dur);
669+
});
670+
});
671+
620672
it('does not fail if strings are not used', function(done) {
621673
Plotly.addFrames(gd, [{name: 8, data: [{x: [8, 7, 6]}]}]).then(function() {
622674
// Verify it was added as a string name:
@@ -634,12 +686,23 @@ describe('Animate API details', function() {
634686
var cnt = 0;
635687
gd.on('plotly_animatingframe', function() {cnt++;});
636688

637-
Plotly.animate(gd, ['frame0', null, undefined], {transition: {duration: 0}, frame: {duration: 0}}).then(function() {
689+
Plotly.addFrames(gd, mockCopy.frames).then(function() {
690+
return Plotly.animate(gd, ['frame0', null, undefined], {transition: {duration: 0}, frame: {duration: 0}});
691+
}).then(function() {
638692
// Check only one animating was fired:
639693
expect(cnt).toEqual(1);
640694

641695
// Check unused frames did not affect the current frame:
642696
expect(gd._fullLayout._currentFrame).toEqual('frame0');
643697
}).catch(fail).then(done);
644698
});
699+
700+
it('null frames should not break everything', function(done) {
701+
gd._transitionData._frames.push(null);
702+
703+
Plotly.animate(gd, null, {
704+
frame: {duration: 0},
705+
transition: {duration: 0}
706+
}).catch(fail).then(done);
707+
});
645708
});

0 commit comments

Comments
 (0)