Repository: holoviz/holoviews
Pull request #6375 (2024-09-17 13:03:48)

Add `sublabel_index` and `sublabel_skip` option for matplotlib backend
This allows to alter the index count of the sublabels, e.g. I have the following plot:
![Screenshot_20240917_145922](https://github.com/user-attachments/assets/a72e7492-2a5d-413f-8f7e-ec4be58a8797)

But the top element (`a)`) is not an actual subplot, just a colorbar for all the other. But exposing this option allows to basically ignore the first subplot. When numbering. In another situation I have an opposite problem, where I created a group of plots, but then I attach manually another subplot within LaTeX, then I want the numbering to skip a few labels.


Patch:
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 4bfa6b7b8..bef067ee9 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -12,7 +12,7 @@ on:
 
 defaults:
   run:
-    shell: bash -el {0}
+    shell: bash -l {0}
 
 env:
   PYTHON_VERSION: "3.11"
@@ -33,14 +33,14 @@ jobs:
     name: Pixi lock
     runs-on: ubuntu-latest
     steps:
-      - uses: holoviz-dev/holoviz_tasks/pixi_lock@pixi
+      - uses: holoviz-dev/holoviz_tasks/pixi_lock@v0
 
   conda_build:
     name: Build Conda
     needs: [pixi_lock]
     runs-on: "ubuntu-latest"
     steps:
-      - uses: holoviz-dev/holoviz_tasks/pixi_install@pixi
+      - uses: holoviz-dev/holoviz_tasks/pixi_install@v0
         with:
           environments: "build"
           download-data: false
@@ -88,7 +88,7 @@ jobs:
     needs: [pixi_lock]
     runs-on: "ubuntu-latest"
     steps:
-      - uses: holoviz-dev/holoviz_tasks/pixi_install@pixi
+      - uses: holoviz-dev/holoviz_tasks/pixi_install@v0
         with:
           environments: "build"
           download-data: false
diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml
index 660cbf350..5dedd6b75 100644
--- a/.github/workflows/docs.yaml
+++ b/.github/workflows/docs.yaml
@@ -22,14 +22,14 @@ on:
 
 defaults:
   run:
-    shell: bash -el {0}
+    shell: bash -l {0}
 
 jobs:
   pixi_lock:
     name: Pixi lock
     runs-on: ubuntu-latest
     steps:
-      - uses: holoviz-dev/holoviz_tasks/pixi_lock@pixi
+      - uses: holoviz-dev/holoviz_tasks/pixi_lock@v0
 
   docs_build:
     name: Build Documentation
@@ -41,7 +41,7 @@ jobs:
     env:
       GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
     steps:
-      - uses: holoviz-dev/holoviz_tasks/pixi_install@pixi
+      - uses: holoviz-dev/holoviz_tasks/pixi_install@v0
         with:
           environments: docs
       - name: Build documentation
diff --git a/.github/workflows/nightly_lock.yaml b/.github/workflows/nightly_lock.yaml
index 9b93b85a2..00e670c88 100644
--- a/.github/workflows/nightly_lock.yaml
+++ b/.github/workflows/nightly_lock.yaml
@@ -13,7 +13,7 @@ jobs:
     runs-on: ubuntu-latest
     timeout-minutes: 5
     steps:
-      - uses: holoviz-dev/holoviz_tasks/pixi_lock@pixi
+      - uses: holoviz-dev/holoviz_tasks/pixi_lock@v0
       - name: Upload lock-file to S3
         if: "!github.event.pull_request.head.repo.fork"
         env:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 75de2eb85..6a3131a02 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -20,7 +20,7 @@ repos:
       - id: check-json
       - id: detect-private-key
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.6.2
+    rev: v0.6.7
     hooks:
       - id: ruff
         files: holoviews/|scripts/
diff --git a/holoviews/__init__.py b/holoviews/__init__.py
index 1ca017cdf..a8b3f5868 100644
--- a/holoviews/__init__.py
+++ b/holoviews/__init__.py
@@ -73,9 +73,9 @@ In a notebook or ipython environment the usual
 To ask the community go to https://discourse.holoviz.org/.
 To report issues go to https://github.com/holoviz/holoviews.
 """
+import builtins
 import os
 import sys
-import warnings
 
 import param
 
@@ -114,12 +114,7 @@ from .util.warnings import (  # noqa: F401
     HoloviewsUserWarning,
 )
 
-# Suppress warnings generated by NumPy in matplotlib
-# Expected to be fixed in next matplotlib release
-warnings.filterwarnings("ignore",
-                        message="elementwise comparison failed; returning scalar instead")
-
-if "IPython" in sys.modules:
+if hasattr(builtins, "__IPYTHON__"):
     from .ipython import notebook_extension
     extension = notebook_extension
 else:
@@ -181,7 +176,7 @@ def help(obj, visualization=True, ansi=True, backend=None,
         pydoc.help(obj)
 
 
-del os, sys, rcfile, warnings
+del builtins, os, rcfile, sys
 
 def __getattr__(name):
     if name == "annotate":
diff --git a/holoviews/core/data/xarray.py b/holoviews/core/data/xarray.py
index 5e7cef72c..9da2170c8 100644
--- a/holoviews/core/data/xarray.py
+++ b/holoviews/core/data/xarray.py
@@ -93,7 +93,7 @@ class XArrayInterface(GridInterface):
         if isinstance(data, xr.DataArray):
             kdim_len = len(kdim_param.default) if kdims is None else len(kdims)
             vdim_len = len(vdim_param.default) if vdims is None else len(vdims)
-            if vdim_len > 1 and kdim_len == len(data.dims)-1 and data.shape[-1] == vdim_len:
+            if kdim_len == len(data.dims)-1 and data.shape[-1] == vdim_len:
                 packed = True
             elif vdims:
                 vdim = vdims[0]
@@ -446,7 +446,11 @@ class XArrayInterface(GridInterface):
         Given a dataset object and data in the appropriate format for
         the interface, return a simple scalar.
         """
-        if not cls.packed(dataset) and len(data.data_vars) == 1:
+        if cls.packed(dataset):
+            array = data.squeeze()
+            if len(array.shape) == 0:
+                return array.item()
+        elif len(data.data_vars) == 1:
             array = data[dataset.vdims[0].name].squeeze()
             if len(array.shape) == 0:
                 return array.item()
diff --git a/holoviews/operation/datashader.py b/holoviews/operation/datashader.py
index b24d28c7f..a725520b9 100644
--- a/holoviews/operation/datashader.py
+++ b/holoviews/operation/datashader.py
@@ -1286,17 +1286,35 @@ class shade(LinkableOperation):
             ydensity = element.ydensity
             bounds = element.bounds
 
+        # Convert to xarray if not already
+        if element.interface.datatype != 'xarray':
+            element = element.clone(datatype=['xarray'])
+
         kdims = element.kdims
         if isinstance(element, ImageStack):
             vdim = element.vdims
             array = element.data
-            if hasattr(array, "to_array"):
-                array = array.to_array("z")
-            array = array.transpose(*[kdim.name for kdim in kdims], ...)
+            # If data is a xarray Dataset it has to be converted to a
+            # DataArray, either by selecting the singular value
+            # dimension or by adding a z-dimension
+            kdims = [kdim.name for kdim in kdims]
+            if not element.interface.packed(element):
+                if len(vdim) == 1:
+                    array = array[vdim[0].name]
+                else:
+                    array = array.to_array("z")
+                    # If data is 3D then we have one extra constant dimension
+                    if array.ndim > 3:
+                        drop = [d for d in array.dims if d not in kdims+["z"]]
+                        array = array.squeeze(dim=drop)
+            array = array.transpose(*kdims, ...)
         else:
             vdim = element.vdims[0].name
             array = element.data[vdim]
 
+        # Dask is not supported by shade so materialize it
+        array = array.compute()
+
         shade_opts = dict(
             how=self.p.cnorm, min_alpha=self.p.min_alpha, alpha=self.p.alpha
         )
@@ -1335,7 +1353,7 @@ class shade(LinkableOperation):
         if self.p.clims:
             shade_opts['span'] = self.p.clims
         elif ds_version > Version('0.5.0') and self.p.cnorm != 'eq_hist':
-            shade_opts['span'] = element.range(vdim)
+            shade_opts['span'] = (array.min().item(), array.max().item())
 
         params = dict(get_param_values(element), kdims=kdims,
                       bounds=bounds, vdims=RGB.vdims[:],
diff --git a/holoviews/operation/resample.py b/holoviews/operation/resample.py
index c97cefd15..bb482a79c 100644
--- a/holoviews/operation/resample.py
+++ b/holoviews/operation/resample.py
@@ -183,6 +183,11 @@ class ResampleOperation2D(ResampleOperation1D):
             if y and element.get_dimension_type(y[0]) in datetime_types:
                 ytype = 'datetime'
 
+        # Adjust width and height depending on pixel ratio
+        pixel_ratio = self._get_pixel_ratio()
+        width = int(width * pixel_ratio)
+        height = int(height * pixel_ratio)
+
         # Compute highest allowed sampling density
         xspan = xend - xstart
         yspan = yend - ystart
@@ -198,12 +203,11 @@ class ResampleOperation2D(ResampleOperation1D):
             yunit, height = 0, 0
         else:
             yunit = float(yspan)/height
-        xs, ys = (np.linspace(xstart+xunit/2., xend-xunit/2., width),
-                  np.linspace(ystart+yunit/2., yend-yunit/2., height))
 
-        pixel_ratio = self._get_pixel_ratio()
-        width = int(width * pixel_ratio)
-        height = int(height * pixel_ratio)
+        xs, ys = (
+            np.linspace(xstart+xunit/2., xend-xunit/2., width),
+            np.linspace(ystart+yunit/2., yend-yunit/2., height)
+        )
         return ((xstart, xend), (ystart, yend)), (xs, ys), (width, height), (xtype, ytype)
 
     def _get_pixel_ratio(self):
diff --git a/holoviews/plotting/bokeh/annotation.py b/holoviews/plotting/bokeh/annotation.py
index c97c44c95..64f9fbdc4 100644
--- a/holoviews/plotting/bokeh/annotation.py
+++ b/holoviews/plotting/bokeh/annotation.py
@@ -45,6 +45,11 @@ class _SyntheticAnnotationPlot(ColorbarPlot):
             raise ImportError(msg)
         super().__init__(element, **kwargs)
 
+    def _get_axis_dims(self, element):
+        if isinstance(element, (HLines, HSpans)):
+            return None, element.kdims[0], None
+        return element.kdims[0], None, None
+
     def _init_glyph(self, plot, mapping, properties):
         self._plot_methods = {"single": self._methods[self.invert_axes]}
         return super()._init_glyph(plot, mapping, properties)
diff --git a/holoviews/plotting/bokeh/callbacks.py b/holoviews/plotting/bokeh/callbacks.py
index 5e6011aaf..89541b595 100644
--- a/holoviews/plotting/bokeh/callbacks.py
+++ b/holoviews/plotting/bokeh/callbacks.py
@@ -50,6 +50,7 @@ from ...streams import (
     Lasso,
     MouseEnter,
     MouseLeave,
+    MultiAxisTap,
     PanEnd,
     PlotReset,
     PlotSize,
@@ -781,6 +782,54 @@ class TapCallback(PopupMixin, PointerXYCallback):
         return value
 
 
+class MultiAxisTapCallback(TapCallback):
+    """
+    Returns the mouse x/y-positions on tap event.
+    """
+
+    attributes = {'x': 'cb_obj.x', 'y': 'cb_obj.y'}
+
+    def _process_msg(self, msg):
+        x_range = self.plot.handles.get('x_range')
+        y_range = self.plot.handles.get('y_range')
+        extra_x = list(self.plot.handles.get('extra_x_ranges', {}).values())
+        extra_y = list(self.plot.handles.get('extra_y_ranges', {}).values())
+        xaxis = self.plot.handles.get('xaxis')
+        yaxis = self.plot.handles.get('yaxis')
+
+        # Compute x/y position relative to first axis
+        x, y = msg['x'], msg['y']
+        x0, x1 = x_range.start, x_range.end
+        y0, y1 = y_range.start, y_range.end
+        if isinstance(xaxis, DatetimeAxis):
+            x = convert_timestamp(x)
+            if isinstance(x0, (float, int)):
+                x0 = convert_timestamp(x0)
+            if isinstance(x1, (float, int)):
+                x1 = convert_timestamp(x1)
+        if isinstance(yaxis, DatetimeAxis):
+            y = convert_timestamp(y)
+            if isinstance(y0, (float, int)):
+                y0 = convert_timestamp(y0)
+            if isinstance(y1, (float, int)):
+                y1 = convert_timestamp(y1)
+        xs, ys = {x_range.name: x}, {y_range.name: y}
+        xspan, yspan = x1 - x0, y1 - y0
+        xfactor, yfactor = (x-x0) / xspan, (y-y0) / yspan
+
+        # Use computed factors to compute x/y position on other axes
+        for values, factor, ranges, axis in (
+            (xs, xfactor, extra_x, xaxis),
+            (ys, yfactor, extra_y, yaxis)
+        ):
+            for rng in ranges:
+                value = rng.start + (rng.end-rng.start) * factor
+                if isinstance(axis, DatetimeAxis) and isinstance(value, (float, int)):
+                    value = convert_timestamp(value)
+                values[rng.name] = value
+
+        return {'xs': xs, 'ys': ys}
+
 
 class SingleTapCallback(TapCallback):
     """
@@ -1585,5 +1634,6 @@ Stream._callbacks['bokeh'].update({
     FreehandDraw: FreehandDrawCallback,
     PolyDraw    : PolyDrawCallback,
     PolyEdit    : PolyEditCallback,
-    SelectMode  : SelectModeCallback
+    SelectMode  : SelectModeCallback,
+    MultiAxisTap: MultiAxisTapCallback
 })
diff --git a/holoviews/plotting/bokeh/chart.py b/holoviews/plotting/bokeh/chart.py
index 17ef69983..dd76fcc68 100644
--- a/holoviews/plotting/bokeh/chart.py
+++ b/holoviews/plotting/bokeh/chart.py
@@ -880,7 +880,7 @@ class BarPlot(BarsMixin, ColorbarPlot, LegendPlot):
 
     def get_data(self, element, ranges, style):
         # Get x, y, group, stack and color dimensions
-        group_dim, stack_dim = None, None
+        group_dim, stack_dim, stack_order = None, None, None
         if element.ndims == 1:
             grouping = None
         elif self.stacked:
@@ -893,6 +893,8 @@ class BarPlot(BarsMixin, ColorbarPlot, LegendPlot):
             else:
                 stack_order = element.dimension_values(1, False)
             stack_order = list(stack_order)
+            stack_data = element.dimension_values(stack_dim)
+            stack_idx = stack_data == stack_data[0]
         else:
             grouping = 'grouped'
             group_dim = element.get_dimension(1)
@@ -921,7 +923,8 @@ class BarPlot(BarsMixin, ColorbarPlot, LegendPlot):
             grouped = {0: element}
             is_dt = isdatetime(xvals)
             if is_dt or xvals.dtype.kind not in 'OU':
-                xdiff = np.abs(np.diff(xvals))
+                xslice = stack_idx if stack_order else slice(None)
+                xdiff = np.abs(np.diff(xvals[xslice]))
                 diff_size = len(np.unique(xdiff))
                 if diff_size == 0 or (diff_size == 1 and xdiff[0] == 0):
                     xdiff = 1
diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py
index 47d5950c7..a8edb5a0f 100644
--- a/holoviews/plotting/bokeh/element.py
+++ b/holoviews/plotting/bokeh/element.py
@@ -802,20 +802,27 @@ class ElementPlot(BokehPlot, GenericElementPlot):
             if dim_range:
                 self._shared[shared_name] = True
 
+        # If we have a single dimension grab it so it can be set as the Range name
+        name = None
+        if dim:
+            name = dim.name
+        elif dims and len(dims) == 1:
+            name = dims[0].name
+
         if self._shared.get(shared_name) and not dim:
             pass
         elif categorical:
             axis_type = 'auto'
-            dim_range = FactorRange()
+            dim_range = FactorRange(name=name)
         elif None in [v0, v1] or any(
             True if isinstance(el, (str, bytes)+util.cftime_types)
             else not util.isfinite(el) for el in [v0, v1]
         ):
-            dim_range = range_type()
+            dim_range = range_type(name=name)
         elif issubclass(range_type, FactorRange):
-            dim_range = range_type(name=dim.name if dim else None)
+            dim_range = range_type(name=name)
         else:
-            dim_range = range_type(start=v0, end=v1, name=dim.name if dim else None)
+            dim_range = range_type(start=v0, end=v1, name=name)
 
         if not dim_range.tags and specs is not None:
             dim_range.tags.append(specs)
diff --git a/holoviews/plotting/mixins.py b/holoviews/plotting/mixins.py
index 524308a1c..8da7c7bf7 100644
--- a/holoviews/plotting/mixins.py
+++ b/holoviews/plotting/mixins.py
@@ -174,7 +174,7 @@ class BarsMixin:
         else:
             y0, y1 = ranges[vdim]['combined']
 
-        x0, x1 = (l, r) if util.isnumeric(l) and len(element.kdims) == 1 else ('', '')
+        x0, x1 = (l, r) if (util.isnumeric(l) or isinstance(l, util.datetime_types)) else ('', '')
         if range_type == 'data':
             return (x0, y0, x1, y1)
 
diff --git a/holoviews/plotting/mpl/chart.py b/holoviews/plotting/mpl/chart.py
index 5da620b85..917b4a631 100644
--- a/holoviews/plotting/mpl/chart.py
+++ b/holoviews/plotting/mpl/chart.py
@@ -936,9 +936,12 @@ class BarPlot(BarsMixin, ColorbarPlot, LegendPlot):
 
         cats = None
         style_dim = None
+        xslice = slice(None)
         if sdim:
             cats = values['stack']
             style_dim = sdim
+            stack_data = element.dimension_values(sdim)
+            xslice = stack_data == stack_data[0]
         elif cdim:
             cats = values['category']
             style_dim = cdim
@@ -954,14 +957,15 @@ class BarPlot(BarsMixin, ColorbarPlot, LegendPlot):
         is_dt = isdatetime(xvals)
         continuous = True
         if is_dt or xvals.dtype.kind not in 'OU' and not (cdim or len(element.kdims) > 1):
-            xdiff_vals = date2num(xvals) if is_dt else xvals
-            xdiff = np.abs(np.diff(xdiff_vals))
-            if len(np.unique(xdiff)) == 1:
-                # if all are same
+            xvals = xvals[xslice]
+            xdiff = np.abs(np.diff(xvals))
+            diff_size = len(np.unique(xdiff))
+            if diff_size == 0 or (diff_size == 1 and xdiff[0] == 0):
                 xdiff = 1
             else:
                 xdiff = np.min(xdiff)
-            width = (1 - self.bar_padding) * xdiff
+            width = (1 - self.bar_padding) * (date2num(xdiff) / 1000 if is_dt else xdiff)
+            data_width = (1 - self.bar_padding) * xdiff
         else:
             xdiff = len(values.get('category', [None]))
             width = (1 - self.bar_padding) / xdiff
@@ -1050,16 +1054,23 @@ class BarPlot(BarsMixin, ColorbarPlot, LegendPlot):
             axis.legend(title=title, **legend_opts)
 
         x_range = ranges[gdim.label]["data"]
-        if continuous and not is_dt:
-            if style.get('align', 'center') == 'center':
-                left_multiplier = 0.5
-                right_multiplier = 0.5
-            else:
-                left_multiplier = 0
-                right_multiplier = 1
+        if style.get('align', 'center') == 'center':
+            left_multiplier = 0.5
+            right_multiplier = 0.5
+        else:
+            left_multiplier = 0
+            right_multiplier = 1
+        if continuous:
+            ranges[gdim.label]["data"] = (
+                x_range[0] - data_width * left_multiplier,
+                x_range[1] + data_width * right_multiplier,
+            )
+        else:
+            locs = [item[0] for item in xticks]
+            xmin, xmax = min(locs), max(locs)
             ranges[gdim.label]["data"] = (
-                x_range[0] - width * left_multiplier,
-                x_range[1] + width * right_multiplier
+                - width * left_multiplier + xmin,
+                width * right_multiplier + xmax
             )
         return bars, xticks if not continuous else None, ax_dims
 
diff --git a/holoviews/plotting/mpl/plot.py b/holoviews/plotting/mpl/plot.py
index f166544d8..d8208709c 100644
--- a/holoviews/plotting/mpl/plot.py
+++ b/holoviews/plotting/mpl/plot.py
@@ -120,12 +120,18 @@ class MPLPlot(DimensionedPlot):
         Allows labeling the subaxes in each plot with various formatters
         including {Alpha}, {alpha}, {numeric} and {roman}.""")
 
+    sublabel_offset = param.Number(default=0, bounds=(0, None), doc="""
+        Allows offsetting the sublabel index.""")
+
     sublabel_position = param.NumericTuple(default=(-0.35, 0.85), doc="""
          Position relative to the plot for placing the optional subfigure label.""")
 
     sublabel_size = param.Number(default=18, doc="""
          Size of optional subfigure label.""")
 
+    sublabel_skip = param.List(default=None, item_type=int, doc="""
+        List of elements to skip when labeling subplots. Numbering starts at 1.""")
+
     projection = param.Parameter(default=None, doc="""
         The projection of the plot axis, default of None is equivalent to
         2D plot, '3d' and 'polar' are also supported by matplotlib by default.
@@ -207,20 +213,29 @@ class MPLPlot(DimensionedPlot):
 
 
     def _subplot_label(self, axis):
+        if self.sublabel_skip and self.layout_num in self.sublabel_skip:
+            return
         layout_num = self.layout_num if self.subplot else 1
-        if self.sublabel_format and not self.adjoined and layout_num > 0:
+        if self.sublabel_skip:
+            if any(n < 1 for n in self.sublabel_skip):
+                raise ValueError('sublabel_skip values must be greater than 0')
+            sublabel_num = len(set(range(layout_num)) - set(self.sublabel_skip))
+        else:
+            sublabel_num = layout_num
+        sublabel_num += self.sublabel_offset
+        if self.sublabel_format and not self.adjoined and sublabel_num > 0:
             from matplotlib.offsetbox import AnchoredText
             labels = {}
             if '{Alpha}' in self.sublabel_format:
-                labels['Alpha'] = int_to_alpha(layout_num-1)
+                labels['Alpha'] = int_to_alpha(sublabel_num-1)
             elif '{alpha}' in self.sublabel_format:
-                labels['alpha'] = int_to_alpha(layout_num-1, upper=False)
+                labels['alpha'] = int_to_alpha(sublabel_num-1, upper=False)
             elif '{numeric}' in self.sublabel_format:
-                labels['numeric'] = self.layout_num
+                labels['numeric'] = sublabel_num
             elif '{Roman}' in self.sublabel_format:
-                labels['Roman'] = int_to_roman(layout_num)
+                labels['Roman'] = int_to_roman(sublabel_num)
             elif '{roman}' in self.sublabel_format:
-                labels['roman'] = int_to_roman(layout_num).lower()
+                labels['roman'] = int_to_roman(sublabel_num).lower()
             at = AnchoredText(self.sublabel_format.format(**labels), loc=3,
                               bbox_to_anchor=self.sublabel_position, frameon=False,
                               prop=dict(size=self.sublabel_size, weight='bold'),
diff --git a/holoviews/streams.py b/holoviews/streams.py
index c8c37656d..c866f4447 100644
--- a/holoviews/streams.py
+++ b/holoviews/streams.py
@@ -1358,6 +1358,18 @@ class Tap(PointerXY):
            Pointer position along the y-axis in data coordinates""")
 
 
+class MultiAxisTap(LinkedStream):
+    """
+    The x/y-positions of a tap or click in data coordinates.
+    """
+
+    xs = param.Dict(default=None, constant=True, doc="""
+           Pointer positions along the x-axes in data coordinates""")
+
+    ys = param.Dict(default=None, constant=True, doc="""
+           Pointer positions along the y-axes in data coordinates""")
+
+
 class DoubleTap(PointerXY):
     """
     The x/y-position of a double-tap or -click in data coordinates.
diff --git a/pixi.toml b/pixi.toml
index d0ddf5060..13dc11a73 100644
--- a/pixi.toml
+++ b/pixi.toml
@@ -18,7 +18,7 @@ test-310 = ["py310", "test-core", "test", "example", "test-example", "test-unit-
 test-311 = ["py311", "test-core", "test", "example", "test-example", "test-unit-task"]
 test-312 = ["py312", "test-core", "test", "example", "test-example", "test-unit-task"]
 test-ui = ["py312", "test-core", "test", "test-ui"]
-test-core = ["py312", "test-core", "test-unit-task"]
+test-core = ["py313", "test-core", "test-unit-task"]
 test-gpu = ["py311", "test-core", "test", "test-gpu"]
 docs = ["py311", "example", "doc"]
 build = ["py311", "build"]
@@ -53,12 +53,12 @@ bokeh_sampledata = "*"
 python = "3.12.*"
 bokeh_sampledata = "*"
 
-# [feature.py313]
-# channels = ["pyviz/label/dev", "conda-forge/label/python_rc", "conda-forge"]
-#
-# [feature.py313.dependencies]
-# python = "3.13.*"
-# bokeh_sampledata = "*"
+[feature.py313]
+channels = ["pyviz/label/dev", "conda-forge/label/python_rc", "conda-forge"]
+
+[feature.py313.dependencies]
+python = "3.13.*"
+bokeh_sampledata = "*"
 
 [feature.example.dependencies]
 cftime = "*"
diff --git a/pyproject.toml b/pyproject.toml
index d9c18e4ab..e0bd7eb04 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -21,6 +21,7 @@ classifiers = [
     "Programming Language :: Python :: 3.10",
     "Programming Language :: Python :: 3.11",
     "Programming Language :: Python :: 3.12",
+    "Programming Language :: Python :: 3.13",
     "Operating System :: OS Independent",
     "Intended Audience :: Science/Research",
     "Intended Audience :: Developers",


Test patch:
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 2f5e4f208..6f2db511b 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -30,7 +30,7 @@ concurrency:
 
 defaults:
   run:
-    shell: bash -el {0}
+    shell: bash -l {0}
 
 env:
   DISPLAY: ":99.0"
@@ -107,7 +107,7 @@ jobs:
     name: Pixi lock
     runs-on: ubuntu-latest
     steps:
-      - uses: holoviz-dev/holoviz_tasks/pixi_lock@pixi
+      - uses: holoviz-dev/holoviz_tasks/pixi_lock@v0
         with:
           cache: ${{ github.event.inputs.cache == 'true' || github.event.inputs.cache == '' }}
 
@@ -120,7 +120,7 @@ jobs:
       matrix: ${{ fromJson(needs.setup.outputs.matrix) }}
     timeout-minutes: 120
     steps:
-      - uses: holoviz-dev/holoviz_tasks/pixi_install@pixi
+      - uses: holoviz-dev/holoviz_tasks/pixi_install@v0
         if: needs.setup.outputs.code_change == 'true'
         with:
           environments: ${{ matrix.environment }}
@@ -154,7 +154,7 @@ jobs:
     env:
       PANEL_LOG_LEVEL: info
     steps:
-      - uses: holoviz-dev/holoviz_tasks/pixi_install@pixi
+      - uses: holoviz-dev/holoviz_tasks/pixi_install@v0
         if: needs.setup.outputs.code_change == 'true'
         with:
           environments: ${{ matrix.environment }}
@@ -188,7 +188,7 @@ jobs:
         environment: ["test-core"]
     timeout-minutes: 120
     steps:
-      - uses: holoviz-dev/holoviz_tasks/pixi_install@pixi
+      - uses: holoviz-dev/holoviz_tasks/pixi_install@v0
         if: needs.setup.outputs.code_change == 'true'
         with:
           environments: ${{ matrix.environment }}
diff --git a/holoviews/tests/operation/test_datashader.py b/holoviews/tests/operation/test_datashader.py
index 5cf1dceae..235eb05f4 100644
--- a/holoviews/tests/operation/test_datashader.py
+++ b/holoviews/tests/operation/test_datashader.py
@@ -121,6 +121,25 @@ class DatashaderAggregateTests(ComparisonTestCase):
                          vdims=[Dimension('Count', nodata=0)])
         self.assertEqual(img, expected)
 
+    def test_aggregate_points_empty(self):
+        points = Points([])
+        img = aggregate(points, dynamic=False,  x_range=(0, 1), y_range=(0, 1),
+                        width=2, height=2, pixel_ratio=1)
+        expected = Image(([0.25, 0.75], [0.25, 0.75], [[0, 0], [0, 0]]),
+                         vdims=[Dimension('Count', nodata=0)])
+        self.assertEqual(img, expected)
+
+    def test_aggregate_points_empty_with_pixel_ratio(self):
+        points = Points([])
+        img = aggregate(points, dynamic=False,  x_range=(0, 1), y_range=(0, 1),
+                        width=2, height=2, pixel_ratio=2)
+        expected = Image((
+            [0.125, 0.375, 0.625, 0.875],
+            [0.125, 0.375, 0.625, 0.875],
+            [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]
+        ), vdims=[Dimension('Count', nodata=0)])
+        self.assertEqual(img, expected)
+
     def test_aggregate_points_count_column(self):
         points = Points([(0.2, 0.3, np.nan), (0.4, 0.7, 22), (0, 0.99,np.nan)], vdims='z')
         img = aggregate(points, dynamic=False,  x_range=(0, 1), y_range=(0, 1),
@@ -808,6 +827,22 @@ class DatashaderCatAggregateTests(ComparisonTestCase):
         actual = img.data
         assert (expected.data.to_array("z").values == actual.T.values).all()
 
+    def test_aggregate_points_categorical_one_category(self):
+        points = Points([(0.2, 0.3, 'A'), (0.4, 0.7, 'A'), (0, 0.99, 'A')], vdims='z')
+        img = aggregate(points, dynamic=False,  x_range=(0, 1), y_range=(0, 1),
+                        width=2, height=2, aggregator=ds.by('z', ds.count()))
+        x = np.array([0.25, 0.75])
+        y = np.array([0.25, 0.75])
+        a = np.array([[1, 2], [0, 0]])
+        xrds = xr.DataArray(
+            a,
+            dims=('x', 'y'),
+            coords={"x": x, "y": y}
+        )
+        expected = ImageStack(xrds, kdims=["x", "y"], vdims=["a"])
+        actual = img.data
+        assert (expected.data.to_array("z").values == actual.T.values).all()
+
     def test_aggregate_points_categorical_mean(self):
         points = Points([(0.2, 0.3, 'A', 0.1), (0.4, 0.7, 'B', 0.2), (0, 0.99, 'C', 0.3)], vdims=['cat', 'z'])
         img = aggregate(points, dynamic=False,  x_range=(0, 1), y_range=(0, 1),
diff --git a/holoviews/tests/plotting/bokeh/test_annotationplot.py b/holoviews/tests/plotting/bokeh/test_annotationplot.py
index 4520435b2..7ce298693 100644
--- a/holoviews/tests/plotting/bokeh/test_annotationplot.py
+++ b/holoviews/tests/plotting/bokeh/test_annotationplot.py
@@ -267,6 +267,21 @@ class TestHVLinesPlot(TestBokehPlot):
         assert list(source.data) == ["y"]
         assert (source.data["y"] == [0, 1, 2, 5.5]).all()
 
+    def test_hlines_plot_multi_y(self):
+        hlines = (
+            HLines({"y1": [1, 2, 3]}, 'y1') * HLines({'y2': [3, 4, 5]}, 'y2')
+        ).opts(multi_y=True)
+        plot = bokeh_renderer.get_plot(hlines)
+        sp1, sp2 = plot.subplots.values()
+        y1_range = sp1.handles['y_range']
+        assert y1_range.name == 'y1'
+        assert y1_range.start == 1
+        assert y1_range.end == 3
+        y2_range = sp2.handles['y_range']
+        assert y2_range.name == 'y2'
+        assert y2_range.start == 3
+        assert y2_range.end == 5
+
     def test_hlines_xlabel_ylabel(self):
         hlines = HLines(
             {"y": [0, 1, 2, 5.5], "extra": [-1, -2, -3, -44]}, vdims=["extra"]
diff --git a/holoviews/tests/plotting/bokeh/test_barplot.py b/holoviews/tests/plotting/bokeh/test_barplot.py
index 272b32103..777d81826 100644
--- a/holoviews/tests/plotting/bokeh/test_barplot.py
+++ b/holoviews/tests/plotting/bokeh/test_barplot.py
@@ -1,6 +1,11 @@
 import numpy as np
 import pandas as pd
-from bokeh.models import CategoricalColorMapper, LinearAxis, LinearColorMapper
+from bokeh.models import (
+    CategoricalColorMapper,
+    DatetimeAxis,
+    LinearAxis,
+    LinearColorMapper,
+)
 
 from holoviews.core.overlay import NdOverlay, Overlay
 from holoviews.element import Bars
@@ -313,6 +318,29 @@ class TestBarPlot(TestBokehPlot):
         plot = bokeh_renderer.get_plot(bars)
         np.testing.assert_almost_equal(plot.handles["glyph"].width, 69120000.0)
 
+    def test_bars_continuous_datetime_timezone_in_overlay(self):
+        # See: https://github.com/holoviz/holoviews/issues/6364
+        bars = Bars((pd.date_range("1/1/2000", periods=10, tz="UTC"), np.random.rand(10)))
+        overlay = Overlay([bars])
+        plot = bokeh_renderer.get_plot(overlay)
+        assert isinstance(plot.handles["xaxis"], DatetimeAxis)
+
+    def test_bars_continuous_datetime_stacked(self):
+        # See: https://github.com/holoviz/holoviews/issues/6288
+        data = pd.DataFrame({
+            "x": pd.to_datetime([
+                    "2017-01-01T00:00:00",
+                    "2017-01-01T00:00:00",
+                    "2017-01-01T01:00:00",
+                    "2017-01-01T01:00:00",
+                ]),
+            "cat": ["A", "B", "A", "B"],
+            "y": [1, 2, 3, 4],
+        })
+        bars = Bars(data, ["x", "cat"], ["y"]).opts(stacked=True)
+        plot = bokeh_renderer.get_plot(bars)
+        assert isinstance(plot.handles["xaxis"], DatetimeAxis)
+
     def test_bars_not_continuous_data_list(self):
         bars = Bars([("A", 1), ("B", 2), ("C", 3)])
         plot = bokeh_renderer.get_plot(bars)
@@ -356,3 +384,10 @@ class TestBarPlot(TestBokehPlot):
         )
         plot = bokeh_renderer.get_plot(bars)
         assert plot.handles["glyph"].width == 0.8
+
+    def test_bar_stacked_stack_variable_sorted(self):
+        # Check that if the stack dim is ordered
+        df = pd.DataFrame({"a": [*range(50), *range(50)], "b": sorted("ab" * 50), "c": range(100)})
+        bars = Bars(df, kdims=["a", "b"], vdims=["c"]).opts(stacked=True)
+        plot = bokeh_renderer.get_plot(bars)
+        assert plot.handles["glyph"].width == 0.8
diff --git a/holoviews/tests/plotting/matplotlib/test_barplot.py b/holoviews/tests/plotting/matplotlib/test_barplot.py
index 5e4f85cd1..7b9d66f50 100644
--- a/holoviews/tests/plotting/matplotlib/test_barplot.py
+++ b/holoviews/tests/plotting/matplotlib/test_barplot.py
@@ -45,7 +45,7 @@ class TestBarPlot(LoggingComparisonTestCase, TestMPLPlot):
         bars = Bars([("A", 1), ("B", 2), ("C", 3)])
         plot = mpl_renderer.get_plot(bars)
         ax = plot.handles["axis"]
-        np.testing.assert_almost_equal(ax.get_xlim(), (-0.54, 2.54))
+        np.testing.assert_almost_equal(ax.get_xlim(), (-0.4, 2.4))
         assert ax.patches[0].get_width() == 0.8
         np.testing.assert_equal(ax.get_xticks(), [0, 1, 2])
         np.testing.assert_equal(
@@ -69,7 +69,7 @@ class TestBarPlot(LoggingComparisonTestCase, TestMPLPlot):
         plot = mpl_renderer.get_plot(bars)
         ax = plot.handles["axis"]
 
-        np.testing.assert_almost_equal(ax.get_xlim(), (-0.3233333, 3.8566667))
+        np.testing.assert_almost_equal(ax.get_xlim(), (-0.1333333,  3.6666667))
         assert ax.patches[0].get_width() == 0.26666666666666666
         ticklabels = ax.get_xticklabels()
         expected = [
@@ -113,7 +113,7 @@ class TestBarPlot(LoggingComparisonTestCase, TestMPLPlot):
         plot = mpl_renderer.get_plot(bars)
         ax = plot.handles["axis"]
 
-        np.testing.assert_almost_equal(ax.get_xlim(), (-0.59, 3.59))
+        np.testing.assert_almost_equal(ax.get_xlim(), (-0.4, 3.4))
         assert ax.patches[0].get_width() == 0.8
         ticklabels = ax.get_xticklabels()
         expected = [
@@ -136,7 +136,7 @@ class TestBarPlot(LoggingComparisonTestCase, TestMPLPlot):
         plot = mpl_renderer.get_plot(bars)
         ax = plot.handles["axis"]
 
-        np.testing.assert_almost_equal(ax.get_xlim(), (-0.34,  2.74))
+        np.testing.assert_almost_equal(ax.get_xlim(), (-0.2,  2.6))
         assert ax.patches[0].get_width() == 0.4
         assert len(ax.get_xticks()) > 3
 
diff --git a/holoviews/tests/plotting/matplotlib/test_layoutplot.py b/holoviews/tests/plotting/matplotlib/test_layoutplot.py
index 97ed2ab41..5c32d7395 100644
--- a/holoviews/tests/plotting/matplotlib/test_layoutplot.py
+++ b/holoviews/tests/plotting/matplotlib/test_layoutplot.py
@@ -58,3 +58,25 @@ class TestLayoutPlot(LoggingComparisonTestCase, TestMPLPlot):
         cp1, cp2 = plot.traverse(lambda x: x, [CurvePlot])
         self.assertTrue(cp1.handles['axis'].get_ylim(), (1, 3))
         self.assertTrue(cp2.handles['axis'].get_ylim(), (10, 30))
+
+    def test_layout_sublabel_offset(self):
+        from holoviews.plotting.mpl import CurvePlot
+        layout = Curve([]) + Curve([]) + Curve([]) + Curve([])
+        layout.opts(sublabel_offset=1)
+        plot = mpl_renderer.get_plot(layout)
+        cps = plot.traverse(lambda x: x, [CurvePlot])
+        assert cps[0].handles["sublabel"].get_text() == "B"
+        assert cps[1].handles["sublabel"].get_text() == "C"
+        assert cps[2].handles["sublabel"].get_text() == "D"
+        assert cps[3].handles["sublabel"].get_text() == "E"
+
+    def test_layout_sublabel_skip(self):
+        from holoviews.plotting.mpl import CurvePlot
+        layout = Curve([]) + Curve([]) + Curve([]) + Curve([])
+        layout.opts(sublabel_skip=[1, 3])
+        plot = mpl_renderer.get_plot(layout)
+        cps = plot.traverse(lambda x: x, [CurvePlot])
+        assert "sublabel" not in cps[0].handles
+        assert cps[1].handles["sublabel"].get_text() == "A"
+        assert "sublabel" not in cps[2].handles
+        assert cps[3].handles["sublabel"].get_text() == "B"
diff --git a/holoviews/tests/ui/bokeh/test_callback.py b/holoviews/tests/ui/bokeh/test_callback.py
index 725df2869..19cd5f0c4 100644
--- a/holoviews/tests/ui/bokeh/test_callback.py
+++ b/holoviews/tests/ui/bokeh/test_callback.py
@@ -1,11 +1,12 @@
 import numpy as np
+import pandas as pd
 import panel as pn
 import pytest
 
 import holoviews as hv
 from holoviews import Curve, DynamicMap, Scatter
 from holoviews.plotting.bokeh.util import bokeh34
-from holoviews.streams import BoundsX, BoundsXY, BoundsY, Lasso, RangeXY
+from holoviews.streams import BoundsX, BoundsXY, BoundsY, Lasso, MultiAxisTap, RangeXY
 
 from .. import expect, wait_until
 
@@ -164,6 +165,54 @@ def test_multi_axis_rangexy(serve_hv):
     ), page)
 
 
+@pytest.mark.usefixtures("bokeh_backend")
+def test_multi_axis_tap(serve_hv):
+    c1 = Curve(np.arange(10).cumsum(), vdims='y1')
+    c2 = Curve(np.arange(20).cumsum(), vdims='y2')
+
+    overlay = (c1 * c2).opts(multi_y=True)
+
+    s = MultiAxisTap(source=overlay)
+
+    page = serve_hv(overlay)
+
+    hv_plot = page.locator('.bk-events')
+
+    expect(hv_plot).to_have_count(1)
+
+    hv_plot.click()
+
+    def test():
+        assert s.xs == {'x': 11.560240963855422}
+        assert s.ys == {'y1': 18.642857142857146, 'y2': 78.71428571428572}
+
+    wait_until(test, page)
+
+
+@pytest.mark.usefixtures("bokeh_backend")
+def test_multi_axis_tap_datetime(serve_hv):
+    c1 = Curve((pd.date_range('2024-01-01', '2024-01-10'), np.arange(10).cumsum()), vdims='y1')
+    c2 = Curve((pd.date_range('2024-01-01', '2024-01-20'), np.arange(20).cumsum()), vdims='y2')
+
+    overlay = (c1 * c2).opts(multi_y=True)
+
+    s = MultiAxisTap(source=overlay)
+
+    page = serve_hv(overlay)
+
+    hv_plot = page.locator('.bk-events')
+
+    expect(hv_plot).to_have_count(1)
+
+    hv_plot.click()
+
+    def test():
+        assert s.xs == {'x': np.datetime64('2024-01-12T13:26:44.819277')}
+        assert s.ys == {'y1': 18.13070539419087, 'y2': 76.551867219917}
+
+    wait_until(test, page)
+
+
 @pytest.mark.usefixtures("bokeh_backend")
 def test_bind_trigger(serve_hv):
     # Regression test for https://github.com/holoviz/holoviews/issues/6013