Repository: holoviz/holoviews (python)

Issue:
Attach ScaleBar to subcoordinate axis
Allow adding scale bars to individual subplot/subcoordinate_y ranges. The current implementation only allows for a scale bar to be positioned relative to the entire plot. This depends on progress in Bokeh: https://github.com/bokeh/bokeh/issues/13921

A possible API might be to use a new arg; something like `scalebar_level="subcoordinate_y"`

<details> <summary> Code </summary>

```python
import holoviews as hv
import numpy as np
hv.extension("bokeh")

# scalebar_level="subcoordinate_y")

common_opts = dict(subcoordinate_y=True, scalebar=True, scalebar_range='y', scalebar_unit=('cm', 'm'),)

c1 = hv.Curve(np.random.rand(1000), label='c1').opts(color='lightblue',  scalebar_location="left", scalebar_opts={'bar_length':.1, 'background_fill_alpha': .2, 'bar_line_color':'darkblue'}, **common_opts)
c2 = hv.Curve(np.random.rand(1000), label='c2').opts(color='indianred', scalebar_location="bottom_right", scalebar_opts={'bar_length':.1, 'background_fill_alpha': .2, 'bar_line_color':'darkred'}, **common_opts)
c3 = hv.Curve(np.random.rand(1000), label='c3').opts(color='plum', scalebar_location="top_left", scalebar_opts={'bar_length':.1, 'background_fill_alpha': .2, 'bar_line_color':'purple'}, **common_opts)
c4 = hv.Curve(np.random.rand(1000), label='c4').opts(color='palegreen', scalebar_location="right", scalebar_opts={'bar_length':.1, 'background_fill_alpha': .2, 'bar_line_color':'darkgreen'}, **common_opts)

curves = hv.Overlay(c1 * c2 * c3 * c4).opts(show_legend=False)
curves.opts(width=800, height=600)
```

</details>


Arrows indicate where the scale bars should be after applying to subcoordinate level:

![image](https://github.com/holoviz/holoviews/assets/6613202/84efb1f7-4424-43be-adeb-901825181270)


Pull request description:
enh: Add Scalebar support for subcoordinate_y plots
Resolves #6292

Example:

``` python
import holoviews as hv
import numpy as np
hv.extension("bokeh")

common_opts = dict(subcoordinate_y=True, scalebar=True, scalebar_unit=('cm', 'm'),)

c1 = hv.Curve(np.random.rand(1000), label='c1').opts(color='lightblue',  scalebar_location="left", **common_opts)
c2 = hv.Curve(np.random.rand(1000), label='c2').opts(color='indianred', scalebar_location="right", **common_opts)
c3 = hv.Curve(np.random.rand(1000), label='c3').opts(color='plum', scalebar_location="left", **common_opts)
c4 = hv.Curve(np.random.rand(1000), label='c4').opts(color='palegreen', scalebar_location="right", **common_opts)

curves = hv.Overlay(c1 * c2 * c3 * c4).opts(show_legend=False)
curves.opts(width=800, height=600)
```

![image](https://github.com/user-attachments/assets/cd7a656d-c7d8-4eee-b20b-8033fe1ed41a)


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/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/plot.py b/holoviews/plotting/bokeh/plot.py
index 91a72967b..109b7067e 100644
--- a/holoviews/plotting/bokeh/plot.py
+++ b/holoviews/plotting/bokeh/plot.py
@@ -249,10 +249,12 @@ class BokehPlot(DimensionedPlot, CallbackPlot):
             if plot.subplots:
                 plot.subplots.clear()
 
-            if isinstance(plot, GenericElementPlot):
-                for callback in plot.callbacks:
-                    streams += callback.streams
-                    callback.cleanup()
+            if not isinstance(plot, (GenericElementPlot, GenericOverlayPlot)):
+                continue
+
+            for callback in plot.callbacks:
+                streams += callback.streams
+                callback.cleanup()
 
             for stream in set(streams):
                 stream._subscribers = [
@@ -261,7 +263,6 @@ class BokehPlot(DimensionedPlot, CallbackPlot):
                     get_method_owner(subscriber) not in plots
                 ]
 
-
     def _fontsize(self, key, label='fontsize', common=True):
         """
         Converts integer fontsizes to a string specifying
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,
diff --git a/holoviews/plotting/plot.py b/holoviews/plotting/plot.py
index eb963afea..444dd6812 100644
--- a/holoviews/plotting/plot.py
+++ b/holoviews/plotting/plot.py
@@ -195,7 +195,7 @@ class Plot(param.Parameterized):
         """
         plots = self.traverse(lambda x: x, [Plot])
         for plot in plots:
-            if not isinstance(plot, (GenericCompositePlot, GenericElementPlot, GenericOverlayPlot)):
+            if not isinstance(plot, (GenericElementPlot, GenericOverlayPlot)):
                 continue
             for stream in set(plot.streams):
                 stream._subscribers = [
diff --git a/pixi.toml b/pixi.toml
index a611755f7..c10edd0c5 100644
--- a/pixi.toml
+++ b/pixi.toml
@@ -19,7 +19,7 @@ test-311 = ["py311", "test-core", "test", "example", "test-example", "test-unit-
 test-312 = ["py312", "test-core", "test", "example", "test-example", "test-unit-task"]
 test-ui = ["py312", "test-core", "test", "test-ui"]
 test-core = ["py313", "test-core", "test-unit-task"]
-test-gpu = ["py311", "test-core", "test", "test-gpu"]
+test-gpu = ["py312", "test-core", "test", "test-gpu"]
 docs = ["py311", "example", "doc"]
 build = ["py311", "build"]
 lint = ["py311", "lint"]
@@ -135,7 +135,7 @@ cmd = 'pytest holoviews/tests/ui --ui --browser chromium'
 depends_on = ["_install-ui"]
 
 [feature.test-gpu]
-channels = ["pyviz/label/dev", "rapidsai", "conda-forge"]
+channels = ["rapidsai"]
 platforms = ["linux-64"]
 
 [feature.test-gpu.activation.env]
@@ -143,7 +143,7 @@ NUMBA_CUDA_LOW_OCCUPANCY_WARNINGS = "0"
 
 [feature.test-gpu.dependencies]
 cuda-version = "12.2.*"
-cudf = "24.08.*"
+cudf = "24.10.*"
 cupy = "*"
 cuspatial = "*"
 librmm = { version = "*", channel = "rapidsai" }


Test patch:
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/plotting/bokeh/test_plot.py b/holoviews/tests/plotting/bokeh/test_plot.py
index 5c7a08a2e..03c9761f2 100644
--- a/holoviews/tests/plotting/bokeh/test_plot.py
+++ b/holoviews/tests/plotting/bokeh/test_plot.py
@@ -14,9 +14,11 @@ from param import concrete_descendents
 from holoviews import Curve
 from holoviews.core.element import Element
 from holoviews.core.options import Store
+from holoviews.core.spaces import DynamicMap
 from holoviews.element.comparison import ComparisonTestCase
 from holoviews.plotting.bokeh.callbacks import Callback
 from holoviews.plotting.bokeh.element import ElementPlot
+from holoviews.streams import Pipe
 
 bokeh_renderer = Store.renderers['bokeh']
 
@@ -82,11 +84,60 @@ class TestBokehPlot(ComparisonTestCase):
                     self.assertIn(lookup[2:-1], cds.data)
 
         # Ensure all the glyph renderers have a hover tool
-        print(renderers, hover)
         for renderer in renderers:
             self.assertTrue(any(renderer in h.renderers for h in hover))
 
 
+def test_element_plot_stream_cleanup():
+    stream = Pipe()
+
+    dmap = DynamicMap(Curve, streams=[stream])
+
+    plot = bokeh_renderer.get_plot(dmap)
+
+    assert len(stream._subscribers) == 1
+
+    plot.cleanup()
+
+    assert not stream._subscribers
+
+
+def test_overlay_plot_stream_cleanup():
+    stream1 = Pipe()
+    stream2 = Pipe()
+
+    dmap1 = DynamicMap(Curve, streams=[stream1])
+    dmap2 = DynamicMap(Curve, streams=[stream2])
+
+    plot = bokeh_renderer.get_plot(dmap1 * dmap2)
+
+    assert len(stream1._subscribers) == 4
+    assert len(stream2._subscribers) == 4
+
+    plot.cleanup()
+
+    assert not stream1._subscribers
+    assert not stream2._subscribers
+
+
+def test_layout_plot_stream_cleanup():
+    stream1 = Pipe()
+    stream2 = Pipe()
+
+    dmap1 = DynamicMap(Curve, streams=[stream1])
+    dmap2 = DynamicMap(Curve, streams=[stream2])
+
+    plot = bokeh_renderer.get_plot(dmap1 + dmap2)
+
+    assert len(stream1._subscribers) == 2
+    assert len(stream2._subscribers) == 2
+
+    plot.cleanup()
+
+    assert not stream1._subscribers
+    assert not stream2._subscribers
+
+
 def test_sync_two_plots():
     curve = lambda i: Curve(np.arange(10) * i, label="ABC"[i])
     plot1 = curve(0) * curve(1)
diff --git a/holoviews/tests/plotting/plotly/test_plot.py b/holoviews/tests/plotting/plotly/test_plot.py
index 3a033f884..0f71a9952 100644
--- a/holoviews/tests/plotting/plotly/test_plot.py
+++ b/holoviews/tests/plotting/plotly/test_plot.py
@@ -2,16 +2,68 @@ import plotly.graph_objs as go
 import pyviz_comms as comms
 from param import concrete_descendents
 
-from holoviews.core import Store
+from holoviews.core import DynamicMap, Store
+from holoviews.element import Curve
 from holoviews.element.comparison import ComparisonTestCase
 from holoviews.plotting.plotly.element import ElementPlot
 from holoviews.plotting.plotly.util import figure_grid
+from holoviews.streams import Pipe
 
 from .. import option_intersections
 
 plotly_renderer = Store.renderers['plotly']
 
 
+def test_element_plot_stream_cleanup():
+    stream = Pipe()
+
+    dmap = DynamicMap(Curve, streams=[stream])
+
+    plot = plotly_renderer.get_plot(dmap)
+
+    assert len(stream._subscribers) == 1
+
+    plot.cleanup()
+
+    assert not stream._subscribers
+
+
+def test_overlay_plot_stream_cleanup():
+    stream1 = Pipe()
+    stream2 = Pipe()
+
+    dmap1 = DynamicMap(Curve, streams=[stream1])
+    dmap2 = DynamicMap(Curve, streams=[stream2])
+
+    plot = plotly_renderer.get_plot(dmap1 * dmap2)
+
+    assert len(stream1._subscribers) == 4
+    assert len(stream2._subscribers) == 4
+
+    plot.cleanup()
+
+    assert not stream1._subscribers
+    assert not stream2._subscribers
+
+
+def test_layout_plot_stream_cleanup():
+    stream1 = Pipe()
+    stream2 = Pipe()
+
+    dmap1 = DynamicMap(Curve, streams=[stream1])
+    dmap2 = DynamicMap(Curve, streams=[stream2])
+
+    plot = plotly_renderer.get_plot(dmap1 + dmap2)
+
+    assert len(stream1._subscribers) == 2
+    assert len(stream2._subscribers) == 2
+
+    plot.cleanup()
+
+    assert not stream1._subscribers
+    assert not stream2._subscribers
+
+
 class TestPlotlyPlot(ComparisonTestCase):
 
     def setUp(self):