Callback to update x-axis with relayOut data

Hi, I have been trying to create a web app to analyse a GPS trace. I need to get specific portions of the trace to compile relevant data from that section. I think what I need is to get the relayOut data from an event, for now selection-data fits well. With the min and max ‘x’ values redraw the figure within that range of values.
I have looked at the following docs and a similar question.

https://dash.plotly.com/clientside-callbacks
https://plotly.com/javascript/plotlyjs-function-reference/
https://community.plotly.com/t/simple-dash-app-yaxis-autoscale-on-xaxis-zoom-in-stock-market-plot/40463/2

I have pieced together some example code which I think is close but not quite there. Any help on this would be appreciated.
Thanks

import pandas as pd
import numpy as np
from dash import Dash, dcc, html, Input, Output, State
import plotly.graph_objects as go
import json
import dash
#create demo data
Bias=10
d = np.arange(0,650, 5).tolist()
s = np.abs(np.sin(d))
s = s + Bias

styles = {'pre': {'border': 'thin lightgrey solid','overflowX': 'scroll'}}

df = pd.DataFrame({'distance': d,'speed': s})

app = Dash(__name__)

#create dash graph
fig = go.Figure()

fig.add_trace(go.Scatter(x=df['distance'], y=df['speed'],
                    mode='lines+markers',
                    name='speed'))

app.layout = html.Div([
    html.Div([
        dcc.Dropdown(
            id='distance-selection',
            options=[
                {'label': '250m', 'value': 250},
                {'label': '500m', 'value': 500},
                ],
            value=500,
            clearable=False

        )], style={'width': '15%', 'display': 'inline-block'}
    ),
	html.Div([
        dcc.Graph(
        id='demo-graph',
        figure=fig
    ), dash.html.Div(id="where")
    ]),
    html.Div([
        dcc.Graph(
        id='demo-graph2',
        figure=fig
    ),
    ]),
    html.Div(className='row', children=[
        html.Div([
            dcc.Markdown("""
                **Selection Data**

            """),
            html.Pre(id='selection-data', style=styles['pre']),
        ], style={'width': '49%', 'float': 'right'}),
    ])    
])

@app.callback(
    Output('selection-data', 'children'),
    Input('demo-graph', 'selectedData'))
def display_selected_data(selectedData):
    return json.dumps(selectedData, indent=2)        


@app.callback(
    Output('demo-graph2', 'figure'),
    Input('demo-graph', 'relayoutData'),
    Input('demo-graph', 'figure'))
def update_graph(relOut, Fig):
    xmin = df.loc[relOut["xaxis.range"]].min()
    xmax = df.loc[relOut["xaxis.range"]].max()
    dff = df[df['distance'].between(xmin, xmax)]
    newLayout = go.Layout(
        title="New Graph",
        xaxis=dff['distance'],
        yaxis=dff['speed']
    )
    Fig['layout'] = newLayout
    return Fig



"""
app.clientside_callback(
    
    function(relOut, Figure) {
        fromS = relOut["xaxis.range"][0]
        toS = relOut["xaxis.range"][1]

    Figure.layout.xaxis = {
        'range': [fromS,toS]
    }
    return {'layout': Figure.layout}:
    }
    ,
    Output('demo-graph2', 'figure'),
    Input('demo-graph', 'relayoutData') 
)
"""


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

Hi @brendon,

Welcome to the community! :slight_smile:

Letting aside the clientside callback part, which is a bit more complicated, there are a few things to adjust in your example in order to make the code work.

First one general observation about your callback. Assuming that there were no bugs and looking purely on Fig, you would expect the relayout to “work” no matter what you do, since you are basically returning graph1 where the layout is already updated. It could be that I am missing something, but doing this might not be very illustrative of what you want to accomplish (change the xaxis range for one graph based purely on relayoutData from another graph).

Regarding the callback “bulk”, you have to be careful because not always the relayoutData will have the axis range as part of the dictionary and you should expect a KeyError when it doesn’t . Besides, I believe “relayoutData” will be None when the graph renders the first time, so you need to set prevent_initial_call=True or handle the case where it is None

Second “xaxis.range” is not a key of relayoutData, but “xaxis.range[0]” and “xaxis.range[1]” are. If you correct it, I would expect an error in xmin and xmax because you are “loc-ing” with a float (pandas will raise an error).

Lastly, what you probably want to specify in the new layout are the axis ranges and they are not pd.Series, but an array with [min, max].

In summary, this should work (please follow up if it doesn’t):

@app.callback(
    Output('demo-graph2', 'figure'),
    Input('demo-graph', 'relayoutData'),
    State('demo-graph2', 'figure'),
    prevent_initial_call=True
)
def update_graph(relOut, fig2):
    if "xaxis.range[0]" not in relOut:
        return fig2
    xmin = relOut["xaxis.range[0]"]
    xmax = relOut["xaxis.range[1]"]
    dff = df[df['distance'].between(xmin, xmax)]
    newLayout = go.Layout(
        title="New Graph",
        xaxis_range=[dff['distance'].min(), dff['distance'].max()], # or [xmin, xmax]
        yaxis_range=[dff['speed'].min(), dff['speed'].max()],
    )
    fig2['layout'] = newLayout
    return fig2
1 Like

Thanks @jlfsjunior, the explanation and code was very helpful.
I’ve amended my actual project code and it is on it’s way to doing what it needs to do.
The graph now resets to the start of the effort, but I think I will be better off from usability in setting the start point with a click-event and then using a slider to scale back to the end of the effort.

For some context, I’m building an app that can display the gps trace from training or competition efforts for analysis by coaches/athletes. Hence my need to change the x axis values to start again from 0 at the start of the selected portion of the graph.

An important question I have is, how to send the new data to update the table? Once the graph is updated, I need the table to update with the currently selected data.

The code below throws some exceptions on this.
I’ve tried differing input’s, both selected and relayout, they have differing issues. Would the table update be better off inside the same callback as the graph?

If there’s an example of something similar that is around I’d be happy to look through it.

The csv and script can be found here
csv data
script

from dash import Dash, dcc, html, Input, Output, State, dash_table
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import dash_bootstrap_components as dbc
import pandas as pd
import json

df = pd.read_csv('DA500.csv')
external_stylesheets = [dbc.themes.DARKLY] # enables dark mode in plot using bootstrap
app = Dash(__name__)
fig = go.Figure()

fig.add_trace(go.Scatter(x=df['distance'], y=df['speed'],
                mode='lines+markers',
                name='speed'))
colors = {
    'background': '#171717',
    'text': '#FFFFFF'
}
styles = {
    'pre': {
        'border': 'thin lightgrey solid',
        'overflowX': 'scroll', 'display':'inline-block', 'width': '49%'
    }
}
bins = [0, 50, 100, 150, 200, 250, 300, 350, 400, 450, 500]
aggfuncs = ['mean', 'max']
col_names = ['var', '0', '50', '100', '150', '200', '250', '300', '350', '400', '450', '500']
df = df[['distance', 'speed', 'strokerate', 'split_d']]
df.loc[:, 'splits']=pd.cut(df['distance'], bins) #create new column 'splits' indexing all rows for Distance grtouped into the bins
dff=df.groupby('splits')[['speed', 'strokerate']].agg(aggfuncs)
dff=dff.round(1)
dff_t=dff.T
dff_t =dff_t.reset_index()
dff_t.columns = col_names

def generate_table(dff_t, max_rows=5):
    return html.Table([
        html.Thead(
            html.Tr([html.Th(col) for col in dff_t.columns])
        ),
        html.Tbody([
            html.Tr([
                html.Td(dff_t.iloc[i][col]) for col in dff_t.columns
            ]) for i in range(min(len(dff_t),max_rows))
        ])
    
    ])

app.layout = html.Div([
    html.Div([

        html.Div([
            html.Div([
                html.Label('Distance Selection'),]),

            dcc.Dropdown(
                id='distance-selection',
                options=[
                    {'label': '250m', 'value': 250},
                    {'label': '500m', 'value': 500},
                ],
                value=500,
                clearable=False

            )], style={'width': '15%', 'display': 'inline-block'}

        ),

        html.Div([
            html.Div([
                html.Label("Select SR Y-axis:"),], style={'float': 'right','font-size': '18px', 'width': '40%'}),

            html.Div([
                dcc.RadioItems(
                    id='radio',
                    options=['Primary', 'Secondary'],
                    value='Secondary',
                    labelStyle={'float': 'right', 'margin-right': 10}
            ),
        ], style={'width': '49%', 'float': 'right'}),
        ], 
    )]),

    html.Div([
        dcc.Graph(
            id="graph", figure = fig           
        ), 
        dcc.Store(
            id='store-value',
        )
    ], style={'width': '100%', 'height':'100%', 'display': 'inline-block', 'padding': '0 20'}),
    html.Div([
        html.Div([
            html.H4(children='vbo-plot Session Data'),
            html.Div(children=[dash_table.DataTable(id='split-table')
            ]),
            generate_table(dff_t,max_rows=5),
            html.Pre(id='selection-data', style=styles['pre']),            
            dcc.Markdown("""
                **Click Data**
                Selection Data
                Click on start point on the speed curve.
            """),
        ],)
    ]),

], style={'backgroundColor': 'white'},
)
#get stored data for checking
@app.callback(
    Output('selection-data', 'children'),
    #Output('store-value', 'data'),
    Input('graph', 'selectedData'))
def display_click_data(selectedData):
    return json.dumps(selectedData, indent=2)

#update fig on selection data   
@app.callback(
    Output('graph', 'figure'),
    Input('graph', 'relayoutData'),
    State('graph', 'figure'),
    prevent_initial_call=True)
def update_graph(relOut, fig):
    if "xaxis.range[0]" not in relOut:
        return fig
    xmin = relOut["xaxis.range[0]"]
    xmax = relOut["xaxis.range[1]"]
    dff = df[df['distance'].between(xmin, xmax)]
    dff['distance']=dff.split_d.cumsum()

    fig = go.Figure()
    fig.add_trace(go.Scatter(x=dff['distance'], y=dff['speed'],
                mode='lines+markers',
                name='speed'))
    #create secondary axis for stroke rate

    newLayout = go.Layout(
        title="updated-now",
        xaxis_range=[dff['distance'].min(), dff['distance'].max()],
        yaxis_range=[dff['speed'].min(), dff['speed'].max()],
    )
    fig['layout'] = newLayout
    return fig

#table callback with new split data
"""
@app.callback(
     Output('split-table', 'data'),
     Input('graph', 'relayoutData'),
)   
def update_table(relOut, dff):
    if "xaxis.range[0]" not in relOut:
        return generate_table()
    xmin = relOut["xaxis.range[0]"]
    xmax = relOut["xaxis.range[1]"]
    dff = df[df['distance'].between(xmin, xmax)]
    dff.loc[:, 'splits']=pd.cut(dff['distance'], bins)
    dff=df.groupby('splits')[['speed', 'strokerate']].agg(aggfuncs)
    dff=dff.round(1)
    dff['distance']=dff.split_d.cumsum() 
    dff_t=dff.T
    dff_t =dff_t.reset_index()
    dff_t.columns = col_names
    return generate_table(dff_t, max_rows=5)
"""

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

Yes, especially because you have repeated code to generate the graph and the table.

It is a bit late for me to look into today, but I can spot in your code that you were trying to pass an html.Table to the data prop of DataTable and that will not work. You can pass html.Table to a html.Div component as “children”, or you have to pass a “json-ified” pandas dataframe to dash_table.DataTable “data”.

Please refer to the documentation to the later, there are simple examples there. If you still can’t figure it out, please let me know and I will take a look tomorrow.