Repository: holoviz/holoviews
Pull request #6209 (2024-04-24 12:14:49)

Allow subcoordinates to be drawn from NdOverlay dimensions
Resolves https://github.com/holoviz/holoviews/issues/6028

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/holoviz/holoviews/pull/6209?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 `16.66667%` with `15 lines` in your changes are missing coverage. Please review.
> Project coverage is 14.27%. Comparing base [(`eb7ae9d`)](https://app.codecov.io/gh/holoviz/holoviews/commit/eb7ae9d58bf655d29b6c7d78bb977bc4d73ca52f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) to head [(`806a136`)](https://app.codecov.io/gh/holoviz/holoviews/pull/6209?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz).

| [Files](https://app.codecov.io/gh/holoviz/holoviews/pull/6209?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/plotting/bokeh/element.py](https://app.codecov.io/gh/holoviz/holoviews/pull/6209?src=pr&el=tree&filepath=holoviews%2Fplotting%2Fbokeh%2Felement.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL3Bsb3R0aW5nL2Jva2VoL2VsZW1lbnQucHk=) | 16.66% | [15 Missing :warning: ](https://app.codecov.io/gh/holoviz/holoviews/pull/6209?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    #6209       +/-   ##
===========================================
- Coverage   88.12%   14.27%   -73.86%     
===========================================
  Files         319      319               
  Lines       67221    67209       -12     
===========================================
- Hits        59241     9594    -49647     
- Misses       7980    57615    +49635     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/holoviz/holoviews/pull/6209?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).

philippjfr: This should be good to merge. I will investigate whether adding support for subcoordinate groups drawn from the NdOverlay dimensions makes sense as a separate PR.

Patch:
diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py
index 8c2b2bdf2..ae500ffb1 100644
--- a/holoviews/plotting/bokeh/element.py
+++ b/holoviews/plotting/bokeh/element.py
@@ -47,8 +47,9 @@ from bokeh.models.tickers import (
 )
 from bokeh.models.tools import Tool
 
-from ...core import CompositeOverlay, Dataset, Dimension, DynamicMap, Element, util
+from ...core import Dataset, Dimension, DynamicMap, Element, util
 from ...core.options import Keywords, SkipRendering, abbreviated_exception
+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
@@ -831,7 +832,7 @@ class ElementPlot(BokehPlot, GenericElementPlot):
 
         ax_specs, yaxes, dimensions = {}, {}, {}
         subcoordinate_axes = 0
-        for el, sp in zip(element, self.subplots.values()):
+        for el, (sp_key, sp) in zip(element, self.subplots.items()):
             ax_dims = sp._get_axis_dims(el)[:2]
             if sp.invert_axes:
                 ax_dims[::-1]
@@ -842,7 +843,10 @@ class ElementPlot(BokehPlot, GenericElementPlot):
             if self._subcoord_overlaid:
                 if opts.get('subcoordinate_y') is None:
                     continue
-                ax_name = el.label
+                if element.kdims:
+                    ax_name = ', '.join(d.pprint_value(k) for d, k in zip(element.kdims, sp_key))
+                else:
+                    ax_name = el.label
                 subcoordinate_axes += 1
             else:
                 ax_name = yd.name
@@ -1129,13 +1133,16 @@ class ElementPlot(BokehPlot, GenericElementPlot):
             elif not self.drawn:
                 ticks, labels = [], []
                 idx = 0
-                for el, sp in zip(self.current_frame, self.subplots.values()):
+                for el, (sp_key, sp) in zip(self.current_frame, self.subplots.items()):
                     if not sp.subcoordinate_y:
                         continue
                     ycenter = idx if isinstance(sp.subcoordinate_y, bool) else 0.5 * sum(sp.subcoordinate_y)
                     idx += 1
                     ticks.append(ycenter)
-                    labels.append(el.label)
+                    if el.label or not self.current_frame.kdims:
+                        labels.append(el.label)
+                    else:
+                        labels.append(', '.join(d.pprint_value(k) for d, k in zip(self.current_frame.kdims, sp_key)))
                 axis_props['ticker'] = FixedTicker(ticks=ticks)
                 if labels is not None:
                     axis_props['major_label_overrides'] = dict(zip(ticks, labels))
@@ -2109,6 +2116,9 @@ class ElementPlot(BokehPlot, GenericElementPlot):
                 if style_element.label in plot.extra_y_ranges:
                     self.handles['subcoordinate_y_range'] = plot.y_range
                     self.handles['y_range'] = plot.extra_y_ranges.pop(style_element.label)
+                elif self.overlay_dims:
+                    key = ', '.join(d.pprint_value(v) for d, v in self.overlay_dims.items())
+                    self.handles['y_range'] = plot.extra_y_ranges.pop(key)
 
         if self.apply_hard_bounds:
             self._apply_hard_bounds(element, ranges)
@@ -3287,12 +3297,14 @@ class OverlayPlot(GenericOverlayPlot, LegendPlot):
             labels = self.hmap.last.traverse(lambda x: x.label, [
                 lambda el: isinstance(el, Element) and el.opts.get('plot').kwargs.get('subcoordinate_y', False)
             ])
-            if any(not label for label in labels):
+            if isinstance(self.hmap.last, NdOverlay):
+                pass
+            elif any(not label for label in labels):
                 raise ValueError(
-                    'Every element wrapped in a subcoordinate_y overlay must have '
-                    'a label.'
+                    'Every Element plotted on a subcoordinate_y axis must have '
+                    'a label or be part of an NdOverlay.'
                 )
-            if len(set(labels)) == 1:
+            elif len(set(labels)) != len(labels):
                 raise ValueError(
                     'Elements wrapped in a subcoordinate_y overlay must all have '
                     'a unique label.'
@@ -3339,7 +3351,7 @@ class OverlayPlot(GenericOverlayPlot, LegendPlot):
             ):
                 subcoord_y_glyph_renderers.append(glyph_renderer)
 
-        if self.subcoordinate_y:
+        if self.subcoordinate_y and plot:
             # Reverse the subcoord-y renderers only.
             reversed_renderers = subcoord_y_glyph_renderers[::-1]
             reordered = []


Test patch:
diff --git a/holoviews/tests/plotting/bokeh/test_subcoordy.py b/holoviews/tests/plotting/bokeh/test_subcoordy.py
index c047fd1e3..c8f5a88ee 100644
--- a/holoviews/tests/plotting/bokeh/test_subcoordy.py
+++ b/holoviews/tests/plotting/bokeh/test_subcoordy.py
@@ -2,7 +2,7 @@ import numpy as np
 import pytest
 from bokeh.models.tools import WheelZoomTool, ZoomInTool, ZoomOutTool
 
-from holoviews.core import Overlay
+from holoviews.core import NdOverlay, Overlay
 from holoviews.element import Curve
 from holoviews.element.annotation import VSpan
 from holoviews.operation.normalization import subcoordinate_group_ranges
@@ -76,11 +76,35 @@ class TestSubcoordinateY(TestBokehPlot):
             assert plot.handles['y_range'].start == ytarget[0]
             assert plot.handles['y_range'].end == ytarget[1]
 
+    def test_ndoverlay_labels(self):
+        overlay = NdOverlay({
+            f'Data {i}': Curve(np.arange(10)*i).opts(subcoordinate_y=True)
+            for i in range(3)
+        }, 'Channel')
+        plot = bokeh_renderer.get_plot(overlay)
+        assert plot.state.yaxis.ticker.ticks == [0, 1, 2]
+        assert plot.state.yaxis.major_label_overrides == {0: 'Data 0', 1: 'Data 1', 2: 'Data 2'}
+        for i, sp in enumerate(plot.subplots.values()):
+            assert sp.handles['glyph_renderer'].coordinates.y_target.start == (i-0.5)
+            assert sp.handles['glyph_renderer'].coordinates.y_target.end == (i+0.5)
+
+    def test_ndoverlay_nd_labels(self):
+        overlay = NdOverlay({
+            ('A', f'Data {i}'): Curve(np.arange(10)*i).opts(subcoordinate_y=True)
+            for i in range(3)
+        }, ['Group', 'Channel'])
+        plot = bokeh_renderer.get_plot(overlay)
+        assert plot.state.yaxis.ticker.ticks == [0, 1, 2]
+        assert plot.state.yaxis.major_label_overrides == {0: 'A, Data 0', 1: 'A, Data 1', 2: 'A, Data 2'}
+        for i, sp in enumerate(plot.subplots.values()):
+            assert sp.handles['glyph_renderer'].coordinates.y_target.start == (i-0.5)
+            assert sp.handles['glyph_renderer'].coordinates.y_target.end == (i+0.5)
+
     def test_no_label(self):
         overlay = Overlay([Curve(range(10)).opts(subcoordinate_y=True) for i in range(2)])
         with pytest.raises(
             ValueError,
-            match='Every element wrapped in a subcoordinate_y overlay must have a label'
+            match='Every Element plotted on a subcoordinate_y axis must have a label or be part of an NdOverlay.'
         ):
             bokeh_renderer.get_plot(overlay)