Update options (opts) without reload/redraw

Is there a way to update the opts of a plot without completely reloading the plot?

I have a dynamic panel based app where I would like to change things on the fly (such as frame_width) without resetting the plot each time. Below is how my plot is constructed (as part of a param class)

    hv_image = hv.QuadMesh(
                (
                    numpy.nan_to_num(self._dataset.xc.values),
                    numpy.nan_to_num(self._dataset.yc.values),
                    element_arr,
                )
            )

            cmap_r = colorcet.palette[self.colour_map]
            self.plot = rasterize(hv_image, aggregator=self.zoom_aggregation,).apply.opts(
                xaxis=None if not self.show_axes else "bottom",
                yaxis=None if not self.show_axes else "left",
                aspect="equal",
                frame_width=self.plot_width,
                frame_height=self.plot_height,
                cmap=cmap_r,
                clim=(self.MIN_VAL, self.MAX),
                colorbar=True,
                logz=self.log_colour,
                tools=["hover"],
                cformatter=tickformat,
            )

And i would like to be able to do something like this (which doesn’t currently work)

@param.depends(
        "plot_width",
        watch=True,
    )
    def update_plot(self):
        if self.plot is not None:
            print(f"Updating width {self.plot_width}")
            self.plot.frame_width = self.plot_width
1 Like

So ordinarily you would handle this by chaining it using .apply.opts(), e.g. you would do self.plot.apply.opts(frame_width=self.param.plot_width), which would return an object which dynamically updates the option with the current plot_width value. However in this particular case this does not work because the width change is currently not reapplied by HoloViews. This means that to do this at the moment you have to use a complicated pattern:

curve = hv.Curve([1, 2, 3])

width = pn.widgets.IntSlider(name='Width', start=400, end=1000)

@pn.depends(width)
def hooks(width):
    def apply_width(plot, element):
        plot.handles['plot'].width = width
    return [apply_width]

pn.Row(width, curve.apply.opts(hooks=hooks))

.apply can evaluate widgets, parameters or functions which have dependencies dynamically and will trigger an update in the plot. hooks provides the ability to declare arbitrary functions which have access to the plot and therefore can modify the Bokeh models. So here we create a function (hooks) which returns a function (apply_width) which will modify the plot width. This ensures the width change is actually applied. Unfortunately frame_width is not applied at the bokeh level so setting that has no effect at all.

So there’s multiple actions on HoloViews and Bokeh developers to make this straightforward:

  1. HoloViews: Ensure that changes in the width/height options are actually propagated to the Bokeh models.
  2. Bokeh: Ensure that changes to frame_width/frame_height actually trigger an update to the rendered plot.
3 Likes

Thanks for your reply, it didn’t end up being quite the solution but I did give me a bit more insight and some more things to try which did lead to exact solution I was hoping for. The trick was returning the update function instead of “self.plot” which I didn’t think of doing internally before. So I have a button which does a full initialise of the plot and the update function applies new options based on changes in the UI (Without resetting the plot :smile:)

@param.depends("refresh.clicks")
    def create_plot(self):
        if self._dataset is None or self.element == "None":
            print("No Image")
            return pn.Column(
                pn.pane.Markdown("""No plot to display"""), pn.pane.Markdown("""No histogram to display""")
            )
        print(f"Element is {self.element}")
        element_arr = self._dataset.image.sel(element=self.element)
        self.MIN = element_arr.min().compute().item()
        self.MAX = element_arr.max().compute().item()

        hv_image = hv.QuadMesh(
            (
                numpy.nan_to_num(self._dataset.xc.values),
                numpy.nan_to_num(self._dataset.yc.values),
                element_arr,
            )
        )

        cmap_r = colorcet.palette[self.colour_map]
        self.plot = rasterize(
            hv_image,
            aggregator=self.zoom_aggregation,
        )

        # self.update_plot()

        logbins = numpy.geomspace(self.MIN_VAL, self.MAX, 50)
        self.plot_hist = self.plot.hist(bins=logbins)[1].opts(
            frame_width=900,
            xformatter=tickformat,
            yformatter=tickformat,
            logx=True,
            xlim=(self.MAX, self.MIN_VAL),
            tools=["hover"],
        )

        return pn.Column(self.update_plot, self.plot_hist)

    @param.depends(
        "plot_width",
        "plot_height",
        "colour_map",
        "show_axes",
        "log_colour",
        watch=True,
    )
    def update_plot(self):
        if self.plot is not None:
            cmap_r = colorcet.palette[self.colour_map]
            return self.plot.apply.opts(
                xaxis=None if not self.show_axes else "bottom",
                yaxis=None if not self.show_axes else "left",
                aspect="equal",
                frame_width=self.plot_width,
                frame_height=self.plot_height,
                cmap=cmap_r,
                clim=(self.MIN_VAL, self.MAX),
                colorbar=True,
                logz=self.log_colour,
                tools=["hover"],
                cformatter=tickformat,
            )
        return None

(Note those are just 2 functions inside a param.Parameterized class ) And then my code that displays it

app = pn.Row(
        pn.WidgetBox(
            pn.Param(mmv.param, default_layout=pn.Column, expand_button=True, show_name=False), mmv.refresh
        ),
        mmv.create_plot,
    )