plotly / plotly/dash

Legend selection from Plotly graph isn’t cleared correctly

Open
#1,853 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug P3
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:
plotly_selection_bug

Any insight on why this might happen or on how to solve it would be greatly appreciated :)
Keep up the good work!

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.