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

The following code is working fine for me in Bokeh 3.3.2:

from bokeh.layouts import column, row
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=False)
hover_plot.line(x="x_hover", y="y_hover", source=hover_source)

CODE = """
if (cb_data.index.indices.length > 0) {
    if (hover_plot.visible === false) {
        hover_plot.visible = true
    }
} else {
    if (hover_plot.visible === true) {
        hover_plot.visible = false
    }
}
"""

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

show(row(plot, hover_plot))

That code works for me with 3.7 as well @nmasnadi but it does not work if I switch the layout to be a column like @richardxdubois original code. I can see a layout shift when the second plot becomes visible in the column arrangement. I think that lends credence to the notion that it is layout changes precipitating a re-inspection.

yes, I should have mentioned that I had the same issue with the column layout as it was moving things around and causing the infinite loop. Thanks for clarifying Bryan.

Maybe a workaround here is to keep the figure visible and instead toggle the renderer’s visibility (or the visibility of the renderer that corresponds to the the hovered index) like this:

I also wonder if @richardxdubois would be interested in directly adding the second figure as the tooltip for the hover tool. This way there is no need for any custom JS callback function. Something like this:

import numpy as np
from bokeh.models.dom import Div, Styles, Template, ToggleGroup
from bokeh.models import RendererGroup

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

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_plot = figure(width=200, height=150, title="Hover Plot", toolbar_location=None)

groups = []
for i in range(5):
    group = RendererGroup(visible=False)
    r = hover_plot.scatter(x=np.random.rand(20), y=np.random.rand(20), color=Category10_10[i+1], visible=False)
    r.group = group
    groups.append(group)

style = Styles(
    display='grid',
    grid_template_columns='auto',
    column_gap='10px',
)
grid = Div(style=style)
grid.children = [hover_plot]

t = Template(children=[grid], actions=[ToggleGroup(groups=groups)])

plot.add_tools(HoverTool(tooltips=t, point_policy='follow_mouse'))

show(plot)

1 Like

I was going to suggest something similar when I had time to work up example code, but you beat me to it. AFAIK it would also work to add a single plot and renderer to the tooltip and update the data source for it on hover, which might be preferable in case the main plot has very many scatter points.

1 Like

Having the figure as the Div tooltip was where I started (from someone’s example here). But in bokeh 3.6, Div no longer has a children attribute.

I’d posted asking if anyone had an idea how to achieve this in the later versions of Bokeh.

And indeed in the meantime, I settled on leaving the 2nd figure visible all the time. It works, if inelegant.

I only relatively recently came across the visible property and have been using it for handy manipulation of the layout, eg to have a pulldown menu disappear if a toggle is triggered.