'Keep plotly updatemenu button active when changing plot range dynamically in Dash

I need some advice concerning the updatemenu buttons in plotly. I am dynamically changing the plot range of a graph and I trigger different traces with the updatemenu buttons. However, as soon as I move the range slider, the default trace is shown again (and the previous button is unselected or not active anymore). I would like the view to stay on the previously selected (via updatemenu button) trace.

I spend like 8 hours trying to fix it and this is my last resort :D Otherwise, it won't get fixed.

I do believe tho, that it is possible. I think it is just beyond my skills at the moment. Maybe someone can come up with a clever implementation.

Thanks in Advance :)

My Ideas


  1. Property uirevision
  2. Custom buttons, which modify a global variable
  3. Creating an extra callback to listen to the update method and saving it in a global variable
  4. Reading out the state of the figure before generating a new one in my callback
  5. Not setting the visible property for any trace

Update: Idea 4 works as intended, see answer below.

Some Comments to the Ideas:

  1. I thought this might be the easy way. However, when I set it to a constant value, it only saved the zoom/selected data. It didn't keep the previously activated trace visible.
  2. By far the most time consuming try due to the variable amount of buttons. I was able to read out which button was pressed, but I couldn't save it in a global variable (I know that it is not recommended, but it doesn't matter here). Even with dcc.Store it didn't work, cause the value changed without me modifying it.
  3. Somehow it is only possible to listen to the restyle and relayout method. I wasn't able to toggle the traces with those two methods, therefore I didn't continue.
  4. I tried including the figure as a state in the @ app.callback() with dash.State. However, I wasn't able to get the currently active button.
  5. The way I did it at the moment is, that it is working when initialized and one immediately sees a graph when changing the slider. If one doesn't provide the visible property, no trace is visible upon changes. Furthermore, I couldn't recreate the wanted behaviour.

Minimal Working Example (with answer implemented)


Comments on Implementation

The current version of the application is way more complicated. I have many more elements in the callbacks and the dataset is a little more complicated.

Due to changing columns in the dataset, it is important, that I create the traces and the update menu buttons after reading out the columns. It would be easier with a fixed amount and fixed labels/ids.

Imports:

import dash
import dash_core_components as dcc
import dash_html_components as html
import plotly.graph_objects as go
import numpy as np
import webbrowser
import pandas as pd

Application:

def launch_app(data) -> None:

    # Creates the Application
    app = dash.Dash(__name__)

    # HTML Layout
    app.layout = html.Div(children=[
        html.Div([
            # Graph Container
            html.Div(id='graph-container'),
            # Range Slider
            html.Div(
                dcc.RangeSlider(id='range-slider', min=0, vertical=True),
            ),
        ], style={"display": "flex", "align-items": "center", "justify-content": "space-evenly"}),
    ])

    # Callback to update graphs when slider changes
    @ app.callback(
        dash.Output("range-slider", "value"),
        dash.Output("graph-container", "children"),
        dash.State("graph-container", "children"),
        dash.Input("range-slider", "value"),
    )
    def update(graph_container, slider):

        # Setting max
        max = len(data["data"]["petal_length"])

        # First call to initialize
        if slider is None:
            end_value = max
            slider_value = [0, end_value]
            return slider_value, update_graphs(0, end_value)

        # Get active Button
        active_button = graph_container[0]['props']['figure']['layout']['updatemenus'][0]['active']

        # Set values depending on the trigger
        start_value = slider[0]
        end_value = slider[1]
        slider_value = slider

        return slider_value, update_graphs(start_value, end_value, active_button)

    # Running the app
    app.run_server(debug=True, port=8050)

Generation of new graphs:

def update_graphs(start: int, end: int, active_button: int = 0) -> list[dcc.Graph]:
    """
    Updates the dcc.Graph and returns the updated ones.

    Parameters
    ----------
    start : int
        lower index which was selected
    end : int
        upper index which was selected
    active_button: int
        which button is active of the plotly updatemenu buttons, by default 0

    Returns
    -------
    list[dcc.Graph]
        Updated Graph
    """

    fig = go.Figure()

    # Read out columns automatically
    all_columns = [col for col in list(data["data"].head())]

    # Generate X-Axis
    xvalues = np.arange(0, len(data["data"]["petal_length"]))

    # CREATION OF TRACES
    for ii, col in enumerate(all_columns):
        if ii == active_button:
            visible = True
        else:
            visible = False
        fig.add_trace(
            go.Scatter(x=xvalues[start: end],
                       y=data["data"][f"{col}"][start: end],
                       visible=visible)
        )

    # Generation of visible array for buttons properties
    show_list = []
    for ii, val in enumerate(all_columns):
        # Initialization
        temp = np.zeros(len(all_columns))
        temp[ii] = 1
        temp = np.array(temp, dtype=bool)
        show_list.append(temp)

    # CREATION OF BUTTONS
    all_buttons = []

    for ii, col in enumerate(all_columns):
        all_buttons.append(dict(label=f"{col}", method="update", args=[
            {"visible": show_list[ii]},
            {"yaxis": {'title': f'yaxis {col}'}}
        ]))

    # Update Menu Buttons
    fig.update_layout(
        updatemenus=[
            dict(
                type="buttons",
                active=active_button,
                showactive=True,
                buttons=all_buttons,
                x=0.0,
                y=1.2,
                xanchor="left",
                yanchor="top",
                direction="right",
            )
        ])

    return [
        dcc.Graph(
            id='time',
            figure=fig,
        )
    ]

Script:

if __name__ == "__main__":

    # Loading sample Data
    iris = pd.read_csv(
        'https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv')
    data = {"id": 20, "data": iris}

    # Opens the server
    webbrowser.open("http://127.0.0.1:8050/")
    # Launches the server
    launch_app(data)


Solution 1:[1]

Your idea (4) to include your figure (or its parent object) as a state in your callback function update should work. You only have to find the correct "active" property and then pass on the active button as an argument to your update_graphs function.

The following changes should do the job:

  1. Add "graph-container" as input.
@ app.callback(
        dash.Output("range-slider", "value"),
        dash.Output("graph-container", "children"),
        dash.Input("graph-container", "children"),
        dash.Input("range-slider", "value"),
    )
    def update(graph_container, slider):
  1. Get the current active button's index and pass it to update_graphs.
active_button = graph_container[1]['props']['figure']['layout']['updatemenus'][0]['active']

return slider_value, update_graphs(start_value, end_value, active_button=active_button)
  1. Receive active_button index in update_graphs and use it.
def update_graphs(start: int, end: int, active_button: int):
  # Some stuff
  # Update Menu Buttons
    fig.update_layout(
        updatemenus=[
            dict(
                # other stuff
                active=active_button,
                
            )
        ])

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Gabriel309