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