Apply zoom on any graphs to all graphs that are dynamically generated (number of graphs vary depending on dropdown input)

Hello,
I have an app where the number of graphs depends on the value selected by the user in a dropdown menu.
I want to apply any zoom the user make on any graphs to be applied to all other graphs.

I have made the reproductible example below but it’s not doing anythine:

import dash

import plotly.graph_objs as go
import pandas as pd
from dash import Input, Output, ALL, dcc, html, State
import datetime as dt

app = dash.Dash(__name__)


# Load data
df = pd.read_csv(
    "https://raw.githubusercontent.com/plotly/datasets/master/finance-charts-apple.csv")
df.columns = [col.replace("AAPL.", "") for col in df.columns]
df['Date'] = pd.to_datetime(df['Date'])
dropdown_options = [{'label': cat, 'value': cat} for cat in [1,2,3]]

app.layout = html.Div([dcc.Dropdown(id='graphsnumber-dropdown', options=dropdown_options, value=1),
        html.Div(id='graph-container') ,
        html.Div(id='OutputContainer')
        ])

@app.callback(
    Output('graph-container', 'children'),
    [Input('graphsnumber-dropdown', 'value')])

def generate_graphs(graphs_num):
    graphs = []
    print(graphs_num)
    for each_num in range(0, graphs_num):
        temp_graph = go.Figure()
        temp_graph.add_trace(
            go.Scatter(x=list(df.Date), y=list(df.High)))
        graphs.append(dcc.Graph(figure=temp_graph))

    return graphs

@app.callback(
    Output({'type': 'graph', 'index': ALL}, 'relayoutData'),
    Output({'type': 'graph', 'index': ALL}, 'figure'),
    Input({'type': 'graph', 'index': ALL}, 'relayoutData'),
    State({'type': 'graph', 'index': ALL}, 'figure'))
def LinkedZoom(relayout_data, figure_states):
    print(relayout_data)
    print(figure_states)
    unique_data = None
    for data in relayout_data:
      if relayout_data.count(data) == 1:
        unique_data = data
    if unique_data:
      for figure_state in figure_states:
        if unique_data.get('xaxis.autorange'):
          figure_state['layout']['xaxis']['autorange'] = True
        else:
          figure_state['layout']['xaxis']['range'] = [unique_data['xaxis.range[0]'], unique_data['xaxis.range[1]']]
          figure_state['layout']['xaxis']['autorange'] = False
      return [unique_data] * len(relayout_data), figure_states
    return relayout_data, figure_states



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

Does anyone know how to make this work?

Thank you.

This might give you an idea.

You would listen too all figures and apply the changed axis range to all other figures [pattern matching callbacks]

Hi, I think what you’ve written is on the right lines.
As it stands the callback is not getting triggered because you’ve not added ids to the dcc.Graph components
If you make a one-line edit to make it…

        graphs.append(dcc.Graph(figure=temp_graph, id={'type':'graph','index':each_num}))

… then the callback does get triggered when you zoom a graph.

The callback will still need a bit of debugging - it’s throwing exceptions on load, for example, and I don’t think the test if relayout_data.count(data) == 1 is doing what you want it to do, but hopefully once the callback is being triggered you should be able to fix those things.

I think once the bugs are sorted it should work, but please say if it’s still problematic

Hi again David :sweat_smile: , sorry to bug you once more with this.

Adding the line to generate the graphs id really helped, thank you.

I got the app to mostly work, but something strange is happening and no matter how much I tracked the callback progress and outputs/inputs through printing and breakpoints, I really don’t understand what is happening.

If I start the app, then select 3 from the dropdown (thereby generating 3 graphs), zooming in and out on any of the 3 graphs triggers the same zoom on all other graphs (although if I zoom in one graph, and then double click on a different graph from the one I zoomed in, the reset doesn’t show the whole y axis on the initial graph used for the zoom).

If I select 2 from the dropdown (thereby generating 2 graphs), zooming on the first graph doesn’t apply the zoom to the second graph, but zooming on the second graph does apply the zoom to the first graph.

Please see below the latest version of the reproductible example, with lots of prints to keep track of the data throughout the callbacks as well as a counter for how many times the callback is fired.

import dash

import plotly.graph_objs as go
import pandas as pd
from dash import Input, Output, ALL, dcc, html, State
import datetime as dt

app = dash.Dash(__name__)


# Load data
df = pd.read_csv(
    "https://raw.githubusercontent.com/plotly/datasets/master/finance-charts-apple.csv")
df.columns = [col.replace("AAPL.", "") for col in df.columns]
df['Date'] = pd.to_datetime(df['Date'])
dropdown_options = [{'label': cat, 'value': cat} for cat in [1,2,3]]

app.layout = html.Div([
        dcc.Input(id='number-input', type='number', value=0),
        dcc.Dropdown(id='graphsnumber-dropdown', options=dropdown_options, value=1),
        html.Div(id='graph-container') ,
        html.Div(id='OutputContainer')
        ])
callback_counter = 0
@app.callback(
    Output('graph-container', 'children'),
    [Input('graphsnumber-dropdown', 'value')])

def generate_graphs(graphs_num):
    graphs = []
    print(graphs_num)
    for each_num in range(0, graphs_num):
        temp_graph = go.Figure()
        temp_graph.add_trace(
            go.Scatter(x=list(df.Date), y=list(df.High)))
        graphs.append(dcc.Graph(figure=temp_graph, id={'type': 'graph', 'index': each_num}))
    return graphs

@app.callback(
    Output({'type': 'graph', 'index': ALL}, 'relayoutData'),
    Output({'type': 'graph', 'index': ALL}, 'figure'),
    Output('number-input', 'value'),
    Input('number-input', 'value'),
    Input({'type': 'graph', 'index': ALL}, 'relayoutData'),
    State({'type': 'graph', 'index': ALL}, 'figure'))
def LinkedZoom(callback_counter, relayout_data, figure_states):
    callback_counter = callback_counter + 1
    print('Firing callback number : ' + str(callback_counter))
    print('                                           ')
    print('*********************************************************************************')
    print('Number of figures at start of callback: ' + str(len(figure_states)) + '.')
    print('                                           ')
    i = 1
    for each_f in figure_states:
        for layout_k in [k for k in each_f['layout'].keys()]:
            if layout_k == 'xaxis':
                print('Figure ' + str(i) + ' xaxis layout state looks like this: ')
                print(each_f['layout']['xaxis'])
                print('                                           ')
            if layout_k == 'autosize':
                print('Figure ' + str(i) + ' autosize is set to: ')
                print(each_f['layout']['autosize'])
                print('                                           ')
        i = i + 1
    print('                                           ')
    print('*********************************************************************************')
    print('Relayout data at start of callback: ')
    print(relayout_data)
    unique_data = None
    for data in relayout_data:
      if relayout_data.count(data) == 1:
          print('                                           ')
          print('*********************************************************************************')
          print('data in ralayout_data when there is only 1 figure : ')
          print(data)
          unique_data = data
      else:
          print('                                           ')
          print('*********************************************************************************')
          print('data in ralayout_data when length of relayout_data is : ' + str(len(relayout_data)))
          print(data)
    if unique_data:
      for figure_state in figure_states:
        print('                                           ')
        print('*********************************************************************************')
        if unique_data.get('xaxis.autorange'):
          print('                                           ')
          print('If the layout in unique_data contains xaxis.autorange, xaxis reset on all figures.')
          figure_state['layout']['xaxis']['autorange'] = True
        else:
            if 'xaxis.range[0]' in unique_data.keys():
                print('                                           ')
                print('Given the user zoom, unique_data xaixs.range is :')
                print([unique_data['xaxis.range[0]'], unique_data['xaxis.range[1]']])
                figure_state['layout']['xaxis']['range'] = [unique_data['xaxis.range[0]'], unique_data['xaxis.range[1]']]
                figure_state['layout']['xaxis']['autorange'] = False
            else:
                print('                                           ')
                print('unqiue_data does not contain an xaxis.range[0]')

      else:
          print('                                           ')
          print('no zoom made on any figure.')

      return [unique_data] * len(relayout_data), figure_states, callback_counter

    else:
        print('*********************************************************************************')
        print('Unique_data is None')

    return relayout_data, figure_states, callback_counter



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

Please let me know if you see where things go wrong.

That solution is SO close :pray: .

Thank you.

I think your problem is that test - if there are two graphs and you zoom the first one you get relayout_data that look like

[some_data, None]

… and relayout_data.count(data) == 1 for both the items in that list

Thansk David,

I’m sorry I’ve spent hours looking at the output of firing the callback and I just don’t understand how it works.

I don’t know why if relayout_data.count(data) == 1 is needed, but I know if I remove it then the callback doesn’t trigger on zoom.

I have added and data != None afterwards and now the zoom sometimes extends when I select 2 in the dropdown, but it’s inconsistent: I’ve observed I first need to select 3 in the dropdown for the zoom to extend when I select 2 in the dropdown, and the result is very random: the zoom might extend from graph 2 to graph 1 for instance but not the otherway around or vice versa.

I just don’t understand when the callback is fired. For instance, the first time the page loads, without selecting anything in the dropdown, the callback is fired three times:

  • the first time, unique_data is None, nothing else is set (no relayout)
  • the second time, relayout_data.count(data) is 1, and data is None, Unique_data is also None
  • the third time, relayout_data.count(data) is 1, and data is {'autosize': True}

But if I select 2 from the dropdown (thereby generating 2 graphs), the callback fires only once:

  • for the first element in relayout_data, relayout_data.count(data) is 2, data is None, Unique_data is None and for the second element in relayout_data these variables have the same values. No more callback is being fired.

If I select 3 from the dropdown (thereby generating 3 graphs), the callback fires twice:

  • the first time the three elements in relayout_data (data) are all None and Unique_data is also None. relayout_data.count(data) is always 3.
  • then the callback is fired a second time (unlike with 2 graphs above), the relayout_data.count(data) is 2 for the first 2 elements of relayout_data, and then for the 3rd element the relayout_dta.count(data) is 1 and data is {'autosize': True}

I don’t know how the app decides to fire these callbacks with no input from the user, and why there are 2 callbacks when the number of graphs in the dropdown is set to 3, but there’s only 1 callback when the dropdown is set to 2.

Even weirder: if after selecting 3 from the dropdown I select 2 and I zoom in one of the graph, then the zoom is extending to the second graph. But if I try to remove the zoom (autosize) on the first graph, then nothing happens, the zoom persists on both graphs. However if I reset the zoom on the second graph, then the zoom resets on both graphs. In the printout of the callback I can see that when the zoom/unzoom is extended to both graphs, relayout_data.count(data) is 1 instead of 2 (despite 2 graphs), but I have no idea why.

I’m really at a loss :frowning:

Please let me know if you suspect what’s going on.

Thanks a lot.

OK, I get it, this is really tricky. I should have known, but I maybe forgot that using relayout is always difficult. :roll_eyes:

Honestly, I think it’s incredibly difficult to work out what’s going on from print statements - a decent debugger (e.g. VS code) is close to essential for this sort of thing.

The code below does (I think) work, though it might not have all the functionality you want.

The thing that makes it so confusing is that, after you’ve zoomed on different graphs, relayout data seems to come through with data populated for more than one graph (every graph you’ve zoomed on, I think), and that data does not necessarily represent the actual current state of the graph (I think it might remember the last zoom made on that graph).

I don’t know an easy way of working out which graph the user has actually zoomed on. So I’ve chosen to ignore the relayout data completely (although that is still what is triggering the callback). Instead I’m storing the most recent range in a dcc.Store, and looking at the figure data to see which one has changed.

import plotly.graph_objs as go
import pandas as pd
from dash import Dash, Input, Output, ALL, dcc, html, State
from dash.exceptions import PreventUpdate

app = Dash(__name__)

# Load data
df = pd.read_csv(
    "https://raw.githubusercontent.com/plotly/datasets/master/finance-charts-apple.csv")
df.columns = [col.replace("AAPL.", "") for col in df.columns]
df['Date'] = pd.to_datetime(df['Date'])
dropdown_options = [{'label': cat, 'value': cat} for cat in [1,2,3]]

app.layout = html.Div([dcc.Dropdown(id='graphsnumber-dropdown', options=dropdown_options, value=1),
        html.Div(id='graph-container') ,
        html.Div(id='OutputContainer'),
        dcc.Store(id='rangestore')
        ])

@app.callback(
    Output('graph-container', 'children'),
    [Input('graphsnumber-dropdown', 'value')])

def generate_graphs(graphs_num):
    graphs = []
    for each_num in range(0, graphs_num):
        temp_graph = go.Figure()
        temp_graph.add_trace(
            go.Scatter(x=list(df.Date), y=list(df.High)))
        graphs.append(dcc.Graph(figure=temp_graph, id={'type':'graph','index':each_num}))
    return graphs

@app.callback(
    Output({'type': 'graph', 'index': ALL}, 'figure'),
    Output('rangestore','data'),
    Input({'type': 'graph', 'index': ALL}, 'relayoutData'),
    State({'type': 'graph', 'index': ALL}, 'figure'),
    State('rangestore','data'),
)
def LinkedZoom(relayout_data, figure_states, rangestore):
    xrange = None
    for figure in figure_states:
      xrtemp = figure['layout']['xaxis']['range']
      if rangestore is None or xrtemp != rangestore:
        xrange = xrtemp
    if xrange:
      for figure_state in figure_states:
        figure_state['layout']['xaxis']['range'] = xrange
        figure_state['layout']['xaxis']['autorange'] = False
      return figure_states, xrange
    else:
      raise PreventUpdate

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

Thank you so much David, it works perfectly (apart from a KeyError: 'xaxis in one of the times the callback was fired, but that doesn’t affec the app and only occured once.
PS: I’ve been using the debugger in Pycharm the whole time but still couldn’t figure things out :see_no_evil: .

Good that it works :slightly_smiling_face:

Somehow I failed to remember that ctx.triggered_id is there to tell us which element triggered the callback, and the dcc.Store isn’t needed if you use that. Here’s a less clunky version of the callback that also uses Patch()

I still feel that retrieving the state of all the figures is still a bit clunky though - that could probably be avoided (if using Patch()) if we go back to using relayoutData as the source of the range information. But then using the figure as the source of the range information has the advantage that is also works when someone presses the ‘reset_axes’ button.

@app.callback(
    Output({'type': 'graph', 'index': ALL}, 'figure'),
    Input({'type': 'graph', 'index': ALL}, 'relayoutData'),
    State({'type': 'graph', 'index': ALL}, 'figure'),
    prevent_initial_call=True
)
def LinkedZoom(relayout_data, figure_states):
    trigger = ctx.triggered_id
    if 'xaxis' in figure_states[trigger["index"]]["layout"]:
      xrange = figure_states[trigger["index"]]['layout']['xaxis']['range']
      patched_figure = Patch()
      patched_figure['layout']['xaxis']['range'] = xrange
      patched_figure['layout']['xaxis']['autorange'] = False
      return [patched_figure for _ in figure_states]
    else:
      raise PreventUpdate