Repository: holoviz/holoviews
Pull request #6377 (2024-09-18 14:45:47)

Handle datetimes and empty selections better for inspect operations
A number of fixes for `inspect` operations:

- Initialize with empty x/y coordinate
- Handle undefined x/y coordinates
- Allow datetime axis
- Allow axes with different dimensions, e.g. datetime and numeric (by normalizing space before computing distances)

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/holoviz/holoviews/pull/6377?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) Report
Attention: Patch coverage is `71.42857%` with `8 lines` in your changes missing coverage. Please review.
> Project coverage is 88.49%. Comparing base [(`88996ed`)](https://app.codecov.io/gh/holoviz/holoviews/commit/88996ed4a3505872403f8285be228df9f7b15548?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) to head [(`077a346`)](https://app.codecov.io/gh/holoviz/holoviews/commit/077a346f8b412ebf439825723c72a473b852ab14?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz).

| [Files with missing lines](https://app.codecov.io/gh/holoviz/holoviews/pull/6377?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) | Patch % | Lines |
|---|---|---|
| [holoviews/operation/datashader.py](https://app.codecov.io/gh/holoviz/holoviews/pull/6377?src=pr&el=tree&filepath=holoviews%2Foperation%2Fdatashader.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL29wZXJhdGlvbi9kYXRhc2hhZGVyLnB5) | 71.42% | [8 Missing :warning: ](https://app.codecov.io/gh/holoviz/holoviews/pull/6377?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #6377      +/-   ##
==========================================
- Coverage   88.50%   88.49%   -0.01%     
==========================================
  Files         323      323              
  Lines       68281    68291      +10     
==========================================
+ Hits        60429    60432       +3     
- Misses       7852     7859       +7     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/holoviz/holoviews/pull/6377?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz).


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..96a519a5d 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[:],
@@ -1784,9 +1802,10 @@ class inspect_mask(Operation):
     operation.
     """
 
-    pixels = param.Integer(default=3, doc="""
+    pixels = param.ClassSelector(default=3, class_=(int, tuple), doc="""
        Size of the mask that should match the pixels parameter used in
-       the associated inspection operation.""")
+       the associated inspection operation. Pixels can be provided as
+       integer or x/y-tuple to perform asymmetric masking.""")
 
     streams = param.ClassSelector(default=[PointerXY], class_=(dict, list))
     x = param.Number(default=0)
@@ -1795,9 +1814,13 @@ class inspect_mask(Operation):
     @classmethod
     def _distance_args(cls, element, x_range, y_range,  pixels):
         ycount, xcount =  element.interface.shape(element, gridded=True)
+        if isinstance(pixels, tuple):
+            xpixels, ypixels = pixels
+        else:
+            xpixels = ypixels = pixels
         x_delta = abs(x_range[1] - x_range[0]) / xcount
         y_delta = abs(y_range[1] - y_range[0]) / ycount
-        return (x_delta*pixels, y_delta*pixels)
+        return (x_delta*xpixels, y_delta*ypixels)
 
     def _process(self, raster, key=None):
         if isinstance(raster, RGB):
@@ -1820,10 +1843,11 @@ class inspect(Operation):
     type.
     """
 
-    pixels = param.Integer(default=3, doc="""
+    pixels = param.ClassSelector(default=3, class_=(int, tuple), doc="""
        Number of pixels in data space around the cursor point to search
        for hits in. The hit within this box mask that is closest to the
-       cursor's position is displayed.""")
+       cursor's position is displayed. Pixels can be provided as
+       integer or x/y-tuple to perform asymmetric masking.""")
 
     null_value = param.Number(default=0, doc="""
        Value of raster which indicates no hits. For instance zero for
@@ -1856,9 +1880,9 @@ class inspect(Operation):
                                                y=PointerXY.param.y),
                                   class_=(dict, list))
 
-    x = param.Number(default=0, doc="x-position to inspect.")
+    x = param.Number(default=None, doc="x-position to inspect.")
 
-    y = param.Number(default=0, doc="y-position to inspect.")
+    y = param.Number(default=None, doc="y-position to inspect.")
 
     _dispatch = {}
 
@@ -1909,33 +1933,40 @@ class inspect_base(inspect):
 
     def _process(self, raster, key=None):
         self._validate(raster)
-        if isinstance(raster, RGB):
-            raster = raster[..., raster.vdims[-1]]
-        x_range, y_range = raster.range(0), raster.range(1)
-        xdelta, ydelta = self._distance_args(raster, x_range, y_range, self.p.pixels)
         x, y = self.p.x, self.p.y
-        val = raster[x-xdelta:x+xdelta, y-ydelta:y+ydelta].reduce(function=np.nansum)
-        if np.isnan(val):
-            val = self.p.null_value
-
-        if ((self.p.value_bounds and
-             not (self.p.value_bounds[0] < val < self.p.value_bounds[1]))
-             or val == self.p.null_value):
-            result = self._empty_df(raster.dataset)
-        else:
-            masked = self._mask_dataframe(raster, x, y, xdelta, ydelta)
-            result = self._sort_by_distance(raster, masked, x, y)
+        if x is not None and y is not None:
+            if isinstance(raster, RGB):
+                raster = raster[..., raster.vdims[-1]]
+            x_range, y_range = raster.range(0), raster.range(1)
+            xdelta, ydelta = self._distance_args(raster, x_range, y_range, self.p.pixels)
+            val = raster[x-xdelta:x+xdelta, y-ydelta:y+ydelta].reduce(function=np.nansum)
+            if np.isnan(val):
+                val = self.p.null_value
+
+            if ((self.p.value_bounds and
+                 not (self.p.value_bounds[0] < val < self.p.value_bounds[1]))
+                or val == self.p.null_value):
+                result = self._empty_df(raster.dataset)
+            else:
+                masked = self._mask_dataframe(raster, x, y, xdelta, ydelta)
+                result = self._sort_by_distance(raster, masked, x, y)
 
-        self.hits = result
+            self.hits = result
+        else:
+            self.hits = result = self._empty_df(raster.dataset)
         df = self.p.transform(result)
         return self._element(raster, df.iloc[:self.p.max_indicators])
 
     @classmethod
-    def _distance_args(cls, element, x_range, y_range,  pixels):
-        ycount, xcount =  element.interface.shape(element, gridded=True)
+    def _distance_args(cls, element, x_range, y_range, pixels):
+        ycount, xcount = element.interface.shape(element, gridded=True)
+        if isinstance(pixels, tuple):
+            xpixels, ypixels = pixels
+        else:
+            xpixels = ypixels = pixels
         x_delta = abs(x_range[1] - x_range[0]) / xcount
         y_delta = abs(y_range[1] - y_range[0]) / ycount
-        return (x_delta*pixels, y_delta*pixels)
+        return (x_delta*xpixels, y_delta*ypixels)
 
     @classmethod
     def _empty_df(cls, dataset):
@@ -1990,9 +2021,21 @@ class inspect_points(inspect_base):
         ds = raster.dataset.clone(df)
         xs, ys = (ds.dimension_values(kd) for kd in raster.kdims)
         dx, dy = xs - x, ys - y
-        distances = pd.Series(dx*dx + dy*dy)
-        return df.iloc[distances.argsort().values]
-
+        xtype, ytype = dx.dtype.kind, dy.dtype.kind
+        if xtype in 'Mm':
+            dx = dx.astype('int64')
+        if ytype in 'Mm':
+            dy = dx.astype('int64')
+        # If coordinate types don't match normalize
+        # coordinate space to ensure that distance
+        # in both direction is handled the same.
+        if xtype != ytype and len(dx) and len(dy):
+            with warnings.catch_warnings():
+                warnings.filterwarnings('ignore', r'invalid value encountered in (divide)')
+                dx = (dx - dx.min()) / (dx.max() - dx.min())
+                dy = (dy - dy.min()) / (dy.max() - dy.min())
+        distances = pd.Series(dx**2 + dy**2)
+        return df.iloc[distances.fillna(0).argsort().values]
 
 
 class inspect_polygons(inspect_base):
@@ -2026,7 +2069,7 @@ class inspect_polygons(inspect_base):
                 xs.append((np.min(gxs)+np.max(gxs))/2)
                 ys.append((np.min(gys)+np.max(gys))/2)
         dx, dy = np.array(xs) - x, np.array(ys) - y
-        distances = pd.Series(dx*dx + dy*dy)
+        distances = pd.Series(dx**2 + dy**2)
         return df.iloc[distances.argsort().values]
 
 
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/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/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 5feb8906c..97f518ff4 100644
--- a/holoviews/tests/operation/test_datashader.py
+++ b/holoviews/tests/operation/test_datashader.py
@@ -827,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),
@@ -1442,14 +1458,25 @@ class GraphBundlingTests(ComparisonTestCase):
         direct = directly_connect_edges(self.graph)._split_edgepaths
         self.assertEqual(direct, self.graph.edgepaths)
 
+
 class InspectorTests(ComparisonTestCase):
     """
     Tests for inspector operations
     """
     def setUp(self):
         points = Points([(0.2, 0.3), (0.4, 0.7), (0, 0.99)])
-        self.pntsimg = rasterize(points, dynamic=False,
-                             x_range=(0, 1), y_range=(0, 1), width=4, height=4)
+        self.pntsimg = rasterize(
+            points, dynamic=False, height=4, width=4,
+            x_range=(0, 1), y_range=(0, 1)
+        )
+        date_pts = Points([
+            (np.datetime64('2024-09-25 11:00'), 0.3),
+            (np.datetime64('2024-09-25 11:01'), 0.7),
+            (np.datetime64('2024-09-25 11:04'), 0.99)])
+        self.datesimg = rasterize(
+            date_pts, dynamic=False, height=4, width=4,
+            x_range=(np.datetime64('2024-09-25 11:00'), np.datetime64('2024-09-25 11:04')), y_range=(0, 1)
+        )
         if spatialpandas is None:
             return
 
@@ -1464,7 +1491,6 @@ class InspectorTests(ComparisonTestCase):
     def tearDown(self):
         Tap.x, Tap.y = None, None
 
-
     def test_inspect_points_or_polygons(self):
         if spatialpandas is None:
             raise SkipTest('Polygon inspect tests require spatialpandas')
@@ -1521,6 +1547,18 @@ class InspectorTests(ComparisonTestCase):
         self.assertEqual(points.streams[0].x, 0.2)
         self.assertEqual(points.streams[0].y, 0.3)
 
+    def test_points_with_dates_inspection_1px_mask(self):
+        points = inspect_points(self.datesimg, max_indicators=3, dynamic=False, pixels=1,
+                                x=np.datetime64('2024-09-25 11:01'), y=-0.1)
+        self.assertEqual(points.dimension_values('x'), np.array([]))
+        self.assertEqual(points.dimension_values('y'), np.array([]))
+
+    def test_points_with_dates_inspection_2px_mask(self):
+        points = inspect_points(self.datesimg, max_indicators=3, dynamic=False, pixels=2,
+                                x=np.datetime64('2024-09-25 11:01'), y=-0.1)
+        self.assertEqual(points.dimension_values('x'), np.array([np.datetime64('2024-09-25 11:00')]))
+        self.assertEqual(points.dimension_values('y'), np.array([0.3]))
+
     def test_polys_inspection_1px_mask_hit(self):
         if spatialpandas is None:
             raise SkipTest('Polygon inspect tests require spatialpandas')
@@ -1540,7 +1578,6 @@ class InspectorTests(ComparisonTestCase):
         self.assertEqual(inspector.hits.iloc[0].geometry,
                          spatialpandas.geometry.polygon.Polygon(data))
 
-
     def test_polys_inspection_1px_mask_miss(self):
         if spatialpandas is None:
             raise SkipTest('Polygon inspect tests require spatialpandas')
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"