I have recently run into a bug regarding the legend selection from Plotly graphs in my Dash app.
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)
Librairies used (and their version):
- pandas==1.3.3
- dash==1.20.0
- dash-core-components==1.16.0
- dash-bootstrap-components==0.12.2
- dash-html-components==1.1.3
- dash-table==4.11.3
I am using python 3.9.
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.
Any insight would be greatly appreciated