Synchronizing the camera across multiple 3d scatter plots/subplots

Hi,

I have two 3d scatterplots side-by-side. The plots show similar data and I’d like to be able to rotate either one of the figures and have the other follow suit so that the two are always viewed from the same angle.

Current behaviour: Rotating graph1 also rotates graph2 such that both remain synchronised (as intended). If you then rotate graph2, graph1 does not follow suit; the two graphs behave independently. If you then rotate graph1 again, graph2 no longer updates either. The same behaviour occurs the other way around (i.e .if you start by rotating graph2).

Intended behaviour: You should be able to switch between rotating either of the graphs and the other should follow suit.

Below is a minimal working example. I’m currently trying to do this with dash, but if there’s a better way with just plotly, that should work great for my use case.

Thanks for you help!

# plotly.__version__ == 5.16.0
import plotly.graph_objects as go 
import plotly.express as px

# dash.__version__ == 2.12.0
from dash import Dash, dcc, html, callback_context 
from dash.dependencies import Input, Output

# to render in notebook
import plotly.io as pio
pio.renderers.default = "notebook_connected"

# Create data for scatter plots
df = px.data.iris()
fig1 = px.scatter_3d(df, x='sepal_length', y='sepal_width', z='petal_width', color='species')
fig2 = px.scatter_3d(df, x='sepal_length', y='sepal_width', z='petal_width', color='species')

# Define app layout
app = Dash(__name__)
app.layout = html.Div([
    html.Div([
        dcc.Graph(id='graph1', figure=fig1),
        dcc.Graph(id='graph2', figure=fig2)
    ], style={'display': 'flex'})
])

# Define callback function to update camera position of one subplot when the other subplot is rotated
@app.callback(Output('graph1', 'figure'), Output('graph2', 'figure'), Input('graph1', 'relayoutData'), Input('graph2', 'relayoutData'))
def update_camera(relayout_data1, relayout_data2):

    # get the id of the component that triggered the callback--used to determine which graph to update
    ctx = callback_context
    caller = None if not ctx.triggered else ctx.triggered[0]['prop_id'].split(".")[0] # in {'graph1', 'graph2', None}

    # initialization, no relayoutData
    if caller is None:
        return (fig1, fig2)

    # graph1 was interacted with: update graph2
    elif (caller == 'graph1') and relayout_data1 and ('scene.camera' in relayout_data1):
        print("1 updated 2") 
        fig2.layout.scene.camera = relayout_data1['scene.camera']
        return (None, fig2)
    
    # graph2 was interacted with: update graph1
    elif (caller == 'graph2') and relayout_data2 and ('scene.camera' in relayout_data2):
        print("2 updated 1")
        fig1.layout.scene.camera = relayout_data2['scene.camera']
        return (fig1, None)

    # we didn't modify the camera, so no updates
    else:
        return (fig1, fig2)

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

Hey @T42, I think I have what you need.

In your callback, make sure to return both fig1 and fig2 for each if ... elif situation, otherwise dash will try to read None data in the next callback update, triggering the error message.

Then, in your callback, you only have to update both figures’ scene camera to the same values.

In the end, here’s your code modified:

# plotly.__version__ == 5.16.0
import plotly.graph_objects as go 
import plotly.express as px

# dash.__version__ == 2.12.0
from dash import Dash, dcc, html, callback_context 
from dash.dependencies import Input, Output

# to render in notebook
import plotly.io as pio
pio.renderers.default = "notebook_connected"

# Create data for scatter plots
df = px.data.iris()
fig1 = px.scatter_3d(df, x='sepal_length', y='sepal_width', z='petal_width', color='species')
fig2 = px.scatter_3d(df, x='sepal_length', y='sepal_width', z='petal_width', color='species')

# Default parameters which are used when `layout.scene.camera` is not provided
camera = dict(
    up=dict(x=0, y=0, z=1),
    center=dict(x=0, y=0, z=0),
    eye=dict(x=1.25, y=1.25, z=1.25)
)

fig1.update_layout(scene_camera=camera)

print(fig1.layout.scene.camera)

# Define app layout
app = Dash(__name__)
app.layout = html.Div([
    html.Div([
        dcc.Graph(id='graph1', figure=fig1),
        dcc.Graph(id='graph2', figure=fig2)
    ], style={'display': 'flex'})
])

# Define callback function to update camera position of one subplot when the other subplot is rotated
@app.callback(Output('graph1', 'figure'), Output('graph2', 'figure'), Input('graph1', 'relayoutData'), Input('graph2', 'relayoutData'))
def update_camera(relayout_data1, relayout_data2):

    # get the id of the component that triggered the callback--used to determine which graph to update
    ctx = callback_context
    caller = None if not ctx.triggered else ctx.triggered[0]['prop_id'].split(".")[0] # in {'graph1', 'graph2', None}

    # initialization, no relayoutData
    if caller is None:
        return (fig1, fig2)

    # graph1 was interacted with: update graph2
    elif (caller == 'graph1') and relayout_data1 and ('scene.camera' in relayout_data1):
        print("1 updated 2")

        fig1.layout.scene.camera = relayout_data1['scene.camera']
        fig2.layout.scene.camera = relayout_data1['scene.camera']

        return (fig1, fig2)
    
    # graph2 was interacted with: update graph1
    elif (caller == 'graph2') and relayout_data2 and ('scene.camera' in relayout_data2):
        print("2 updated 1")

        fig2.layout.scene.camera = relayout_data2['scene.camera']
        fig1.layout.scene.camera = relayout_data2['scene.camera']

        return (fig1, fig2)

    # we didn't modify the camera, so no updates
    else:
        return (fig1, fig2)

if __name__ == '__main__':
    port = 8050
    app.run_server(debug=True, port=port)
1 Like

Ah, that’s got it. Thanks a lot!