Repository: holoviz/holoviews
Pull request #6253 (2024-05-30 13:22:47)

Update dependencies
Updates required dependencies:
Numpy 1.0 --> 1.21
Pandas 0.20.0 --> 1.3
Bokeh (moved up from recommended)

Updates recommended dependencies:
Removed notebook and IPython. 
Moved Bokeh up to required. 

hoxbro: > goal of aligning the deps across the channels, rather than the intent to gain a few seconds on the CI.

Users are also affected by this. We pull in a big dependency that we don't use. I just noticed it is because of our CI. 

For alignment, I would propose these as required dependencies.

``` python
bokeh >=3.1
colorcet
numpy >=1.0
packaging
pandas >=0.20.0
panel >=1.0
param >=2.0,<3.0
pyviz_comms >=2.1
```

Maybe even bump the lower pin of pandas and numpy to something released in the last 3-5 years, or even remove them as they are so low it doesn't matter. 

(numpy 1.0 was released 25th October 2006, pandas 0.20.0 was released 5th May 2017)

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/holoviz/holoviews/pull/6253?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 `90.90909%` with `3 lines` in your changes are missing coverage. Please review.
> Project coverage is 88.49%. Comparing base [(`f39e0a0`)](https://app.codecov.io/gh/holoviz/holoviews/commit/f39e0a08f4eb598f8cb802cdb7c98cf99f46d87b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) to head [(`a70576f`)](https://app.codecov.io/gh/holoviz/holoviews/commit/a70576f21bab0065ec87ad115dedcfe88b1917b2?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz).
> Report is 1 commits behind head on main.

| [Files](https://app.codecov.io/gh/holoviz/holoviews/pull/6253?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/renderer.py](https://app.codecov.io/gh/holoviz/holoviews/pull/6253?src=pr&el=tree&filepath=holoviews%2Fplotting%2Frenderer.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL3Bsb3R0aW5nL3JlbmRlcmVyLnB5) | 50.00% | [2 Missing :warning: ](https://app.codecov.io/gh/holoviz/holoviews/pull/6253?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) |
| [holoviews/core/util.py](https://app.codecov.io/gh/holoviz/holoviews/pull/6253?src=pr&el=tree&filepath=holoviews%2Fcore%2Futil.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL2NvcmUvdXRpbC5weQ==) | 95.65% | [1 Missing :warning: ](https://app.codecov.io/gh/holoviz/holoviews/pull/6253?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    #6253      +/-   ##
==========================================
+ Coverage   88.48%   88.49%   +0.01%     
==========================================
  Files         323      323              
  Lines       67640    67599      -41     
==========================================
- Hits        59848    59825      -23     
+ Misses       7792     7774      -18     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/holoviz/holoviews/pull/6253?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/doc/conf.py b/doc/conf.py
index 1c2cb85a5..c2b031e72 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -67,6 +67,10 @@ nbsite_analytics = {
     'goatcounter_holoviz': True,
 }
 
+rediraffe_redirects = {
+    'gallery/demos/bokeh/eeg_viewer': 'gallery/demos/bokeh/multichannel_timeseries_viewer',
+}
+
 nbsite_gallery_conf = {
     'backends': ['bokeh', 'matplotlib', 'plotly'],
     'galleries': {},
diff --git a/doc/install.md b/doc/install.md
index fd1ca54b9..f442bf203 100644
--- a/doc/install.md
+++ b/doc/install.md
@@ -8,12 +8,10 @@ provided by the [Anaconda](https://docs.anaconda.com/anaconda/install/)
 or [Miniconda](https://docs.conda.io/en/latest/miniconda.html)
 scientific Python distributions:
 
-    conda install -c pyviz holoviews
+    conda install holoviews
 
-This recommended installation includes the default
-[Matplotlib](http://matplotlib.org) plotting library backend, the more
-interactive [Bokeh](http://bokeh.pydata.org) plotting library backend,
-and the [Jupyter Notebook](http://jupyter.org).
+This installation includes the default [Matplotlib](http://matplotlib.org)
+plotting library backend, the more interactive [Bokeh](http://bokeh.pydata.org) plotting library backend.
 
 A similar set of packages can be installed using `pip`, if that command
 is available on your system:
diff --git a/examples/gallery/demos/bokeh/eeg_viewer.ipynb b/examples/gallery/demos/bokeh/eeg_viewer.ipynb
deleted file mode 100644
index 52f9ed23f..000000000
--- a/examples/gallery/demos/bokeh/eeg_viewer.ipynb
+++ /dev/null
@@ -1,172 +0,0 @@
-{
- "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_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 initially viewable area with `boundsx` and `boundsy`, and finally a max range of 2 seconds with `intervalsx`. 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",
-    "    intervalsx=(None, 2),\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/gallery/demos/bokeh/multichannel_timeseries_viewer.ipynb b/examples/gallery/demos/bokeh/multichannel_timeseries_viewer.ipynb
new file mode 100644
index 000000000..b06a8d4d0
--- /dev/null
+++ b/examples/gallery/demos/bokeh/multichannel_timeseries_viewer.ipynb
@@ -0,0 +1,238 @@
+{
+ "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 from different data groups in a single plot using `subcoordinate_y`.\n",
+    "2. Normalize the timeseries per data group.\n",
+    "3. 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) and position 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.operation.normalization import subcoordinate_group_ranges\n",
+    "from holoviews.operation.datashader import rasterize\n",
+    "from holoviews.plotting.links import RangeToolLink\n",
+    "from scipy.stats import zscore\n",
+    "import colorcet as cc\n",
+    "\n",
+    "hv.extension('bokeh')"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "1c95f241-2314-42b0-b6cb-2c0baf332686",
+   "metadata": {},
+   "source": [
+    "## Generating data\n",
+    "\n",
+    "Let's start by `EEG` and position (`POS`) data. We'll create a timeseries for each EEG channel using sine waves with varying frequencies, and random data for three position channels. We'll set these two data groups to have different amplitudes and units."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "6ac3812a",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import numpy as np\n",
+    "import pandas as pd\n",
+    "import holoviews as hv\n",
+    "from holoviews.operation.normalization import subcoordinate_group_ranges\n",
+    "from holoviews.plotting.links import RangeToolLink\n",
+    "from scipy.stats import zscore\n",
+    "\n",
+    "hv.extension('bokeh')\n",
+    "\n",
+    "GROUP_EEG = 'EEG'\n",
+    "GROUP_POS = 'Position'\n",
+    "N_CHANNELS_EEG = 10\n",
+    "N_CHANNELS_POS = 3\n",
+    "N_SECONDS = 5\n",
+    "SAMPLING_RATE_EEG = 200\n",
+    "SAMPLING_RATE_POS = 25\n",
+    "INIT_FREQ = 2  # Initial frequency in Hz\n",
+    "FREQ_INC = 5  # Frequency increment\n",
+    "AMPLITUDE_EEG = 1000  # EEG amplitude multiplier\n",
+    "AMPLITUDE_POS = 2  # Position amplitude multiplier\n",
+    "\n",
+    "# Generate time for EEG and position data\n",
+    "total_samples_eeg = N_SECONDS * SAMPLING_RATE_EEG\n",
+    "total_samples_pos = N_SECONDS * SAMPLING_RATE_POS\n",
+    "time_eeg = np.linspace(0, N_SECONDS, total_samples_eeg)\n",
+    "time_pos = np.linspace(0, N_SECONDS, total_samples_pos)\n",
+    "\n",
+    "# Generate EEG timeseries data\n",
+    "def generate_eeg_data(index):\n",
+    "    return AMPLITUDE_EEG * np.sin(2 * np.pi * (INIT_FREQ + index * FREQ_INC) * time_eeg)\n",
+    "\n",
+    "eeg_channels = [str(i) for i in np.arange(N_CHANNELS_EEG)]\n",
+    "eeg_data = np.array([generate_eeg_data(i) for i in np.arange(N_CHANNELS_EEG)])\n",
+    "eeg_df = pd.DataFrame(eeg_data.T, index=time_eeg, columns=eeg_channels)\n",
+    "eeg_df.index.name = 'Time'\n",
+    "\n",
+    "# Generate position data\n",
+    "pos_channels = ['X', 'Y', 'Z'] # avoid lowercase 'x' and 'y' as channel/dimension names\n",
+    "pos_data = AMPLITUDE_POS * np.random.randn(N_CHANNELS_POS, total_samples_pos).cumsum(axis=1)\n",
+    "pos_df = pd.DataFrame(pos_data.T, index=time_pos, columns=pos_channels)\n",
+    "pos_df.index.name = 'Time'"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "ec9e71b8-a995-4c0f-bdbb-5d148d8fa138",
+   "metadata": {},
+   "source": [
+    "## Visualizing EEG Data\n",
+    "\n",
+    "Next, let's dive into visualizing the data. We construct each timeseries using a `Curve` element, assigning it a `group`, a `label` and setting `subcoordinate_y=True`. All these curves are then aggregated into a list per data group, 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 about the group, channel, time, and amplitude value when you hover over any of the curves."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "9476769f-3935-4236-b010-1511d1a1e77f",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Create a Curve per data series\n",
+    "def df_to_curves(df, kdim, vdim, color='black', group='EEG'):\n",
+    "    curves = []\n",
+    "    for i, (channel, channel_data) in enumerate(df.items()):\n",
+    "        ds = hv.Dataset((channel_data.index, channel_data), [kdim, vdim])\n",
+    "        curve = hv.Curve(ds, kdim, vdim, group=group, label=str(channel))\n",
+    "        curve.opts(\n",
+    "            subcoordinate_y=True, color=color if isinstance(color, str) else color[i], line_width=1, \n",
+    "            hover_tooltips=hover_tooltips, tools=['xwheel_zoom'], line_alpha=.8,\n",
+    "        )\n",
+    "        curves.append(curve)\n",
+    "    return curves\n",
+    "\n",
+    "hover_tooltips = [(\"Group\", \"$group\"), (\"Channel\", \"$label\"), (\"Time\"), (\"Value\")]\n",
+    "\n",
+    "vdim_EEG = hv.Dimension(\"Value\", unit=\"µV\")\n",
+    "vdim_POS = hv.Dimension(\"Value\", unit=\"cm\")\n",
+    "time_dim = hv.Dimension(\"Time\", unit=\"s\")\n",
+    "\n",
+    "eeg_curves = df_to_curves(eeg_df, time_dim, vdim_EEG, color='black', group='EEG')\n",
+    "pos_curves = df_to_curves(pos_df, time_dim, vdim_POS, color=cc.glasbey_cool, group='POS')\n",
+    "\n",
+    "# Combine EEG and POS curves into an Overlay\n",
+    "eeg_curves_overlay = hv.Overlay(eeg_curves, kdims=\"Channel\")\n",
+    "pos_curves_overlay = hv.Overlay(pos_curves, kdims=\"Channel\")\n",
+    "curves_overlay = (eeg_curves_overlay * pos_curves_overlay).opts(\n",
+    "    xlabel=time_dim.pprint_label, ylabel=\"Channel\", show_legend=False, aspect=3, responsive=True,\n",
+    ")\n",
+    "curves_overlay"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "983e1f84-6006-4d64-9144-4aba0ad93946",
+   "metadata": {},
+   "source": [
+    "Note that the overlay above has multiple wheel-zoom tools in the toolbar, you can hover over the icons in the toolbar to reveal each of the first two control the Y-axis zoom of their respective data group within each curve's subcoordinate range, and the third wheel zoom tool controls the X-axis scale of all the curves together.\n",
+    "\n",
+    "By default, all the curves, including across data groups, have the same y-axis range that is computed from the min and max across all channels. As a consequence, the position curves in blue, which have a much smaller amplitude than timeseries in the EEG data group, appear to be quite flat and are hard to inspect. To deal with this situation, we can transform the *Overlay* with the `subcoordinate_group_ranges` operation that will apply a min-max normalization of the timeseries per group."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "2bb78a48-5c6a-4969-bf58-539fce784364",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Apply group-wise normalization\n",
+    "normalized_overlay = subcoordinate_group_ranges(curves_overlay)\n",
+    "normalized_overlay"
+   ]
+  },
+  {
+   "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 timeseries `Overlay` and the minimap `Image`, we ensure they share the same y-axis range. We will also leverage rasterization in case the full image resolution is too large to render on the screen."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "40fa2198-c3b5-41e1-944f-f8b812612168",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "y_positions = range(N_CHANNELS_EEG + N_CHANNELS_POS)\n",
+    "\n",
+    "# Reindex the lower frequency DataFrame to match the higher frequency index\n",
+    "pos_df_interp = pos_df.reindex(eeg_df.index).interpolate(method='index')\n",
+    "\n",
+    "# concatenate the EEG and interpolated POS data and z-score the full data array\n",
+    "z_data = zscore(np.concatenate((eeg_df.values, pos_df_interp.values), axis=1), axis=0).T\n",
+    "\n",
+    "minimap = rasterize(hv.Image((time_eeg, y_positions , z_data), [time_dim, \"Channel\"], \"Value\"))\n",
+    "minimap = minimap.opts(\n",
+    "    cmap=\"RdBu_r\", xlabel='', alpha=.7,\n",
+    "    yticks=[(y_positions[0], f'EEG {eeg_channels[0]}'), (y_positions[-1], f'POS {pos_channels[-1]}')],\n",
+    "    height=120, responsive=True, toolbar='disable', cnorm='eq_hist'\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 timeseries `Overlay`, setting bounds for the initially viewable area with `boundsx` and `boundsy`, and finally demonstrate setting an upper max zoom range of 3 seconds with `intervalsx`. 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, normalized_overlay, axes=[\"x\", \"y\"],\n",
+    "    boundsx=(.5, 3), boundsy=(1.5, 12.5),\n",
+    "    intervalsx=(None, 3),\n",
+    ")\n",
+    "\n",
+    "dashboard = (normalized_overlay + minimap).cols(1).opts(shared_axes=False)\n",
+    "dashboard"
+   ]
+  }
+ ],
+ "metadata": {
+  "language_info": {
+   "name": "python",
+   "pygments_lexer": "ipython3"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/holoviews/core/data/pandas.py b/holoviews/core/data/pandas.py
index ea552194f..a097bdf95 100644
--- a/holoviews/core/data/pandas.py
+++ b/holoviews/core/data/pandas.py
@@ -195,8 +195,7 @@ class PandasInterface(Interface, PandasAPI):
         else:
             column = dataset.data[dimension.name]
         if column.dtype.kind == 'O':
-            if (not isinstance(dataset.data, pd.DataFrame) or
-                util.pandas_version < Version('0.17.0')):
+            if not isinstance(dataset.data, pd.DataFrame):
                 column = column.sort(inplace=False)
             else:
                 column = column.sort_values()
@@ -221,9 +220,7 @@ class PandasInterface(Interface, PandasAPI):
 
     @classmethod
     def concat_fn(cls, dataframes, **kwargs):
-        if util.pandas_version >= Version('0.23.0'):
-            kwargs['sort'] = False
-        return pd.concat(dataframes, **kwargs)
+        return pd.concat(dataframes, sort=False, **kwargs)
 
 
     @classmethod
@@ -346,8 +343,7 @@ class PandasInterface(Interface, PandasAPI):
             by = []
         cols = [dataset.get_dimension(d, strict=True).name for d in by]
 
-        if (not isinstance(dataset.data, pd.DataFrame) or
-            util.pandas_version < Version('0.17.0')):
+        if not isinstance(dataset.data, pd.DataFrame):
             return dataset.data.sort(columns=cols, ascending=not reverse)
         return dataset.data.sort_values(by=cols, ascending=not reverse)
 
diff --git a/holoviews/core/util.py b/holoviews/core/util.py
index 2e2732bfb..dadba0c08 100644
--- a/holoviews/core/util.py
+++ b/holoviews/core/util.py
@@ -23,6 +23,9 @@ import numpy as np
 import pandas as pd
 import param
 from packaging.version import Version
+from pandas.core.arrays.masked import BaseMaskedArray
+from pandas.core.dtypes.dtypes import DatetimeTZDtype
+from pandas.core.dtypes.generic import ABCExtensionArray, ABCIndex, ABCSeries
 
 # Python 2 builtins
 basestring = str
@@ -31,59 +34,22 @@ unicode = str
 cmp = lambda a, b: (a>b)-(a<b)
 
 get_keywords = operator.attrgetter('varkw')
-generator_types = (zip, range, types.GeneratorType)
-numpy_version = Version(np.__version__)
-param_version = Version(param.__version__)
-
-datetime_types = (np.datetime64, dt.datetime, dt.date, dt.time)
-timedelta_types = (np.timedelta64, dt.timedelta,)
-arraylike_types = (np.ndarray,)
-masked_types = ()
-
-anonymous_dimension_label = '_'
-
-# Argspec was removed in Python 3.11
-ArgSpec = namedtuple('ArgSpec', 'args varargs keywords defaults')
-
-_NP_SIZE_LARGE = 1_000_000
-_NP_SAMPLE_SIZE = 1_000_000
-_PANDAS_ROWS_LARGE = 1_000_000
-_PANDAS_SAMPLE_SIZE = 1_000_000
 
+# Versions
 numpy_version = Version(Version(np.__version__).base_version)
+param_version = Version(param.__version__)
+pandas_version = Version(pd.__version__)
+
 NUMPY_GE_200 = numpy_version >= Version("2")
 
-pandas_version = Version(pd.__version__)
-try:
-    if pandas_version >= Version('1.3.0'):
-        from pandas.core.dtypes.dtypes import DatetimeTZDtype as DatetimeTZDtypeType
-        from pandas.core.dtypes.generic import (
-            ABCIndex as ABCIndexClass,
-            ABCSeries,
-        )
-    elif pandas_version >= Version('0.24.0'):
-        from pandas.core.dtypes.dtypes import DatetimeTZDtype as DatetimeTZDtypeType
-        from pandas.core.dtypes.generic import ABCIndexClass, ABCSeries
-    elif pandas_version > Version('0.20.0'):
-        from pandas.core.dtypes.dtypes import DatetimeTZDtypeType
-        from pandas.core.dtypes.generic import ABCIndexClass, ABCSeries
-    else:
-        from pandas.types.dtypes import DatetimeTZDtypeType
-        from pandas.types.dtypes.generic import ABCIndexClass, ABCSeries
-    pandas_datetime_types = (pd.Timestamp, DatetimeTZDtypeType, pd.Period)
-    pandas_timedelta_types = (pd.Timedelta,)
-    datetime_types = datetime_types + pandas_datetime_types
-    timedelta_types = timedelta_types + pandas_timedelta_types
-    arraylike_types = arraylike_types + (ABCSeries, ABCIndexClass)
-    if pandas_version > Version('0.23.0'):
-        from pandas.core.dtypes.generic import ABCExtensionArray
-        arraylike_types = arraylike_types + (ABCExtensionArray,)
-    if pandas_version > Version('1.0'):
-        from pandas.core.arrays.masked import BaseMaskedArray
-        masked_types = (BaseMaskedArray,)
-except Exception as e:
-    param.main.param.warning('pandas could not register all extension types '
-                                f'imports failed with the following error: {e}')
+# Types
+generator_types = (zip, range, types.GeneratorType)
+pandas_datetime_types = (pd.Timestamp, DatetimeTZDtype, pd.Period)
+pandas_timedelta_types = (pd.Timedelta,)
+datetime_types = (np.datetime64, dt.datetime, dt.date, dt.time, *pandas_datetime_types)
+timedelta_types = (np.timedelta64, dt.timedelta, *pandas_timedelta_types)
+arraylike_types = (np.ndarray, ABCSeries, ABCIndex, ABCExtensionArray)
+masked_types = (BaseMaskedArray,)
 
 try:
     import cftime
@@ -93,6 +59,15 @@ except ImportError:
     cftime_types = ()
 _STANDARD_CALENDARS = {'standard', 'gregorian', 'proleptic_gregorian'}
 
+anonymous_dimension_label = '_'
+
+# Argspec was removed in Python 3.11
+ArgSpec = namedtuple('ArgSpec', 'args varargs keywords defaults')
+
+_NP_SIZE_LARGE = 1_000_000
+_NP_SAMPLE_SIZE = 1_000_000
+_PANDAS_ROWS_LARGE = 1_000_000
+_PANDAS_SAMPLE_SIZE = 1_000_000
 
 # To avoid pandas warning about using DataFrameGroupBy.function
 # introduced in Pandas 2.1.
@@ -890,10 +865,7 @@ def isnat(val):
     """
     if (isinstance(val, (np.datetime64, np.timedelta64)) or
         (isinstance(val, np.ndarray) and val.dtype.kind == 'M')):
-        if numpy_version >= Version('1.13'):
-            return np.isnat(val)
-        else:
-            return val.view('i8') == nat_as_integer
+        return np.isnat(val)
     elif val is pd.NaT:
         return True
     elif isinstance(val, pandas_datetime_types+pandas_timedelta_types):
@@ -928,19 +900,16 @@ def isfinite(val):
         elif val.dtype.kind in 'US':
             return ~pd.isna(val)
         finite = np.isfinite(val)
-        if pandas_version >= Version('1.0.0'):
-            finite &= ~pd.isna(val)
+        finite &= ~pd.isna(val)
         return finite
     elif isinstance(val, datetime_types+timedelta_types):
         return not isnat(val)
     elif isinstance(val, (str, bytes)):
         return True
     finite = np.isfinite(val)
-    if pandas_version >= Version('1.0.0'):
-        if finite is pd.NA:
-            return False
-        return finite & ~pd.isna(np.asarray(val))
-    return finite
+    if finite is pd.NA:
+        return False
+    return finite & ~pd.isna(np.asarray(val))
 
 
 def isdatetime(value):
diff --git a/holoviews/operation/timeseries.py b/holoviews/operation/timeseries.py
index c076e9db0..e6713c01e 100644
--- a/holoviews/operation/timeseries.py
+++ b/holoviews/operation/timeseries.py
@@ -1,11 +1,10 @@
 import numpy as np
 import pandas as pd
 import param
-from packaging.version import Version
 
 from ..core import Element, Operation
 from ..core.data import PandasInterface
-from ..core.util import _PANDAS_FUNC_LOOKUP, pandas_version
+from ..core.util import _PANDAS_FUNC_LOOKUP
 from ..element import Scatter
 
 
@@ -51,8 +50,7 @@ class rolling(Operation,RollingBase):
         df = df.set_index(xdim).rolling(win_type=self.p.window_type,
                                         **self._roll_kwargs())
         if self.p.window_type is None:
-            kwargs = {'raw': True} if pandas_version >= Version('0.23.0') else {}
-            rolled = df.apply(self.p.function, **kwargs)
+            rolled = df.apply(self.p.function, raw=True)
         elif self.p.function is np.mean:
             rolled = df.mean()
         elif self.p.function is np.sum:
diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py
index e46be7150..48138bfa6 100644
--- a/holoviews/plotting/bokeh/element.py
+++ b/holoviews/plotting/bokeh/element.py
@@ -76,6 +76,7 @@ from .util import (
     get_axis_class,
     get_scale,
     get_tab_title,
+    get_ticker_axis_props,
     glyph_order,
     hold_policy,
     match_ax_type,
@@ -246,6 +247,16 @@ class ElementPlot(BokehPlot, GenericElementPlot):
     hover_mode = param.ObjectSelector(default='mouse', objects=['mouse', 'vline', 'hline'], doc="""
         The hover mode determines how the hover tool is activated.""")
 
+    xticks = param.ClassSelector(class_=(int, list, tuple, np.ndarray, Ticker), default=None, doc="""
+        Ticks along x-axis specified as an integer, explicit list of
+        tick locations, or bokeh Ticker object. If set to None default
+        bokeh ticking behavior is applied.""")
+
+    yticks = param.ClassSelector(class_=(int, list, tuple, np.ndarray, Ticker), default=None, doc="""
+        Ticks along y-axis specified as an integer, explicit list of
+        tick locations, or bokeh Ticker object. If set to None default
+        bokeh ticking behavior is applied.""")
+
     toolbar = param.ObjectSelector(default='right',
                                    objects=["above", "below",
                                             "left", "right", "disable", None],
@@ -1051,29 +1062,9 @@ class ElementPlot(BokehPlot, GenericElementPlot):
             if rotation:
                 axis_props['major_label_orientation'] = np.radians(rotation)
             ticker = self.xticks if axis == 'x' else self.yticks
-            if isinstance(ticker, np.ndarray):
-                ticker = list(ticker)
-            if isinstance(ticker, Ticker):
-                axis_props['ticker'] = ticker
-            elif isinstance(ticker, int):
-                axis_props['ticker'] = BasicTicker(desired_num_ticks=ticker)
-            elif isinstance(ticker, (tuple, list)):
-                if all(isinstance(t, tuple) for t in ticker):
-                    ticks, labels = zip(*ticker)
-                    # Ensure floats which are integers are serialized as ints
-                    # because in JS the lookup fails otherwise
-                    ticks = [int(t) if isinstance(t, float) and t.is_integer() else t
-                             for t in ticks]
-                    labels = [l if isinstance(l, str) else str(l)
-                              for l in labels]
-                else:
-                    ticks, labels = ticker, None
-                if ticks and util.isdatetime(ticks[0]):
-                    ticks = [util.dt_to_int(tick, 'ms') for tick in ticks]
-                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':
+            if not (self._subcoord_overlaid and axis == 'y'):
+                axis_props.update(get_ticker_axis_props(ticker))
+            else:
                 ticks, labels = [], []
                 idx = 0
                 for el, sp in zip(self.current_frame, self.subplots.values()):
@@ -2470,6 +2461,11 @@ class ColorbarPlot(ElementPlot):
         #FFFFFFFF or a length 3 or length 4 tuple specifying values in
         the range 0-1 or a named HTML color.""")
 
+    cticks = param.ClassSelector(class_=(int, list, tuple, np.ndarray, Ticker), default=None, doc="""
+        Ticks along colorbar-axis specified as an integer, explicit list of
+        tick locations, or bokeh Ticker object. If set to None default
+        bokeh ticking behavior is applied.""")
+
     logz = param.Boolean(default=False, doc="""
          Whether to apply log scaling to the z-axis.""")
 
@@ -2513,6 +2509,9 @@ class ColorbarPlot(ElementPlot):
         if self.cformatter is not None:
             self.colorbar_opts.update({'formatter': wrap_formatter(self.cformatter, 'c')})
 
+        if self.cticks is not None:
+            self.colorbar_opts.update(get_ticker_axis_props(self.cticks))
+
         for tk in ['cticks', 'ticks']:
             ticksize = self._fontsize(tk, common=False).get('fontsize')
             if ticksize is not None:
diff --git a/holoviews/plotting/bokeh/util.py b/holoviews/plotting/bokeh/util.py
index 9b5a662b9..5d25b2349 100644
--- a/holoviews/plotting/bokeh/util.py
+++ b/holoviews/plotting/bokeh/util.py
@@ -39,12 +39,14 @@ from bokeh.models import (
 )
 from bokeh.models.formatters import PrintfTickFormatter, TickFormatter
 from bokeh.models.scales import CategoricalScale, LinearScale, LogScale
+from bokeh.models.tickers import BasicTicker, FixedTicker, Ticker
 from bokeh.models.widgets import DataTable, Div
 from bokeh.plotting import figure
 from bokeh.themes import built_in_themes
 from bokeh.themes.theme import Theme
 from packaging.version import Version
 
+from ...core import util
 from ...core.layout import Layout
 from ...core.ndmapping import NdMapping
 from ...core.overlay import NdOverlay, Overlay
@@ -1167,3 +1169,30 @@ def dtype_fix_hook(plot, element):
                 for k, v in data.items():
                     if hasattr(v, "dtype") and v.dtype.kind == "U":
                         data[k] = v.tolist()
+
+
+def get_ticker_axis_props(ticker):
+    axis_props = {}
+    if isinstance(ticker, np.ndarray):
+        ticker = list(ticker)
+    if isinstance(ticker, Ticker):
+        axis_props['ticker'] = ticker
+    elif isinstance(ticker, int):
+        axis_props['ticker'] = BasicTicker(desired_num_ticks=ticker)
+    elif isinstance(ticker, (tuple, list)):
+        if all(isinstance(t, tuple) for t in ticker):
+            ticks, labels = zip(*ticker)
+            # Ensure floats which are integers are serialized as ints
+            # because in JS the lookup fails otherwise
+            ticks = [int(t) if isinstance(t, float) and t.is_integer() else t
+                        for t in ticks]
+            labels = [l if isinstance(l, str) else str(l)
+                        for l in labels]
+        else:
+            ticks, labels = ticker, None
+        if ticks and util.isdatetime(ticks[0]):
+            ticks = [util.dt_to_int(tick, 'ms') for tick in ticks]
+        axis_props['ticker'] = FixedTicker(ticks=ticks)
+        if labels is not None:
+            axis_props['major_label_overrides'] = dict(zip(ticks, labels))
+    return axis_props
diff --git a/holoviews/plotting/plot.py b/holoviews/plotting/plot.py
index 4bd69f675..cc9bdbc48 100644
--- a/holoviews/plotting/plot.py
+++ b/holoviews/plotting/plot.py
@@ -1132,15 +1132,13 @@ class GenericElementPlot(DimensionedPlot):
     yrotation = param.Integer(default=None, bounds=(0, 360), doc="""
         Rotation angle of the yticks.""")
 
-    xticks = param.Parameter(default=None, doc="""
+    xticks = param.ClassSelector(class_=(int, list, tuple, np.ndarray), default=None, doc="""
         Ticks along x-axis specified as an integer, explicit list of
-        tick locations, or bokeh Ticker object. If set to None default
-        bokeh ticking behavior is applied.""")
+        tick locations. If set to None default ticking behavior is applied.""")
 
-    yticks = param.Parameter(default=None, doc="""
+    yticks = param.ClassSelector(class_=(int, list, tuple, np.ndarray), default=None, doc="""
         Ticks along y-axis specified as an integer, explicit list of
-        tick locations, or bokeh Ticker object. If set to None
-        default bokeh ticking behavior is applied.""")
+        tick locations. If set to None default ticking behavior is applied.""")
 
     # A dictionary mapping of the plot methods used to draw the
     # glyphs corresponding to the ElementPlot, can support two
diff --git a/holoviews/plotting/renderer.py b/holoviews/plotting/renderer.py
index 04f86b5b4..7d8fcb97f 100644
--- a/holoviews/plotting/renderer.py
+++ b/holoviews/plotting/renderer.py
@@ -16,21 +16,20 @@ from bokeh.io import curdoc
 from bokeh.resources import CDN, INLINE
 from packaging.version import Version
 from panel import config
-from panel.io.notebook import ipywidget, load_notebook, render_mimebundle, render_model
+from panel.io.notebook import (
+    JupyterCommManagerBinary,
+    ipywidget,
+    load_notebook,
+    render_mimebundle,
+    render_model,
+)
 from panel.io.state import state
 from panel.models.comm_manager import CommManager as PnCommManager
 from panel.pane import HoloViews as HoloViewsPane
 from panel.viewable import Viewable
 from panel.widgets.player import PlayerBase
-from pyviz_comms import CommManager
-
-try:
-    # Added in Panel 1.0 to support JS -> Python binary comms
-    from panel.io.notebook import JupyterCommManagerBinary as JupyterCommManager
-except ImportError:
-    from pyviz_comms import JupyterCommManager
-
 from param.parameterized import bothmethod
+from pyviz_comms import CommManager
 
 from ..core import AdjointLayout, DynamicMap, HoloMap, Layout
 from ..core.data import disable_pipeline
@@ -665,8 +664,8 @@ class Renderer(Exporter):
             if not ip or not hasattr(ip, 'kernel'):
                 return
             cls.notebook_context = True
-            cls.comm_manager = JupyterCommManager
-            state._comm_manager = JupyterCommManager
+            cls.comm_manager = JupyterCommManagerBinary
+            state._comm_manager = JupyterCommManagerBinary
 
     @classmethod
     def _delete_plot(cls, plot_id):
diff --git a/holoviews/util/transform.py b/holoviews/util/transform.py
index 69965af1d..0298aa3fa 100644
--- a/holoviews/util/transform.py
+++ b/holoviews/util/transform.py
@@ -170,18 +170,7 @@ def categorize(values, categories, default=None):
     return result
 
 
-if hasattr(np, 'isin'):
-    isin = _maybe_map(np.isin)
-else:
-    # for 1.4 <= numpy < 1.13; in1d() available since 1.4
-    def _isin(element, test_elements, assume_unique=False, invert=False):
-        # from 1.13's numpy.lib.arraysetops
-        element = np.asarray(element)
-        return np.in1d(element, test_elements, assume_unique=assume_unique,
-                invert=invert).reshape(element.shape)
-    isin = _maybe_map(_isin)
-    del _isin
-
+isin = _maybe_map(np.isin)
 digitize = _maybe_map(np.digitize)
 astype = _maybe_map(np.asarray)
 round_ = _maybe_map(np.round)
diff --git a/pixi.toml b/pixi.toml
index bf8954c53..f9ef58c64 100644
--- a/pixi.toml
+++ b/pixi.toml
@@ -26,6 +26,7 @@ build = ["py311", "build"]
 lint = ["py311", "lint"]
 
 [dependencies]
+bokeh = ">=3.1"
 colorcet = "*"
 numpy = ">=1.0"
 packaging = "*"
@@ -35,10 +36,7 @@ param = ">=2.0,<3.0"
 pip = "*"
 pyviz_comms = ">=2.1"
 # Recommended
-bokeh = ">=3.1"
-ipython = ">=5.4.0"
 matplotlib-base = ">=3"
-notebook = "*"
 plotly = ">=4.0"
 
 [feature.py39.dependencies]
@@ -58,6 +56,7 @@ cftime = "*"
 dask-core = "*"
 datashader = ">=0.11.1"
 ffmpeg = "*"
+ipython = ">=5.4.0"
 netcdf4 = "*"
 networkx = "*"
 notebook = "*"
diff --git a/pyproject.toml b/pyproject.toml
index 319326779..489cf75a5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -30,13 +30,14 @@ classifiers = [
     "Topic :: Software Development :: Libraries",
 ]
 dependencies = [
-    "param >=2.0,<3.0",
-    "numpy >=1.0",
-    "pyviz_comms >=2.1",
-    "panel >=1.0",
+    "bokeh >=3.1",
     "colorcet",
+    "numpy >=1.21",
     "packaging",
-    "pandas >=0.20.0",
+    "pandas >=1.3",
+    "panel >=1.0",
+    "param >=2.0,<3.0",
+    "pyviz_comms >=2.1",
 ]
 
 [project.urls]
@@ -45,7 +46,7 @@ Source = "https://github.com/holoviz/holoviews"
 HoloViz = "https://holoviz.org/"
 
 [project.optional-dependencies]
-recommended = ["ipython >=5.4.0", "notebook", "matplotlib >=3", "bokeh >=3.1", "plotly >=4.0"]
+recommended = ["matplotlib >=3", "plotly >=4.0"]
 tests = ["pytest", "pytest-rerunfailures"]
 
 [project.scripts]
diff --git a/scripts/conda/recipe/meta.yaml b/scripts/conda/recipe/meta.yaml
index 5b1968dee..8b94a418c 100644
--- a/scripts/conda/recipe/meta.yaml
+++ b/scripts/conda/recipe/meta.yaml
@@ -30,7 +30,9 @@ requirements:
     - {{ dep }}
     {% endfor %}
     {% for dep in project['optional-dependencies']['recommended'] %}
-    - {{ dep }}
+    {% if dep.startswith('matplotlib') %}
+    - {{ dep.replace('matplotlib', 'matplotlib-base') }}
+    {% endif %}
     {% endfor %}
 
 test:


Test patch:
diff --git a/holoviews/tests/plotting/bokeh/test_elementplot.py b/holoviews/tests/plotting/bokeh/test_elementplot.py
index 9cef2f3b6..af8e52b24 100644
--- a/holoviews/tests/plotting/bokeh/test_elementplot.py
+++ b/holoviews/tests/plotting/bokeh/test_elementplot.py
@@ -885,6 +885,51 @@ class TestColorbarPlot(LoggingComparisonTestCase, TestBokehPlot):
         self.assertEqual(cmapper.factors, ['0', '1', '2', '3'])
         self.assertEqual(cmapper.palette, ['blue', 'red', 'green', 'purple'])
 
+    def test_cticks_int(self):
+        img = Image(np.array([[0, 1], [2, 3]])).opts(cticks=3, colorbar=True)
+        plot = bokeh_renderer.get_plot(img)
+        colorbar = plot.handles["colorbar"]
+        ticker = colorbar.ticker
+        assert ticker.desired_num_ticks == 3
+
+    def test_cticks_list(self):
+        img = Image(np.array([[0, 1], [2, 3]])).opts(cticks=[1, 2], colorbar=True)
+        plot = bokeh_renderer.get_plot(img)
+        colorbar = plot.handles["colorbar"]
+        ticker = colorbar.ticker
+        assert ticker.ticks == [1, 2]
+
+    def test_cticks_tuple(self):
+        img = Image(np.array([[0, 1], [2, 3]])).opts(cticks=(1, 2), colorbar=True)
+        plot = bokeh_renderer.get_plot(img)
+        colorbar = plot.handles["colorbar"]
+        ticker = colorbar.ticker
+        assert ticker.ticks == (1, 2)
+
+    def test_cticks_np_array(self):
+        img = Image(np.array([[0, 1], [2, 3]])).opts(cticks=np.array([1, 2]), colorbar=True)
+        plot = bokeh_renderer.get_plot(img)
+        colorbar = plot.handles["colorbar"]
+        ticker = colorbar.ticker
+        assert ticker.ticks == [1, 2]
+
+    def test_cticks_labels(self):
+        img = Image(np.array([[0, 1], [2, 3]])).opts(cticks=[(1, "A"), (2, "B")], colorbar=True)
+        plot = bokeh_renderer.get_plot(img)
+        colorbar = plot.handles["colorbar"]
+        assert colorbar.major_label_overrides == {1: "A", 2: "B"}
+        ticker = colorbar.ticker
+        assert ticker.ticks == [1, 2]
+
+    def test_cticks_ticker(self):
+        img = Image(np.array([[0, 1], [2, 3]])).opts(
+            cticks=FixedTicker(ticks=[0, 1]), colorbar=True
+        )
+        plot = bokeh_renderer.get_plot(img)
+        colorbar = plot.handles["colorbar"]
+        ticker = colorbar.ticker
+        assert ticker.ticks == [0, 1]
+
 
 class TestOverlayPlot(TestBokehPlot):