plotly / dash

Data Apps & Dashboards for Python. No JavaScript Required.
https://plotly.com/dash
MIT License
21.45k stars 2.07k forks source link

Legend selection from Plotly graph isn’t cleared correctly #1853

Open constance-scherer opened 2 years ago

constance-scherer commented 2 years ago

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 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:

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!

kevinboeuf commented 2 years ago

I have a very similar issue, and did not find anything about it. Would anyone have a solution for this problem please?