Clientside callback selectedpoints

Hey all,

Thanks for the great work on dash and plotly. I am trying to implement cross filtering in scatterplots with the selectedpoints field in form of a client side callback. The callback gets the selected points from a python callback and the resulting figures have the required structure but nothing gets selected.

`

app.clientside_callback(

"""

function(data, figure) {

    console.error(data);

    for (var i = 0; i < figure.length; i++) {

        const ids = figure[i]['data'][0]['customdata']

        const intersect = data.filter(value => ids.includes(value));

        const selected = intersect.map(x => ids.indexOf(x))

        selected.sort();

        figure[i]['data'][0]['selectedpoints'] = selected        }

    console.error(figure[2]);

    return figure;

}

""",

Output({"type": "value_plot", "id": ALL}, "figure"),

Input("selection","data"),

State({"type": "value_plot", "id": ALL}, "figure"),

prevent_initial_call=True

)

`

Best linus

Hi Linus,

Welcome to the community! :slightly_smiling_face:

Which component does Input("selection","data") refer to?

Hey
this is another callback in Python that extracts the customdata
field from the list of selectedData for all plots. To define which points are selected.
It stores them in a dcc.Store.

Does the callback gets triggered? What are the values that data and selected assume when it does?

If you can add a short reproducible example of your app (you can use px.data.iris(), just one figure and the dcc.Store), then it would make it a lot easier to spot the issue.

Sorry for the late response,

I wrote a little example:



import dash
import pandas as pd
import plotly.graph_objects as go
import dash.dcc as dcc
import dash.html as html
import plotly.express as px
from dash.dependencies import Input, Output, State, ALL


iris_df = px.data.iris()
setosa = iris_df[iris_df["species"] == "setosa"]


def layout():
    figures = []
    measures = ["sepal_length", "sepal_width", "petal_length", "petal_width"]
    plots = [(x, y) for x in measures for y in measures]
    for plot in plots:
        if plot[0] == plot[1]:
            continue
        fig = go.Figure()
        fig.add_trace(
            go.Scatter(
                x = setosa[plot[0]],
                y = setosa[plot[1]],
                customdata= list(setosa.index),
                mode="markers"
            )

        )
        fig.update_layout(
                        title="{}: {}".format(plot[0], plot[1]),
                        xaxis_title=str(plot[0]),
                        yaxis_title=str(plot[1]))
        
        graph = dcc.Graph(id= {'x' : plot[0], 'y' : plot[1] } , figure=fig)
        figures.append(graph)
    return(html.Div([dcc.Store(id="selection"),html.Div(figures)]))

app = dash.Dash(__name__)
app.layout = layout()

@app.callback(
    Output("selection", 'data'),
    Input({'x': ALL, 'y': ALL}, 'selectedData'),
    prevent_initial_call=True
)
def selection(sel):
    points = []
    for i in sel:
        if i is None:
            continue
        for point in i['points']:
            points.append(point['customdata'])
    print(points)
    return list(set(points))


app.clientside_callback(
"""
function(data, figure) {
    console.error(data);
    for (var i = 0; i < figure.length; i++) {
        const ids = figure[i]['data'][0]['customdata']
        const intersect = data.filter(value => ids.includes(value));
        const selected = intersect.map(x => ids.indexOf(x))
        selected.sort();
        figure[i]['data'][0]['selectedpoints'] = selected
    }
    console.error(figure[2]);
    return figure;
}""",
Output({'x': ALL, 'y': ALL}, "figure"),
Input("selection","data"),
State({'x': ALL, 'y': ALL}, "figure"),
prevent_initial_call=True
)



if __name__ == "__main__":
    app.run_server(debug=True)


No problem. The issue starts here:

dcc.Graph(id= {'x' : plot[0], 'y' : plot[1] } , figure=fig)

# ...

@app.callback(
    # ...
    Input({'x': ALL, 'y': ALL}, 'selectedData'),
)

You won’t get any errors, but the id assignment is not correct. It should be a dictionary with “type” and “index” keys, so something like:

for idx, plot in enumerate(plots):
    # (your code)...
     graph = dcc.Graph(id={"type": "graph", "index": idx} , figure=fig)

Then the callback can be triggered by (similarly to the State in the clientside callback):

Input({"type": "graph", "index": ALL}, 'selectedData'),
1 Like

Thanks for helping me out.
I tried it like this but it didn’t seem to work.
My current code :


import dash
import pandas as pd
import plotly.graph_objects as go
import dash.dcc as dcc
import dash.html as html
import plotly.express as px
from dash.dependencies import Input, Output, State, ALL


iris_df = px.data.iris()
setosa = iris_df[iris_df["species"] == "setosa"]


def layout():
    figures = []
    measures = ["sepal_length", "sepal_width", "petal_length", "petal_width"]
    plots = [(x, y) for x in measures for y in measures]
    for idx, plot in enumerate(plots):
        if plot[0] == plot[1]:
            continue
        fig = go.Figure()
        fig.add_trace(
            go.Scatter(
                x = setosa[plot[0]],
                y = setosa[plot[1]],
                customdata= list(setosa.index),
                mode="markers"
            )

        )
        fig.update_layout(
                        title="{}: {}".format(plot[0], plot[1]),
                        xaxis_title=str(plot[0]),
                        yaxis_title=str(plot[1]))
        
        graph = dcc.Graph(id= {"type": "graph", "index": idx} , figure=fig)
        figures.append(graph)
    return(html.Div([dcc.Store(id="selection"),html.Div(figures)]))

app = dash.Dash(__name__)
app.layout = layout()

@app.callback(
    Output("selection", 'data'),
    Input({"type": "graph", "index": ALL}, 'selectedData'),
    prevent_initial_call=True
)
def selection(sel):
    points = []
    for i in sel:
        if i is None:
            continue
        for point in i['points']:
            points.append(point['customdata'])
    print(points)
    return list(set(points))


app.clientside_callback(
"""
function(data, figure) {
    console.error(data);
    for (var i = 0; i < figure.length; i++) {
        const ids = figure[i]['data'][0]['customdata']
        const intersect = data.filter(value => ids.includes(value));
        const selected = intersect.map(x => ids.indexOf(x))
        selected.sort();
        figure[i]['data'][0]['selectedpoints'] = selected
    }
    console.error(figure[2]);
    return figure;
}""",
Output({"type": "graph", "index": ALL}, "figure"),
Input("selection","data"),
State({"type": "graph", "index": ALL}, "figure"),
prevent_initial_call=True
)



if __name__ == "__main__":
    app.run_server(debug=True)

Does the clientside callback work? If it does, then what is the expected outcome of it?

If you want to the selection to be propagated through all the graphs (like some sort of cross-filtering), then I think you’ll first need to apply a different color to selected points via the selected_marker_color attribute of go.Scatter (or modifying figure.selected.marker.color on the js side…). I am not sure that the transparency change in unselected points can happen automatically, meaning, the points might be selected but you won’t see any difference as you do in the graph where the selection is originally made. Therefore the color change.

A second issue has to do with Plotly.js not re-rendering figures if the figure object is modified in-place on the javascript side. I can’t find any references to this issue, but I have seen it popping up on the forum or on Github… For that, you can simply create a copy of the figure objects, modify them and return, so your function would look more or less like:

function(data, figure) {
    console.error(data);
    const fig = figure.map(d => Object.assign({}, d));
    for (var i = 0; i < figure.length; i++) {
        const ids = fig[i]['data'][0]['customdata']
        const intersect = data.filter(value => ids.includes(value));
        const selected = intersect.map(x => ids.indexOf(x))
        selected.sort();
        fig[i]['data'][0]['selectedpoints'] = selected
    }
    console.error(figure[2]);
    return fig;
}

Let me know if this helps!

Thanks for all the help. :grinning: I tried copying the array but it still didn’t work. The marker color didn’t change anything as well. I opened a github issue: [BUG] clientside callback doesn’t update figures · Issue #1950 · plotly/dash (github.com)

Your naming confused me at first - since you have ALL it’s not a single figure but many figures, so better to have the arg name reflect that :slight_smile:

In-place modification is likely the issue here. If you want a simple (but low-performance if your figure is large) solution, you can:

return JSON.parse(JSON.stringify(fig))

or perhaps do that upfront:

const figCopy = JSON.parse(JSON.stringify(fig))

But a better solution is to use layout.datarevision - just make sure its value changes and the plot should recognize the need to update. layout | JavaScript | Plotly

1 Like

You are right the naming was confusing. I got the json parsing working thanks a ton for that. :smiley: But the datarevision isn’t working for me maybe I am doing something wrong:


import dash
import pandas as pd
import plotly.graph_objects as go
import dash.dcc as dcc
import dash.html as html
import plotly.express as px
from dash.dependencies import Input, Output, State, ALL


iris_df = px.data.iris()
setosa = iris_df[iris_df["species"] == "setosa"]


def layout():
    figures = []
    measures = ["sepal_length", "sepal_width", "petal_length", "petal_width"]
    plots = [(x, y) for x in measures for y in measures]
    for idx, plot in enumerate(plots):
        if plot[0] == plot[1]:
            continue
        fig = go.Figure()
        fig.add_trace(
            go.Scatter(
                x = setosa[plot[0]],
                y = setosa[plot[1]],
                customdata= list(setosa.index),
                mode="markers",
                selected= {"marker": {"color":"red"}}
            )

        )
        fig.update_layout(
                        title="{}: {}".format(plot[0], plot[1]),
                        xaxis_title=str(plot[0]),
                        yaxis_title=str(plot[1]),
                        datarevision= 0,
                        selectionrevision = 0)
        
        graph = dcc.Graph(id= {"type": "graph", "index": idx} , figure=fig)
        figures.append(graph)
    return(html.Div([dcc.Store(id="selection"),html.Div(figures)]))

app = dash.Dash(__name__)
app.layout = layout()

@app.callback(
    Output("selection", 'data'),
    Input({"type": "graph", "index": ALL}, 'selectedData'),
    prevent_initial_call=True
)
def selection(sel):
    points = []
    for i in sel:
        if i is None:
            continue
        for point in i['points']:
            points.append(point['customdata'])
    print(points)
    return list(set(points))


app.clientside_callback(
"""
function(data, figures) {

    for (var i = 0; i < figures.length; i++) {
        const ids = figures[i]['data'][0]['customdata']
        const intersect = data.filter(value => ids.includes(value));
        const selected = intersect.map(x => ids.indexOf(x))
        selected.sort();
        figures[i]['data'][0]['selectedpoints'] = selected
        figures[i]['layout']['datarevision'] = figures[i]['layout']['datarevision']+1 ;
    }
    console.error(figures[2]);
    return figures;
}""",
Output({"type": "graph", "index": ALL}, "figure"),
Input("selection","data"),
State({"type": "graph", "index": ALL}, "figure"),
prevent_initial_call=True
)



if __name__ == "__main__":
    app.run_server(debug=True)