diff --git a/CHANGELOG.md b/CHANGELOG.md index ce89761e4a..6a5fa0e200 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed +- [#1915](https://github.com/plotly/dash/pull/1915) Fix bug [#1474](https://github.com/plotly/dash/issues/1474) when both dcc.Graph and go.Figure have animation, and when the second animation in Figure is executed, the Frames from the first animation are played instead of the second one. + ### Fixed - [#1953](https://github.com/plotly/dash/pull/1953) Fix bug [#1783](https://github.com/plotly/dash/issues/1783) in which a failed hot reloader blocks the UI with alerts. diff --git a/components/dash-core-components/src/fragments/Graph.react.js b/components/dash-core-components/src/fragments/Graph.react.js index 0205b9190a..431e69f3e5 100644 --- a/components/dash-core-components/src/fragments/Graph.react.js +++ b/components/dash-core-components/src/fragments/Graph.react.js @@ -147,29 +147,36 @@ class PlotlyGraph extends Component { const {animate, animation_options, responsive} = props; const gd = this.gd.current; - figure = props._dashprivate_transformFigure(figure, gd); config = props._dashprivate_transformConfig(config, gd); + const figureClone = { + data: figure.data, + layout: this.getLayout(figure.layout, responsive), + frames: figure.frames, + config: this.getConfig(config, responsive), + }; + if ( animate && this._hasPlotted && figure.data.length === gd.data.length ) { - return Plotly.animate(gd, figure, animation_options); + // in case we've have figure frames, + // we need to recreate frames before animation + if (figure.frames) { + return Plotly.deleteFrames(gd) + .then(() => Plotly.addFrames(gd, figure.frames)) + .then(() => + Plotly.animate(gd, figureClone, animation_options) + ); + } + return Plotly.animate(gd, figureClone, animation_options); } - const configClone = this.getConfig(config, responsive); - const layoutClone = this.getLayout(figure.layout, responsive); - gd.classList.add('dash-graph--pending'); - return Plotly.react(gd, { - data: figure.data, - layout: layoutClone, - frames: figure.frames, - config: configClone, - }).then(() => { + return Plotly.react(gd, figureClone).then(() => { const gd = this.gd.current; // double-check gd hasn't been unmounted diff --git a/components/dash-core-components/tests/integration/graph/test_graph_basics.py b/components/dash-core-components/tests/integration/graph/test_graph_basics.py index b612f5e152..8823922f09 100644 --- a/components/dash-core-components/tests/integration/graph/test_graph_basics.py +++ b/components/dash-core-components/tests/integration/graph/test_graph_basics.py @@ -1,9 +1,10 @@ +import json import pytest import pandas as pd from multiprocessing import Value, Lock import numpy as np from time import sleep - +import plotly.graph_objects as go from dash import Dash, Input, Output, dcc, html import dash.testing.wait as wait @@ -162,3 +163,121 @@ def update_graph(n_clicks): dash_dcc.wait_for_element("#my-graph:not([data-dash-is-loading])") assert dash_dcc.get_logs() == [] + + +def test_grbs005_graph_update_frames(dash_dcc): + app = Dash(__name__) + + def get_scatter(multiplier, offset): + return go.Scatter( + x=list(map(lambda n: n * multiplier, [0, 1, 2])), + y=list(map(lambda n: n + offset, [0, 1, 2])), + mode="markers", + ) + + def get_figure(data, frames, title): + return go.Figure( + data=data, + layout=go.Layout( + title=title, + yaxis=dict(range=[-1, 5]), + xaxis=dict(range=[-3, 3]), + updatemenus=[ + dict( + type="buttons", + buttons=[ + dict( + label="Play", + method="animate", + args=[ + None, + { + "frame": {"duration": 100, "redraw": True}, + "fromcurrent": False, + "transition": { + "duration": 500, + "easing": "quadratic-in-out", + }, + }, + ], + ) + ], + ) + ], + ), + frames=frames, + ) + + app.layout = html.Div( + [ + html.Label("Choose dataset"), + dcc.RadioItems( + id="change-data", + options=[ + {"label": "No data", "value": 0}, + {"label": "Data A", "value": 1}, + {"label": "Data B", "value": 2}, + ], + value=0, + ), + dcc.Graph( + id="test-change", + animate=True, + animation_options={"frame": {"redraw": True}}, + ), + html.Div(id="relayout-data"), + ] + ) + + @app.callback( + Output("relayout-data", "children"), + [Input("test-change", "figure")], + ) + def show_relayout_data(data): + frames = data.get("frames", []) + if frames: + return json.dumps(frames[0]["data"][0]["x"]) + return "" + + @app.callback( + Output("test-change", "figure"), + Input("change-data", "value"), + ) + def set_data(dataset): + if dataset == 1: + title = "Dataset A" + data = get_scatter(1, 0) + frames = [ + go.Frame(data=get_scatter(1, 1)), + ] + elif dataset == 2: + title = "Dataset B" + data = get_scatter(-1, 0) + frames = [ + go.Frame(data=get_scatter(-1, 1)), + ] + else: + title = "Select a dataset" + data = [] + frames = [] + + fig = get_figure(data, frames, title) + return fig + + dash_dcc.start_server(app) + dash_dcc.wait_for_element("#test-change") + + dash_dcc.find_elements('input[type="radio"]')[0].click() + assert dash_dcc.wait_for_text_to_equal( + "#relayout-data", "" + ), "initial graph data must contain empty string" + + dash_dcc.find_elements('input[type="radio"]')[1].click() + assert dash_dcc.wait_for_text_to_equal( + "#relayout-data", "[0, 1, 2]" + ), "graph data must contain frame [0,1,2]" + + dash_dcc.find_elements('input[type="radio"]')[2].click() + assert dash_dcc.wait_for_text_to_equal( + "#relayout-data", "[0, -1, -2]" + ), "graph data must contain frame [0,-1,-2]"