holoviz / holoviews

With Holoviews, your data visualizes itself.
https://holoviews.org
BSD 3-Clause "New" or "Revised" License
2.69k stars 402 forks source link

`link_selections.instance` is incompatible with manually attached streams #6348

Open jerry-kobold opened 1 month ago

jerry-kobold commented 1 month ago

ALL software version info

holoviews==1.19.2 bokeh==3.4.3 panel==1.4.4

Description of expected behavior and the observed behavior

I am trying to accomplish the following: I want two plots (generated from the same data) that are linked to each other by some column. Additionally, I want to be able to hook a stream into the plots that fires on selection so I can do something with the selected points. However, when I apply the linkage, I lose the selection stream callback. I would expect both the linked selection and the manually installed callbacks to fire.

Complete, minimal, self-contained example code that reproduces the issue

import numpy as np
import pandas as pd
import panel as pn
import param

hv.extension("bokeh")
pn.extension()

link_plots = pn.widgets.Checkbox(name="link plots?")
ls = link_selections.instance(index_cols=[hv.Dimension("id")], link_inputs=True)
selected = pn.widgets.StaticText(name="selected")

def debug(index):
    # our callback that will do something useful
    selected.value = f"{', '.join([str(idx) for idx in index])}"

def make_plot(link):

    data = np.random.default_rng(seed=42).normal(size=(100, 3))
    idx = np.arange(100)
    cols = ["x", "y", "z"]
    df = pd.DataFrame(data, columns=cols)
    df["id"] = idx
    ds = hv.Dataset(df)

    plot = hv.Points(ds, kdims=["x", "y"], vdims=[]).opts(tools=["box_select"])

    sel_stream = hv.streams.Selection1D()
    sel_stream.clear("user")
    sel_stream.add_subscriber(debug)
    sel_stream.source = plot

    if link:
        plot = ls(plot)

    return plot

img1 = pn.bind(make_plot, link_plots)
img2 = pn.bind(make_plot, link_plots)

pn.Column(pn.Row(img1, img2, link_plots), selected).servable()

If the checkbox is unchecked, the debug callbacks will fire as expected, but the selections will not be linked. On the other hand, if the checkbox is checked, then the selections will be linked, but the callbacks attached to sel_stream will not fire.

Stack traceback and/or browser JavaScript console output

There isn't any error associated with this, but it seems like not the behavior one would expect. I drilled down into the HoloViews code and it seems like when the link instance object attaches the callabacks to the plot, the callbacks that are attached from sel_stream get erased, but I don't know why.

Screenshots or screencasts of the bug in action

https://github.com/user-attachments/assets/9931301f-14cc-4d8d-99d7-f920ce912390

jerry-kobold commented 1 month ago

I think I've traced the source of the problem: when you pass a plot to a link_selections.instance object, that plot will get cloned several times. After it gets cloned, the connection between that plot and previous streams which have been defined to use it as the source, as reflected in the Stream.registry dictionary, is severed. Consequently, when the Bokeh callbacks are constructed, that stream is no longer transformed into a callback and so the debug function in the above example is never invoked.

I have come up with a hacky way of patching this by essentially pulling out the old plot by its _plot_id from the registry and then assigning it to be the source of the stream. The following snippet (applied to the example above) illustrates the basic concept:

    if link:
        plot_id = plot._plot_id
        plot = ls(plot)
        cloned_plot = [plot_key for plot_key in list(Stream.registry.keys()) if plot_key._plot_id == plot_id]
        cloned_plot = cloned_plot[0]
        sel_stream.source = cloned_plot

This works, but feels gross. It seems like it should be easy enough to handle in the internals of the link_selections.instance object, but I'm not sure if this is a good solution or not. Happy to make a PR for this if it seems worthwhile to do.

hoxbro commented 1 month ago

I would also expect this to work, so I will mark this as a bug.

I've been playing around with it myself. Without a fix, I think the "cleanest" way is to create two streams and bind them to the debug function.

import holoviews as hv
import numpy as np
import pandas as pd
import panel as pn

hv.extension("bokeh")

ls = hv.link_selections.instance(cross_filter_mode="overwrite")
sel_stream1 = hv.streams.Selection1D()
sel_stream2 = hv.streams.Selection1D()

def make_plot(index):
    data = np.random.default_rng(seed=42).normal(size=(100, 2))
    df = pd.DataFrame(data, columns=["x", "y"])
    return hv.Points(df, kdims=["x", "y"], vdims=[]).opts(tools=["box_select"])

img1 = hv.DynamicMap(make_plot, streams=[sel_stream1])
img2 = hv.DynamicMap(make_plot, streams=[sel_stream2])
img = ls(img1 + img2)

selected = pn.widgets.StaticText(name="selected", value="")

def debug(index):
    selected.value = f"{', '.join([str(idx) for idx in index])}"

pn.bind(debug, sel_stream1.param.index, watch=True)
pn.bind(debug, sel_stream2.param.index, watch=True)

pn.Column(img, selected).servable()