Dash animated 3D graph with callback to change animation frames

Hi there,

I’m looking for some help in building a callback in Dash that updates the frames of an animation.

Specifically, my main figure is a plotly Mesh3d, which has a mesh of a cube, and an animation that makes the cube move: The x,y,z coordinates of the verts change per frame. Now, I’d like users to be able to change the animation of motion to an animation of color change, via a dropdown menu.

However, I’m having trouble updating the figure in a callback: The callback unexpectedly leads the animation slider to influence the camera angle.

Attempt 1

First, I tried simply updating the data, frames, and layout keys by returning these in a dict (sorry MWE is a bit long…):

import dash
import dash_core_components as dcc
import dash_html_components as html
import plotly.graph_objects as go
import numpy as np

def make_frames(x, y, z, i, j, k, 
                animation='motion', n_frames=10):
    frames = []
    for frame_n in np.arange(n_frames, dtype='int'):
        if animation == 'motion':
            data = go.Mesh3d(x=x+frame_n*.1, y=y+frame_n*.1, z=z, i=i, j=j, k=k, color='rgb(0.5,0.5,0.5)')
        elif animation == 'color':
            data = go.Mesh3d(x=x, y=y, z=z, i=i, j=j, k=k, color='rgb('+str(frame_n*.1)+',0,0)')
            
        frames.append(go.Frame(data=data, name=str(frame_n)))
    return frames

def build_sliders():
    # slider for animation
    sliders_dict = {
        "active": 0,
        "yanchor": "top",
        "xanchor": "left",
        "currentvalue": {
            "font": {"size": 20},
            "prefix": "Step:",
            "visible": True,
            "xanchor": "right"
        },
        "transition": {"duration": 300, "easing": "cubic-in-out"},
        "pad": {"b": 10, "t": 50},
        "len": 0.9,
        "x": 0.1,
        "y": 0,
        "steps": []
    }

    # add frames
    frames = make_frames(x,y,z,i,j,k, animation='motion')
    n_frames = 10
    for frame_n in np.arange(n_frames, dtype='int'):
        slider_step = {"args": [
            [int(frame_n)],
            {"frame": {"duration": 300, "redraw": True},
             "mode": "immediate",
             "transition": {"duration": 300}}
            ],
            "label": int(frame_n),
            "method": "animate"}
        sliders_dict["steps"].append(slider_step)
    return sliders_dict


# verts, faces for cube
x = [0, 0, 1, 1, 0, 0, 1, 1]
y = [0, 1, 1, 0, 0, 1, 1, 0]
z = [0, 0, 0, 0, 1, 1, 1, 1]
i = [7, 0, 0, 0, 4, 4, 6, 6, 4, 0, 3, 2]
j = [3, 4, 1, 2, 5, 6, 5, 2, 0, 1, 6, 3]
k = [0, 7, 2, 3, 6, 7, 1, 1, 5, 5, 7, 6]

# Make figure
fig = go.Figure(data=[
    go.Mesh3d(
        # 8 vertices of a cube
        x=x,
        y=y,
        z=z,
        color='rgb(0.5,0.5,0.5)',
        # i, j and k give the vertices of triangles
        i=i,
        j=j,
        k=k,
        name='y'
    )
])

# add frames, slider, update layout
frames = make_frames(x,y,z,i,j,k, animation='motion')
sliders_dict = build_sliders()    
fig['layout']['scene'] = {'aspectmode': 'cube',
                            'xaxis': {'range': [-3, 3], 'title': 'x', 'gridcolor': '#FFFFFF'},
                            'yaxis': {'range': [-3, 3], 'title': 'y', 'gridcolor': '#FFFFFF'},
                            'zaxis': {'range': [-3, 3], 'title': 'z', 'gridcolor': '#FFFFFF'}}
fig['layout']['sliders'] = [sliders_dict]
fig['frames'] = frames


# Create app
external_stylesheets = ['https://codepen.io/chriddyp/pen/bWLwgP.css']
app = dash.Dash(__name__, external_stylesheets=external_stylesheets)

app.layout = html.Div(children=[
    html.H1(children='MWE', style={'textAlign': 'center'}),
    html.Div([  # Div containing everything
        html.Div([  # div for mesh: Left
            html.Div([
                dcc.Dropdown(id='animation_type', 
                             options=[{'label': i, 'value': i} for i in ['motion', 'color']],
                             value='motion')
            ]),
            html.Div([
                dcc.Graph(
                    id='mesh_fig',
                    figure=fig)
            ])
        ])
    ])
])

# Callback
@app.callback(
    dash.dependencies.Output('mesh_fig', 'figure'),
    [dash.dependencies.Input('animation_type', 'value')])
def update_figure(animation_type):
    if animation_type == 'motion':
        data = go.Mesh3d(x=x, y=y, z=z,
                         color='rgb(0.5,0.5,0.5)', i=i, j=j, k=k, name='y')
    else:
        data = go.Mesh3d(x=x, y=y, z=z,
                         color='rgb(0,0,0)', i=i, j=j, k=k, name='y')
    frames = make_frames(x,y,z,i,j,k,animation=animation_type)
    layout_dict = {'scene': {'aspectmode': 'cube',
                             'xaxis': {'range': [-3, 3], 'title': 'x', 'gridcolor': '#FFFFFF'},
                             'yaxis': {'range': [-3, 3], 'title': 'y', 'gridcolor': '#FFFFFF'},
                             'zaxis': {'range': [-3, 3], 'title': 'z', 'gridcolor': '#FFFFFF'}},
                   'sliders': [build_sliders()]}
    return {'data': [data],
            'frames': frames,
            'layout': layout_dict}

if __name__ == '__main__':
    app.run_server(debug=True, host="localhost", port=8050)

At first, this seems to work as intended: After selecting color from the dropdown menu, the animation slider indeed changes the color (instead of the location) of the cube. However, there’s an annoying side effect: after selecting color from the dropdown menu, using the animation slider always resets the camera to the default. Hence, you can’t change the angle with which you want to view the animation.
(NB: refreshing the browser window actually ‘solves’ this: if you change the dropdown to color, and then refresh the window, you can actually use the animation slider to change color without changing the camera angle. Curious, eh?)

Attempt 2

I also tried to use the animate property of dcc.Graph as follows:

            html.Div([
                dcc.Graph(
                    id='mesh_fig',
                    figure=fig,
                    animate=True,
                    animation_options={'frame': { 'redraw': True}})
            ])

Which does appear to solve the problem of Attempt 1 (i.e., the camera angle isn’t affected by the animation slider after changing the dropdown menu’s motion to color); but unfortunately, this introduces a new problem, namely that the data seem to change due to the callback, but not the frames. That is, after selecting color from the dropdown menu, the animation slider still animates motion (and also reverts the cube’s color to grey). (I know animate is in beta, so I’m guessing this may just not be implemented yet here).

Does anyone know if I’m doing something wrong? Or, if this (especially Attempt 1) is a bug, is there any workaround? (I’m thinking, since the browser window refresh actually makes the figure work as intended again, is there any way to trigger a “refresh” of the div containing the figure or something?

Thanks in advance!

I’m still hoping someone can help out / point me in the right direction. Just for clarity, I created a gif of the problem, maybe it helps understanding what’s going on. Once switched to “color”, the animation works, but any change of the slider automatically resets the camera angle.

For now I created a workaround by substituting the dcc.Graph element with an html.Iframe, and then using a callback to load a new plotly 3dMesh html (pre-written). It solves the problem above but it’s not a great solution since it also makes me lose the ability to do any other callback/access the clickData from the 3D mesh.

For future reference, I found a better workaround that doesn’t require iframe. The issue appears to arise due to the dcc.Graph component having the same id before and after the callback. After updating the dcc.Graph using the dropdown menu, the gl3d scene doesn’t seem to save the user-specified camera’s state properly anymore. I think it doesn’t detect/store that the cameradata has changed upon user interaction (but I lack proper JS/react knowledge to dive deeper into this).

Anyway, if you have the callback update the children of the parent div of dcc.Graph, and change the id of dcc.Graph itself, the result works normally, e.g.:

app.layout = html.Div(children=[
    html.H1(children='MWE', style={'textAlign': 'center'}),
    html.Div([  # Div containing everything
        html.Div([  # div for mesh: Left
            html.Div([
                dcc.Dropdown(id='animation_type', 
                             options=[{'label': i, 'value': i} for i in ['motion', 'color']],
                             value='motion')
            ]),
            html.Div(id='parent_div', children=[
                dcc.Graph(
                    id='mesh_fig',
                    figure=fig)
            ])
        ])
    ])
])
@app.callback(
    dash.dependencies.Output('parent_div', 'children'),
    [dash.dependencies.Input('animation_type', 'value')])
def update_figure(animation_type):
    if animation_type == 'motion':
        data = go.Mesh3d(x=x, y=y, z=z,
                         color='rgb(0.5,0.5,0.5)', i=i, j=j, k=k, name='y')
    else:
        data = go.Mesh3d(x=x, y=y, z=z,
                         color='rgb(0,0,0)', i=i, j=j, k=k, name='y')
    frames = make_frames(x,y,z,i,j,k,animation=animation_type)
    layout_dict = {'scene': {'aspectmode': 'cube',
                             'xaxis': {'range': [-3, 3], 'title': 'x', 'gridcolor': '#FFFFFF'},
                             'yaxis': {'range': [-3, 3], 'title': 'y', 'gridcolor': '#FFFFFF'},
                             'zaxis': {'range': [-3, 3], 'title': 'z', 'gridcolor': '#FFFFFF'}},
                   'sliders': [build_sliders()]}
    go.Figure(data=[data], layout=layout_dict, frames=frames)

    return [dcc.Graph(figure=fig, id='new_id_name')]

This still limits the ability to interact with the dcc.Graph itself (eg get clickData or something), so it’s not perfect, but it seems to serve my purposes.

Hi @steefm , I encounter the exact same issue. I tried your work-around by running the codes you provided, but the issue still exist. Could you elaborate more on how you get it resolved please?