Repository: holoviz/holoviews
Pull request #6412 (2024-10-15 07:15:17)

enh: Rename `index` column to `__index__` and remove old version check
The rename to `__index__` is needed as currently passing in the following `ds.summary(val=ds.min("s"), index=ds.where(ds.min("s")))` as an aggregator would always place `index` at the end. This can also happen with `__index__`, but the chance of this collision seems much lower. This was flagged in the [original pr](https://github.com/holoviz/holoviews/pull/5805#discussion_r1269628614) but was never done.

The version rename is to align the naming of the versions check across holoviews and will done for all files in https://github.com/holoviz/holoviews/pull/6413. The main point is also to make it easier to understand what the version constant means. 

The dropping of compatibility with Datashader 0.5.0, which was released somewhere between 2016 and 2018... 
![image](https://github.com/user-attachments/assets/086ca284-cf54-4245-bd42-2d2a0635a5bd)


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/holoviz/holoviews/pull/6412?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 88.50%. Comparing base [(`14849d5`)](https://app.codecov.io/gh/holoviz/holoviews/commit/14849d585d291c7fa07e597b5c8a858b8b869190?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) to head [(`2be0c36`)](https://app.codecov.io/gh/holoviz/holoviews/commit/2be0c364795249eccf902561ee76e9c48d24693b?dropdown=coverage&el=desc&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    #6412   +/-   ##
=======================================
  Coverage   88.50%   88.50%           
=======================================
  Files         323      323           
  Lines       68531    68531           
=======================================
  Hits        60650    60650           
  Misses       7881     7881           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/holoviz/holoviews/pull/6412?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/holoviews/core/data/cudf.py b/holoviews/core/data/cudf.py
index 0cd8f6d0e..f316e5e6e 100644
--- a/holoviews/core/data/cudf.py
+++ b/holoviews/core/data/cudf.py
@@ -205,7 +205,8 @@ class cuDFInterface(PandasInterface):
                 with warnings.catch_warnings():
                     warnings.filterwarnings('ignore', r'invalid value encountered')
                     if sel.start is not None:
-                        new_masks.append(sel.start <= arr)
+                        # Comparison has to be in this order due to issues with datetime comparison (see #6407)
+                        new_masks.append(arr >= sel.start)
                     if sel.stop is not None:
                         new_masks.append(arr < sel.stop)
                 if not new_masks:
diff --git a/holoviews/operation/datashader.py b/holoviews/operation/datashader.py
index 96a519a5d..9dc6c010a 100644
--- a/holoviews/operation/datashader.py
+++ b/holoviews/operation/datashader.py
@@ -69,9 +69,10 @@ from ..element.util import connect_tri_edges_pd
 from ..streams import PointerXY
 from .resample import LinkableOperation, ResampleOperation2D
 
-ds_version = Version(ds.__version__)
-ds15 = ds_version >= Version('0.15.1')
-ds16 = ds_version >= Version('0.16.0')
+DATASHADER_VERSION = Version(ds.__version__).release
+DATASHADER_GE_0_14_0 = DATASHADER_VERSION >= (0, 14, 0)
+DATASHADER_GE_0_15_1 = DATASHADER_VERSION >= (0, 15, 1)
+DATASHADER_GE_0_16_0 = DATASHADER_VERSION >= (0, 16, 0)
 
 
 class AggregationOperation(ResampleOperation2D):
@@ -114,7 +115,7 @@ class AggregationOperation(ResampleOperation2D):
 
     @classmethod
     def _get_aggregator(cls, element, agg, add_field=True):
-        if ds15:
+        if DATASHADER_GE_0_15_1:
             agg_types = (rd.count, rd.any, rd.where)
         else:
             agg_types = (rd.count, rd.any)
@@ -379,7 +380,7 @@ class aggregate(LineAggregationOperation):
                         x_range=x_range, y_range=y_range)
 
         agg_kwargs = {}
-        if self.p.line_width and glyph == 'line' and ds_version >= Version('0.14.0'):
+        if self.p.line_width and glyph == 'line' and DATASHADER_GE_0_14_0:
             agg_kwargs['line_width'] = self.p.line_width
 
         dfdata = PandasInterface.as_dframe(data)
@@ -388,9 +389,9 @@ class aggregate(LineAggregationOperation):
         if sel_fn:
             if isinstance(params["vdims"], (Dimension, str)):
                 params["vdims"] = [params["vdims"]]
-            sum_agg = ds.summary(**{str(params["vdims"][0]): agg_fn, "index": ds.where(sel_fn)})
+            sum_agg = ds.summary(**{str(params["vdims"][0]): agg_fn, "__index__": ds.where(sel_fn)})
             agg = self._apply_datashader(dfdata, cvs_fn, sum_agg, agg_kwargs, x, y)
-            _ignore = [*params["vdims"], "index"]
+            _ignore = [*params["vdims"], "__index__"]
             sel_vdims = [s for s in agg if s not in _ignore]
             params["vdims"] = [*params["vdims"], *sel_vdims]
         else:
@@ -405,8 +406,7 @@ class aggregate(LineAggregationOperation):
 
         if isinstance(agg, xr.Dataset) or agg.ndim == 2:
             # Replacing x and y coordinates to avoid numerical precision issues
-            eldata = agg if ds_version > Version('0.5.0') else (xs, ys, agg.data)
-            return self.p.element_type(eldata, **params)
+            return self.p.element_type(agg, **params)
         else:
             params['vdims'] = list(map(str, agg.coords[agg_fn.column].data))
             return ImageStack(agg, **params)
@@ -421,14 +421,14 @@ class aggregate(LineAggregationOperation):
             )
             agg = cvs_fn(dfdata, x.name, y.name, agg_fn, **agg_kwargs)
 
-        is_where_index = ds15 and isinstance(agg_fn, ds.where) and isinstance(agg_fn.column, rd.SpecialColumn)
-        is_summary_index = isinstance(agg_fn, ds.summary) and "index" in agg
+        is_where_index = DATASHADER_GE_0_15_1 and isinstance(agg_fn, ds.where) and isinstance(agg_fn.column, rd.SpecialColumn)
+        is_summary_index = isinstance(agg_fn, ds.summary) and "__index__" in agg
         if is_where_index or is_summary_index:
             if is_where_index:
                 data = agg.data
-                agg = agg.to_dataset(name="index")
+                agg = agg.to_dataset(name="__index__")
             else:  # summary index
-                data = agg.index.data
+                data = agg["__index__"].data
             neg1 = data == -1
             for col in dfdata.columns:
                 if col in agg.coords:
@@ -708,7 +708,7 @@ class spikes_aggregate(LineAggregationOperation):
                         x_range=x_range, y_range=y_range)
 
         agg_kwargs = {}
-        if ds_version >= Version('0.14.0'):
+        if DATASHADER_GE_0_14_0:
             agg_kwargs['line_width'] = self.p.line_width
 
         rename_dict = {k: v for k, v in rename_dict.items() if k != v}
@@ -770,14 +770,12 @@ class geom_aggregate(AggregationOperation):
 
         if agg.ndim == 2:
             # Replacing x and y coordinates to avoid numerical precision issues
-            eldata = agg if ds_version > Version('0.5.0') else (xs, ys, agg.data)
-            return self.p.element_type(eldata, **params)
+            return self.p.element_type(agg, **params)
         else:
             layers = {}
             for c in agg.coords[agg_fn.column].data:
                 cagg = agg.sel(**{agg_fn.column: c})
-                eldata = cagg if ds_version > Version('0.5.0') else (xs, ys, cagg.data)
-                layers[c] = self.p.element_type(eldata, **params)
+                layers[c] = self.p.element_type(cagg, **params)
             return NdOverlay(layers, kdims=[element.get_dimension(agg_fn.column)])
 
 
@@ -788,7 +786,7 @@ class segments_aggregate(geom_aggregate, LineAggregationOperation):
 
     def _aggregate(self, cvs, df, x0, y0, x1, y1, agg_fn):
         agg_kwargs = {}
-        if ds_version >= Version('0.14.0'):
+        if DATASHADER_GE_0_14_0:
             agg_kwargs['line_width'] = self.p.line_width
 
         return cvs.line(df, [x0, x1], [y0, y1], agg_fn, axis=1, **agg_kwargs)
@@ -878,9 +876,6 @@ class regrid(AggregationOperation):
 
 
     def _process(self, element, key=None):
-        if ds_version <= Version('0.5.0'):
-            raise RuntimeError('regrid operation requires datashader>=0.6.0')
-
         # Compute coords, anges and size
         x, y = element.kdims
         coords = tuple(element.dimension_values(d, expanded=False) for d in [x, y])
@@ -1040,7 +1035,7 @@ class trimesh_rasterize(aggregate):
         precompute = self.p.precompute
         if interp == 'linear': interp = 'bilinear'
         wireframe = False
-        if (not (element.vdims or (isinstance(element, TriMesh) and element.nodes.vdims))) and ds_version <= Version('0.6.9'):
+        if (not (element.vdims or (isinstance(element, TriMesh) and element.nodes.vdims))) and DATASHADER_VERSION <= (0, 6, 9):
             self.p.aggregator = ds.any() if isinstance(agg, ds.any) or agg == 'any' else ds.count()
             return aggregate._process(self, element, key)
         elif ((not interp and (isinstance(agg, (ds.any, ds.count)) or
@@ -1101,11 +1096,11 @@ class quadmesh_rasterize(trimesh_rasterize):
     """
 
     def _precompute(self, element, agg):
-        if ds_version <= Version('0.7.0'):
+        if DATASHADER_VERSION <= (0, 7, 0):
             return super()._precompute(element.trimesh(), agg)
 
     def _process(self, element, key=None):
-        if ds_version <= Version('0.7.0'):
+        if DATASHADER_VERSION <= (0, 7, 0):
             return super()._process(element, key)
 
         if element.interface.datatype != 'xarray':
@@ -1318,7 +1313,7 @@ class shade(LinkableOperation):
         shade_opts = dict(
             how=self.p.cnorm, min_alpha=self.p.min_alpha, alpha=self.p.alpha
         )
-        if ds_version >= Version('0.14.0'):
+        if DATASHADER_GE_0_14_0:
             shade_opts['rescale_discrete_levels'] = self.p.rescale_discrete_levels
 
         # Compute shading options depending on whether
@@ -1352,7 +1347,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':
+        elif self.p.cnorm != 'eq_hist':
             shade_opts['span'] = (array.min().item(), array.max().item())
 
         params = dict(get_param_values(element), kdims=kdims,
@@ -1409,7 +1404,7 @@ class geometry_rasterize(LineAggregationOperation):
             data, col = self._precomputed[element._plot_id]
         else:
             if (('spatialpandas' not in element.interface.datatype) and
-                (not (ds16 and 'geodataframe' in element.interface.datatype))):
+                (not (DATASHADER_GE_0_16_0 and 'geodataframe' in element.interface.datatype))):
                 element = element.clone(datatype=['spatialpandas'])
             data = element.data
             col = element.interface.geo_column(data)
@@ -1424,7 +1419,7 @@ class geometry_rasterize(LineAggregationOperation):
         if isinstance(element, Polygons):
             agg = cvs.polygons(data, **agg_kwargs)
         elif isinstance(element, Path):
-            if self.p.line_width and ds_version >= Version('0.14.0'):
+            if self.p.line_width and DATASHADER_GE_0_14_0:
                 agg_kwargs['line_width'] = self.p.line_width
             agg = cvs.line(data, **agg_kwargs)
         elif isinstance(element, Points):
@@ -1627,7 +1622,7 @@ class SpreadingOperation(LinkableOperation):
     to make sparse plots more visible.
     """
 
-    how = param.ObjectSelector(default='source' if ds_version <= Version('0.11.1') else None,
+    how = param.ObjectSelector(default='source' if DATASHADER_VERSION <= (0, 11, 1) else None,
             objects=[None, 'source', 'over', 'saturate', 'add', 'max', 'min'], doc="""
         The name of the compositing operator to use when combining
         pixels. Default of None uses 'over' operator for RGB elements
diff --git a/holoviews/plotting/bokeh/callbacks.py b/holoviews/plotting/bokeh/callbacks.py
index 89541b595..0386a20c1 100644
--- a/holoviews/plotting/bokeh/callbacks.py
+++ b/holoviews/plotting/bokeh/callbacks.py
@@ -1191,13 +1191,25 @@ class Selection1DCallback(PopupMixin, Callback):
               }
               if (!xs || !ys) { return }
               for (const i of indices) {
-                const tx = xs[i]
+                let ix = xs[i]
+                let iy = ys[i]
+                let tx, ty
+                if (typeof ix === 'number') {
+                  tx = ix
+                  ty = iy
+                } else {
+                  while (ix.length && (typeof ix[0] !== 'number')) {
+                    ix = ix[0]
+                    iy = iy[0]
+                  }
+                  tx = Math.max(...ix)
+                  ty = Math.max(...iy)
+                }
                 if (!x || (tx > x)) {
-                  x = xs[i]
+                  x = tx
                 }
-                const ty = ys[i]
                 if (!y || (ty > y)) {
-                  y = ys[i]
+                  y = ty
                 }
               }
               if (x && y) {
diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py
index ea243584c..b17662554 100644
--- a/holoviews/plotting/bokeh/element.py
+++ b/holoviews/plotting/bokeh/element.py
@@ -54,6 +54,7 @@ from ...core.overlay import CompositeOverlay, NdOverlay
 from ...element import Annotation, Contours, Graph, Path, Tiles, VectorField
 from ...streams import Buffer, PlotSize, RangeXY
 from ...util.transform import dim
+from ...util.warnings import warn
 from ..plot import GenericElementPlot, GenericOverlayPlot
 from ..util import color_intervals, dim_axis_label, dim_range_key, process_cmap
 from .plot import BokehPlot
@@ -73,6 +74,7 @@ from .util import (
     bokeh32,
     bokeh34,
     bokeh35,
+    bokeh36,
     cds_column_replace,
     compute_layout_properties,
     date_to_integer,
@@ -209,7 +211,7 @@ class ElementPlot(BokehPlot, GenericElementPlot):
         The scalebar_unit is only used if scalebar is True.""")
 
     scalebar_location = param.Selector(
-        default="bottom_right",
+        default=None,
         objects=[
             "top_left", "top_center", "top_right",
             "center_left", "center_center", "center_right",
@@ -219,6 +221,8 @@ class ElementPlot(BokehPlot, GenericElementPlot):
         doc="""
             Location anchor for positioning scale bar.
 
+            Default to 'bottom_right', except if subcoordinate_y is True then it will default to 'right'.
+
             The scalebar_location is only used if scalebar is True.""")
 
     scalebar_label = param.String(
@@ -2111,7 +2115,7 @@ class ElementPlot(BokehPlot, GenericElementPlot):
         self._postprocess_hover(renderer, source)
 
         if self.scalebar:
-            self._draw_scalebar(plot)
+            self._draw_scalebar(plot=plot, renderer=renderer)
 
         zooms_subcoordy = self.handles.get('zooms_subcoordy')
         if zooms_subcoordy is not None:
@@ -2401,7 +2405,7 @@ class ElementPlot(BokehPlot, GenericElementPlot):
         return any(self.lookup_options(frame, 'norm').options.get('framewise')
                    for frame in current_frames)
 
-    def _draw_scalebar(self, plot):
+    def _draw_scalebar(self, *, plot, renderer):
         """Draw scalebar on the plot
 
         This will draw a scalebar on the plot. See the documentation for
@@ -2409,10 +2413,14 @@ class ElementPlot(BokehPlot, GenericElementPlot):
         `scalebar_opts`, and `scalebar_unit` for more information.
 
         Requires Bokeh 3.4
+
+        For scalebar on a subcoordinate_y plot Bokeh 3.6 is needed.
         """
 
         if not bokeh34:
             raise RuntimeError("Scalebar requires Bokeh >= 3.4.0")
+        elif not bokeh36 and self._subcoord_overlaid:
+            warn("Scalebar with subcoordinate_y requires Bokeh >= 3.6.0", RuntimeWarning)
 
         from bokeh.models import Metric, ScaleBar
 
@@ -2423,17 +2431,34 @@ class ElementPlot(BokehPlot, GenericElementPlot):
         else:
             base_unit = unit
 
-        _default_scalebar_opts = {"background_fill_alpha": 0.8}
-        opts = dict(_default_scalebar_opts, **self.scalebar_opts)
+        if self._subcoord_overlaid:
+            srange = renderer.coordinates.y_source
+            orientation = "vertical"
+            # Integer is used for the location as `major_label_overrides` overrides the
+            # label with {0: labelA, 1: labelB}
+            location = (self.scalebar_location or "right", len(plot.renderers) - 1)
+            default_scalebar_opts = {
+                'bar_line_width': 3,
+                'label_location': 'left',
+                'background_fill_color': 'white',
+                'background_fill_alpha': 0.6,
+                'length_sizing': 'adaptive',
+                # Adding location so people can overwrite the default
+                'location': location,
+            }
+        else:
+            srange = plot.x_range if self.scalebar_range == "x" else plot.y_range
+            orientation = "horizontal" if self.scalebar_range == "x" else "vertical"
+            location = self.scalebar_location or "bottom_right"
+            default_scalebar_opts = {"background_fill_alpha": 0.8, "location": location}
 
         scale_bar = ScaleBar(
-            range=plot.x_range if self.scalebar_range == "x" else plot.y_range,
-            orientation="horizontal" if self.scalebar_range == "x" else "vertical",
+            range=srange,
+            orientation=orientation,
             unit=unit,
             dimensional=Metric(base_unit=base_unit),
-            location=self.scalebar_location,
             label=self.scalebar_label,
-            **opts,
+            **dict(default_scalebar_opts, **self.scalebar_opts),
         )
         self.handles['scalebar'] = scale_bar
         plot.add_layout(scale_bar)
diff --git a/holoviews/plotting/bokeh/util.py b/holoviews/plotting/bokeh/util.py
index bf69b888a..02d8cba1a 100644
--- a/holoviews/plotting/bokeh/util.py
+++ b/holoviews/plotting/bokeh/util.py
@@ -67,6 +67,7 @@ bokeh32 = bokeh_version >= Version("3.2")
 bokeh33 = bokeh_version >= Version("3.3")
 bokeh34 = bokeh_version >= Version("3.4")
 bokeh35 = bokeh_version >= Version("3.5")
+bokeh36 = bokeh_version >= Version("3.6")
 
 TOOL_TYPES = {
     'pan': tools.PanTool,


Test patch:
diff --git a/holoviews/tests/operation/test_datashader.py b/holoviews/tests/operation/test_datashader.py
index 97f518ff4..823b75f34 100644
--- a/holoviews/tests/operation/test_datashader.py
+++ b/holoviews/tests/operation/test_datashader.py
@@ -6,7 +6,6 @@ import numpy as np
 import pandas as pd
 import pytest
 from numpy import nan
-from packaging.version import Version
 
 from holoviews import (
     RGB,
@@ -38,29 +37,30 @@ from holoviews.streams import Tap
 from holoviews.util import render
 
 try:
-    import dask.dataframe as dd
     import datashader as ds
-    import xarray as xr
-
-    from holoviews.operation.datashader import (
-        AggregationOperation,
-        aggregate,
-        datashade,
-        directly_connect_edges,
-        ds_version,
-        dynspread,
-        inspect,
-        inspect_points,
-        inspect_polygons,
-        rasterize,
-        regrid,
-        shade,
-        spread,
-        stack,
-    )
 except ImportError:
     raise SkipTest('Datashader not available')
 
+import dask.dataframe as dd
+import xarray as xr
+
+from holoviews.operation.datashader import (
+    DATASHADER_VERSION,
+    AggregationOperation,
+    aggregate,
+    datashade,
+    directly_connect_edges,
+    dynspread,
+    inspect,
+    inspect_points,
+    inspect_polygons,
+    rasterize,
+    regrid,
+    shade,
+    spread,
+    stack,
+)
+
 try:
     import spatialpandas
 except ImportError:
@@ -807,7 +807,7 @@ class DatashaderAggregateTests(ComparisonTestCase):
 class DatashaderCatAggregateTests(ComparisonTestCase):
 
     def setUp(self):
-        if ds_version < Version('0.11.0'):
+        if DATASHADER_VERSION < (0, 11, 0):
             raise SkipTest('Regridding operations require datashader>=0.11.0')
 
     def test_aggregate_points_categorical(self):
@@ -918,10 +918,6 @@ class DatashaderRegridTests(ComparisonTestCase):
     Tests for datashader aggregation
     """
 
-    def setUp(self):
-        if ds_version <= Version('0.5.0'):
-            raise SkipTest('Regridding operations require datashader>=0.6.0')
-
     def test_regrid_mean(self):
         img = Image((range(10), range(5), np.arange(10) * np.arange(5)[np.newaxis].T))
         regridded = regrid(img, width=2, height=2, dynamic=False)
@@ -997,7 +993,7 @@ class DatashaderRasterizeTests(ComparisonTestCase):
     """
 
     def setUp(self):
-        if ds_version <= Version('0.6.4'):
+        if DATASHADER_VERSION <= (0, 6, 4):
             raise SkipTest('Regridding operations require datashader>=0.7.0')
 
         self.simplexes = [(0, 1, 2), (3, 2, 1)]
@@ -1314,12 +1310,12 @@ def test_rasterize_where_agg_no_column(point_plot, agg_input_fn, index_col):
     rast_input = dict(dynamic=False,  x_range=(-1, 1), y_range=(-1, 1), width=2, height=2)
     img = rasterize(point_plot, aggregator=agg_fn, **rast_input)
 
-    assert list(img.data) == ["index", "s", "val", "cat"]
+    assert list(img.data) == ["__index__", "s", "val", "cat"]
     assert list(img.vdims) == ["val", "s", "cat"]  # val first and no index
 
     # N=100 in point_data is chosen to have a big enough sample size
     # so that the index are not the same for the different agg_input_fn
-    np.testing.assert_array_equal(img.data["index"].data.flatten(), index_col)
+    np.testing.assert_array_equal(img.data["__index__"].data.flatten(), index_col)
 
     img_simple = rasterize(point_plot, aggregator=agg_input_fn("val"), **rast_input)
     np.testing.assert_array_equal(img_simple["val"], img["val"])
@@ -1357,7 +1353,7 @@ def test_rasterize_selector(point_plot, sel_fn):
     img = rasterize(point_plot, selector=sel_fn("val"), **rast_input)
 
     # Count is from the aggregator
-    assert list(img.data) == ["Count", "index", "s", "val", "cat"]
+    assert list(img.data) == ["Count", "__index__", "s", "val", "cat"]
     assert list(img.vdims) == ["Count", "s", "val", "cat"]  # no index
 
     # The output for the selector should be equal to the output for the aggregator using
@@ -1401,7 +1397,7 @@ class DatashaderSpreadTests(ComparisonTestCase):
         self.assertEqual(spreaded, RGB(arr))
 
     def test_spread_img_1px(self):
-        if ds_version < Version('0.12.0'):
+        if DATASHADER_VERSION < (0, 12, 0):
             raise SkipTest('Datashader does not support DataArray yet')
         arr = np.array([[0, 0, 0], [0, 0, 0], [1, 1, 1]]).T
         spreaded = spread(Image(arr))
@@ -1448,7 +1444,7 @@ class DatashaderStackTests(ComparisonTestCase):
 class GraphBundlingTests(ComparisonTestCase):
 
     def setUp(self):
-        if ds_version <= Version('0.7.0'):
+        if DATASHADER_VERSION <= (0, 7, 0):
             raise SkipTest('Regridding operations require datashader>=0.7.0')
         self.source = np.arange(8)
         self.target = np.zeros(8)
diff --git a/holoviews/tests/plotting/bokeh/test_elementplot.py b/holoviews/tests/plotting/bokeh/test_elementplot.py
index c465ac46b..50c34c507 100644
--- a/holoviews/tests/plotting/bokeh/test_elementplot.py
+++ b/holoviews/tests/plotting/bokeh/test_elementplot.py
@@ -21,7 +21,7 @@ from holoviews import opts
 from holoviews.core import Dimension, DynamicMap, HoloMap, NdOverlay, Overlay
 from holoviews.core.util import dt_to_int
 from holoviews.element import Curve, HeatMap, Image, Labels, Scatter
-from holoviews.plotting.bokeh.util import bokeh34
+from holoviews.plotting.bokeh.util import bokeh34, bokeh36
 from holoviews.plotting.util import process_cmap
 from holoviews.streams import PointDraw, Stream
 from holoviews.util import render
@@ -885,6 +885,31 @@ class TestScalebarPlot:
         scalebar_icon = [tool for tool in toolbar.tools if tool.description == "Toggle ScaleBar"]
         assert len(scalebar_icon) == 1
 
+    @pytest.mark.skipif(not bokeh36, reason="requires Bokeh >= 3.6")
+    @pytest.mark.parametrize("enabled1", [True, False])
+    @pytest.mark.parametrize("enabled2", [True, False])
+    @pytest.mark.parametrize("enabled3", [True, False])
+    def test_scalebar_with_subcoordinate_y(self, enabled1, enabled2, enabled3):
+        from bokeh.models import ScaleBar
+
+        enabled = [enabled1, enabled2, enabled3]
+        curve1 = Curve([1, 2, 3], label='c1').opts(scalebar=enabled1, subcoordinate_y=True)
+        curve2 = Curve([1, 2, 3], label='c2').opts(scalebar=enabled2, subcoordinate_y=True)
+        curve3 = Curve([1, 2, 3], label='c3').opts(scalebar=enabled3, subcoordinate_y=True)
+        curves = curve1 * curve2 * curve3
+
+        plot = bokeh_renderer.get_plot(curves).handles["plot"]
+        coordinates = [r.coordinates for r in plot.renderers][::-1]
+        sb = (c for c in plot.center if isinstance(c, ScaleBar))
+        scalebars = [next(sb) if e else None for e in enabled]
+        assert sum(map(bool, scalebars)) == sum(enabled)
+
+        for coordinate, scalebar, idx in zip(coordinates, scalebars, "123"):
+            assert coordinate.y_source.name == f"c{idx}"
+            if scalebar is None:
+                continue
+            assert coordinate.y_source is scalebar.range
+
 
 class TestColorbarPlot(LoggingComparisonTestCase, TestBokehPlot):
 
diff --git a/holoviews/tests/ui/bokeh/test_callback.py b/holoviews/tests/ui/bokeh/test_callback.py
index 19cd5f0c4..1fe6ba082 100644
--- a/holoviews/tests/ui/bokeh/test_callback.py
+++ b/holoviews/tests/ui/bokeh/test_callback.py
@@ -262,7 +262,43 @@ def test_stream_popup(serve_hv):
     hv_plot.click()
     expect(hv_plot).to_have_count(1)
 
-    locator = page.locator("#tap")
+    locator = page.locator(".markdown")
+    expect(locator).to_have_count(1)
+
+
+@skip_popup
+@pytest.mark.usefixtures("bokeh_backend")
+def test_stream_popup_polygons_tap(serve_hv):
+    def popup_form(name):
+        return f"# {name}"
+
+    points = hv.Polygons([(0, 0), (0, 1), (1, 1), (1, 0)]).opts(tools=["tap"])
+    hv.streams.Tap(source=points, popup=popup_form("Tap"))
+
+    page = serve_hv(points)
+    hv_plot = page.locator('.bk-events')
+    hv_plot.click()
+    expect(hv_plot).to_have_count(1)
+
+    locator = page.locator(".markdown")
+    expect(locator).to_have_count(1)
+
+
+@skip_popup
+@pytest.mark.usefixtures("bokeh_backend")
+def test_stream_popup_polygons_selection1d(serve_hv):
+    def popup_form(name):
+        return f"# {name}"
+
+    points = hv.Polygons([(0, 0), (0, 1), (1, 1), (1, 0)]).opts(tools=["tap"])
+    hv.streams.Selection1D(source=points, popup=popup_form("Tap"))
+
+    page = serve_hv(points)
+    hv_plot = page.locator('.bk-events')
+    hv_plot.click()
+    expect(hv_plot).to_have_count(1)
+
+    locator = page.locator(".markdown")
     expect(locator).to_have_count(1)