📣 Preserving UI State, like Zoom, in dcc.Graph with uirevision with Dash

:wave: Hello everyone, happy monday :slightly_smiling_face:

I’d to share with you a really cool dash-core-components prerelease: the ability to preserve UI state in the dcc.Graph between callbacks.

Right now, if you’re updating the figure property of a dcc.Graph component, the graph’s “view” will get reset when the callback is fired. That is, if you zoom in your graph, click on the legend items, or twist a 3D surface plot, then those changes won’t be preserved across callback updates.

For many callbacks, this is OK and actually desirable: if your graph is updating with completely new data, perhaps with completely different axes ranges, you’ll want the graph to recompute its ranges.

However, for certain callbacks, especially those that have a similar set of axes ranges, you may want to preserve the UI state between callbacks: if your viewers painstakingly zoom into a certain region of a chart then they might not that graph to completely reset when a dcc.Interval fires or if they want to compare that region with another dataset.

pip install dash-core-components==0.39.0rc4

This version includes a new property in the layout property of the figure property of dcc.Graph: uirevision.

uirevision is where the magic happens. This key is tracked internally by dcc.Graph, when it changes from one update to the next, it resets all of the user-driven interactions (like zooming, panning, clicking on legend items). If it remains the same, then that user-driven UI state doesn’t change.
It can be equal to anything, the important thing is to make sure that it changes when you want to reset the user state.

In the example below, we only reset the zoom when we change the dataset dropdown. If we change the color or if we add a “reference” trace, then we don’t reset the zoom. Thus, we set the uirevision property to the dataset value. Read more in the comments embedded in the example.

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

df = pd.read_csv('https://raw.githubusercontent.com/plotly/datasets/master/stockdata.csv')

df['reference'] = df[df.columns[0]]

app = dash.Dash(__name__)
app.scripts.config.serve_locally = True
app.css.config.serve_locally = True

app.layout = html.Div([
    html.Label('Color'),
    dcc.Dropdown(
        id='color',
        options=[
            {'label': 'Navy', 'value': '#001f3f'},
            {'label': 'Blue', 'value': '#0074D9'},
            {'label': 'Aqua', 'value': '#7FDBFF'},
            {'label': 'TEAL', 'value': '#39CCCC'},
            {'label': 'OLIVE', 'value': '#3D9970'},
            {'label': 'GREEN', 'value': '#2ECC40'},
            {'label': 'LIME', 'value': '#01FF70'},
            {'label': 'YELLOW', 'value': '#FFDC00'},
            {'label': 'ORANGE', 'value': '#FF851B'},
            {'label': 'RED', 'value': '#FF4136'},
            {'label': 'MAROON', 'value': '#85144b'},
            {'label': 'FUCHSIA', 'value': '#F012BE'},
            {'label': 'PURPLE', 'value': '#B10DC9'},
        ],
        value='#001f3f'
    ),

    html.Label('Reference'),
    dcc.RadioItems(
        id='reference',
        options=[{'label': i, 'value': i} for i in ['Display', 'Hide']],
        value='Display'
    ),

    html.Label('Dataset'),
    dcc.Dropdown(
        id='dataset',
        options=[{'label': i, 'value': i} for i in df.columns],
        value=df.columns[0]
    ),

    dcc.Graph(id='graph')
])


@app.callback(
    Output('graph', 'figure'),
    [Input('color', 'value'),
     Input('reference', 'value'),
     Input('dataset', 'value')])
def display_graph(color, reference, dataset):
    data = [{
        'x': df.index,
        'y': df[dataset],
        'mode': 'lines',
        'marker': {'color': color},
        'name': dataset
    }]
    if reference == 'Display':
        data.append({'x': df.index, 'y': df['reference'], 'mode': 'lines', 'name': 'Reference'})
    return {
        'data': data,
        'layout': {
            # `uirevsion` is where the magic happens
            # this key is tracked internally by `dcc.Graph`,
            # when it changes from one update to the next,
            # it resets all of the user-driven interactions
            # (like zooming, panning, clicking on legend items).
            # if it remains the same, then that user-driven UI state
            # doesn't change.
            # it can be equal to anything, the important thing is
            # to make sure that it changes when you want to reset the user
            # state.
            #
            # in this example, we *only* want to reset the user UI state
            # when the user has changed their dataset. That is:
            # - if they toggle on or off reference, don't reset the UI state
            # - if they change the color, then don't reset the UI state
            # so, `uirevsion` needs to change when the `dataset` changes:
            # this is easy to program, we'll just set `uirevision` to be the
            # `dataset` value itself.
            #
            # if we wanted the `uirevision` to change when we add the "reference"
            # line, then we could set this to be `'{}{}'.format(dataset, reference)`
            'uirevision': dataset,

            'legend': {'x': 0, 'y': 1}
        }
    }


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

Let us know what you think! We will leave this open for community feedback for a week or two before releasing it.

29 Likes

Also, for reference, here’s where the real work happened in this feature :wrench:: https://github.com/plotly/plotly.js/pull/3236

The usage @chriddyp shows - where you simply specify layout.uirevision that changes only when you want to reset any user-driven changes to the plot - covers most cases. In fact, often you can just set it to a truthy constant (True, or 'the user is always right') and forget about it. That should work as long as you’re not replacing the whole data set on the plot. If you do replace the whole data set it’s important to change uirevision though, or the plot could be zoomed into a blank plot after the change, which could be confusing.

There are also cases where you want more control. Say the x and y data for a plot can be changed separately. Perhaps x is always time, but y can change between price and volume. You might want to preserve x zoom while resetting y zoom. There are lots of uirevision attributes, that normally all inherit from layout.uirevision but you can set them separately if you want. In this case set a constant xaxis.uirevision = 'time' but let yaxis.revision change between 'price' and 'volume'. Be sure to still set layout.uirevision to preserve other items like trace visibility and modebar buttons!

Trace visibility is a little different: traces are tracked by their uid (or by index in data if no uid is given) - So let’s say your plot can have traces for France, Germany, and UK. Initially only Germany and UK are plotted and the user hides Germany (the first trace) using the legend. Then they add France, which becomes the new first trace. Using uirevision without uid, France is immediately hidden! But if you give each trace a uid ('France', 'Germany', 'UK' would do), the visible: 'legendonly' flag will follow Germany as it moves to the second trace.

The visibility state of all traces is preserved based on layout.legend.uirevision. So if you want to re-show all traces the user may have hidden by clicking the legend, change legend.uirevision. (We do have a trace.uirevision, but it only controls a few things like parcoords constraintrange)

4 Likes

@chriddyp This is awesome!
I use dash to make a live streaming app and it will be very handy to preserve the dcc.graph UI state.
Is there anyway to set uirevision to something that would make this constant? (Rather than setting it to something like dataset)

Yeah, so uirevision can be anything, the important thing is that when you change it, we reset the graph. So if you never want to reset the graph, just set it to a constant string: any constant string, ‘foo’ or ‘static’ or ‘dash’ etc

Awesome, thanks you for clarifying!

Hello!
That doesn’t seem to recognise it on my Dash:

print(dcc.version)

gives me :

0.39.0rc4

So I should be fine. But the piece of code:

@app.callback(
Output('Charts','children'),
[Input('DropDown Live Charts', 'value'),
 Input('dataset_storage', 'children')]
)

def update_charts(factor_names,jon_dataset):
# =============================================================================
#     print('live chart data loading time')
#     t0 = time.time()
# =============================================================================

fact_mid_db = pd.read_csv(fact_mid_chart_path,index_col=0,parse_dates = True)
fact_mid_db = fact_mid_db[pricing.index.tolist()]
# =============================================================================
#     print(type(fact_mid_db.index[0]))
# =============================================================================
# =============================================================================
#     t1= time.time()
#     print (t1-t0)    
#     t0 = time.time()  
# =============================================================================

Charts = []
for factor in factor_names:
    Charts.append(html.Div([
        dcc.Graph(
            figure=go.Figure(
                data=[
                    go.Scatter(
                        x = fact_mid_db.index.tolist(),
                        y = fact_mid_db[factor],
                        mode = 'lines',
                        name = factor),
                ],
                
                layout=go.Layout(
                    title=factor,
                    xaxis=dict(tickangle=-45,automargin=True),
                    yaxis=dict(tickangle=-45,automargin=True),
                    margin=dict(l=20, r=0, t=30, b=0),
                    uirevision='DropDown Live Charts'
                ),
                
            ),
            id=factor+'_graph'
        ),
    ], className = 'three columns',style={'margin-left':15,'margin-right':15,'margin-top':5,'margin-bottom':5}))
# =============================================================================
#     t1= time.time()
#     print (t1-t0)  
# =============================================================================
              
return Charts

Doesn’t let me run everything:

prop_descriptions=self._prop_descriptions))
ValueError: Invalid property specified for object of type plotly.graph_objs.Layout: 'uirevision'

Any idea?

Thank you

@QueRico you are getting that ValueError from plotly.py because it’s validation functions haven’t been updated to include the new layout property uirevision yet.

For now, replacing go.Figure and go.Layout with dict will prevent this validation problem.

2 Likes

2 posts were split to a new topic: Preserving state and selected inputs of a previous tab

Yup can confirm.
I had a bit of difficulty crafting the correct dictionary for Layout, but found it easy to use go.Layout and print that object to the terminal, copy and paste that dictionary and finally edit it.

This is awesome, wish I had this when I started working on my app (ended up storing the zoom state in a cache between callbacks) but I’m very glad you implemented this. Thank you!

1 Like

Don’t see any comments on the associated PRs. Is there a target release for adding the uirevision prop to dash-core-components?

Good question. For now, use the pre-releases. For this to get part of an official release dash-core-components, we’ll need to publish a new plotly.js release and then upgrade dash-core-components with the new release. I believe uirevision will be part of plotly.js 1.43.0 (Releases · plotly/plotly.js · GitHub), this week or next.

started playing with dash this weekend and loving it so far. Just came accross this problem and the new property works awesome! pretty excited to play with dash more!

1 Like

Thanks! I was doing sliders to work around the problem, but this is just amazing! It’s working mega!

I’m trying to add this uirevision to dcc.graph mapbox layout, but not having any luck. Is there an example of that type of implementation of uirevision? Is this possible yet? Would be a great addition and very important for mapping applications. Cheers

This should be possible but I personally haven’t tried this. Here are the keys that uirevision controls: center, zoom, bearing, pitch (source code: plotly.js/src/plots/mapbox/layout_attributes.js at 4f8628f4c7b8f0d604f1f6e7585e962428316cff · plotly/plotly.js · GitHub).

Could you create a small, reproducable example?

Thank you for the direction, but I’ve made some additional attempts and am not getting the uirevision to work. Probably something simple I’m missing (hopefully). I’ve also tried emulating the functionality of the dash-opioid example which seems to use the State from dash.dependencies… no luck there either. Here’s a simple example though that I’d like to get working. Any further thoughts on uirevision with mapbox is greatly appreciated. Cheers

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


# Set mapbox public access token
mapbox_access_token = PROVIDE YOUR TOKEN HERE

app = dash.Dash(__name__)

'''
~~~~~~~~~~~~~~~~
~~ APP LAYOUT ~~
~~~~~~~~~~~~~~~~
'''

app.layout = html.Div([
html.Label('Color'),
dcc.Dropdown(
    id='selected_color',
    options=[
        {'label': 'Navy', 'value': '#001f3f'},
        {'label': 'Blue', 'value': '#0074D9'},
        {'label': 'Aqua', 'value': '#7FDBFF'},
        {'label': 'TEAL', 'value': '#39CCCC'},
    ],
    value='#001f3f'
),

dcc.Graph(
    id='mymap',
    figure=dict(
        data = dict(
            lon=[-105.055618],
            lat=[39.70911],
            type='scattermapbox',
        ),
        layout = dict(
            mapbox = dict(
                layers = [],
                accesstoken = mapbox_access_token,
                style = 'light',
                center=dict(
                    lat=39.70911,
                    lon=-105.055618,            
                ),
                pitch=0,
                zoom=14.5
            )
        )
    )
)
])


'''
~~~~~~~~~~~~~~~~
~~ APP CALLBACKS ~~
~~~~~~~~~~~~~~~~
'''

@app.callback(
Output('mymap', 'figure'),
[Input('selected_color','value')])
def displaymap(selected_color):
data = [dict(
    lon=[-105.055618],
    lat=[39.70911],
    type='scattermapbox',
    marker=dict(
        size=50, color=selected_color
    ),
    opacity = 1,
)]
layout = dict(
    height = 600,
    mapbox = dict(
        uirevision='no reset of zoom',
        accesstoken = mapbox_access_token,
        style = 'light'
    ),
)
figure = dict(data=data,layout=layout)
return figure

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