Sync PointDraw, Dataframe and Tabulator

Hi,

I’m having issues making a two-way sync between a Holoviews plot and a Panel Tabulator widget.

The following code only work from Plot to Table, not the other way around I cannot find a way to make it.

The reasons I’m doing this are:

  • Process the data added via the PointDraw tool so that the list of points are sorted by column A and rounded to the second decimal. This is important that they are sorted (and relying on the table sorting functionality) in this case;
  • Use the customisation capability of Tabulator (as opposed to use Annotators for example);
  • Allow the User to enter data both way: via a PointDraw on the graph or via the table directly.
import param
import panel as pn
import pandas as pd
import holoviews as hv
from holoviews.streams import PointDraw

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


class MyPlot(param.Parameterized):
    df = param.DataFrame(columns=["A", "B"])
    stream = param.ClassSelector(
        default=PointDraw(num_objects=10), class_=(PointDraw,), precedence=-1
    )

    @pn.depends("stream.data", watch=True)
    def _update_dataframe(self):
        """Function to reflect the change in the PointDraw data to the original data"""
        df = pd.DataFrame(self.stream.data).round(2).sort_values("A").reset_index()
        if "index" in df.columns:
            df = df.drop(["index"], axis=1)

        self.df = df

    def table(self):
        return pn.widgets.Tabulator(self.df, width=500, layout="fit_columns")

    def plot(self):
        line = hv.Curve(self.df, "A", "B")
        points = hv.Scatter(self.df, "A", "B")
        points.opts(active_tools=["point_draw"], width=500, height=500, size=20)

        self.stream.source = points

        return points * line

    def view(self):
        return pn.Row(self.plot, self.table, width_policy="max").servable()


df = pd.DataFrame(data={"A": [0, 1], "B": [0, 1]})
MyPlot(df=df).view()

If I click on the graph, a point is added, and the change is reflected in the table.
plot --> self.df --> table

How can I achieve the reverse: I modify a value in the table and the graph updates accordingly?
table --> self.df --> plot

Any help would be great. Thanks.

Hi,

I was able to be close to a solution but I am getting the following warning message when I edit the table and process the edit data:

WARNING:param.ParamMethod02722: HoloViews pane model Figure(id='1952', ...) could not be replaced with new model Figure(id='2238', ...), ensure that the parent is not modified at the same time the panel is being updated.

The code I use is:

import param
import panel as pn
import pandas as pd
import holoviews as hv
from holoviews.streams import PointDraw

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

base_style = dict(
    height=600,
    responsive=True,
    toolbar="above",
    gridstyle={
        "grid_line_color": "#e0e0e0",
        "grid_line_width": 1,
    },
    show_grid=True,
    fontsize={
        "ticks": "8pt",
        "title": "12pt",
        "xlabel": "10pt",
        "ylabel": "10pt",
    },
    xticks=4,
    xaxis="top",
    invert_yaxis=True,
)

profile_style = dict(size=10)


class DataProfile(param.Parameterized):

    values = param.DataFrame(doc="Values as Pandas DataFrame")

    stream = param.ClassSelector(
        default=PointDraw(num_objects=10),
        class_=(PointDraw,),
        precedence=-1,
        instantiate=True,
        doc="Param that will handle the data reactivity between the table and the plot",
    )

    sort_col = param.String(
        default="y",
        doc="column of the Pandas DataFrame which will be used for sorting (i.e: y)",
    )

    val_col = param.String(
        doc="column of the Pandas DataFrame which contain the main value (i.e: x)"
    )

    x_axis_label = param.String(default=None, doc="Label for the x axis of the plot")

    y_axis_label = param.String(default="y", doc="Label for the y axis of the plot")

    def __init__(self, **params):
        super().__init__(**params)

        if self.x_axis_label is None:
            self.x_axis_label = self.val_col

    def table(self):
        table_ = pn.widgets.Tabulator(self.values, width=500, layout="fit_columns")
        table_.link(self.values, callbacks={"value": self._callback_tabulator})
        return table_

    def plot(self):
        points = hv.Scatter(self.values, self.val_col, self.sort_col)
        points.opts(tools=["hover"], active_tools=["point_draw"], **profile_style)
        self.stream.source = points
        line = hv.Curve(self.values, self.val_col, self.sort_col)
        return (points * line).opts(
            **base_style,
            xlabel=self.x_axis_label,
            ylabel=self.y_axis_label,
        )

    @pn.depends("stream.data", watch=True)
    def _update_dataframe(self):
        """Function to reflect the change in the PointDraw data to the original data"""
        stream_df = pd.DataFrame(self.stream.data)
        if stream_df.equals(self.values) is False:
            # Here the data processing happens
            df = stream_df.round(2).sort_values(self.sort_col).reset_index()
            if "index" in df.columns:
                df = df.drop(["index"], axis=1)
            self.values = df

    def _callback_tabulator(self, target, event):
        """Simple callback to update the values when the table is updated"""
        # This will throw a warning and the connection is lost between the plot and the table
        self.values = event.new.sort_values(self.sort_col).reset_index()

    def view(self):
        return pn.Row(self.plot, self.table, width_policy="max")


class MyPlot(param.Parameterized):

    profile_values = param.DataFrame(columns=["x", "y"])

    def __init__(self, **params):
        super().__init__(**params)

        self.profile = DataProfile(
            values=self.profile_values,
            val_col="x",
            sort_col="y",
            x_axis_label="A more meaningful title [m]",
        )

    def view(self):
        return pn.Column(
            self.profile.table,
            self.profile.plot,
            width_policy="max",
        )


MyPlot(
    profile_values=pd.DataFrame(data={"x": [0, 5, 10], "y": [0, 3, 10]}),
).view().servable(title="Example with two way edit")

If I add a point to the plot via the PointDraw method, it works fine and the table gets updated and the data is sorted as intended along the y column in this example.

The problem is that if I edit the table, I get the warning message as mentioned above and the table and plot are not in sync anymore?

What I am doing wrong here?
In my use case, this is important that any point added in the graph or table is sorted by the sort_col (here in the example set to y).

Any help would be really appreciated as I really don’t understand what is the problem here.

Something funky with the index of your return DataFrame. Add drop=True to reset_index:

self.values = event.new.sort_values(self.sort_col).reset_index(drop=True)

Anyone know how to add Selection1D into the mix? I’ve tried here.