Legend selection from Plotly graph isn’t cleared correctly
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 24.4k
- Forks
- 2.3k
- Avg merge
- 2d 7h
- Merged PRs (30d)
- 13
Description
I have recently run into a bug regarding the legend selection from Plotly graphs in my Dash app.
Environment
dash 1.20.0
dash-bootstrap-components 0.12.2
dash-core-components 1.16.0
dash-html-components 1.1.3
dash-renderer 1.9.1
dash-table 4.11.3
- The issue is frontend related, and has been spotted on (at least) two different machines:
- OS: Linux (Ubuntu) / Windows
- Browser: Firefox / Firefox
- Version: 94.0 (64 bits) / 78.15.0esr (64 bits)
The bug
I'm working with a few tabs, the last of which displays several interconnected Plotly graphs and components. One of the graphs is considered as "the main graph", and making a selection from this graph results in changes in the other components displayed. When switching to another tab after a selection from the legend has been made and cleared (by double-clicking on an item then double-clicking again), then switching back, a new selection from the legend will not be properly reset.
I’ve included a minimal code example to reproduce the behaviour:
import json
import random
import pandas as pd
pd.options.plotting.backend = "plotly"
import dash
from dash.dependencies import Input, Output, State
import dash_table as dt
import dash_core_components as dcc
import dash_html_components as html
import dash_bootstrap_components as dbc
external_stylesheets = ['https://codepen.io/chriddyp/pen/bWLwgP.css',
"https://codepen.io/chriddyp/pen/brPBPO.css"]
app = dash.Dash(__name__, external_stylesheets=external_stylesheets)
# -------------------- DATA --------------------
plot_df = pd.DataFrame({
'x': [1,2,3,4,5],
'y': [1,2,3,4,5],
'text': ['a', 'b', 'c', 'd', 'e'],
'label': [-1, -1, -1, -1, -1]
})
# -------------------- LAYOUTS --------------------
tab1_layout = html.Div(
[
html.H1('Tab 1'),
html.P('Please navigate to tab 2.')
]
)
tab2_layout = html.Div(
[
html.Div([html.H1('Tab 2')]),
# Slider
html.Div(
[
html.H3(
children=['Slider'],
),
dcc.Slider(
id="slider",
min=2,
max=5,
step=1,
value=2,
marks={
2: "2",
5: "5",
},
disabled=False,
),
]
),
# Graph
html.Div(
[
html.H3(
children=['Graph'],
),
dcc.Graph(
id="graph",
config={
'modeBarButtonsToRemove': ['autoScale2d', 'zoomIn2d', 'zoomOut2d', 'resetScale2d', 'hoverClosestCartesian', 'hoverCompareCartesian', 'toggleSpikelines'],
'displaylogo': False
}
),
]
),
# DataTable
html.Div(
[
html.H3(
children=['DataTable'],
),
dt.DataTable(
id='data_table',
sort_action="native",
page_action="native",
page_current= 0,
page_size= 100,
style_as_list_view=True
),
]
),
],
style={
'width':'50%',
},
)
app.layout = html.Div(
[
dcc.Store(id='store_data', data=plot_df.to_json()),
dcc.Store(id='displayed_legends'),
dcc.Tabs(
id='main_tabs',
value='tab1',
children=[
dcc.Tab(id="tab_1", label='1. Tab 1', value='tab1', children=[tab1_layout]),
dcc.Tab(id="tab_2", label='2. Tab 2', value='tab2', children=[tab2_layout])
],
),
]
)
# -------------------- CALLBACKS --------------------
@app.callback(
[Output('graph', 'figure'),
Output('store_data', 'data')],
Input('slider', 'value'),
State('store_data', 'data')
)
def update_graph_from_slider(n, data_json) :
plot_df = pd.read_json(data_json)
labels_int = list(range(0, n))
k = len(plot_df) - len(labels_int)
if k > 0:
labels_int += random.choices(range(0, n), k=k)
labels = [str(e) for e in labels_int]
plot_df['label'] = labels
plot_df = plot_df.sort_values(by='label', ascending=True)
plot_fig = plot_df.plot.scatter(x='x', y='y', hover_data={'x': False, 'y': False, 'label': True, 'text': True} ,color='label')
return plot_fig, plot_df.to_json()
@app.callback(
Output('displayed_legends', 'data'),
Input('graph', 'restyleData'),
State('displayed_legends', 'data')
)
def update_selected_legend_items(restyleEvent, displayedLegendsItems):
if displayedLegendsItems == None:
displayedLegendsItems = {}
if restyleEvent != None:
states = restyleEvent[0]['visible']
labels = restyleEvent[1]
for index in range(len(restyleEvent[0]['visible'])):
if states[index] == 'legendonly':#legend item has been deactivated
displayedLegendsItems[labels[index]] = False
elif states[index] == True:#legend item has been activated
displayedLegendsItems[labels[index]] = True
return displayedLegendsItems
@app.callback(
[Output('data_table', 'columns'),
Output('data_table', 'data'),],
[Input('graph', 'selectedData'),
Input('graph', 'relayoutData'),
Input('displayed_legends', 'data')],
State('store_data', 'data'),
)
def display_selected_data(selectedData, relayoutData, selectedTopics, data_json):
res = get_filtered_nodes(data_json, selectedTopics, selectedData)
df_table = pd.DataFrame(res)
visibility = {}
changed_id = [p['prop_id'] for p in dash.callback_context.triggered][0]
if 'graph' in changed_id:
if 'relayoutData' in changed_id:
relayoutDataKeys = relayoutData.keys()
# if we zoomed, auto zoomed or rescaled,don't update the list
if 'dragmode' in relayoutDataKeys or 'xaxis.range[0]' in relayoutDataKeys or 'xaxis.autorange' in relayoutDataKeys:
res = dash.no_update
if res != dash.no_update and len(res) == 0:
visibility = {'display':'none'}
return [{"name": i, "id": i} for i in df_table.columns], df_table.to_dict('records')
# -------------------- FUNCTIONS --------------------
def get_filtered_nodes(data, selectedTopics, selectedData):
res = []
selectedTopicsKeys = selectedTopics.keys()
if selectedData != None:#There is an active selection
pointsList = json.loads(json.dumps(selectedData))
if pointsList != None and len(pointsList['points']) > 0:#Some points are selected
for point in pointsList['points']:
text = point['customdata'][1]
label = point['customdata'][0]
if label not in selectedTopicsKeys or selectedTopics[label] == True:
res.append({'text': text, 'label': label})
elif data != None:#No active selection
dataArray = json.loads(data)
textArray = dataArray['text']
if 'label' in dataArray :
labelsArray = dataArray['label']
for key in textArray.keys():
if labelsArray[key] not in selectedTopicsKeys or selectedTopics[labelsArray[key]] == True:
res.append({'text': textArray[key], 'label': labelsArray[key]})
return res
if __name__ == "__main__":
app.run_server(debug=True)
And here are the detailed step to replicate the bug:
- run the app as usual
- go to tab 2
- select any item from the legend (by double-clicking on it)
- clear selection (by double-clicking anywhere on the legend again)
- go to tab 1
- go back to tab 2
- select any item from the legend (by double-clicking on it)
- clear selection by double-clicking again: the datatable is not updated
- optional: select new value from slider
- optional: select any item from the legend (by double-clicking on it)
- optional: clear selection by double-clicking yet again: this time, it works as intended (from my experiments, the bug only affects the last value where a selection has been made before switching tabs).
Looking a bit into this bug, it appears that the restyleData property from the graph, which is normally used to detect a change in selection from the legend, isn’t updated when the selection is cleared and thus retains the previously selected item.
Expected behavior
I expect the legend selection to be completely cleared and to update the related components.
Screenshots
Hopefully, this gif can help better visualize my problem if running the minimal code is not an option:

Any insight on why this might happen or on how to solve it would be greatly appreciated :)
Keep up the good work!
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Use the provided minimal Dash app as the reproduction entry point; inspect the graph's restyleData across the tab-switch sequence and the update_selected_legend_items callback. Verify how clearing the legend affects display_selected_data and the datatable after returning to tab 2. Done means the legend-clear action consistently updates the related components.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- data-visualization, frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Stale
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100