Highlight markers interactively in a scatterplot using circular callbacks

Premise

Update Graphs on Hover is one of my favorite examples in the Dash docs. In the code snippet below, I’ve added a functionality that lets you highlight points (countries in this case) of particular interest. You can do so through the Select country dropdown. But you can also hover over any marker and click it to add a circle. This will also add the country name to the legend, and include the country name in the dropdown. Finally, you can click on the marker again to make the highlighting disappear. Or you can click the x in the dropdown. All this requires a setup that includes some admittedly messy circular callbacks, as well as overcoming a well known issue with lingering clickData by injecting an empty dictionary {} into the clickData property of the dcc.Graph component. But it works!

Use case

Personally, I find it interesting to study how different countries compare for two different categories using the Set X-axis category and Set Y-axis category dropdowns, and then change categories to see how they compare for other datasets. The functionality described above will let you keep track of your countries of interest as you change the data categories displayed in the figure.

Animation

enter image description here

I hope some of you find the useful, and I would love to hear your feedback. And if anyone takes a look at the code, please let me know if you find more efficient ways to obtain the same functionality. The following snippet is written for Plotly 5.10.0 and Dash 2.6.2, and will most likely not work for Dash versions prior to 2.6.0 without some additional tweaking.

Code

from dash import Dash, html, dcc, Input, Output
from dash import Dash, html, dcc, ctx
import dash_bootstrap_components as dbc
import pandas as pd
import plotly.express as px
import shelve
import plotly.graph_objects as go
import numpy as np
import black
from itertools import cycle

# data
df = pd.read_csv("https://plotly.github.io/datasets/country_indicators.csv")

# Colormap for highlighting traces.
# This makes sure that each country is associted with the same
# color no matter the selection from the dropdown or clickinfo from the figure
col_cycle = cycle(px.colors.qualitative.Plotly)
coldict = {c: next(col_cycle) for c in df['Country Name'].unique()}

# dash
app = Dash(external_stylesheets=[dbc.themes.SANDSTONE])
app.layout = dbc.Container(
    [
        dbc.Card(
            [dbc.CardHeader(["Circular references in callbacks"], className="bg-secondary bg-opacity-75 fs-5"),
                dbc.Row(
                    [
                        dbc.Col(
                            [html.H4(children="Set X-axis category", className="card-title pt-3"),
                             dcc.Dropdown(
                                df["Indicator Name"].unique(),
                                "Fertility rate, total (births per woman)",
                                id="crossfilter-xaxis-column",
                            ),
                                dcc.RadioItems(
                                ["Linear", "Log"],
                                "Linear",
                                id="crossfilter-xaxis-type",
                                labelStyle={"display": "inline-block",
                                            "marginTop": "5px"},
                            ),
                                html.H4(children="Set Y-axis category",
                                        className="card-title pt-3"),
                                dcc.Dropdown(
                                df["Indicator Name"].unique(),
                                "Life expectancy at birth, total (years)",
                                id="crossfilter-yaxis-column",
                            ),
                                dcc.RadioItems(
                                ["Linear", "Log"],
                                "Linear",
                                id="crossfilter-yaxis-type",
                                labelStyle={"display": "inline-block",
                                            "marginTop": "5px"},
                            ),
                                html.H4(children="Select year",
                                        className="card-title pt-3"),
                                dcc.Slider(
                                df["Year"].min(),
                                df["Year"].max(),
                                step=None,
                                id="crossfilter-year--slider",
                                value=df["Year"].max(),
                                marks={
                                    str(year): str(year)
                                    for year in df["Year"].unique()
                                },
                                updatemode='drag',

                            ), html.H4("Select countries", className="card-title pt-2"),
                                dcc.Dropdown(
                                id="dd1_focus",
                                options=[
                                    {"label": col, "value": col}
                                    for col in df["Country Name"].unique()
                                ],
                                multi=True,
                                clearable=False,
                            ),
                            ],
                            width=3,
                            className="border border-start-0 border-top-0 border-bottom-0 p-4",

                        ),
                        dbc.Col(
                            [dcc.Graph(
                                id="crossfilter-indicator-scatter",
                                # animate=True,
                                hoverData={
                                    "points": [{"customdata": "Norway"}]
                                },
                            ),
                            ],
                            width=9,
                        ),
                    ]
            ),
            ],
        ),
    ],
    className="mt-2",
    fluid=True
)

@app.callback(
    Output("crossfilter-indicator-scatter", "figure"),
    Input("crossfilter-xaxis-column", "value"),
    Input("crossfilter-yaxis-column", "value"),
    Input("crossfilter-xaxis-type", "value"),
    Input("crossfilter-yaxis-type", "value"),
    Input("crossfilter-year--slider", "value"),
    Input("dd1_focus", "value"),
)
def update_graph(
    xaxis_column_name,
    yaxis_column_name,
    xaxis_type,
    yaxis_type,
    year_value,
    dd1_focus_values,

):
    dff = df[df["Year"] == year_value]
    fig = px.scatter(
        x=dff[dff["Indicator Name"] == xaxis_column_name]["Value"],
        y=dff[dff["Indicator Name"] == yaxis_column_name]["Value"],
        hover_name=dff[dff["Indicator Name"] ==
                       yaxis_column_name]["Country Name"],
    )

    fig.update_traces(
        customdata=dff[dff["Indicator Name"] ==
                       yaxis_column_name]["Country Name"]
    )

    fig.update_xaxes(
        title=xaxis_column_name, type="linear" if xaxis_type == "Linear" else "log"
    )

    fig.update_yaxes(
        title=yaxis_column_name, type="linear" if yaxis_type == "Linear" else "log"
    )

    fig.update_layout(
        margin={"l": 40, "b": 40, "t": 10, "r": 0}, hovermode="closest")

    # Highlight selections with individual markers for each member of selection
    try:
        selection = dd1_focus_values
        dicts = []
        for s in selection:
            try:
                ix = list(fig.data[0].customdata).index(s)
                dicts.append(
                    {"name": s, "x": fig.data[0].x[ix], "y": fig.data[0].y[ix]}
                )
            except:
                pass

        if not len(dicts) == 0:
            for d in dicts:
                fig.add_trace(
                    go.Scatter(
                        x=[d["x"]],
                        y=[d["y"]],
                        name=d["name"],
                        mode="markers",
                        marker_symbol="circle-open",
                        marker_line_width=4,
                        marker_color=coldict[d["name"]],
                        marker_size=14,
                        hoverinfo="skip",
                    )
                )
    except:
        pass

    fig.update_layout(height=650)
    fig.update_layout(margin=dict(l=20, r=275, t=20, b=20))
    fig.update_layout(uirevision='constant', legend=dict(orientation="v"))
    return fig#, selection


@app.callback(
    Output("dd1_focus", "value"),
    Output(
        "crossfilter-indicator-scatter", "clickData"
    ),  # Used to reset clickData in order to avoid a circular reference between clickData and selections from dd1
    Input("crossfilter-indicator-scatter", "clickData"),
    Input("dd1_focus", "value"),
)
def print_clickdata1(clickinfo, dd1_existing_selection):
    # If dropdown has values, clickdata is added to that list and duplicates are removed.
    if dd1_existing_selection is not None and bool(dd1_existing_selection) and bool(clickinfo):
        # The following try/pass needs to be there since
        # dropdown values sometimes are REMOVED by clicking the x option
        if clickinfo["points"][0]["customdata"] not in dd1_existing_selection:
            try:
                new_selection = dd1_existing_selection + [
                    clickinfo["points"][0]["customdata"]
                ]
                new_selection = list(dict.fromkeys(new_selection))
            except:
                new_selection = dd1_existing_selection
        else:
            dd1_existing_selection.remove(clickinfo["points"][0]["customdata"])
            new_selection = dd1_existing_selection
    else:
        try:
            # If dropdown has no values,
            # clickdata is attempted to be added, and if that failscd
            # an empty list is set to the values
            new_selection = [clickinfo["points"][0]["customdata"]]
        except:
             new_selection = dd1_existing_selection

    return new_selection, {},


if __name__ == "__main__":
    app.run_server(debug=True, threaded=True, port=8080)
3 Likes

Thanks for sharing, @vestland
I think that feature is rare and very useful: to be able to chose markers on a scatter plot that are connected to the legend and a Dash component, like the dropdown in this case.

1 Like

Really interesting @vestland !

I also have a use case in my mind: If you would want to create a coordinate system by three points, for example by selecting points of a point cloud. You could visually keep track of the points and alter them if needed. I might try that.

2 Likes

Sounds great! I hope you give it a try!

Hi @vestland, your approach works perfectly for my use case, thanks for the idea!

csys1

2 Likes

Hi @aimped, glad to hear it! That’s very very cool!

1 Like