Callback to relayout to apply zoom to all graphs not doing anything

Hello,
I am trying to use relayout data in order to apply the same x axis selection to all graphs on a page when the user zoom on one graph (user zoom on one graph, zoom automatically applies to all graphs).
I’ve seen several posts on the topic but none give a full example.
I have tried this:

import dash
import dash_core_components as dcc
import dash_html_components as html
from dash.dependencies import Input, Output, ALL

# Sample data
data1 = {'x': [1, 2, 3, 4, 5], 'y1': [4, 6, 5, 8, 2]}
data2 = {'x': [2, 3, 4, 5, 6], 'y2': [2, 1, 3, 1, 5]}

# Function to generate a graph with unique ID
def generate_graph(data, graph_id):
    return dcc.Graph(
        id=graph_id,
        figure={
            'data': [{'x': data['x'], 'y': data[list(data.keys())[0]], 'name': list(data.keys())[0]}],
            'layout': {'xaxis': {'title': 'X-axis'}, 'yaxis': {'title': 'Y-axis'}}
        }
    )

# Create Dash app
app = dash.Dash(__name__)

# Create graphs
graph1 = generate_graph(data1.copy(), 'graph1')
graph2 = generate_graph(data2.copy(), 'graph2')

# App layout
app.layout = html.Div([graph1, graph2])

# Shared callback to update both graphs on zoom
@app.callback(
    Output({'type': 'graph'}, 'figure'),  # Update figure of both graphs
    [Input({'type': 'graph', 'index': ALL}, 'relayout')]  # Listen to relayout on any graph (ALL wildcard)
)
def update_all_figures(relayout):
    # Extract xaxis zoom range from relayout data (if available)
    x_range = relayout.get('xaxis.range', None)

    # Update figure layout with the shared zoom range (if any)
    return [{'data': data['data'], 'layout': {'xaxis': {'range': x_range}}} for data in [data1, data2]]

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

But nothing happens when I zoom in.

Could you please let me know what I’m missing?

Thank you.

relayoutData is quite tricky to deal with, so I think it’s fair to start by asking:

Could you do what you want by having two subplots with a shared x-axis?

fig = make_subplots(rows=2, shared_xaxes=True, ...)

… if you can’t, this works (except for the ‘reset axes’ button, which would need more coding):

import dash
from dash import Input, Output, ALL, dcc, html
import plotly.graph_objects as go
from dash.exceptions import PreventUpdate

# Sample data
data1 = {'x': [1, 2, 3, 4, 5], 'y': [4, 6, 5, 8, 2]}
data2 = {'x': [2, 3, 4, 5, 6], 'y': [2, 1, 3, 1, 5]}

# Function to generate a graph with unique ID
def generate_figure(data, xrange=None):
    figure=go.Figure()
    figure.add_trace(go.Scatter(x=data['x'], y=data['y']))
    if xrange:
        figure.update_xaxes(range=xrange)
    return figure

def generate_graph(data, graph_id):
    return dcc.Graph(
        id={'type': 'graph', 'index': graph_id},
        figure=generate_figure(data)
    )

# Create Dash app
app = dash.Dash(__name__)

# Create graphs
graph1 = generate_graph(data1.copy(), 'graph1')
graph2 = generate_graph(data2.copy(), 'graph2')

# App layout
app.layout = html.Div([graph1, graph2])

# Shared callback to update both graphs on zoom
@app.callback(
    Output({'type': 'graph', 'index': ALL}, 'figure'),  # Update figure of both graphs
    [Input({'type': 'graph', 'index': ALL}, 'relayoutData')],  # Listen to relayout on any graph (ALL wildcard)
    prevent_initial_call=True
)
def update_all_figures(relayout):
    # Extract xaxis zoom range from relayout data (if available)
    x_range = None
    if relayout:
        for elem in relayout:
            if 'xaxis.range[0]' in elem:
                x_range = [elem['xaxis.range[0]'], elem['xaxis.range[1]']]

    if x_range is None:
        raise PreventUpdate
    else:
        return [generate_figure(data, x_range) for data in [data1, data2]]

if __name__ == '__main__':
    app.run_server()

Thank you so much for this, very useful.

I think when trying to give a minimal reproductible example, I ommitted that the number of graphs in the page depends on checkboxes.

Ex: if checkbox A and B are selected, there are two graphs, if only checkbox A is selected, there is one graph etc…

So I need the zoom on any of the graphs to apply to any other graphs on the page.

I know there is the ALL feature that can be used to designated all graphs, but I’ve tried to pass it as an input to a callback and I got an error.

Do you have any advice on how I could achieve this?

Thank you very much for your help.

The code snippet I gave does use ALL, and does (I hope) allow for an indeterminate number of graphs in the input - the loop for elem in relayout: is finding which one has been zoomed.

The snippet as written doesn’t allow for an indeterminate number of graphs when constructing its output however. The length of the list in the output has to correspond to the number of matching elements - i.e. it has to be equal to len(relayout). I’ve assumed this is ==2, but it would need to be modified for your more general case.

Thank you very much, I see how I should loop through the list of dictionaries now.
I have edited the reproductible example to add a checklist and a third graph:

import dash
from dash import Input, Output, ALL, dcc, html
import plotly.graph_objects as go
from dash.exceptions import PreventUpdate

# Sample data
data_init = {'1': {'x': [1, 2, 3, 4, 5], 'y': [4, 6, 5, 8, 2]},
'2':{'x': [2, 3, 4, 5, 6], 'y': [2, 1, 3, 1, 5]},
        '3':{'x': [2, 3, 4, 5, 6], 'y': [2, 1, 3, 1, 5]}}

checklist_options_l = []
for each_op in data_init.keys():
    checklist_options_l.append({'label': each_op, 'value': each_op})

# Function to generate a graph with unique ID
def generate_figure(data, xrange=None):
    figure=go.Figure()
    figure.add_trace(go.Scatter(x=data['x'], y=data['y']))
    if xrange:
        figure.update_xaxes(range=xrange)
    return figure

def generate_graph(data, graph_id):
    return dcc.Graph(
        id={'type': 'graph', 'index': graph_id},
        figure=generate_figure(data)
    )

# Create Dash app
app = dash.Dash(__name__)

# App layout
app.layout = html.Div(children = [dcc.Checklist(
                                      id='checklist',
                                      options=checklist_options_l,
                                      value=[]  # No initial selection
                                  ),
                                    html.Div(id='graphs-container')
                                  ])

@app.callback(
    Output(component_id='graphs-container', component_property='children'),
    [Input(component_id='checklist', component_property='value')],
    prevent_initial_call=True
)

def get_graphs(checklist_options):
    graphs = []
    for each_option in checklist_options:
        graph = generate_graph(data_init[each_option].copy(), 'graph' + each_option)
        graphs.append(graph)

    return graphs

# Shared callback to update both graphs on zoom
@app.callback(
    Output({'type': 'graph', 'index': ALL}, 'figure'),  # Update figure of both graphs
    [Input(component_id='checklist', component_property='value'),
     Input({'type': 'graph', 'index': ALL}, 'relayoutData')],  # Listen to relayout on any graph (ALL wildcard)
    prevent_initial_call=True
)
def update_all_figures(selected_options, relayout):
    data_l = []
    for each_option in selected_options:
        data_l.append(data_init[each_option])
    # Extract xaxis zoom range from relayout data (if available)
    x_range = None
    if relayout:
        for elem in relayout:
            print(elem)
            if 'xaxis.range[0]' in elem:
                x_range = [elem['xaxis.range[0]'], elem['xaxis.range[1]']]

    if x_range is None:
        raise PreventUpdate
    else:
        return [generate_figure(data, x_range) for data in data_l]

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

It works great if I select only 2 checkboxes (any of them), but the app throws an error if I try to select all three checkboxes and I can’t see why.

The graphs don’t all reset when I double click on one graph to reset the zoom on every graph.

Thank you so much for your time and help.

I’ve tried this as I realised the list in x_range contained 2 elements which maybe need to have as many as the selected options, but it didn’t fix the issue:

def update_all_figures(selected_options, relayout):
    data_l = []
    for each_option in selected_options:
        data_l.append(data_init[each_option])
    # Extract xaxis zoom range from relayout data (if available)
    x_range = None
    if relayout:
        for elem in relayout:
            print(elem)
            if 'xaxis.range[0]' in elem:
                #x_range = [elem['xaxis.range[0]'], elem['xaxis.range[1]']]
                x_range = [elem['xaxis.range[' + str(i) + ']'] for i in range(0,len(data_l)+1)]
    if x_range is None:
        raise PreventUpdate
    else:
        return [generate_figure(data, x_range) for data in data_l]

Evidently I don’t know what I’m doing and just taking shots in the dark :see_no_evil: .

I think the crashing is because ‘elem’, can be None, in which case if ‘xaxis.range[0]’ in elem fails.
That just needs an extra test to be inserted to avoid the error.

But then relayoutData really is quirky and difficult to deal with.

Everyone is ‘taking shots in the dark’ in the sense that (I think) it’s impossible to find out exactly what turns up in relayoutData from the documentation. The practical way to find out is try it and see. Proper debug environments (such as you get in VS Code) are (imo) enormously helpful - it’s possible to set a breakpoint inside a callback, and examine exactly what’s in the data when the callback gets triggered.

In this case what you find is that on double-click (and also the reset axes button) you get relayoutData that looks like this…
[{'xaxis.autorange': True, 'yaxis.autorange': True}, {'autosize': True}]
… and you have to find some way of coping with that

I’m not sure you’re finished even then. It’s all very complex - are you really sure you can’t do this using subplots with a shared X axis? That feels like an enormously simpler way to go about it.

Thank you very much for this!

I was printing elem so I could see when I selected the third plot I got a None instead of {‘autosize’:True}, but I don’t understand why.
I was wondering if it has to do with the fact that the first callback needs to run first to output the relayout containing 3 graphs and not 2, and then the second callback can output the relayout to all figures after a zoom. But if that’s what is going on I don’t know how to make sure the first callback starts before the second. I’ve seen other potsts that suggest looking at using State and passing it from first callback to second callback but didn’t manage to implement it.

I run in Pycharm with breakpoint but breakpoint within callbacks don’t work. I should try in VS Code maybe.

I started with a subplot but that was undreadable: I need to be able to remove lines on click and zoom to look at the timeseries and the x axis was also too far below, and the legend not aligned with the corresponding plots. I have up to 10 plots to display and I look at the discrepancy between forecast and historical at a certain point in time by zooming and then I scroll down the page to look at the variables and understand which one is likely responsible for the effect I see on the first plot.

I’ll look into trying to ignore the None and see what happens.

Thanks a lot for your help!

Ok just saw the breakpoint within callbacks DO work in Pycharm :see_no_evil: , no idea where I got the idea it didn’t…
That’s gonna make my life a bit easier investigating this :female_detective:

Breakpoints in callbacks work very reliably for me in VS Code (using app.run_server(debug=False) )

I run it client-server with the Dash app running on a Linux box and the browser on my laptop, so hoping it also works with everything on a laptop, which I guess is how most people are set up.

I’ve spent quite some time stepping into the code and printing the relayout but I don’t see anything that explains why the relayout content suddenly becomes None at the third graph but not at the second. Because you first wrote the script of 2 fixed graphs, I wonder if there is something in the script that I don’t see and that still relate to a fixed 2 graph layout and that I need to make dynamic. But I’ve looked a lot and really can’t see what.
I’ll post a separate question with the checkbox and dynamic graphs as my initial question here didn’t originally specify the dynamic nature of the graphs.
Thanks a lot for your help!

Probably a quirk of plotly, not something wrong with your code. And probably very hard to track down why it does this, with no real benefit in knowing. The pragmatic approach is to find out what plotly actually delivers, and then work out how to deal with it.

(Having said that, if any plotly guru here does happen to know why it does this, it would be interesting to know :slight_smile: )

1 Like