Auto-scaling axes in Datashader-based crossfilter app (without framewise=True)

I am working on a dashboard to explore a time-series dataset with a large number of variables. The means and variances of these variables span several orders of magnitude, so it would be convenient to rescale the corresponding axis when one of the variables is changed.

I cannot figure out a way to trigger such an action without rendering the figure unusable; I tried setting framewise=True on the shaded DynamicMap, but it doesn’t work with Datashader. My hypothesis is that zooming triggers a RangeXY stream event, which causes a new raster to be drawn, and the effect of framewise=True is to resize the axes to the data whenever the data changes. In other words, this creates a stabilizing feedback loop: a raster is drawn to fill the current frame, and then DynamicMap fits the frame to the current raster.

I believe this GitHub issue touches on the same problem. @jstevens had this suggestion:

I think you should be able to use a panel HoloViews pane to display the dynamicmap and force an update by setting the object parameter when you need it to redraw. Hope that points you in the right direction!

Unfortunately, after days of experimenting with watchers, streams, and param.depends(), I still can’t figure out how to access this parameter and ensure the corresponding axis is rescaled when (and only when) the “X” or “Y” parameters are changed.

Below is a simplified example, with generated data. It’s inspired by Crossfilter — HoloViews v1.15.4 and by Dashboard — Examples 0.1.0 documentation (pyviz.org). I think the axis auto-ranging wasn’t an issue when I structured my application like the Crossfilter demo (with param.depends acting on a stateless function) but it became unwieldy as the number of customization widgets grew.

import numpy as np
import pandas as pd
import param as pm
import panel as pn
import holoviews as hv
import holoviews.operation.datashader as hvds
from scipy.stats import beta, multivariate_normal
from typing import Sequence

CNORM_OPTS = {
    'Histogram Equalization': 'eq_hist',
    'Linear Scale': 'linear',
    'Logarithmic Scale': 'log',
    'Cube-Root Scale': 'cbrt'
}

def make_data(columns: Sequence[str], nrows: int, seed: int=1) -> pd.DataFrame:
    rng = np.random.default_rng(seed)
    ncols = len(columns)
    corrs = 0.5*np.eye(ncols, dtype=np.float64)
    corr_dist = beta(a=2, b=2)
    corr_factors = corr_dist.rvs(size=6, random_state=rng)
    corrs[np.triu_indices_from(corrs, k=1)] = 2 * corr_factors - 1
    corrs = corrs + corrs.T
    
    mean_mag = np.power(10., rng.uniform(-3, 3, ncols))
    means = (-1)**rng.integers(0, 2, ncols) * mean_mag
    stds = np.exp(rng.uniform(-3, 0, ncols)) * mean_mag
    stddiag = np.diag(stds)
    cov = stddiag @ corrs @ stddiag
    
    mvnormal = multivariate_normal(mean=means, cov=cov)
    dframe = pd.DataFrame(
        data=mvnormal.rvs(size=nrows, random_state=rng),
        columns=columns
    )
    return dframe

class Crossfilter(pm.Parameterized):
    df = pm.DataFrame(precedence=-1)
    x  = pm.Selector()
    y  = pm.Selector()
    cnorm = pm.Selector(CNORM_OPTS)
    # style_opts = pm.Dict(precedence=-1)

    @pm.depends('df', watch=True)
    def _populate_xy(self, **kwargs):
        if self.df is None:
            return
        col_list = list(self.df.columns)
        self.param.x.objects = col_list
        self.param.y.objects = col_list
        self.x = self.param.x.objects[0]
        self.y = self.param.y.objects[1]

    @pm.depends('x','y')
    def elem(self):
        lbl = f'{self.y:s} vs. {self.x:s}'
        trace = hv.Points(self.df, [self.x, self.y], label=lbl)
        return trace

    def viewable(self, **kwargs):
        shaded = hvds.datashade(
            hv.DynamicMap(self.elem),
            cmap='fire',
            cnorm=self.param.cnorm,
            rescale_discrete_levels=False
        )
        spreaded = hvds.spread(shaded)
        style_opts = kwargs.get('style_opts', dict())
        pane = spreaded.apply.opts(**style_opts)
        return pane

    def get_panel(self, **kwargs):
        widgets = pn.Param(self.param, expand_button=False)
        figure = pn.Row(widgets, self.viewable(**kwargs))
        return figure

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.param.trigger('df')
cols = ['Alpha', 'Beta', 'Gamma', 'Delta']
crossfilt = Crossfilter(df=make_data(cols, 100000))
style_opts = dict(width=1200, height=800, bgcolor='#202060')
crossfilt.get_panel(style_opts=style_opts).show(port=12345)