Update dynamically created figure y-axis based on rangeselector

i have a very simple layout:

app.layout = dbc.Container([dbc.Tabs([dbc.Tab(label='Tab1', tab_id='tab1'),
                                      dbc.Tab(label='Tab2', tab_id='tab2')],
                                      id='tabs',
                                      active_tab='tab1'),
                            html.Div(id='tab-content')])

then based on some database settings, the flask-login current user has a list of dicts that looks like

current_user.charts = [{'chart_id': 'chart_1', 'asset': 'someasset', 'lookback': '2Y'},
                       {'chart_id': 'chart_2', 'asset': 'someasset2', 'lookback': '5Y'}]

so then in my callbacks.py file i have the code

def register_callbacks(dashapp):
    @dashapp.callback(
        Output('tab-content', 'children'),
        [Input('tabs', 'active_tab')])
    def render_tab(active_tab):
        if active_tab == 'tab1':
           return render_tab1(current_user.charts)
        else:
           return html.Div([html.Br(), 'some junk tab2 text'])

    def render_tab1(chart_list):
        charts = []
        for chart_settings in chart_list:
           chart_id = chart_settings['chart_id']
           chart_data_df = get_data(chart_settings['asset'], 
                                    chart_settings['lookback'])
           #chart_data_df is a pandas dataframe index=DateTimeIndex, with one column close
           #which is a timeseries of prices for the given asset.
           fig = plotyexpress.line(chart_data_df,
                                   labels={'variable': 'Data Legend'})
            
           fig.update_xaxes(tickangle = 45, title_text = "Date")

           fig.update_yaxes(range=[0, max(chart_data_df.max(axis=1)) * 1.25],
                            title_text = "Close")
           #create the rangle slider
           fig.update_layout(
                xaxis=dict(
                    rangeselector=dict(
                        buttons=list([ 
                            {'count': 1, 'label': '1m', 'step': 'month', 'stepmode':'backward'},
                            {'count': 6, 'label': '6m', 'step': 'month', 'stepmode':'backward'},
                            {'count': 1, 'label': 'YTD', 'step': 'year', 'stepmode':'todate'},
                            {'count': 1, 'label': '1y', 'step': 'year', 'stepmode':'backward'},
                            {'step': 'all'}
                        ])
                    ),
                    rangeslider={'visible': False},
                    type="date"
                )
           )
           charts.append(dcc.Graph(id=chart_id, figure=fig))
        return html.Div(charts, id='tab1_charts')

this does an excellent job of creating my charts on the tab as the page is rendered, and it allows me to customize the charts based on updating the database, versus hardcoding them.

however my question is that when you use the rangeselector it doesn’t change the Y-axis scale of the graphs. all the documentation on the callbacks

for example, the callback has to define the id of the figure in the callback decorator, however I don’t know this when the app is registering, since its unknown until the page is rendered. thus I don’t know how to properly draft a callback function to adjust the y-axis min/max

any idea?

Hi,

Welcome to the community! :slight_smile:

This sounds like a tough problem… So let me start with the easy part:

for example, the callback has to define the id of the figure in the callback decorator, however I don’t know this when the app is registering, since its unknown until the page is rendered

If you are looking for a generic callback that would work for some (or all) dcc.Graph components in the page, you can use a pattern matching callback with MATCH. So instead of define dcc.Graph(id=chart_id), you should use dcc.Graph(id={"type": "chart", "index": chart_id}), then the callback can use the pattern Input({"type": "chart", "index": MATCH}, "figure") (or Output/State, or other prop)…

The solution that you shared in the link will not work outside FigureWidgets (jupyter notebooks), so for Dash you need to do something slightly different. A good starting point is a callback like this:

@app.callback(
    Output({"type": "chart", "index": MATCH}, "figure"),
    Input({"type": "chart", "index": MATCH}, "relayoutData"),
    State({"type": "chart", "index": MATCH}, "figure"),
    prevent_initial_update=True,
)
def update_figure(relayout_data, fig):
    if (relayout_data is None) or ("xaxis.range[0]" not in relayout_data):
        raise PreventUpdate  # from dash.exceptions import PreventUpdate

    in_view = df.loc[relayout_data["xaxis.range[0]"] : relayout_data["xaxis.range[1]"]]

    # You must have layout_yaxis_autorange set to False
    fig["layout"]["yaxis"]["range"] = [
        in_view.min(),
        in_view.max(),
    ]

    return fig

Your rangeselector buttons will trigger a relayoutData update that in some cases will come with the new range for x axis (as well as when you zoom a portion of the timeseries). I use the xaxis range to figure out which points are “in view”, just like in @jmmease example. Knowing that, you can redefine the yaxis range and return the modified figure dict.

Note that 1- I haven’t added any extra spacing in the min/max, so you might want to adapt it a bit to your convenience, and 2- this is supposed to be a first working example of such functionality and the performance is bad (data is sent back-and-forth to the client, which is slow).

The way to solve point 2 is to write a clientside callback to do this update. Here is a much simplified version of such figure update on the javascript side.

1 Like

awesome, this is super helpful (and yes my goal was to get it working slower on the server-side and then move it to a client side javascript callback). question for you, on the rangeslider the last button {'step': 'all'} which basically resets the y-axis to its original form, when this button is clicked, the callback is not fired (and thus the range is the last selected one. how does this event get fired so it can be reset ?

Hello Sir may I ask if you can see my problem with my dash app, i think i posted it in the forum

Can you print how relayout_data looks like when you press the last button and share with me?

I don’t think that this particular relayout event has coordinates keys on it, so the callback update is prevented. What you could do is to compute the initial x range (when the plot is created) , then use the relayout_data to identify when the “all” button is pressed (or when there is a double-click in the graph, I believe they are the same…) and use the initial range in that case. Or it could be just a matter of changing the if statement to do something like:

if (relayout_data is None):
        raise PreventUpdate  # from dash.exceptions import PreventUpdate

if  "xaxis.range[0]" not in relayout_data:
    # relayout_data is not none, but it doesn't have coordinates either
    # so it should use the entire dataframe as "in_view"
    in_view = df
else:
    in_view = df.loc[relayout_data["xaxis.range[0]"] : relayout_data["xaxis.range[1]"]]
#...

yeah thats my bad, i figured it out, its just in that initial if block, now just how to code it in javascript, although server side this still works very fast, so i might not even bother.

1 Like