How to undo add_selection() after custom selection

Hello!

Is there a remove_selection() function to clear a selection box I add with Figure.add_selection()?

Here is some context. In my app, I customize the behavior of box select, adjusting which points are selected by the user. In a selectedData callback for my graph, I set trace.update(selectedpoints=adjusted_selection). This works but only for a second, as immediately another event occurs with empty selection that undoes what I just set. I can get around this issue by first setting selectedpoints as above, and then calling figure.add_selection(...) to match the points I selected. In this way, the second callback still sees the selection and keeps it there. The problem is that I can’t undo the selection I add with add_selection: it always stays there. I can add more with box select but not remove them.

What am I missing?

Hey @en84 welcome to the forums!

Are you using dash? Could you add some code because unfortunately I don’t fully understand what you are trying to do.

Here is an abridged version of the code that reproduces the problem. You will need a TSV with a column “position” with x-values and “value” with y-values (from 0 to 100).

from typing import Any, List, Optional, Tuple
from attrs import define, field

import dash
import dash_bootstrap_components as dbc
import pandas as pd
import plotly.express as px
import plotly.graph_objs as go
from dash import dash_table, dcc, html
from dash.dependencies import Input, Output

@define
class MyData:
    df: pd.DataFrame = field()
    figure: go.Figure = field(init=False)

    def __attrs_post_init__(self) -> None:
        self.figure = self.make_figure()

    def make_figure(self) -> None:
        self.df["position"] = range(self.df.shape[0])
        self.df["value"] = self.df["count"]

        figure = go.Figure()
        figure.add_trace(
            go.Scatter(
                x=self.df["position"],
                y=self.df["value"],
                mode="markers",
                name="my_series",
            )
        )
        figure.update_layout(yaxis_title="Value")
        return figure

my_data: Optional[MyData] = None

app = dash.Dash("my_app", prevent_initial_callbacks="initial_duplicate")

app.layout = html.Div(
    [
        dcc.Graph(id="my_plot", figure=px.scatter(None)),
        html.Div(dbc.Button("Load from file", id="load_btn")),
        html.Div(id="selection_div"),
    ]
)


def expand_selection(indices: List[int]) -> List[int]:
    if indices:
        return list(range(min(indices), max(indices) + 1))
    return []


@app.callback(
    Output("my_plot", "figure", allow_duplicate=True),
    Output("selection_div", "children"),
    Input("my_plot", "selectedData"),
)
def adjust_selection(sel_data: Any) -> Tuple[go.Figure, html.Div]:
    print("> adjust_selection()")
    global my_data

    if my_data is None:
        print("  - no data")
        return (go.Figure(), html.Div(html.P("Data not loaded")))

    figure: go.Figure = my_data.figure

    if sel_data is None or "range" not in sel_data:
        print("  - no selection")
        figure.for_each_trace(lambda trace: trace.update(selectedpoints=[]))
        return (figure, html.Div(html.P("No data selected")))
    
    print("  - selection")


    selection_start = sel_data["range"]["x"][0]
    selection_end = sel_data["range"]["x"][1]

    selected_points = [point["pointIndex"] for point in sel_data["points"]]
    selected_points = expand_selection(selected_points)

    figure.for_each_trace(lambda trace: trace.update(selectedpoints=selected_points))
    # figure.add_selection(x0=selection_start, y0=0, x1=selection_end, y1=100)

    selected_rows = (my_data.df["position"] >= selection_start) & (my_data.df["position"] <= selection_end)
    selected_df = my_data.df.loc[selected_rows]

    my_table = html.Div(
        [
            html.P("Selected Data"),
            dash_table.DataTable(
                selected_df.to_dict("records"),
                id="my_table",
                columns=[{"name": c, "id": c} for c in selected_df.columns]
            )
        ],
    )

    return (figure, my_table)


@app.callback(
    Output("my_plot", "figure"),
    Input("load_btn", "n_clicks"),
    prevent_initial_call=True,
)
def button_clicked(load_btn: Any) -> go.Figure:
    print("> button_clicked")

    return load_data_from_file()


def load_data_from_file() -> go.Figure:
    print("> load_data_from_file")
    global my_data

    df = pd.read_csv("my_data_table.tsv", sep="\t")
    print(f"  df is {df.shape[0]}x{df.shape[1]}")

    my_data = MyData(df)

    return my_data.figure


# Run the app
if __name__ == "__main__":
    app.run_server(host="0.0.0.0", debug=True)

Using this code, when I load data and then select some points I see this output:

> adjust_selection()
  - no data
> button_clicked
> load_data_from_file
  df is 2000x10
> adjust_selection()
  - selection
> adjust_selection()   <<< ???
  - no selection

As the first adjust_selection() is triggered, all works as expected. But immediately after, a second event is triggered (with no selection), and it clear the table and deselects all points in the graph. I do not understand where the second event comes from.

To go back to the original graph, I need to double click on the graph - twice - as the first time nothing happens.

To persist the selection, one can uncomment the add_selection() line. It gives me this output:

> adjust_selection()
  - no data
> button_clicked
> load_data_from_file
  df is 2000x10
> adjust_selection()
  - selection
> adjust_selection()   <<< selection persists
  - selection

Now the table remains populated, and the graph shows the selected points, but also the selection box around them.

If I double-click on the graph, the table is cleared but the selection (and the box) remain in the graph. If I double-click a second time (and more), the original graph flashes and goes back to the selection.

I seem to understand this better now - please do correct me if I’m wrong!

Because adjust_selection() returns a Figure, which is going to overwrite the figure currently displayed, callbacks are triggered as when the page is loaded for the first time (?). This would explain the second call to that function with no selection. A new figure is displayed, which overwrites the previous one. The selection box around the points of interest was on the previous figure but not on the new one.

Nevertheless, it would be great to be able to remove a selection added with add_selection().