Javascript hover callback in infinite loop when setting figure visible

I’m (still) working away at figuring out how to update a 2nd figure from a hover callback. This in bokeh 3.6.2.

I have not been able to make the 2nd figure pop up when a point is hovered over, so I’ve made it part of the layout. My compromise was to pass the figure into the js callback and change the visibility when the mouse is over a point (and the figure updated via the cds) or not.

In callback_code, if I comment out setting hover_plot.visible to false, the app behaves as expected (except of course hover_plot is never set invisible). With the code as is, it appears to go into an infinite loop of calls to the callback, and with the test on index === null (et seq) always being true.

Is this a bug, or am I causing the events by having changed the visibility in the model?

Here’s a complete code example:

from bokeh.layouts import column, layout, row
from bokeh.plotting import figure, show, save
from bokeh.models import HoverTool, ColumnDataSource, CustomJS, Div
from bokeh.models.dom import Template

# Sample data for the main plot
source = ColumnDataSource(data=dict(x=[1, 2, 3, 4, 5], y=[6, 7, 2, 4, 5], names=["A", "B", "C", "D", "E"],
                                    ))

# Sample data for the hover plot
x_hover = [0, 1, 2]
y_hover = [0, 1, 0]
image_urls = ["", "", ""]
hover_source = ColumnDataSource(data=dict(x_hover=x_hover, y_hover=y_hover))

# Create a hidden Bokeh figure for the tooltip
hover_plot = figure(width=200, height=150, title="Hover Plot", visible=True)

hover_plot.line(x="x_hover", y="y_hover", source=hover_source)

# Create the main figure
main_plot = figure(width=400, height=300, title="Main Plot")

mp = main_plot.scatter(x='x', y='y', size=10, source=source)

# Configure the HoverTool to embed the figure in the tooltip

callback_code = """
    //console.log("Entered callback")

    const data = source.data;
    const hoverData = hover_source.data;
    const index = cb_data.index;
    const geometry = cb_data.geometry;

    if (hover_plot.visible)
        hover_plot.visible = false;
        console.log("off point set", hover_plot.visible);
        
    if (index === null || index === undefined || index.indices.length === 0)
        console.log("geometry for null index", geometry, index);
        return;
        
    const x = geometry.x;
    const y = geometry.y;

    let selected_index = null;
    let min_distance = Infinity;

    const sx = source.data['x'];
    const sy = source.data['y'];

    for (let i = 0; i < sx.length; i++) {
        const dx = sx[i] - x;
        const dy = sy[i] - y;
        const distance = Math.sqrt(dx*dx + dy*dy);
        if (distance < min_distance) {
            min_distance = distance;
            selected_index = i;
        }
    }

    // Update hover data
    hoverData['x_hover'] = [x - 0.5, x, x + 0.5];
    hoverData['y_hover'] = [y - 0.3, y + 0.4, y - 0.3];

    // Update the source.
    hover_source.change.emit();
    console.log("before setting visible true", hover_plot.visible);
    hover_plot.visible = true;
    console.log("after setting visible true", hover_plot.visible);
    hover_plot.title.text=String(selected_index);
    
    """

hover = HoverTool()
# Define a CustomJS callback that updates hover data on hover
h_callback = CustomJS(args=dict(hover_source=hover_source, source=source, hover_plot=hover_plot), code=callback_code)

hover.callback = h_callback

main_plot.add_tools(hover)

print(main_plot, hover_plot, hover, hover.callback)

canvas = layout(column(main_plot, hover_plot))

# Show the main plot
save(canvas)

@richardxdubois My best guess is that updating the layout triggers browser events that Bokeh sees as requiring a re-inspection for the hover tool, but it would require some deeper investigation to know for sure.

Normally, in cases where you have a callback ping-pong, one approach is to condition on some global flag that says “we are already in the callback” and exit immediately in that case. I did spend some time trying something like that for but some reason could not get it to work (any “global” I tried to use was seemingly reset to undefined on every callback invocation :man_shrugging: )

In any case, I am afraid I don’t have any more immediate advice. In case you want to submit an issue or discussion to see if someone can dig further, here is a simpler stripped down reproducer:

from bokeh.layouts import column
from bokeh.plotting import figure, show
from bokeh.models import HoverTool, ColumnDataSource, CustomJS

source = ColumnDataSource(data=dict(x=[1, 2, 3, 4, 5], y=[6, 7, 2, 4, 5]))
plot = figure(width=400, height=300, title="Main Plot")
plot.scatter(x='x', y='y', size=10, source=source)

hover_source = ColumnDataSource(data=dict(x_hover=[0, 1, 2], y_hover=[0, 1, 0]))
hover_plot = figure(width=200, height=150, title="Hover Plot", visible=True)
hover_plot.line(x="x_hover", y="y_hover", source=hover_source)

CODE = """
    if (hover_plot.visible) {
        hover_plot.visible = false
    }

    const index = cb_data.index;
    if (index == null || index == undefined || index.indices.length == 0) {
        return
    }

    console.log("before setting visible true", hover_plot.visible)
    hover_plot.visible = true
"""

hover = HoverTool()
hover.callback = CustomJS(args=dict(hover_plot=hover_plot), code=CODE)
plot.add_tools(hover)

show(column(plot, hover_plot))