Black Lives Matter. Please consider donating to Black Girls Code today.

Preserve trace visibility in callback

Hi,
I’m trying to figure out how to keep hidden traces hidden when a Graph is redrawn through a callback. Consider a simple line chart (with multiple traces) with a RangeSlider that determines the range of the x-axis. If the user clicks a trace in the legend to turn it off, then moves the slider, the trace is turned back on (because the whole graph is redrawn). Is there a way to preserve the visibility of traces? I know that I can set visible to legendonly in each trace, but how do I read the existing visibility in the callback. It doesn’t seem to work to have the figure itself be an Input to the callback in which it is an Output.
Any ideas?
Thanks!
Here’s a simple example:

import dash
import dash_core_components as dcc
import dash_html_components as html
import cufflinks as cf
cf.go_offline()

app = dash.Dash()

#some dummy data
df = cf.datagen.lines()

def test_plot(start=0, end=100):
    figure = df.iloc[start:end].iplot(asFigure=True)
    return figure

app.layout = html.Div(
    [
        dcc.Graph(
            id='test_graph',
            figure=test_plot()
        ),
        dcc.RangeSlider(
            id='test_slider',
            min=0,
            max=100,
            value=[0,100]
        )
    ]
)


@app.callback(
    dash.dependencies.Output('test_graph', 'figure'),
#I'd like to be able to put the figure itself as an input here, so I could read the trace visibility and preserve it when creating the new figure.
    [dash.dependencies.Input('test_slider', 'value')])
def update_figure(value):
    return test_plot(value[0], value[1])


if __name__ == '__main__':
    app.run_server(use_reloader=True)
1 Like

Great question @cforelle! It seems like these should be the default or at least an option in the Graph object. Something like Graph(..., freeze=True) or Graph(..., preserve_interactions=True) or something. This comes up if the user zooms into a region and the updates a callback: the graph scales back out (zoomed region is not preserved).

For now, you can add the figure as a state=dash.dependencies.State() property which will provide the value of the figure but will not trigger the callback when that value changes (which would cause an infinite loop). So:

@app.callback(
    dash.dependencies.Output('test_graph', 'figure'),
    [dash.dependencies.Input('test_slider', 'value')],
    state=[dash.dependencies.State('test_graph', 'figure')])
def update_figure(value, previous_figure):

1 Like

Thanks, @chriddypstate is exactly what I’m looking for. So this works:

def test_plot(start=0, end=100, previous=None):
    figure = df.iloc[start:end].iplot(asFigure=True)
    if previous:
        visible_state = {}
        for i in previous['data']:
            visible = i['visible'] if 'visible' in i.keys() else True
            visible_state[i['name']] = visible
        for j in figure['data']:
            j['visible'] = visible_state[j['name']]
    return figure 

It’s obviously a little clunky (and surely there’s a more pythonic way of doing it). A freeze=True option would be great. One caveat is that this approach only makes sense if the callback doesn’t change the names of the traces. But I guess that’s to be expected–a freeze doesn’t really work if you are changing the traces.

1 Like
Hello,

I tried the method suggested by @chriddyp on my scatter plot but the trace visibility (toggled by clicking on the trace legends) doesn’t persist between callbacks. Would passing the specific traces in a callback fix this? If so is there a way I can specifically access the toggled traces in a callback? If not, any tips would be greatly appreciated.

Thanks In Advance!
Here’s my code
    app.layout = html.Div([
        html.Div(
            style={'font-family': 'Product Sans'},
            children=dcc.Dropdown(
                id='device-dropdown',
                options=dropdown_options,
                multi=True,
                placeholder='Select the devices you want to display',
                value=[]
            ),
        ),
        dcc.Graph(id='live-update-graph'),
        dcc.Interval(
            id='interval-component',
            interval=1.5*1000, # in milliseconds
            n_intervals=0
        ),
        html.P(id='temp')
    ])

    # Multiple components can update everytime interval gets fired.
    @app.callback(Output('live-update-graph', 'figure'),
                 [Input('device-dropdown', 'value'),
                  Input('interval-component', 'n_intervals')],
                 [State('live-update-graph', 'figure')])
    def update_graph_live(devices, n, previous_figure):
        power, net_traff = get_power_and_net_traff(devices)

        return create_figure_temp(devices, power, net_traff)

There might be a way to do this by getting the event data from the restyle event (not currently exposed) and passing that data into the callback as State and using it to determine whether or not the legend items have been clicked on or not. I created an issue about exposing the restyle event here: https://github.com/plotly/dash-core-components/issues/197

More generally, I wonder if we should just have some kind of flag that tells the graph whether it should reset user interactions or not (like zooming, panning, clicking on legends, clicking on mode bar items). In some cases, like if you were to switch the chart type or display completely different data, you’d want to reset the user interactions. In other cases, you wouldn’t necessarily want to.

Hi Chris,

Thanks for the response @chriddyp and placing the issue. I think that it would be a fantastic idea to add a flag as well, especially in the case of this post.

On a similar note, I am trying @cforelle’s solution. But I don’t see a visible field. What would you suggest?

This is what I have right now.

@app.callback(Output('live-update-graph', 'figure'),
                 [Input('device-dropdown', 'value'),
                  Input('interval-component', 'n_intervals')],
                 [State('live-update-graph', 'figure')])
def update_graph_live(devices, n, prev_fig):
    power, net_traff = get_power_and_net_traff(devices)

    fig = create_figure_temp(devices, power, net_traff)
    
    if prev_fig: preserve_trace_visibility(prev_fig['data'], fig['data'])

    return fig

def preserve_trace_visibility(prev_data, curr_data):
    hidden_traces = set()

    for trace in prev_data:
        if not trace['visible']:
            hidden_traces.add(trace['name'])

    for trace in curr_data:
        if trace['name'] in hidden_traces:
            trace['visible'] = False

Hi @chriddyp, any updates on this?

Here’s what I got working:

@app.callback(
    dash.dependencies.Output('data_graph', 'figure'),
    [...],
    [dash.dependencies.State('data_graph', 'figure')])
def populate_plot(..., previous_graph):
    visibilities = {d.get('name'): d.get('visible') for d in previous_graph['data']}

    name = 'Trace 1'
    trace1 = go.Scatter(
        x=list(...)
        ,y=list(...)
        ,text=list(...)
        ,hoverinfo='text+name'
        ,name=name
        ,mode='lines+markers'
        ,marker=dict(
            color='rgb(0,145,230)',
            size=15,
            opacity=1,
            line={'width': 0.5, 'color': 'white'}
        )
        ,visible=visibilities.get(name) or 'legendonly'
    )

    data = [trace1, ...]
    layout = ...

    return dict(data=data, layout=layout)

This way, you can set your default visibility with visible=visibilities.get(name) or 'legendonly' but also allow for unanticipated visibilities down the line.

Hey @RagingRoosevelt thanks for the suggestion! I tried it out. I’m having an all or nothing situation. My graph either goes visible=True for all traces or visible=‘legendonly’ for all. Did you ever run into that issue?

Using the dict to track per-trace visibility should prevent that. Any way you could reproduce your code or a minimal example?

Many thanks to everyone’s feedback on this issue :heart:. We’ve incorporated your feedback and have created a first-class solution via a uirevsion property. See 📣 Preserving UI State, like Zoom, in `dcc.Graph` for an example and documentation.