Slow update of plot even with client side callback - trying to highlight traces on hover

Hi,

I have a plot with multiple line traces. I want to highlight a line when hovering over it, by increasing the line width and by changing the opacity of all other traces. This is what it looks like:

highlight_on_hover

For a small number of traces, this works well. In my application however, I expect to have at least 100 traces.

I already tried implementing this both as a regular server side callback and as a client side callback but even with a client side callback, the plot updates are slow for 100 traces. I built a small example app here:

import dash
from dash import dcc, html, callback, clientside_callback, Input, Output, State, no_update
import pandas as pd
import numpy as np
import plotly
from plotly import graph_objects as go


app = dash.Dash(__name__)


app.layout = html.Div(
    [
        dcc.Input(id="n-curves-input", value=10, type="number", min=1),
        html.Button(id="update-button", children="Update Plot"),
        dcc.Graph(id="plot"),
    ]
)


@callback(
    Output("plot", "figure"),
    Input("update-button", "n_clicks"),
    State("n-curves-input", "value"),
)
def update_plot(_n_clicks, n_curves):
    df = pd.DataFrame()
    df["curve_id"] = np.arange(n_curves)
    curve_data = np.random.randn(n_curves, 45)

    fig = go.Figure()
    for i, row in df.iterrows():
        fig.add_trace(
            go.Scatter(
                x=np.arange(45),
                y=curve_data[i],
                mode="lines+markers",
                marker_color="rgba(0,0,0,0)",
                line_color=plotly.colors.DEFAULT_PLOTLY_COLORS[i % len(plotly.colors.DEFAULT_PLOTLY_COLORS)],
                customdata=[row["curve_id"]] * 45,
                hovertemplate="<b>%{customdata}</b><br><br>",
                line_width=2
            )
        )

    fig.update_layout(height=800)

    return fig

# server side callback
# @callback(
#     Output("plot", "figure", allow_duplicate=True),
#     Input("plot", "hoverData"),
#     State("plot", "figure"),
#     prevent_initial_call=True,
# )
# def highlight_curve(hover_data, fig):
#     if hover_data is None:
#         return no_update
#     curve_id = hover_data["points"][0]["customdata"]
#     for trace in fig["data"]:
#         if trace["customdata"][0] == curve_id:
#             trace["line"]["width"] = 4
#             trace["opacity"] = 1
#         else:
#             trace["line"]["width"] = 2
#             trace["opacity"] = 0.4

#     return fig


# client side callback
clientside_callback(
    """
    function(hoverData, fig) {
        if (!hoverData) {
            return window.dash_clientside.no_update;
        }
        const curveId = hoverData.points[0].customdata;
        for (let i = 0; i < fig.data.length; i++) {
            if (fig.data[i].customdata[0] === curveId) {
                fig.data[i].line.width = 4;
                fig.data[i].opacity = 1;
            } else {
                fig.data[i].line.width = 2;
                fig.data[i].opacity = 0.4;
            }
        }
        return {...fig};
    }
    """,
    Output("plot", "figure", allow_duplicate=True),
    Input("plot", "hoverData"),
    State("plot", "figure"),
    prevent_initial_call=True,
)


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

Do you have any idea for improving the performance of this so that the highlighting feels smooth for ~100-200 traces?

Hi @christoph.b

Try using Partial Property Updates:

Hi @AnnMarieW

Thank you for your reply! :slightly_smiling_face:

I tried using Partial Property Updates and while this works, I’d say the performance feels about the same as using the clientside callback.

Here is the updated code:

import dash
from dash import dcc, html, callback, clientside_callback, Input, Output, State, no_update, Patch
import pandas as pd
import numpy as np
import plotly
from plotly import graph_objects as go


app = dash.Dash(__name__)


app.layout = html.Div(
    [
        dcc.Input(id="n-curves-input", value=10, type="number", min=1),
        html.Button(id="update-button", children="Update Plot"),
        dcc.Graph(id="plot"),
    ]
)


@callback(
    Output("plot", "figure"),
    Input("update-button", "n_clicks"),
    State("n-curves-input", "value"),
)
def update_plot(_n_clicks, n_curves):
    df = pd.DataFrame()
    df["curve_id"] = np.arange(n_curves)
    curve_data = np.random.randn(n_curves, 45)

    fig = go.Figure()
    for i, row in df.iterrows():
        fig.add_trace(
            go.Scatter(
                x=np.arange(45),
                y=curve_data[i],
                mode="lines+markers",
                marker_color="rgba(0,0,0,0)",
                line_color=plotly.colors.DEFAULT_PLOTLY_COLORS[i % len(plotly.colors.DEFAULT_PLOTLY_COLORS)],
                customdata=[row["curve_id"]] * 45,
                hovertemplate="<b>%{customdata}</b><br><br>",
                line_width=2
            )
        )

    fig.update_layout(height=800)

    return fig

# server side callback with partial property update
@callback(
    Output("plot", "figure", allow_duplicate=True),
    Input("plot", "hoverData"),
    State("plot", "figure"),
    prevent_initial_call=True,
)
def highlight_curve(hover_data, fig):
    if hover_data is None:
        return no_update

    curve_id = hover_data["points"][0]["customdata"]
    patched_fig = Patch()

    for i, trace in enumerate(fig["data"]):
        if trace["customdata"][0] == curve_id:
            patched_fig["data"][i]["line"]["width"] = 4
            patched_fig["data"][i]["opacity"] = 1
        else:
            patched_fig["data"][i]["line"]["width"] = 2
            patched_fig["data"][i]["opacity"] = 0.4

    return patched_fig


# client side callback
# clientside_callback(
#     """
#     function(hoverData, fig) {
#         if (!hoverData) {
#             return window.dash_clientside.no_update;
#         }
#         const curveId = hoverData.points[0].customdata;
#         for (let i = 0; i < fig.data.length; i++) {
#             if (fig.data[i].customdata[0] === curveId) {
#                 fig.data[i].line.width = 4;
#                 fig.data[i].opacity = 1;
#             } else {
#                 fig.data[i].line.width = 2;
#                 fig.data[i].opacity = 0.4;
#             }
#         }
#         return {...fig};
#     }
#     """,
#     Output("plot", "figure", allow_duplicate=True),
#     Input("plot", "hoverData"),
#     State("plot", "figure"),
#     prevent_initial_call=True,
# )


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

Do you have any other suggestions?

Hi @christoph.b,

your callbacks (client and server side) receive and send the whole figure each time you hover over one of the lines, which is quite a overhead.
I am not really sure if it works because plotly figures are mainly based on svgs and managed by react (as far as I understand it right) but what I would try is to create a query selector for the traces and just update those. something like…

clientside callback

clientside_callback(
    """
    ( hoverData ) => {
        const curveId = hoverData["points"][0]["customdata"];
        const traceSelector = (...create trace identifier)

        if ( !hoverData) {
            (...keep settings or make all visible)
        }

        // select hovered trace and update visible state
        let updateTrace = document.querySelector("#svgMain " + traceSelector);
        updateTrace.setAttribute("data", "data-trace-hover-visible")

        //remove visible state from trace
        let resetTrace = document.querySelector(trace["data-trace-hover-visible"])
        resetTrace.removeAttribute("data-trace-hover-visible")
    }
    """
    Input("plot", "hoverData")

pure js

( hoverData ) => {
        const curveId = hoverData["points"][0]["customdata"];
        const traceSelector = (...create trace identifier)

        if ( !hoverData) {
            (...keep settings or make all visible)
        }

        // select hovered trace and update visible state
        let updateTrace = document.querySelector("#svgMain " + traceSelector);
        updateTrace.setAttribute("data", "data-trace-hover-visible")

        //remove visible state from trace
        let resetTrace = document.querySelector(trace["data-trace-hover-visible"])
        resetTrace.removeAttribute("data-trace-hover-visible")
    }

While this is just pseudo code, maybe some research based on this can lead you to the right solution.

Here is a thread about svg query selectors and maybe @AnnMarieW knows exactly how traces can be identified and selected.

Hi @Datenschubse :slightly_smiling_face:

Thank you so much for your reply, it really did point me into the right direction.
Based on your suggestion I came up with this solution:

import dash
from dash import dcc, html, callback, clientside_callback, Input, Output, State, no_update, Patch
import pandas as pd
import numpy as np
import plotly
from plotly import graph_objects as go


app = dash.Dash(__name__)


app.layout = html.Div(
    [
        dcc.Input(id="n-curves-input", value=50, type="number", min=1),
        html.Button(id="update-button", children="Update Plot"),
        dcc.Graph(id="plot", clear_on_unhover=True),
    ]
)


@callback(
    Output("plot", "figure"),
    Input("update-button", "n_clicks"),
    State("n-curves-input", "value"),
)
def update_plot(_n_clicks, n_curves):
    df = pd.DataFrame()
    df["curve_id"] = np.arange(n_curves)
    curve_data = np.random.randn(n_curves, 45)

    fig = go.Figure()
    for i, row in df.iterrows():
        fig.add_trace(
            go.Scatter(
                x=np.arange(45),
                y=curve_data[i],
                mode="lines+markers",
                marker_color="rgba(0,0,0,0)",
                line_color=plotly.colors.DEFAULT_PLOTLY_COLORS[i % len(plotly.colors.DEFAULT_PLOTLY_COLORS)],
                customdata=[row["curve_id"]] * 45,
                hovertemplate="<b>%{customdata}</b><br><br>",
                line_width=2
            )
        )

    fig.update_layout(height=800)

    return fig


clientside_callback(
    """
    function(hoverData, fig) {
        let plot_trace_elems = document.getElementsByClassName("scatterlayer mlayer")[0].children;
        const curveId = hoverData? hoverData.points[0].customdata: null;
        for (let i = 0; i < fig.data.length; i++) {
            if (!curveId) {
                plot_trace_elems[i].style.opacity = 1;
                let lines = plot_trace_elems[i].getElementsByClassName("js-line")[0];
                lines.style.strokeWidth = 2;
            }
            else if (fig.data[i].customdata[0] === curveId) {
                plot_trace_elems[i].style.opacity = 1;
                let lines = plot_trace_elems[i].getElementsByClassName("js-line")[0];
                lines.style.strokeWidth = 4;
            } else {
                plot_trace_elems[i].style.opacity = 0.4;
                let lines = plot_trace_elems[i].getElementsByClassName("js-line")[0];
                lines.style.strokeWidth = 2;
            }
        }
        return window.dash_clientside.no_update;
    }
    """,
    Output("plot", "figure", allow_duplicate=True),
    Input("plot", "hoverData"),
    State("plot", "figure"),
    prevent_initial_call=True,
)


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

I am not sure if this is the best or most efficient way to do this since I am not very familiar with JS but it is way faster than my previous solution.

1 Like

Nice! Just one small suggestion depending on your Dash Version - since Dash 2.17 you can have callbacks with not output what would be applicable in this case.

And optimisation wise, what you could do is to manage the stroke-width and opacity in css based on a data attribute. This will not force a re render when you change the visibility.

Just fyi! :slight_smile:

Now I removed the output of the callback and used CSS rules to change the stroke-width and opacity. Here is the updated code:

assets/custom.css

.scatterlayer .trace[hide-opacity="true"] {
    opacity: 0.4 !important;
}

.scatterlayer .trace[hide-opacity="false"] {
    opacity: 1.0 !important;
}

.scatterlayer .trace .js-line[highlight-line-width="true"] {
    stroke-width: 4px !important;
}

.scatterlayer .trace .js-line[highlight-line-width="false"] {
    stroke-width: 2px !important;
}

app.py

import dash
from dash import dcc, html, callback, clientside_callback, Input, Output, State, no_update, Patch
import pandas as pd
import numpy as np
import plotly
from plotly import graph_objects as go


app = dash.Dash(__name__)


app.layout = html.Div(
    [
        dcc.Input(id="n-curves-input", value=50, type="number", min=1),
        html.Button(id="update-button", children="Update Plot"),
        dcc.Graph(id="plot", clear_on_unhover=True),
    ]
)


@callback(
    Output("plot", "figure"),
    Input("update-button", "n_clicks"),
    State("n-curves-input", "value"),
)
def update_plot(_n_clicks, n_curves):
    df = pd.DataFrame()
    df["curve_id"] = np.arange(n_curves)
    curve_data = np.random.randn(n_curves, 45)

    fig = go.Figure()
    for i, row in df.iterrows():
        fig.add_trace(
            go.Scatter(
                x=np.arange(45),
                y=curve_data[i],
                mode="lines+markers",
                marker_color="rgba(0,0,0,0)",
                line_color=plotly.colors.DEFAULT_PLOTLY_COLORS[i % len(plotly.colors.DEFAULT_PLOTLY_COLORS)],
                customdata=[row["curve_id"]] * 45,
                hovertemplate="<b>%{customdata}</b><br><br>",
                line_width=2
            )
        )

    fig.update_layout(height=800)

    return fig


clientside_callback(
    """
    function(hoverData, fig) {
        let traces = document.getElementsByClassName("scatterlayer mlayer")[0].children;
        const curveId = hoverData? hoverData.points[0].customdata: null;
        for (let i = 0; i < fig.data.length; i++) {
            let lines = traces[i].getElementsByClassName("js-line")[0];
            if (!curveId) {
                traces[i].setAttribute("hide-opacity", "false");
                lines.setAttribute("highlight-line-width", "false");
            }
            else if (fig.data[i].customdata[0] === curveId) {
                traces[i].setAttribute("hide-opacity", "false");
                lines.setAttribute("highlight-line-width", "true");
            } else {
                traces[i].setAttribute("hide-opacity", "true");
                lines.setAttribute("highlight-line-width", "false");
            }
        }
    }
    """,
    Input("plot", "hoverData"),
    State("plot", "figure"),
    prevent_initial_call=True,
)


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

With this, the highlighting of the traces if very fast, even for a large number of traces. Thanks again!

1 Like