Filter tabulator on plot zoom / select

Hi, I want to filter a tabulator based on zoom or box select in a plot. I adjusted the code in this topic Using box_select to Return a Filtered Dataframe to do what I want, but I wonder if there is a better way because this seems quite hacky. Here is the code:

import numpy as np
import pandas as pd
import holoviews as hv
import panel as pn
import hvplot.pandas
from holoviews import streams

pn.extension(sizing_mode="stretch_width")

n=200
xs = np.linspace(0, 1, n)
ys = np.cumsum(np.random.randn(n))
df = pd.DataFrame({'x': xs, 'y': ys})

def get_plot():
    return df.set_index('x').hvplot.step(tools=["box_select"])

source = hv.Curve({})
boundsx = streams.BoundsX(source=source, boundsx=(0, 0))

t = pn.widgets.Tabulator(df.head())

@pn.depends(boundsx.param.boundsx)
def data_view(boundsx):
    x_start, x_end = boundsx
    filtered_df = df[(df["x"].between(x_start, x_end))]
    if boundsx != (0,0):    
        t.value = filtered_df.head()

component = pn.Column(
    hv.DynamicMap(get_plot) * source,
    t,
    pn.pane.DataFrame(data_view, visible=False)
)

An alternative for this particular example, which is less hacky but has other downsides, is doing it like this:

boundsx = streams.BoundsX(source=source, boundsx=(0, 0))

@pn.depends(boundsx.param.boundsx)
def data_view(boundsx):
    x_start, x_end = boundsx
    filtered_df = df[(df["x"].between(x_start, x_end))]
    return filtered_df

t = pn.widgets.Tabulator(data_view)

component = pn.Column(
    hv.DynamicMap(get_plot) * source,
    t
)

Whereas this does not work:

boundsx = streams.BoundsX(source=source, boundsx=(0, 0))

t = pn.widgets.Tabulator(df.head())

@pn.depends(boundsx.param.boundsx)
def data_view(boundsx):
    x_start, x_end = boundsx
    filtered_df = df[(df["x"].between(x_start, x_end))]
    t.value = filtered_df.head()

component = pn.Column(
    hv.DynamicMap(get_plot) * source,
    t
)

Questions:

  • My biggest question is why I cannot define a callback on the plot event like in the “this does not work” example without having to add a widget based on the callback somehow? Or maybe I can, but how then?
  • The downside of the first solution is that I need to artificially add an invisble component to the layout just in order to have data_view part of some component. The downside of the second solution is that I also want the tabulator to be filtered on other events, not just from this plot.
  • Why do I need the DynamicMap overlaid on an empty plot? Again coming back to the first bullet, is there no way to just define a callback on a plot zoom / select? Behind the scenes, the DynamicMap must be registering some listener to the bokeh plot event – is that not exposed somewhere?

Thanks in advance for any help

I made a step towards a better solution. To answer the first question in my original post, replacing pn.depends with param.depends, thereby circumventing panel and using the underlying param lib directly, does the trick. I guess the pn.depends introduces some dependency on actually having to visualize the callback somehow. When doing this, make sure to set watch=True so that the callback gets triggered automatically.

On the later questions, I tried doing this on Bokeh plots directly and the problem seems to be that one can’t define pure Python callbacks without running a Bokeh server. So that might be part of the problem.

So the solution I have now is (skipping setup code):

p = df.set_index('x').hvplot.step(tools=["box_select"])
box = streams.BoundsXY(source=p, bounds=(0,0,0,0))

t = pn.widgets.Tabulator(df.head())

# use param.depends directly
@param.depends(box.param.bounds, watch=True)
def filter_df(bounds):
    x_start, y_start, x_end, y_end = bounds
    filtered_df = df[(df["x"].between(x_start, x_end))]
    t.value = filtered_df.head()

# plot a bounding rectangle. hv.Bounds(0.001) also works, plots a tiny rectangle centered at 0,0
bounds = hv.DynamicMap(lambda bounds: hv.Bounds(bounds), streams=[box])

component = pn.Column(
    p * bounds,
    t
)

Update: @pn.depends(box.param.bounds, watch=True) actually also works, so the problem wasn’t with pn, it’s with having watch default False. watch=False just informs Panel that there is a dependency but doesn’t trigger it automatically, so it’s then up to Panel to trigger it, which I guess it only does if there is some widget

Update and solution: actually, with watch=True it turns out that we don’t need DynamicMap at all anymore, so all my questions are solved. Full code below for reference. I’ll stop talking to myself now.

import numpy as np
import pandas as pd
import holoviews as hv
import panel as pn
import hvplot.pandas
from holoviews import streams
import param

pn.extension(sizing_mode="stretch_width")
pn.extension('tabulator')

n=200
xs = np.linspace(0, 1, n)
ys = np.cumsum(np.random.randn(n))
df = pd.DataFrame({'x': xs, 'y': ys})

p = df.set_index('x').hvplot.step(tools=["box_select"])
box = streams.BoundsXY(source=p, bounds=(0,0,0,0))

t = pn.widgets.Tabulator(df.head())

@param.depends(box.param.bounds, watch=True)
def filter_df(bounds):
    x_start, y_start, x_end, y_end = bounds
    filtered_df = df[(df["x"].between(x_start, x_end))]
    t.value = filtered_df.head()

component = pn.Column(
    p,
    t
)