Repository: holoviz/holoviews

Issue:
`subcoordinate_y` y-tick misalignment when overlaying VSpan
When using `subcoordinate_y`, the y-tick becomes misaligned when overlaying an annotation (hv.VSpan).


<details>
<summary>Code with ANNOTATE switch</summary>

```python
ANNOTATE = False

import numpy as np
import holoviews as hv
from bokeh.models import HoverTool
from holoviews import Dataset
from holoviews.plotting.links import RangeToolLink
from scipy.stats import zscore

hv.extension('bokeh')

N_CHANNELS = 10
N_SECONDS = 5
SAMPLING_RATE = 200
INIT_FREQ = 2  # Initial frequency in Hz
FREQ_INC = 5  # Frequency increment
AMPLITUDE = 1

# Generate time and channel labels
total_samples = N_SECONDS * SAMPLING_RATE
time = np.linspace(0, N_SECONDS, total_samples)
channels = [f'EEG {i}' for i in range(N_CHANNELS)]

# Generate sine wave data
data = np.array([AMPLITUDE * np.sin(2 * np.pi * (INIT_FREQ + i * FREQ_INC) * time)
                     for i in range(N_CHANNELS)])

hover = HoverTool(tooltips=[
    ("Channel", "@channel"),
    ("Time", "$x s"),
    ("Amplitude", "$y µV")
])

annotation = hv.VSpan(1, 1.5).opts(color='yellow', alpha=.15) # example annotation (start, end) time

channel_curves = []
for channel, channel_data in zip(channels, data):
    ds = Dataset((time, channel_data, channel), ["Time", "Amplitude", "channel"])
    curve = hv.Curve(ds, "Time", ["Amplitude", "channel"], label=f'{channel}')
    curve.opts(color="black", line_width=1, subcoordinate_y=True, tools=[hover])
    channel_curves.append(curve)

if ANNOTATE:
    eeg_viewer = (annotation * hv.Overlay(channel_curves, kdims="Channel"))
else:
    eeg_viewer = hv.Overlay(channel_curves, kdims="Channel")
eeg_viewer = eeg_viewer.opts(padding=0,
    xlabel="Time (s)", ylabel="Channel", show_legend=False, aspect=3, responsive=True,
)

y_positions = range(N_CHANNELS)
yticks = [(i , ich) for i, ich in enumerate(channels)]

z_data = zscore(data, axis=1)

minimap = hv.Image((time, y_positions , z_data), ["Time (s)", "Channel"], "Amplitude (uV)")
minimap = minimap.opts(
    cmap="RdBu_r", xlabel='Time (s)', alpha=.5, yticks=[yticks[0], yticks[-1]],
    height=150, responsive=True, default_tools=[], clim=(-z_data.std(), z_data.std())
)

RangeToolLink(
    minimap, eeg_viewer, axes=["x", "y"],
    boundsx=(None, 2), boundsy=(None, 6.5)
)
if ANNOTATE:
    dashboard = (eeg_viewer + minimap * annotation).opts(merge_tools=False).cols(1)
else:
    dashboard = (eeg_viewer + minimap).opts(merge_tools=False).cols(1)
dashboard
```

</details>

## ANNOTATE = False
![image](https://github.com/holoviz/holoviews/assets/6613202/baedeb31-b263-420e-99c9-a5c311250891)


## ANNOTATE = True
![image](https://github.com/holoviz/holoviews/assets/6613202/0e2cf963-9571-47ce-9963-dad56888c0b7)




Discussion:
This apparently scales by number of annotations

<img width="1040" alt="image" src="https://github.com/holoviz/holoviews/assets/6613202/746321ec-9853-4d94-a368-f3cbeb86c34d">

This is a bug, likely a for loop not starting correctly. A MRE:

``` python
import holoviews as hv
import numpy as np

hv.extension("bokeh")

fn = lambda channel: hv.Curve(
    (np.arange(10), np.random.rand(10), channel), "Time", "Channel", label=channel
).opts(subcoordinate_y=True)
subcoord = hv.Overlay([fn(n) for n in "ABCD"], kdims="Channel")
annotation = hv.VSpan(1, 1.5)

subcoord * annotation  # Works
annotation * subcoord  # Fails
```

![image](https://github.com/holoviz/holoviews/assets/19758978/844626cd-6bcf-4493-a8c5-70b27c2a9bf5)


Resolving patch:
diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py
--- a/holoviews/plotting/bokeh/element.py
+++ b/holoviews/plotting/bokeh/element.py
@@ -605,7 +605,7 @@ def _create_extra_axes(self, plots, subplots, element, ranges):
                     'axis_label_text_font_size': sp._fontsize('ylabel').get('fontsize'),
                     'major_label_text_font_size': sp._fontsize('yticks').get('fontsize')
                 },
-                'subcoordinate_y': subcoordinate_axes-1 if self._subcoord_overlaid else None
+                'subcoordinate_y': (subcoordinate_axes - 1) if self._subcoord_overlaid else None
             }
 
         for ydim, info in yaxes.items():
@@ -893,10 +893,12 @@ def _axis_properties(self, axis, key, plot, dimension=None,
                     axis_props['major_label_overrides'] = dict(zip(ticks, labels))
             elif self._subcoord_overlaid and axis == 'y':
                 ticks, labels = [], []
-                for i, (el, sp) in enumerate(zip(self.current_frame, self.subplots.values())):
+                idx = 0
+                for el, sp in zip(self.current_frame, self.subplots.values()):
                     if not sp.subcoordinate_y:
                         continue
-                    ycenter = i if isinstance(sp.subcoordinate_y, bool) else 0.5 * sum(sp.subcoordinate_y)
+                    ycenter = idx if isinstance(sp.subcoordinate_y, bool) else 0.5 * sum(sp.subcoordinate_y)
+                    idx += 1
                     ticks.append(ycenter)
                     labels.append(el.label)
                 axis_props['ticker'] = FixedTicker(ticks=ticks)


Test patch:
diff --git a/holoviews/tests/plotting/bokeh/test_subcoordy.py b/holoviews/tests/plotting/bokeh/test_subcoordy.py
--- a/holoviews/tests/plotting/bokeh/test_subcoordy.py
+++ b/holoviews/tests/plotting/bokeh/test_subcoordy.py
@@ -76,6 +76,20 @@ def test_overlaid_without_label_no_error(self):
         with_span = overlay * VSpan(1, 2)
         bokeh_renderer.get_plot(with_span)
 
+    def test_underlaid_ytick_alignment(self):
+        overlay = Overlay([Curve(range(10), label=f'Data {i}').opts(subcoordinate_y=True) for i in range(2)])
+        with_span = VSpan(1, 2) * overlay
+        plot = bokeh_renderer.get_plot(with_span)
+        # the yticks are aligned with their subcoordinate_y axis
+        assert plot.state.yaxis.ticker.ticks == [0, 1]
+
+    def test_overlaid_ytick_alignment(self):
+        overlay = Overlay([Curve(range(10), label=f'Data {i}').opts(subcoordinate_y=True) for i in range(2)])
+        with_span = overlay * VSpan(1, 2)
+        plot = bokeh_renderer.get_plot(with_span)
+        # the yticks are aligned with their subcoordinate_y axis
+        assert plot.state.yaxis.ticker.ticks == [0, 1]
+
     def test_custom_ylabel(self):
         overlay = Overlay([Curve(range(10), label=f'Data {i}').opts(subcoordinate_y=True) for i in range(2)])
         overlay.opts(ylabel='Y label')