Repository: holoviz/holoviews
Pull request #5904 (2023-09-23 16:12:42)

Actually enable Mathjax with enable_mathjax
This should now enable Mathjax without both in a notebook and when running `panel serve`.

``` python
import holoviews as hv
import panel as pn

hv.extension("bokeh", enable_mathjax=True)

a = hv.Rectangles([[0, 0, 1, 1]]).opts(title=r"$$\alpha \beta$$", xlabel="$$y$$")

pn.panel(a).servable()
```

Should fix the problem described in https://github.com/holoviz/holoviews/issues/5740#issuecomment-1705372739

codecov-commenter: ## [Codecov](https://app.codecov.io/gh/holoviz/holoviews/pull/5904?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) Report
> Merging [#5904](https://app.codecov.io/gh/holoviz/holoviews/pull/5904?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) (db0f506) into [main](https://app.codecov.io/gh/holoviz/holoviews/commit/79008ed8385025782800677fdaee6cfe05fc4dc8?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) (79008ed) will **decrease** coverage by `65.09%`.
> The diff coverage is `33.33%`.

```diff
@@             Coverage Diff             @@
##             main    #5904       +/-   ##
===========================================
- Coverage   88.41%   23.33%   -65.09%     
===========================================
  Files         312      312               
  Lines       64953    64951        -2     
===========================================
- Hits        57427    15154    -42273     
- Misses       7526    49797    +42271     
```

| [Flag](https://app.codecov.io/gh/holoviz/holoviews/pull/5904/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) | Coverage Δ | |
|---|---|---|
| [ui-tests](https://app.codecov.io/gh/holoviz/holoviews/pull/5904/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) | `23.33% <33.33%> (+<0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Files Changed](https://app.codecov.io/gh/holoviz/holoviews/pull/5904?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) | Coverage Δ | |
|---|---|---|
| [holoviews/ipython/\_\_init\_\_.py](https://app.codecov.io/gh/holoviz/holoviews/pull/5904?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL2lweXRob24vX19pbml0X18ucHk=) | `27.32% <0.00%> (-48.45%)` | :arrow_down: |
| [holoviews/util/\_\_init\_\_.py](https://app.codecov.io/gh/holoviz/holoviews/pull/5904?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL3V0aWwvX19pbml0X18ucHk=) | `33.39% <50.00%> (-40.59%)` | :arrow_down: |

... and [295 files with indirect coverage changes](https://app.codecov.io/gh/holoviz/holoviews/pull/5904/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz)

:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz)


Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 524281a25..0131ab47a 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -17,7 +17,7 @@ repos:
         exclude: \.min\.js$
       - id: trailing-whitespace
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.0.290
+    rev: v0.0.291
     hooks:
       - id: ruff
         args: [holoviews]
diff --git a/examples/gallery/demos/bokeh/eeg_viewer.ipynb b/examples/gallery/demos/bokeh/eeg_viewer.ipynb
new file mode 100644
index 000000000..01f3cb98c
--- /dev/null
+++ b/examples/gallery/demos/bokeh/eeg_viewer.ipynb
@@ -0,0 +1,172 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "549b47a4",
+   "metadata": {},
+   "source": [
+    "This example demonstrates advanced visualization techniques using HoloViews with the Bokeh plotting backend. You'll learn how to:\n",
+    "\n",
+    "1. Display multiple timeseries in a single plot using `subcoordinate_y`.\n",
+    "2. Create and link a minimap to the main plot with `RangeToolLink`.\n",
+    "\n",
+    "Specifically, we'll simulate [Electroencephalography](https://en.wikipedia.org/wiki/Electroencephalography) (EEG) data, plot it, and then create a minimap based on the [z-score](https://en.wikipedia.org/wiki/Standard_score) of the data for easier navigation."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "8109537b-5fba-4f07-aba4-91a56f7e95c7",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import numpy as np\n",
+    "import holoviews as hv\n",
+    "from bokeh.models import HoverTool\n",
+    "from holoviews.plotting.links import RangeToolLink\n",
+    "from scipy.stats import zscore\n",
+    "\n",
+    "hv.extension('bokeh')"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "1c95f241-2314-42b0-b6cb-2c0baf332686",
+   "metadata": {},
+   "source": [
+    "## Generating EEG data\n",
+    "\n",
+    "Let's start by simulating some EEG data. We'll create a timeseries for each channel using sine waves with varying frequencies."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "5f4a9dbe",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "\n",
+    "N_CHANNELS = 10\n",
+    "N_SECONDS = 5\n",
+    "SAMPLING_RATE = 200\n",
+    "INIT_FREQ = 2  # Initial frequency in Hz\n",
+    "FREQ_INC = 5  # Frequency increment\n",
+    "AMPLITUDE = 1\n",
+    "\n",
+    "# Generate time and channel labels\n",
+    "total_samples = N_SECONDS * SAMPLING_RATE\n",
+    "time = np.linspace(0, N_SECONDS, total_samples)\n",
+    "channels = [f'EEG {i}' for i in range(N_CHANNELS)]\n",
+    "\n",
+    "# Generate sine wave data\n",
+    "data = np.array([AMPLITUDE * np.sin(2 * np.pi * (INIT_FREQ + i * FREQ_INC) * time)\n",
+    "                     for i in range(N_CHANNELS)])"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "ec9e71b8-a995-4c0f-bdbb-5d148d8fa138",
+   "metadata": {},
+   "source": [
+    "## Visualizing EEG Data\n",
+    "\n",
+    "Next, let's dive into visualizing the EEG data. We construct each timeseries using a `Curve` element, assigning it a `label` and setting `subcoordinate_y=True`. All these curves are then aggregated into a list, which serves as the input for an `Overlay` element. Rendering this `Overlay` produces a plot where the timeseries are stacked vertically.\n",
+    "\n",
+    "Additionally, we'll enhance user interaction by implementing a custom hover tool. This will display key information—channel, time, and amplitude—when you hover over any of the curves."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "9476769f-3935-4236-b010-1511d1a1e77f",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "hover = HoverTool(tooltips=[\n",
+    "    (\"Channel\", \"@channel\"),\n",
+    "    (\"Time\", \"$x s\"),\n",
+    "    (\"Amplitude\", \"$y µV\")\n",
+    "])\n",
+    "\n",
+    "channel_curves = []\n",
+    "for channel, channel_data in zip(channels, data):\n",
+    "    ds = hv.Dataset((time, channel_data, channel), [\"Time\", \"Amplitude\", \"channel\"])\n",
+    "    curve = hv.Curve(ds, \"Time\", [\"Amplitude\", \"channel\"], label=channel)\n",
+    "    curve.opts(\n",
+    "        subcoordinate_y=True, color=\"black\", line_width=1, tools=[hover],\n",
+    "    )\n",
+    "    channel_curves.append(curve)\n",
+    "\n",
+    "eeg = hv.Overlay(channel_curves, kdims=\"Channel\").opts(\n",
+    "    xlabel=\"Time (s)\", ylabel=\"Channel\", show_legend=False, aspect=3, responsive=True,\n",
+    ")\n",
+    "eeg"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "b4f603e2-039d-421a-ba9a-ed9e77efab99",
+   "metadata": {},
+   "source": [
+    "## Creating the Minimap\n",
+    "\n",
+    "A minimap can provide a quick overview of the data and help you navigate through it. We'll compute the z-score for each channel and represent it as an image; the z-score will normalize the data and bring out the patterns more clearly. To enable linking in the next step between the EEG `Overlay` and the minimap `Image`, we ensure they share the same y-axis range."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "40fa2198-c3b5-41e1-944f-f8b812612168",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "y_positions = range(N_CHANNELS)\n",
+    "yticks = [(i , ich) for i, ich in enumerate(channels)]\n",
+    "\n",
+    "z_data = zscore(data, axis=1)\n",
+    "\n",
+    "minimap = hv.Image((time, y_positions , z_data), [\"Time (s)\", \"Channel\"], \"Amplitude (uV)\")\n",
+    "minimap = minimap.opts(\n",
+    "    cmap=\"RdBu_r\", xlabel='Time (s)', alpha=.5, yticks=[yticks[0], yticks[-1]],\n",
+    "    height=150, responsive=True, default_tools=[], clim=(-z_data.std(), z_data.std())\n",
+    ")\n",
+    "minimap"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "a5b77970-342f-4428-bd1c-4dbef1e6a2b5",
+   "metadata": {},
+   "source": [
+    "## Building the dashboard\n",
+    "\n",
+    "Finally, we use [`RangeToolLink`](../../../user_guide/Linking_Plots.ipynb) to connect the minimap `Image` and the EEG `Overlay`, setting bounds for the initial viewable area. Once the plots are linked and assembled into a unified dashboard, you can interact with it. Experiment by dragging the selection box on the minimap or resizing it by clicking and dragging its edges."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "260489eb-2dbf-4c88-ba83-dd1cba0e547b",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "RangeToolLink(\n",
+    "    minimap, eeg, axes=[\"x\", \"y\"],\n",
+    "    boundsx=(None, 2), boundsy=(None, 6.5)\n",
+    ")\n",
+    "\n",
+    "dashboard = (eeg + minimap).opts(merge_tools=False).cols(1)\n",
+    "dashboard"
+   ]
+  }
+ ],
+ "metadata": {
+  "language_info": {
+   "name": "python",
+   "pygments_lexer": "ipython3"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/examples/user_guide/Customizing_Plots.ipynb b/examples/user_guide/Customizing_Plots.ipynb
index 24c5b8d91..5fea84edb 100644
--- a/examples/user_guide/Customizing_Plots.ipynb
+++ b/examples/user_guide/Customizing_Plots.ipynb
@@ -745,6 +745,34 @@
     "\n",
     "Note that as of HoloViews 1.17.0, `multi_y` does not have streaming plot support, extra axis labels are not dynamic and only the `RangeXY` linked stream is aware of additional y-axes."
    ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Subcoordinate y-axis\n",
+    "*(Available in HoloViews >= 1.18)*\n",
+    "\n",
+    "HoloViews enables you to create overlays where each element has its own distinct y-axis subcoordinate system. To activate this feature, set the `subcoordinate_y` keyword to True for **each** overlay element; the default is False. When using `subcoordinate_y=True`, setting a `label` for each element is required for proper rendering and identification.This will automatically distribute overlay elements along the y-axis.\n",
+    "\n",
+    "For more fine-grained control over y-axis positioning, you can specify a numerical 2-tuple for subcoordinate_y with values ranging from 0 to 1. Additionally, the `subcoordinate_scale` keyword, which defaults to 1, allows you to adjust the vertical scale of each element. This option is only applicable when `subcoordinate_y=True`. For example, setting a single Curve's `subcoordinate_scale` to 2 will result in it overlapping 50% with its adjacent elements."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "x = np.linspace(0, 10*np.pi)\n",
+    "\n",
+    "curves = [\n",
+    "    hv.Curve((x + i*np.pi/2, np.sin(x)), label=f'Line {i}').opts(subcoordinate_y=True, subcoordinate_scale=1.2)\n",
+    "    for i in range(3)\n",
+    "]\n",
+    "\n",
+    "hv.Overlay(curves).opts(show_legend=False)"
+   ]
   }
  ],
  "metadata": {
diff --git a/holoviews/core/spaces.py b/holoviews/core/spaces.py
index 22298db4b..83930331e 100644
--- a/holoviews/core/spaces.py
+++ b/holoviews/core/spaces.py
@@ -459,7 +459,7 @@ class Callable(param.Parameterized):
     """
 
     callable = param.Callable(default=None, constant=True, doc="""
-         The callable function being wrapped.""")
+         The callable function being wrapped.""", **util.disallow_refs)
 
     inputs = param.List(default=[], constant=True, doc="""
          The list of inputs the callable function is wrapping. Used
@@ -546,6 +546,8 @@ class Callable(param.Parameterized):
         # Nothing to do for callbacks that accept no arguments
         kwarg_hash = kwargs.pop('_memoization_hash_', ())
         (self.args, self.kwargs) = (args, kwargs)
+        if hasattr(self.callable, 'rx'):
+            return self.callable.rx.resolve()
         if not args and not kwargs and not any(kwarg_hash): return self.callable()
         inputs = [i for i in self.inputs if isinstance(i, DynamicMap)]
         streams = []
@@ -772,7 +774,9 @@ class DynamicMap(HoloMap):
             streams = streams_list_from_dict(streams)
 
         # If callback is a parameterized method and watch is disabled add as stream
-        if (params.get('watch', True) and (util.is_param_method(callback, has_deps=True) or
+        if util.param_version > util.Version('2.0.0rc1') and param.parameterized.resolve_ref(callback):
+            streams.append(callback)
+        elif (params.get('watch', True) and (util.is_param_method(callback, has_deps=True) or
             (isinstance(callback, FunctionType) and hasattr(callback, '_dinfo')))):
             streams.append(callback)
 
diff --git a/holoviews/core/util.py b/holoviews/core/util.py
index 21876175d..4ae9f1c0d 100644
--- a/holoviews/core/util.py
+++ b/holoviews/core/util.py
@@ -43,6 +43,8 @@ masked_types = ()
 
 anonymous_dimension_label = '_'
 
+disallow_refs = {'allow_refs': False} if param_version > Version('2.0.0rc1') else {}
+
 # Argspec was removed in Python 3.11
 ArgSpec = namedtuple('ArgSpec', 'args varargs keywords defaults')
 
@@ -1617,7 +1619,10 @@ def resolve_dependent_value(value):
         from panel.depends import param_value_if_widget
         from panel.widgets import RangeSlider
         range_widget = isinstance(value, RangeSlider)
-        value = param_value_if_widget(value)
+        if param_version > Version('2.0.0rc1'):
+            value = param.parameterized.resolve_value(value)
+        else:
+            value = param_value_if_widget(value)
 
     if is_param_method(value, has_deps=True):
         value = value()
diff --git a/holoviews/ipython/__init__.py b/holoviews/ipython/__init__.py
index 81fd6f075..ece8d2609 100644
--- a/holoviews/ipython/__init__.py
+++ b/holoviews/ipython/__init__.py
@@ -186,7 +186,7 @@ class notebook_extension(extension):
         same_cell_execution = getattr(self, '_repeat_execution_in_cell', False)
         for r in [r for r in resources if r != 'holoviews']:
             Store.renderers[r].load_nb(inline=p.inline)
-        Renderer.load_nb(inline=p.inline, reloading=same_cell_execution, enable_mathjax=self.enable_mathjax)
+        Renderer.load_nb(inline=p.inline, reloading=same_cell_execution, enable_mathjax=p.enable_mathjax)
 
         if hasattr(ip, 'kernel') and not loaded:
             Renderer.comm_manager.get_client_comm(notebook_extension._process_comm_msg,
diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py
index 079311b93..c1d20faa2 100644
--- a/holoviews/plotting/bokeh/element.py
+++ b/holoviews/plotting/bokeh/element.py
@@ -154,6 +154,14 @@ class ElementPlot(BokehPlot, GenericElementPlot):
        elements and the overlay container, allowing customization on a
        per-axis basis.""")
 
+    subcoordinate_y = param.ClassSelector(default=False, class_=(bool, tuple), doc="""
+       Enables sub-coordinate systems for this plot. Accepts also a numerical
+       two-tuple that must be a range between 0 and 1, the plot will be
+       rendered on this vertical range of the axis.""")
+
+    subcoordinate_scale = param.Number(default=1, bounds=(0, None), inclusive_bounds=(False, True), doc="""
+       Scale factor for subcoordinate ranges to control the level of overlap.""")
+
     responsive = param.ObjectSelector(default=False, objects=[False, True, 'width', 'height'])
 
     fontsize = param.Parameter(default={'title': '12pt'}, allow_None=True,  doc="""
@@ -229,6 +237,7 @@ class ElementPlot(BokehPlot, GenericElementPlot):
     _stream_data = True
 
     def __init__(self, element, plot=None, **params):
+        self._subcoord_standalone_ = None
         self.current_ranges = None
         super().__init__(element, **params)
         self.handles = {} if plot is None else self.handles['plot']
@@ -401,6 +410,21 @@ class ElementPlot(BokehPlot, GenericElementPlot):
 
         return dim_range
 
+    @property
+    def _subcoord_overlaid(self):
+        """
+        Indicates when the context is a subcoordinate plot, either from within
+        the overlay rendering or one of its subplots. Used to skip code paths
+        when rendering an element outside of an overlay.
+        """
+        if self._subcoord_standalone_ is not None:
+            return self._subcoord_standalone_
+        self._subcoord_standalone_ = (
+            (isinstance(self, OverlayPlot) and self.subcoordinate_y) or
+            (not isinstance(self, OverlayPlot) and self.overlaid and self.subcoordinate_y)
+        )
+        return self._subcoord_standalone_
+
     def _axis_props(self, plots, subplots, element, ranges, pos, *, dim=None,
                     range_tags_extras=[], extra_range_name=None):
 
@@ -416,7 +440,10 @@ class ElementPlot(BokehPlot, GenericElementPlot):
         # a synthetic dimension and exclusively supported for y-axes.
         if pos == 1 and dim:
             dims = [dim]
-            v0, v1 = util.max_range([elrange.get(dim, {'combined': (None, None)})['combined'] for elrange in ranges.values()])
+            v0, v1 = util.max_range([
+                elrange.get(dim.name, {'combined': (None, None)})['combined']
+                for elrange in ranges.values()
+            ])
             axis_label = str(dim)
             specs = ((dim.name, dim.label, dim.unit),)
         else:
@@ -428,7 +455,17 @@ class ElementPlot(BokehPlot, GenericElementPlot):
                 l, b, r, t = self.get_extents(range_el, ranges)
             if self.invert_axes:
                 l, b, r, t = b, l, t, r
-            v0, v1 = (l, r) if pos == 0 else (b, t)
+            if pos == 1 and self._subcoord_overlaid:
+                if isinstance(self.subcoordinate_y, bool):
+                    offset = self.subcoordinate_scale / 2.
+                    # This sum() is equal to n+1, n being the number of elements contained
+                    # in the overlay with subcoordinate_y=True, as the traversal goes through
+                    # the root overlay that has subcoordinate_y=True too since it's propagated.
+                    v0, v1 = 0-offset, sum(self.traverse(lambda p: p.subcoordinate_y))-2+offset
+                else:
+                    v0, v1 = 0, 1
+            else:
+                v0, v1 = (l, r) if pos == 0 else (b, t)
 
             axis_dims = list(self._get_axis_dims(el))
             if self.invert_axes:
@@ -452,7 +489,9 @@ class ElementPlot(BokehPlot, GenericElementPlot):
                 dims = dims[:2][::-1]
 
         categorical = any(self.traverse(lambda plot: plot._categorical))
-        if dims is not None and any(dim.name in ranges and 'factors' in ranges[dim.name] for dim in dims):
+        if self.subcoordinate_y:
+            categorical = False
+        elif dims is not None and any(dim.name in ranges and 'factors' in ranges[dim.name] for dim in dims):
             categorical = True
         else:
             categorical = any(isinstance(v, (str, bytes)) for v in (v0, v1))
@@ -474,12 +513,12 @@ class ElementPlot(BokehPlot, GenericElementPlot):
 
         norm_opts = self.lookup_options(el, 'norm').options
         shared_name = extra_range_name or ('x-main-range' if pos == 0 else 'y-main-range')
-        if plots and self.shared_axes and not norm_opts.get('axiswise', False):
+        if plots and self.shared_axes and not norm_opts.get('axiswise', False) and not dim:
             dim_range = self._shared_axis_range(plots, specs, range_type, axis_type, pos)
             if dim_range:
                 self._shared[shared_name] = True
 
-        if self._shared.get(shared_name):
+        if self._shared.get(shared_name) and not dim:
             pass
         elif categorical:
             axis_type = 'auto'
@@ -507,16 +546,24 @@ class ElementPlot(BokehPlot, GenericElementPlot):
             axpos0, axpos1 = 'left', 'right'
 
         ax_specs, yaxes, dimensions = {}, {}, {}
+        subcoordinate_axes = 0
         for el, sp in zip(element, self.subplots.values()):
             ax_dims = sp._get_axis_dims(el)[:2]
             if sp.invert_axes:
                 ax_dims[::-1]
             yd = ax_dims[1]
-            dimensions[yd.name] = yd
             opts = el.opts.get('plot', backend='bokeh').kwargs
             if not isinstance(yd, Dimension) or yd.name in yaxes:
                 continue
-            yaxes[yd.name] = {
+            if self._subcoord_overlaid:
+                if opts.get('subcoordinate_y') is None:
+                    continue
+                ax_name = el.label
+                subcoordinate_axes += 1
+            else:
+                ax_name = yd.name
+            dimensions[ax_name] = yd
+            yaxes[ax_name] = {
                 'position': opts.get('yaxis', axpos1 if len(yaxes) else axpos0),
                 'autorange': opts.get('autorange', None),
                 'logx': opts.get('logx', False),
@@ -528,11 +575,14 @@ class ElementPlot(BokehPlot, GenericElementPlot):
                 'fontsize': {
                     '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
             }
 
         for ydim, info in yaxes.items():
             range_tags_extras = {'invert_yaxis': info['invert_yaxis']}
+            if info['subcoordinate_y'] is not None:
+                range_tags_extras['subcoordinate_y'] = info['subcoordinate_y']
             if info['autorange'] == 'y':
                 range_tags_extras['autorange'] = True
                 lowerlim, upperlim = info['ylim'][0], info['ylim'][1]
@@ -542,7 +592,6 @@ class ElementPlot(BokehPlot, GenericElementPlot):
                     range_tags_extras['y-upperlim'] = upperlim
             else:
                 range_tags_extras['autorange'] = False
-
             ax_props = self._axis_props(
                 plots, subplots, element, ranges, pos=1, dim=dimensions[ydim],
                 range_tags_extras=range_tags_extras,
@@ -582,18 +631,23 @@ class ElementPlot(BokehPlot, GenericElementPlot):
             else:
                 range_tags_extras['autorange'] = False
             axis_specs['y']['y'] = self._axis_props(
-                plots, subplots, element, ranges, pos=1, range_tags_extras = range_tags_extras
+                plots, subplots, element, ranges, pos=1, range_tags_extras=range_tags_extras
             ) + (self.yaxis, {})
 
+        if self._subcoord_overlaid:
+            _, extra_axis_specs = self._create_extra_axes(plots, subplots, element, ranges)
+            axis_specs['y'].update(extra_axis_specs)
+
         properties, axis_props = {}, {'x': {}, 'y': {}}
         for axis, axis_spec in axis_specs.items():
             for (axis_dim, (axis_type, axis_label, axis_range, axis_position, fontsize)) in axis_spec.items():
                 scale = get_scale(axis_range, axis_type)
                 if f'{axis}_range' in properties:
                     properties[f'extra_{axis}_ranges'] = extra_ranges = properties.get(f'extra_{axis}_ranges', {})
-                    properties[f'extra_{axis}_scales'] = extra_scales = properties.get(f'extra_{axis}_scales', {})
                     extra_ranges[axis_dim] = axis_range
-                    extra_scales[axis_dim] = scale
+                    if not self.subcoordinate_y:
+                        properties[f'extra_{axis}_scales'] = extra_scales = properties.get(f'extra_{axis}_scales', {})
+                        extra_scales[axis_dim] = scale
                 else:
                     properties[f'{axis}_range'] = axis_range
                     properties[f'{axis}_scale'] = scale
@@ -642,6 +696,10 @@ class ElementPlot(BokehPlot, GenericElementPlot):
         fig.xaxis[0].update(**axis_props['x'])
         fig.yaxis[0].update(**axis_props['y'])
 
+        # Do not add the extra axes to the layout if subcoordinates are used
+        if self._subcoord_overlaid:
+            return fig
+
         multi_ax = 'x' if self.invert_axes else 'y'
         for axis_dim, range_obj in properties.get(f'extra_{multi_ax}_ranges', {}).items():
             axis_type, axis_label, _, axis_position, fontsize = axis_specs[multi_ax][axis_dim]
@@ -805,6 +863,17 @@ class ElementPlot(BokehPlot, GenericElementPlot):
                 axis_props['ticker'] = FixedTicker(ticks=ticks)
                 if labels is not 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())):
+                    if not sp.subcoordinate_y:
+                        continue
+                    ycenter = i if isinstance(sp.subcoordinate_y, bool) else 0.5 * sum(sp.subcoordinate_y)
+                    ticks.append(ycenter)
+                    labels.append(el.label)
+                axis_props['ticker'] = FixedTicker(ticks=ticks)
+                if labels is not None:
+                    axis_props['major_label_overrides'] = dict(zip(ticks, labels))
         formatter = self.xformatter if axis == 'x' else self.yformatter
         if formatter:
             formatter = wrap_formatter(formatter, axis)
@@ -956,6 +1025,9 @@ class ElementPlot(BokehPlot, GenericElementPlot):
 
         self._update_main_ranges(element, x_range, y_range, ranges)
 
+        if self._subcoord_overlaid:
+            return
+
         # ALERT: stream handling not handled
         streaming = False
         multi_dim = 'x' if self.invert_axes else 'y'
@@ -1002,8 +1074,7 @@ class ElementPlot(BokehPlot, GenericElementPlot):
                     and (framewise or streaming))
                    or xfactors is not None)
         yupdate = ((not (self.model_changed(x_range) or self.model_changed(plot))
-                    and (framewise or streaming))
-                   or yfactors is not None)
+                    and (framewise or streaming) or yfactors is not None) and not self.subcoordinate_y)
 
         options = self._traverse_options(element, 'plot', ['width', 'height'], defaults=False)
         fixed_width = (self.frame_width or options.get('width'))
@@ -1115,7 +1186,7 @@ class ElementPlot(BokehPlot, GenericElementPlot):
         if not self.drawn or xupdate:
             self._update_range(x_range, l, r, xfactors, self.invert_xaxis,
                                self._shared['x-main-range'], self.logx, streaming)
-        if not self.drawn or yupdate:
+        if not (self.drawn or self.subcoordinate_y) or yupdate:
             self._update_range(
                 y_range, b, t, yfactors, self._get_tag(y_range, 'invert_yaxis'),
                 self._shared['y-main-range'], self.logy, streaming
@@ -1394,13 +1465,28 @@ class ElementPlot(BokehPlot, GenericElementPlot):
         if 'legend_field' in properties and 'legend_label' in properties:
             del properties['legend_label']
 
-        if self.handles['x_range'].name in plot.extra_x_ranges:
-            properties['x_range_name'] = self.handles['y_range'].name
-        if self.handles['y_range'].name in plot.extra_y_ranges:
+        if self.handles['x_range'].name in plot.extra_x_ranges and not self.subcoordinate_y:
+            properties['x_range_name'] = self.handles['x_range'].name
+        if self.handles['y_range'].name in plot.extra_y_ranges and not self.subcoordinate_y:
             properties['y_range_name'] = self.handles['y_range'].name
 
         if "name" not in properties:
             properties["name"] = properties.get("legend_label") or properties.get("legend_field")
+
+        if self._subcoord_overlaid:
+            y_source_range = self.handles['y_range']
+            if isinstance(self.subcoordinate_y, bool):
+                center = y_source_range.tags[1]['subcoordinate_y']
+                offset = self.subcoordinate_scale/2.
+                ytarget_range = dict(start=center-offset, end=center+offset)
+            else:
+                ytarget_range = dict(start=self.subcoordinate_y[0], end=self.subcoordinate_y[1])
+            plot = plot.subplot(
+                x_source=plot.x_range,
+                x_target=plot.x_range,
+                y_source=y_source_range,
+                y_target=Range1d(**ytarget_range),
+            )
         renderer = getattr(plot, plot_method)(**dict(properties, **mapping))
         return renderer, renderer.glyph
 
@@ -1773,6 +1859,9 @@ class ElementPlot(BokehPlot, GenericElementPlot):
             axes, plot_ranges = self._find_axes(plot, element)
             self.handles['xaxis'], self.handles['yaxis'] = axes
             self.handles['x_range'], self.handles['y_range'] = plot_ranges
+            if self._subcoord_overlaid:
+                if style_element.label in plot.extra_y_ranges:
+                    self.handles['y_range'] = plot.extra_y_ranges.pop(style_element.label)
         self.handles['plot'] = plot
 
         if self.autorange:
@@ -2526,7 +2615,8 @@ class OverlayPlot(GenericOverlayPlot, LegendPlot):
                           'xformatter', 'yformatter', 'active_tools',
                           'min_height', 'max_height', 'min_width', 'min_height',
                           'margin', 'aspect', 'data_aspect', 'frame_width',
-                          'frame_height', 'responsive', 'fontscale']
+                          'frame_height', 'responsive', 'fontscale', 'subcoordinate_y',
+                          'subcoordinate_scale']
 
     def __init__(self, overlay, **kwargs):
         self._multi_y_propagation = self.lookup_options(overlay, 'plot').options.get('multi_y', False)
@@ -2750,6 +2840,22 @@ class OverlayPlot(GenericOverlayPlot, LegendPlot):
         return super()._get_axis_dims(element)
 
     def initialize_plot(self, ranges=None, plot=None, plots=None):
+        if self.multi_y and self.subcoordinate_y:
+            raise ValueError('multi_y and subcoordinate_y are not supported together.')
+        if self.subcoordinate_y:
+            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):
+                raise ValueError(
+                    'Every element wrapped in a subcoordinate_y overlay must have '
+                    'a label.'
+                )
+            if len(set(labels)) == 1:
+                raise ValueError(
+                    'Elements wrapped in a subcoordinate_y overlay must all have '
+                    'a unique label.'
+                )
         key = util.wrap_tuple(self.hmap.last_key)
         nonempty = [(k, el) for k, el in self.hmap.data.items() if el]
         if not nonempty:
diff --git a/holoviews/streams.py b/holoviews/streams.py
index efdaddabe..41ee8924d 100644
--- a/holoviews/streams.py
+++ b/holoviews/streams.py
@@ -50,8 +50,11 @@ def streams_list_from_dict(streams):
     params = {}
     for k, v in streams.items():
         if 'panel' in sys.modules:
-            from panel.depends import param_value_if_widget
-            v = param_value_if_widget(v)
+            if util.param_version > util.Version('2.0.0rc1'):
+                v = param.parameterized.transform_reference(v)
+            else:
+                from panel.depends import param_value_if_widget
+                v = param_value_if_widget(v)
         if isinstance(v, param.Parameter) and v.owner is not None:
             params[k] = v
         else:
@@ -222,8 +225,15 @@ class Stream(param.Parameterized):
                 rename = {(p.owner, p.name): k for k, p in deps.get('kw', {}).items()}
                 s = Params(parameters=dep_params, rename=rename)
             else:
-                invalid.append(s)
-                continue
+                if util.param_version > util.Version('2.0.0rc1'):
+                    deps = param.parameterized.resolve_ref(s)
+                else:
+                    deps = None
+                if deps:
+                    s = Params(parameters=deps)
+                else:
+                    invalid.append(s)
+                    continue
             if isinstance(s, Params):
                 pid = id(s.parameterized)
                 overlap = (set(s.parameters) & parameterizeds[pid])
@@ -672,7 +682,7 @@ class Params(Stream):
     parameterized = param.ClassSelector(class_=(param.Parameterized,
                                                 param.parameterized.ParameterizedMetaclass),
                                         constant=True, allow_None=True, doc="""
-        Parameterized instance to watch for parameter changes.""")
+        Parameterized instance to watch for parameter changes.""", **util.disallow_refs)
 
     parameters = param.List(default=[], constant=True, doc="""
         Parameters on the parameterized to watch.""")
diff --git a/holoviews/util/__init__.py b/holoviews/util/__init__.py
index a22434a90..6386bd68a 100644
--- a/holoviews/util/__init__.py
+++ b/holoviews/util/__init__.py
@@ -715,6 +715,9 @@ class extension(_pyviz_extension):
 
         import panel as pn
 
+        if params.get("enable_mathjax", False) and selected_backend == "bokeh":
+            pn.extension("mathjax")
+
         if pn.config.comms == "default":
             if "google.colab" in sys.modules:
                 pn.config.comms = "colab"
@@ -885,7 +888,7 @@ class Dynamic(param.ParameterizedFunction):
         Whether the cloned DynamicMap will share the same cache.""")
 
     streams = param.ClassSelector(default=[], class_=(list, dict), doc="""
-        List of streams to attach to the returned DynamicMap""")
+        List of streams to attach to the returned DynamicMap""", **util.disallow_refs)
 
     def __call__(self, map_obj, **params):
         watch = params.pop('watch', True)


Test patch:
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 401f6c4f8..553bc7264 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -30,20 +30,11 @@ env:
 
 jobs:
   pre_commit:
-    name: Run pre-commit hooks
+    name: Run pre-commit
     runs-on: 'ubuntu-latest'
     steps:
-      - uses: actions/checkout@v3
-        with:
-          fetch-depth: "1"
-      - name: set PY
-        run: echo "PY=$(python -VV | sha256sum | cut -d' ' -f1)" >> $GITHUB_ENV
-      - uses: actions/cache@v3
-        with:
-          path: ~/.cache/pre-commit
-          key: pre-commit|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }}
-      - name: pre-commit
-        uses: pre-commit/action@v3.0.0
+      - uses: holoviz-dev/holoviz_tasks/pre-commit@v0.1a17
+
   unit_test_suite:
     name: Unit tests on Python ${{ matrix.python-version }}, ${{ matrix.os }}
     needs: [pre_commit]
@@ -66,7 +57,7 @@ jobs:
       DESC: "Python ${{ matrix.python-version }}, ${{ matrix.os }} unit tests"
       PYTHON_VERSION: ${{ matrix.python-version }}
     steps:
-      - uses: holoviz-dev/holoviz_tasks/install@v0.1a15
+      - uses: holoviz-dev/holoviz_tasks/install@v0.1a17
         with:
           name: unit_test_suite
           python-version: ${{ matrix.python-version }}
@@ -113,7 +104,7 @@ jobs:
       # it as one of the sources.
       PYCTDEV_SELF_CHANNEL: "pyviz/label/dev"
     steps:
-      - uses: holoviz-dev/holoviz_tasks/install@v0.1a15
+      - uses: holoviz-dev/holoviz_tasks/install@v0.1a17
         with:
           name: ui_test_suite
           python-version: ${{ matrix.python-version }}
@@ -153,7 +144,7 @@ jobs:
       DESC: "Python ${{ matrix.python-version }}, ${{ matrix.os }} core tests"
       PYTHON_VERSION: ${{ matrix.python-version }}
     steps:
-      - uses: holoviz-dev/holoviz_tasks/install@v0.1a15
+      - uses: holoviz-dev/holoviz_tasks/install@v0.1a17
         with:
           name: core_test_suite
           python-version: ${{ matrix.python-version }}
diff --git a/holoviews/tests/plotting/bokeh/test_subcoordy.py b/holoviews/tests/plotting/bokeh/test_subcoordy.py
new file mode 100644
index 000000000..d36ae6447
--- /dev/null
+++ b/holoviews/tests/plotting/bokeh/test_subcoordy.py
@@ -0,0 +1,190 @@
+import numpy as np
+import pytest
+
+from holoviews.core import Overlay
+from holoviews.element import Curve
+from holoviews.element.annotation import VSpan
+
+from .test_plot import TestBokehPlot, bokeh_renderer
+
+
+class TestSubcoordinateY(TestBokehPlot):
+
+    # With subcoordinate_y set to True
+
+    def test_bool_base(self):
+        overlay = Overlay([Curve(range(10), label=f'Data {i}').opts(subcoordinate_y=True) for i in range(2)])
+        plot = bokeh_renderer.get_plot(overlay)
+        # subcoordinate_y is propagated to the overlay
+        assert plot.subcoordinate_y is True
+        # the figure has only one yaxis
+        assert len(plot.state.yaxis) == 1
+        # the overlay has two subplots
+        assert len(plot.subplots) == 2
+        assert ('Curve', 'Data_0') in plot.subplots
+        assert ('Curve', 'Data_1') in plot.subplots
+        # the range per subplots are correctly computed
+        sp1 = plot.subplots[('Curve', 'Data_0')]
+        assert sp1.handles['glyph_renderer'].coordinates.y_target.start == -0.5
+        assert sp1.handles['glyph_renderer'].coordinates.y_target.end == 0.5
+        sp2 = plot.subplots[('Curve', 'Data_1')]
+        assert sp2.handles['glyph_renderer'].coordinates.y_target.start == 0.5
+        assert sp2.handles['glyph_renderer'].coordinates.y_target.end == 1.5
+        # y_range is correctly computed
+        assert plot.handles['y_range'].start == -0.5
+        assert plot.handles['y_range'].end == 1.5
+        # extra_y_range is empty
+        assert plot.handles['extra_y_ranges'] == {}
+        # the ticks show the labels
+        assert plot.state.yaxis.ticker.ticks == [0, 1]
+        assert plot.state.yaxis.major_label_overrides == {0: 'Data 0', 1: 'Data 1'}
+
+    def test_bool_scale(self):
+        test_data = [
+            (0.5, (-0.25, 0.25), (0.75, 1.25), (-0.25, 1.25)),
+            (1, (-0.5, 0.5), (0.5, 1.5), (-0.5, 1.5)),
+            (2, (-1, 1), (0, 2), (-1, 2)),
+            (5, (-2.5, 2.5), (-1.5, 3.5), (-2.5, 3.5)),
+        ]
+        for scale, ytarget1, ytarget2, ytarget in test_data:
+            overlay = Overlay([
+                Curve(range(10), label=f'Data {i}').opts(subcoordinate_y=True, subcoordinate_scale=scale)
+                for i in range(2)
+            ])
+            plot = bokeh_renderer.get_plot(overlay)
+            # the range per subplots are correctly computed
+            sp1 = plot.subplots[('Curve', 'Data_0')]
+            assert sp1.handles['glyph_renderer'].coordinates.y_target.start == ytarget1[0]
+            assert sp1.handles['glyph_renderer'].coordinates.y_target.end == ytarget1[1]
+            sp2 = plot.subplots[('Curve', 'Data_1')]
+            assert sp2.handles['glyph_renderer'].coordinates.y_target.start == ytarget2[0]
+            assert sp2.handles['glyph_renderer'].coordinates.y_target.end == ytarget2[1]
+            # y_range is correctly computed
+            assert plot.handles['y_range'].start == ytarget[0]
+            assert plot.handles['y_range'].end == ytarget[1]
+
+    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'
+        ):
+            bokeh_renderer.get_plot(overlay)
+
+    def test_overlaid_without_label_no_error(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)
+        bokeh_renderer.get_plot(with_span)
+
+    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')
+        plot = bokeh_renderer.get_plot(overlay)
+        # the figure axis has the label set
+        assert plot.state.yaxis.axis_label == 'Y label'
+        # the ticks show the labels
+        assert plot.state.yaxis.ticker.ticks == [0, 1]
+        assert plot.state.yaxis.major_label_overrides == {0: 'Data 0', 1: 'Data 1'}
+
+    def test_legend_label(self):
+        overlay = Overlay([Curve(range(10), label=f'Data {i}').opts(subcoordinate_y=True) for i in range(2)])
+        plot = bokeh_renderer.get_plot(overlay)
+        legend_labels = [l.label['value'] for l in plot.state.legend[0].items]
+        # the legend displays the labels
+        assert legend_labels == ['Data 0', 'Data 1']
+
+    def test_shared_multi_axes(self):
+        overlay1 = Overlay([Curve(np.arange(10), label=f'Data {i}').opts(subcoordinate_y=True) for i in range(2)])
+        overlay2 = Overlay([Curve(np.arange(10) + 5, label=f'Data {i}').opts(subcoordinate_y=True) for i in range(2)])
+
+        plot = bokeh_renderer.get_plot(overlay1 + overlay2)
+
+        oplot1 = plot.subplots[(0, 0)].subplots['main']
+        oplot2 = plot.subplots[(0, 1)].subplots['main']
+        assert (oplot1.handles['y_range'].start, oplot1.handles['y_range'].end) == (-0.5, 1.5)
+        assert oplot1.handles['extra_y_ranges'] == {}
+        assert (oplot2.handles['y_range'].start, oplot2.handles['y_range'].end) == (-0.5, 1.5)
+        assert oplot2.handles['extra_y_ranges'] == {}
+
+    def test_invisible_yaxis(self):
+        overlay = Overlay([Curve(range(10), label=f'Data {i}').opts(subcoordinate_y=True) for i in range(2)])
+        overlay.opts(yaxis=None)
+        plot = bokeh_renderer.get_plot(overlay)
+        assert not plot.state.yaxis.visible
+
+    def test_axis_labels(self):
+        overlay = Overlay([Curve(range(10), label=f'Data {i}').opts(subcoordinate_y=True) for i in range(2)])
+        plot = bokeh_renderer.get_plot(overlay)
+
+        assert plot.state.xaxis.axis_label == 'x'
+        assert plot.state.yaxis.axis_label == 'y'
+
+    def test_only_x_axis_labels(self):
+        overlay = Overlay([Curve(range(10), label=f'Data {i}').opts(subcoordinate_y=True) for i in range(2)])
+        overlay.opts(labelled=['x'])
+        plot = bokeh_renderer.get_plot(overlay)
+
+        assert plot.state.xaxis.axis_label == 'x'
+        assert plot.state.yaxis.axis_label == ''
+
+    def test_none_x_axis_labels(self):
+        overlay = Overlay([Curve(range(10), vdims=['A'], label=f'Data {i}').opts(subcoordinate_y=True) for i in range(2)])
+        plot = bokeh_renderer.get_plot(overlay)
+
+        assert plot.state.xaxis.axis_label == 'x'
+        assert plot.state.yaxis.axis_label == 'A'
+
+    # With subcoordinate_y set to a range
+
+    def test_range_base(self):
+        overlay = Overlay([
+            Curve(range(10), label='Data 0').opts(subcoordinate_y=(0, 0.5)),
+            Curve(range(10), label='Data 1').opts(subcoordinate_y=(0.5, 1)),
+        ])
+        plot = bokeh_renderer.get_plot(overlay)
+        # subcoordinate_y is propagated to the overlay, just the first one though :(
+        assert plot.subcoordinate_y == (0, 0.5)
+        # the figure has only one yaxis
+        assert len(plot.state.yaxis) == 1
+        # the overlay has two subplots
+        assert len(plot.subplots) == 2
+        assert ('Curve', 'Data_0') in plot.subplots
+        assert ('Curve', 'Data_1') in plot.subplots
+        # the range per subplots are correctly computed
+        sp1 = plot.subplots[('Curve', 'Data_0')]
+        assert sp1.handles['glyph_renderer'].coordinates.y_target.start == 0
+        assert sp1.handles['glyph_renderer'].coordinates.y_target.end == 0.5
+        sp2 = plot.subplots[('Curve', 'Data_1')]
+        assert sp2.handles['glyph_renderer'].coordinates.y_target.start == 0.5
+        assert sp2.handles['glyph_renderer'].coordinates.y_target.end == 1
+        # y_range is correctly computed
+        assert plot.handles['y_range'].start == 0
+        assert plot.handles['y_range'].end == 1
+        # extra_y_range is empty
+        assert plot.handles['extra_y_ranges'] == {}
+        # the ticks show the labels
+        assert plot.state.yaxis.ticker.ticks == [0.25, 0.75]
+        assert plot.state.yaxis.major_label_overrides == {0.25: 'Data 0', 0.75: 'Data 1'}
+
+    def test_plot_standalone(self):
+        standalone = Curve(range(10), label='Data 0').opts(subcoordinate_y=True)
+        plot = bokeh_renderer.get_plot(standalone)
+        assert (plot.state.x_range.start, plot.state.x_range.end) == (0, 9)
+        assert (plot.state.y_range.start, plot.state.y_range.end) == (0, 9)
+
+    def test_multi_y_error(self):
+        overlay = Overlay([Curve(range(10), label=f'Data {i}').opts(subcoordinate_y=True) for i in range(2)])
+        overlay.opts(multi_y=True)
+        with pytest.raises(
+            ValueError,
+            match='multi_y and subcoordinate_y are not supported together'
+        ):
+            bokeh_renderer.get_plot(overlay)
+
+    def test_same_label_error(self):
+        overlay = Overlay([Curve(range(10), label='Same').opts(subcoordinate_y=True) for _ in range(2)])
+        with pytest.raises(
+            ValueError,
+            match='Elements wrapped in a subcoordinate_y overlay must all have a unique label',
+        ):
+            bokeh_renderer.get_plot(overlay)
diff --git a/holoviews/tests/util/test_transform.py b/holoviews/tests/util/test_transform.py
index 00384bdbd..3fad8b2cc 100644
--- a/holoviews/tests/util/test_transform.py
+++ b/holoviews/tests/util/test_transform.py
@@ -481,7 +481,7 @@ class TestDimTransforms(ComparisonTestCase):
         with warnings.catch_warnings():
             # The kwargs is {'axis': None} and is already handled by the code.
             # This context manager can be removed, when it raises an TypeError instead of warning.
-            warnings.simplefilter("ignore", "Passing additional kwargs to Rolling.mean")
+            warnings.filterwarnings("ignore", "Passing additional kwargs to Rolling.mean")
             self.assert_apply(expr, self.linear_ints.rolling(1).mean())