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, @chriddyp–state
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.
2 Likes
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: add support for `restyle` event, similar to `relayout` event · Issue #197 · plotly/dash-core-components · GitHub
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 . 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.