Repository: holoviz/holoviews
Pull request #6586 (2025-05-23 08:55:05)

enh: Improve the content displayed when loading the extension in a notebook
Goals:
- Exploit the fact that HoloViews displays its logo and the backends logo to provide more information
  - Make the icons clickable, linking to the documentation
  - On hover, the icons show `{package} {version}`
- Let the user know they should probably not delete this cell. In the future I'd love to be able to link to a page that gives more explanation, it doesn't exist yet.
- Allow hvPlot to customize the icon and content that gets displayed

<img width="390" alt="image" src="https://github.com/user-attachments/assets/e007c208-efd0-4f52-a074-6c20b91ea860" />

Hover on the HoloViews icon:
<img width="392" alt="image" src="https://github.com/user-attachments/assets/7c523e46-1d2c-4816-8306-65d08d64329a" />

Hover on the Bokeh icon:
<img width="381" alt="image" src="https://github.com/user-attachments/assets/9a8080a5-e8cf-4c43-bf72-5cc6f334474e" />

Hover on the question mark:
<img width="481" alt="image" src="https://github.com/user-attachments/assets/739a0fde-dcd1-485f-b5cb-3a25a92d72f6" />

This enables hvPlot to also include its icon.

<img width="276" alt="image" src="https://github.com/user-attachments/assets/20c1101e-f215-4603-9b36-5ed9ef8b8ac9" />


maximlt: Draft for now but very interested in getting some feedback already to know if this is going the right way and could make it in the next release.
hoxbro: I don't like the text; maybe add only the I icon and place it next to the logos. This is, in my opinion, less intrusive. 
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/holoviz/holoviews/pull/6586?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 `65.21739%` with `8 lines` in your changes missing coverage. Please review.
> Project coverage is 88.80%. Comparing base [(`7241ba4`)](https://app.codecov.io/gh/holoviz/holoviews/commit/7241ba425a5276c47121498d0749ec05cd5c18f1?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) to head [(`8ff9045`)](https://app.codecov.io/gh/holoviz/holoviews/commit/8ff904527f9bf4ea234288b6f6b202a45b654850?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz).
> Report is 2 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/holoviz/holoviews/pull/6586?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/ipython/\_\_init\_\_.py](https://app.codecov.io/gh/holoviz/holoviews/pull/6586?src=pr&el=tree&filepath=holoviews%2Fipython%2F__init__.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL2lweXRob24vX19pbml0X18ucHk=) | 65.21% | [8 Missing :warning: ](https://app.codecov.io/gh/holoviz/holoviews/pull/6586?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    #6586      +/-   ##
==========================================
- Coverage   88.81%   88.80%   -0.01%     
==========================================
  Files         326      326              
  Lines       69506    69537      +31     
==========================================
+ Hits        61732    61753      +21     
- Misses       7774     7784      +10     
```

</details>

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

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>

Patch:
diff --git a/.github/workflows/conventional_commits.yaml b/.github/workflows/conventional_commits.yaml
index bd04a4141..1d0de5590 100644
--- a/.github/workflows/conventional_commits.yaml
+++ b/.github/workflows/conventional_commits.yaml
@@ -42,3 +42,8 @@ jobs:
             plotly
           ignoreLabels: |
             ignore-semantic-pull-request
+          subjectPatternError: |
+            The subject "{subject}" found in the pull request title "{title}"
+            didn't match the configured pattern. Please ensure that the subject
+            doesn't start with an uppercase character. Also make sure that the
+            type ends with a colol, e.g. `docs:`
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 568092621..46d6206e5 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -17,9 +17,9 @@ repos:
       - id: check-json
       - id: detect-private-key
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.11.0
+    rev: v0.11.13
     hooks:
-      - id: ruff
+      - id: ruff-check
   - repo: https://github.com/hoxbro/clean_notebook
     rev: v0.1.17
     hooks:
diff --git a/doc/user_guide/index.rst b/doc/user_guide/index.rst
index 252b65393..fd01abdde 100644
--- a/doc/user_guide/index.rst
+++ b/doc/user_guide/index.rst
@@ -66,8 +66,12 @@ concepts in HoloViews:
 `Working with large data <Large_Data.html>`_
  Leverage Datashader to interactively explore millions or billions of datapoints.
 
+`Interactive Hover for Big Data <Interactive_Hover_for_Big_Data.html>`_
+ Use the ``selector`` with Datashader to enable fast, interactive hover tooltips that
+ reveal individual data points without sacrificing aggregation.
+
 `Working with Streaming Data <Streaming_Data.html>`_
- Demonstrates how to leverage the streamz library with HoloViews to work with streaming datasets.
+ Demonstrates how to leverage HoloViews to work with streaming datasets.
 
 `Creating interactive dashboards <Dashboards.html>`_
  Use external widget libraries to build custom, interactive dashboards.
@@ -142,6 +146,7 @@ These guides provide detail about specific additional features in HoloViews:
     Data Processing Pipelines <Data_Pipelines>
     Creating interactive network graphs <Network_Graphs>
     Working with large data <Large_Data>
+    Interactive Hover for Big Data <Interactive_Hover_for_Big_Data>
     Working with streaming data <Streaming_Data>
     Creating interactive dashboards <Dashboards>
     Customizing Plots <Customizing_Plots>
diff --git a/examples/reference/elements/bokeh/Dendrogram.ipynb b/examples/reference/elements/bokeh/Dendrogram.ipynb
index ae759b2a6..b71737c63 100644
--- a/examples/reference/elements/bokeh/Dendrogram.ipynb
+++ b/examples/reference/elements/bokeh/Dendrogram.ipynb
@@ -19,8 +19,6 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "import random\n",
-    "\n",
     "import numpy as np\n",
     "import pandas as pd\n",
     "\n",
@@ -77,12 +75,25 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "df = pd.DataFrame(\n",
-    "    [(i, chr(65 + j), random.random()) for j in range(10) for i in range(5)],\n",
-    "    columns=[\"z\", \"x\", \"y\"],\n",
+    "data = [\n",
+    "    [7, 8, 0, 0, 0, 0],\n",
+    "    [8, 6, 0, 0, 0, 0],\n",
+    "    [0, 0, 9, 8, 2, 7],\n",
+    "    [0, 0, 0, 1, 8, 2],\n",
+    "    [0, 7, 0, 7, 0, 0],\n",
+    "]\n",
+    "df = pd.DataFrame([\n",
+    "    {\"cluster\": f\"clust {i}\", \"gene\": f\"gene {j}\", \"value\": data[i][j]}\n",
+    "    for j in range(6)\n",
+    "    for i in range(5)\n",
+    "])\n",
+    "\n",
+    "# Setting the colors to the normalized value and only have a size for non-zero values\n",
+    "opts = dict(\n",
+    "    color=hv.dim(\"value\").norm(), size=(hv.dim(\"value\") != 0) * 30, cmap=\"Reds\", tools=[\"hover\"]\n",
     ")\n",
-    "heatmap = hv.HeatMap(df)\n",
-    "heatmap"
+    "plot = hv.Points(df, kdims=[\"gene\", \"cluster\"]).opts(**opts)\n",
+    "plot"
    ]
   },
   {
@@ -93,7 +104,7 @@
    "source": [
     "from holoviews.operation import dendrogram\n",
     "\n",
-    "hv.operation.dendrogram(heatmap, adjoint_dims=[\"x\", \"z\"], main_dim=\"y\")"
+    "hv.operation.dendrogram(plot, adjoint_dims=[\"cluster\"], main_dim=\"value\")"
    ]
   }
  ],
diff --git a/examples/reference/elements/bokeh/Points.ipynb b/examples/reference/elements/bokeh/Points.ipynb
index 48b85a959..ae7f041e6 100644
--- a/examples/reference/elements/bokeh/Points.ipynb
+++ b/examples/reference/elements/bokeh/Points.ipynb
@@ -155,6 +155,30 @@
     "\n",
     "For full documentation and the available style and plot options, use ``hv.help(hv.Points).``"
    ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Scale by radius\n",
+    "If you want to scale the size of points based on a radius, this ensures that the visual scale remains consistent when you zoom in or out—since radius is specified in data coordinates, not screen pixels.\n",
+    "\n",
+    "Below is an example using `hv.Points`, where the radius is determined by normalizing the `zs` values:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "x = np.arange(5)\n",
+    "y = np.arange(4)\n",
+    "z = np.arange(20).reshape(4, 5)\n",
+    "\n",
+    "radius_dim = dim(\"z\").norm() / 2\n",
+    "hv.Points((x, y, z), kdims=[\"x\", \"y\"], vdims=\"z\").opts(radius=radius_dim)"
+   ]
   }
  ],
  "metadata": {
diff --git a/examples/user_guide/10-Indexing_and_Selecting_Data.ipynb b/examples/user_guide/10-Indexing_and_Selecting_Data.ipynb
index d3afe29d4..19c504797 100644
--- a/examples/user_guide/10-Indexing_and_Selecting_Data.ipynb
+++ b/examples/user_guide/10-Indexing_and_Selecting_Data.ipynb
@@ -17,11 +17,11 @@
     "\n",
     "| Operation      | Example syntax   |  Description |\n",
     "|:---------------|:----------------:|:-------------|\n",
-    "| **indexing**   | e[5.5], e[3,5.5] | Selecting a single data value, returning one actual numerical value from the existing data\n",
-    "| **slice**      | e[3:5.5], e[3:5.5,0:1] | Selecting a contiguous portion from an Element, returning the same type of Element\n",
-    "| **sample**     | e.sample(y=5.5),<br>e.sample((3,3)) |  Selecting one or more regularly spaced data values, returning a new type of Element\n",
-    "| **select**     | e.select(y=5.5),<br>e.select(y=(3,5.5)) | More verbose notation covering all supported slice and index operations by dimension name.\n",
-    "| **iloc**       | e[2, :],<br>e[2:5, :]  | Indexes and slices by row and column tabular index supporting integer indexes, slices, lists and boolean indices.\n",
+    "| **indexing**   | `e[5.5]`, `e[3,5.5]` | Selecting a single data value, returning one actual numerical value from the existing data\n",
+    "| **slice**      | `e[3:5.5]`, `e[3:5.5,0:1]` | Selecting a contiguous portion from an Element, returning the same type of Element\n",
+    "| **sample**     | `e.sample(y=5.5)`,<br>`e.sample((3,3))` |  Selecting one or more regularly spaced data values, returning a new type of Element\n",
+    "| **select**     | `e.select(y=5.5)`,<br>`e.select(y=(3,5.5))`,<br>`e.select({\"y\": (3, 3.5)}` | More verbose notation covering all supported slice and index operations by dimension name.\n",
+    "| **iloc**       | `e[2, :]`,<br>`e[2:5, :]` | Indexes and slices by row and column tabular index supporting integer indexes, slices, lists and boolean indices.\n",
     "\n",
     "These operations are all concerned with selecting some subset of the data values, without combining across data values (e.g. averaging) or otherwise transforming the actual data. In the [Tabular Data](./08-Tabular_Datasets.ipynb) user guide we will look at additional operations on the data that reduce, summarize, or transform the data in other ways, in addition to the selections covered here.\n",
     "\n",
@@ -241,21 +241,56 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "# Tabular indexing and slicing"
+    "## Using `.select`\n",
+    "\n",
+    "The `[]` (i.e. `__getitem__`) syntax provides a convenient and concise mechanism for selecting and indexing, however to be very explicit even for nested objects the `.select` method provides an equivalent mechanism.\n",
+    "\n",
+    "The `.select` method allows providing selections as keyword arguments indexed by dimension name but also allows passing a dictionary of selectors directly:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "img * box + img.select(x=(1, 9), y=(4, 5)) + img.select({\"x\": (1, 9), \"y\": (4, 5)})"
    ]
   },
   {
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "While most indexing in HoloViews works by selecting the values along a dimension it is also frequently useful to index and slice using integer row and column indices. For this purpose most HoloViews objects have a ``.iloc`` indexing interface (mirroring the [pandas](http://pandas.pydata.org/pandas-docs/stable/indexing.html#different-choices-for-indexing) API), which supports all the usual indexing semantics. Supported iloc arguments include:\n",
-    "\n",
-    "* An integer e.g. 5\n",
-    "\n",
-    "* A list or array of integers [4, 3, 0]\n",
+    "The select operation also supports so called selection expressions built using the `hv.dim` transforms:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "curve = hv.Curve((xs, np.sin(xs)))\n",
     "\n",
-    "* A slice object with ints 1:7\n",
+    "curve * curve.select(hv.dim('x') > 12)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Tabular indexing and slicing"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "While most indexing in HoloViews works by selecting the values along a dimension it is also frequently useful to index and slice using integer row and column indices. For this purpose most HoloViews objects have a ``.iloc`` indexing interface (mirroring the [pandas](http://pandas.pydata.org/pandas-docs/stable/indexing.html#different-choices-for-indexing) API), which supports all the usual indexing semantics. Supported iloc arguments include:\n",
     "\n",
+    "* An integer e.g. `5`\n",
+    "* A list or array of integers `[4, 3, 0]`\n",
+    "* A slice object with ints `1:7`\n",
     "* A boolean array"
    ]
   },
diff --git a/examples/user_guide/16-Streaming_Data.ipynb b/examples/user_guide/16-Streaming_Data.ipynb
index daa479a25..5908b4662 100644
--- a/examples/user_guide/16-Streaming_Data.ipynb
+++ b/examples/user_guide/16-Streaming_Data.ipynb
@@ -10,9 +10,7 @@
     "\n",
     "This user guide shows a third way of building an interactive plot, using ``DynamicMap`` and streams.  Here, instead of pushing plot metadata (such as zoom ranges, user triggered events such as ``Tap`` and so on) to a ``DynamicMap`` callback, the underlying data in the visualized elements are updated directly using a HoloViews ``Stream``.\n",
     "\n",
-    "In particular, we will show how the HoloViews ``Pipe`` and ``Buffer`` streams can be used to work with streaming data sources without having to fetch or generate the data from inside the ``DynamicMap`` callable. Apart from simply setting element data from outside a ``DynamicMap``, we will also explore ways of working with streaming data coordinated by the separate [``streamz``](http://matthewrocklin.com/blog/work/2017/10/16/streaming-dataframes-1) library from Matt Rocklin, which can make building complex streaming pipelines much simpler.\n",
-    "\n",
-    "As this notebook makes use of the ``streamz`` library, you will need to install it with ``conda install streamz`` or ``pip install streamz``."
+    "In particular, we will show how the HoloViews ``Pipe`` and ``Buffer`` streams can be used to work with streaming data sources without having to fetch or generate the data from inside the ``DynamicMap`` callable. Apart from simply setting element data from outside a ``DynamicMap``.\n"
    ]
   },
   {
@@ -25,8 +23,6 @@
     "\n",
     "import numpy as np\n",
     "import pandas as pd\n",
-    "import streamz\n",
-    "import streamz.dataframe\n",
     "\n",
     "import holoviews as hv\n",
     "from holoviews import opts\n",
@@ -211,212 +207,9 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "## Using the Streamz library\n",
-    "\n",
-    "Now that we have discovered what ``Pipe`` and ``Buffer`` can do it's time to show how you can use them together with the ``streamz`` library. Although HoloViews does not depend on ``streamz`` and you can use the streaming functionality without needing to learn about it, the two libraries work well together, allowing you to build pipelines to manage continuous streams of data. Streamz is easy to use for simple tasks, but also supports complex pipelines that involve branching, joining, flow control, feedback and more. Here we will mostly focus on connecting streamz output to ``Pipe`` and then ``Buffer`` so for more details about the streamz API, consult the [streamz documentation](https://streamz.readthedocs.io/en/latest/).\n",
-    "\n",
-    "#### Using ``streamz.Stream`` together with ``Pipe``\n",
-    "\n",
-    "Let's start with a fairly simple example:\n",
-    "\n",
-    "1. Declare a ``streamz.Stream`` and a ``Pipe`` object and connect them into a pipeline into which we can push data. \n",
-    "2. Use a ``sliding_window`` of 10, which will first wait for 10 sets of stream updates to accumulate. At that point and for every subsequent update, it will apply ``pd.concat`` to combine the most recent 10 updates into a new dataframe.\n",
-    "3. Use the ``sink`` method on the ``streamz.Stream`` to ``send`` the resulting collection of 10 updates to ``Pipe``.\n",
-    "4. Declare a ``DynamicMap`` that takes the sliding window of concatenated DataFrames and displays it using a ``Scatter`` Element.\n",
-    "5. Color the ``Scatter`` points by their 'count' and set a range, then display:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "point_source = streamz.Stream()\n",
-    "pipe = Pipe(data=pd.DataFrame({'x': [], 'y': [], 'count': []}))\n",
-    "point_source.sliding_window(20).map(pd.concat).sink(pipe.send) # Connect streamz to the Pipe\n",
-    "scatter_dmap = hv.DynamicMap(hv.Scatter, streams=[pipe])"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "After set up our streaming pipeline we can again display it:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "scatter_dmap.opts(bgcolor='black', color='count', ylim=(-4, 4), show_legend=False)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "<img class=\"gif\" src=\"https://assets.holoviews.org/gifs/guides/user_guide/Streaming_Data/streamz1.gif\"></img>"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "There is now a pipeline, but initially this plot will be empty, because no data has been sent to it. To see the plot update, let's use the ``emit`` method of ``streamz.Stream`` to send small chunks of random pandas ``DataFrame``s to our plot:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "for i in range(100):\n",
-    "    df = pd.DataFrame({'x': np.random.rand(100), 'y': np.random.randn(100), 'count': i},\n",
-    "                      columns=['x', 'y', 'count'])\n",
-    "    point_source.emit(df)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "#### Using StreamingDataFrame and StreamingSeries\n",
-    "\n",
-    "The streamz library provides ``StreamingDataFrame`` and ``StreamingSeries`` as a powerful way to easily work with live sources of tabular data. This makes it perfectly suited to work with ``Buffer``. With the ``StreamingDataFrame`` we can easily stream data, apply computations such as cumulative and rolling statistics and then visualize the data with HoloViews.\n",
-    "\n",
-    "The ``streamz.dataframe`` module provides a ``Random`` utility that generates a ``StreamingDataFrame`` that emits random data with a certain frequency at a specified interval. The ``example`` attribute lets us see the structure and dtypes of the data we can expect:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "simple_sdf = streamz.dataframe.Random(freq='10ms', interval='100ms')\n",
-    "print(simple_sdf.index)\n",
-    "simple_sdf.example.dtypes"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Since the ``StreamingDataFrame`` provides a pandas-like API, we can specify operations on the data directly. In this example we subtract a fixed offset and then compute the cumulative sum, giving us a randomly drifting timeseries. We can then pass the x-values of this dataframe to the HoloViews ``Buffer`` and supply ``hv.Curve`` as the ``DynamicMap`` callback to stream the data into a HoloViews ``Curve`` (with the default key and value dimensions):"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "sdf = (simple_sdf-0.5).cumsum()\n",
-    "hv.DynamicMap(hv.Curve, streams=[Buffer(sdf.x)]).opts(width=500, show_grid=True)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "<img class=\"gif\" src=\"https://assets.holoviews.org/gifs/guides/user_guide/Streaming_Data/streamz3.gif\"></img>"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "The ``Random`` StreamingDataFrame will asynchronously emit events, driving the visualization forward, until it is explicitly stopped, which we can do by calling the ``stop`` method."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "simple_sdf.stop()"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "#### Making use of the ``StreamingDataFrame`` API\n",
-    "\n",
-    "So far we have only computed the cumulative sum, but the ``StreamingDataFrame`` actually has an extensive API that lets us run a broad range of streaming computations on our data. For example, let's apply a rolling mean to our x-values with a window of 500ms and overlay it on top of the 'raw' data:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "source_df = streamz.dataframe.Random(freq='5ms', interval='100ms')\n",
-    "sdf = (source_df-0.5).cumsum()\n",
-    "raw_dmap = hv.DynamicMap(hv.Curve, streams=[Buffer(sdf.x)])\n",
-    "smooth_dmap = hv.DynamicMap(hv.Curve, streams=[Buffer(sdf.x.rolling('500ms').mean())])\n",
-    "\n",
-    "(raw_dmap.relabel('raw') * smooth_dmap.relabel('smooth')).opts(\n",
-    "    opts.Curve(width=500, show_grid=True))"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "<img class=\"gif\" src=\"https://assets.holoviews.org/gifs/guides/user_guide/Streaming_Data/streamz4.gif\"></img>"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "source_df.stop()"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "#### Customizing elements with ``functools.partial``\n",
-    "\n",
-    "In this notebook we have avoided defining custom functions for ``DynamicMap`` by simply supplying the element class and using the element constructor instead. Although this works well for examples, it often won't generalize to real-life situations, because you don't have an opportunity to use anything other than the default dimensions. One simple way to get around this limitation is to use ``functools.partial``:\n",
-    "\n"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "from functools import partial"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Now you can now easily create an inline callable that creates an element with custom key and value dimensions by supplying them to ``partial`` in the form ``partial(hv.Element, kdims=[...], vdims=[...])``. In the next section, we will see an example of this pattern using ``hv.BoxWhisker``."
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "#### Controlling the length\n",
-    "\n",
-    "By default the ``Buffer`` accumulates a ``length`` of 1000 samples. In many cases this may be excessive, but we can specify a shorter (or longer) length value to control how much history we accumulate, often depending on the element type.\n",
+    "## Asynchronous updates using asyncio\n",
     "\n",
-    "In the following example, a custom ``length`` is used together with a ``partial`` wrapping ``hv.BoxWhisker`` in order to display a cumulative sum generated from a stream of random dataframes:"
+    "In most cases, instead of pushing updates manually from the same Python process, you'll want the object to update asynchronously as new data arrives. Since both Jupyter and Bokeh server run on an `asyncio` event-loop in both cases to define a non-blocking co-routine that can push data to our stream whenever it is ready. We can define an asynchronous functionwith a `asyncio.sleep` timeout and schedule it as a task. Once we have declared the callback we can call ``start`` to begin emitting events:"
    ]
   },
   {
@@ -425,169 +218,19 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "multi_source = streamz.dataframe.Random(freq='50ms', interval='500ms')\n",
-    "sdf = (multi_source-0.5).cumsum()\n",
-    "hv.DynamicMap(hv.Table, streams=[Buffer(sdf.x, length=10)]) +\\\n",
-    "hv.DynamicMap(partial(hv.BoxWhisker, kdims=[], vdims='x'), streams=[Buffer(sdf.x, length=100)])"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "<img class=\"gif\" src=\"https://assets.holoviews.org/gifs/guides/user_guide/Streaming_Data/streamz5.gif\"></img>"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Here the given stream ``sdf`` is being consumed by a table showing a short length (where only the items visible in the table need to be kept), along with a plot computing averages and variances over a longer length (100 items).\n",
-    "\n",
-    "#### Updating multiple cells\n",
-    "\n",
-    "Since a ``StreamingDataFrame`` will emit data until it is stopped, we can subscribe multiple plots across different cells to the same stream.  Here, let's add a ``Scatter`` plot of the same data stream as in the preceding cell:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "hv.DynamicMap(hv.Scatter, streams=[Buffer(sdf.x)]).redim.label(x='value', index='time')"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "<img class=\"gif\" src=\"https://assets.holoviews.org/gifs/guides/user_guide/Streaming_Data/streamz6.gif\"></img>"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Here we let the ``Scatter`` elements use the column names from the supplied ``DataFrames`` which are relabelled using the ``redim`` method. Stopping the stream will now stop updates to all three of these DynamicMaps:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "multi_source.stop()"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "## Operations over streaming data\n",
-    "\n",
-    "As we discovered above, the ``Buffer`` lets us set a ``length``, which defines how many rows we want to accumulate. We can use this to our advantage and apply an operation over this length window. In this example we declare a ``Dataset`` and then apply the ``histogram`` operation to compute a ``Histogram`` over the specified ``length`` window:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "hist_source = streamz.dataframe.Random(freq='5ms', interval='100ms')\n",
-    "sdf = (hist_source-0.5).cumsum()\n",
-    "dmap = hv.DynamicMap(hv.Dataset, streams=[Buffer(sdf.x, length=500)])\n",
-    "hv.operation.histogram(dmap, dimension='x')"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "<img class=\"gif\" src=\"https://assets.holoviews.org/gifs/guides/user_guide/Streaming_Data/streamz7.gif\"></img>"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "hist_source.stop()"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "#### Datashading\n",
-    "\n",
-    "The same approach will also work for the datashader operation letting us datashade the entire ``length`` window even if we make it very large such as 1 million samples:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "from bokeh.palettes import Blues8\n",
-    "\n",
-    "from holoviews.operation.datashader import datashade\n",
-    "\n",
-    "large_source = streamz.dataframe.Random(freq='100us', interval='200ms')\n",
-    "sdf = (large_source-0.5).cumsum()\n",
-    "dmap = hv.DynamicMap(hv.Curve, streams=[Buffer(sdf.x, length=1000000)])\n",
-    "datashade(dmap, streams=[hv.streams.PlotSize], cnorm='linear', cmap=list(Blues8)).opts(width=600)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "<img class=\"gif\" src=\"https://assets.holoviews.org/gifs/guides/user_guide/Streaming_Data/streamz8.gif\"></img>"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "large_source.stop()"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "## Asynchronous updates using the tornado ``IOLoop``\n",
-    "\n",
-    "In most cases, instead of pushing updates manually from the same Python process, you'll want the object to update asynchronously as new data arrives. Since both Jupyter and Bokeh server run on [tornado](http://www.tornadoweb.org/en/stable/), we can use the tornado ``IOLoop`` in both cases to define a non-blocking co-routine that can push data to our stream whenever it is ready. The ``PeriodicCallback`` makes this approach very simple, we simply define a function which will be called periodically with a timeout defined in milliseconds. Once we have declared the callback we can call ``start`` to begin emitting events:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "from tornado import gen\n",
-    "from tornado.ioloop import PeriodicCallback\n",
+    "import asyncio\n",
     "\n",
     "count = 0\n",
     "buffer = Buffer(np.zeros((0, 2)), length=50)\n",
     "\n",
-    "@gen.coroutine\n",
-    "def f():\n",
+    "async def f():\n",
     "    global count\n",
-    "    count += 1\n",
-    "    buffer.send(np.array([[count, np.random.rand()]]))\n",
+    "    while True:\n",
+    "        await asyncio.sleep(0.1)\n",
+    "        count += 1\n",
+    "        buffer.send(np.array([[count, np.random.rand()]]))\n",
     "\n",
-    "cb = PeriodicCallback(f, 100)\n",
-    "cb.start()\n",
+    "task = asyncio.create_task(f())\n",
     "\n",
     "hv.DynamicMap(hv.Curve, streams=[buffer]).opts(padding=0.1, width=600)"
    ]
@@ -612,7 +255,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "cb.stop()"
+    "task.cancel()"
    ]
   },
   {
diff --git a/examples/user_guide/Customizing_Plots.ipynb b/examples/user_guide/Customizing_Plots.ipynb
index da0293bf8..337af4d76 100644
--- a/examples/user_guide/Customizing_Plots.ipynb
+++ b/examples/user_guide/Customizing_Plots.ipynb
@@ -41,7 +41,7 @@
     "        - [Logarithmic axes](#log-axes)\n",
     "        - [Datetime axes](#datetime-axes)\n",
     "        - [Categorical axes](#categorical-axes)\n",
-    "    - [Axis position](#axis-position): Positioning and hiding axes\n",
+    "    - [Axis positions](#axis-positions): Positioning and hiding axes\n",
     "    - [Inverting axes](#inverting-axes): Flipping the x-/y-axes and inverting an axis\n",
     "    - [Axis labels](#axis-labels): Setting axis labels using dimensions and options\n",
     "    - [Axis ranges](#axis-ranges): Controlling axes ranges using dimensions, padding and options\n",
diff --git a/examples/user_guide/Interactive_Hover_for_Big_Data.ipynb b/examples/user_guide/Interactive_Hover_for_Big_Data.ipynb
index 6336d8447..704ca1997 100644
--- a/examples/user_guide/Interactive_Hover_for_Big_Data.ipynb
+++ b/examples/user_guide/Interactive_Hover_for_Big_Data.ipynb
@@ -18,6 +18,10 @@
     "This notebook uses dynamic updates, which require running a live Jupyter or Bokeh server. When viewed statically, the plots will not update, you can zoom and pan, and hover information will not be available. \n",
     ":::\n",
     "\n",
+    ":::{note}\n",
+    "This functionality requires Bokeh version 3.7 or greater.\n",
+    ":::\n",
+    "\n",
     "Let's start by creating a Points element with a DataFrame consisting of five datasets combined. Each of the datasets has a random x, y-coordinate based on a normal distribution centered at a specific (x, y) location, with varying standard deviations. The datasets—labeled `d1` through `d5`—represent different clusters:\n",
     "\n",
     "- `d1` is tightly clustered around (2, 2) with a small spread of 0.03,\n",
diff --git a/examples/user_guide/Plotting_with_Bokeh.ipynb b/examples/user_guide/Plotting_with_Bokeh.ipynb
index ff709e1be..fd3010d6e 100644
--- a/examples/user_guide/Plotting_with_Bokeh.ipynb
+++ b/examples/user_guide/Plotting_with_Bokeh.ipynb
@@ -690,6 +690,67 @@
     "(hv.Curve([1, 2, 3]).opts(toolbar='above') + hv.Curve([1, 2, 3]).opts(toolbar=None)).opts(merge_tools=False)"
    ]
   },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "#### Auto-hiding Toolbar\n",
+    "\n",
+    "You can make the toolbar automatically hide until the user hovers over the plot by setting the `autohide_toolbar` option to `True`. This is particularly useful for:\n",
+    "\n",
+    "1. Keeping the focus on your data visualization - the data should be foremost in the viewer's attention\n",
+    "2. Reducing visual clutter around the plot interface\n",
+    "3. Making tools available only when needed, rather than having them constantly visible\n",
+    "4. Creating cleaner exports of plots where tools aren't typically needed\n",
+    "\n",
+    "When saving or exporting the plot to an image format, the toolbar is typically not shown in the saved image.  However, you can decide to show the toolbar by setting `toolbar=True` when using `hv.save()`, e.g `hv.save(obj, 'file_path.png', toolbar=True)`.\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "hv.Curve([1, 2, 3]).opts(autohide_toolbar=True)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The feature works with all plot types, including layouts and overlays:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Layout example\n",
+    "(hv.Curve([1, 2, 3]) + hv.Curve([2, 3, 4])).opts(autohide_toolbar=True)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Overlay example\n",
+    "(hv.Curve([1, 2, 3]) * hv.Curve([2, 3, 4])).opts(autohide_toolbar=True)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "::: {note}\n",
+    "``autohide_toolbar`` has no effect if the toolbar is disabled (``toolbar=None``).\n",
+    ":::"
+   ]
+  },
   {
    "cell_type": "markdown",
    "metadata": {},
diff --git a/holoviews/__init__.py b/holoviews/__init__.py
index 377be990c..d8ca19f60 100644
--- a/holoviews/__init__.py
+++ b/holoviews/__init__.py
@@ -8,7 +8,7 @@ the process of plotting.
 HoloViews
 
 - supports a wide range of data sources including Pandas, Dask, XArray
-Rapids cuDF, Streamz, Intake, Geopandas, NetworkX and Ibis.
+Rapids cuDF, Geopandas, NetworkX and Ibis.
 - supports the plotting backends Bokeh (default), Matplotlib and Plotly.
 - allows you to drop into the rest of the
 HoloViz ecosystem when more power or flexibility is needed.
diff --git a/holoviews/core/data/__init__.py b/holoviews/core/data/__init__.py
index 649862dbd..6d9240db5 100644
--- a/holoviews/core/data/__init__.py
+++ b/holoviews/core/data/__init__.py
@@ -611,10 +611,16 @@ class Dataset(Element, metaclass=PipelineMeta):
             from holoviews import dim
             ds.select(selection_expr=dim('x') % 2 == 0)
 
+        * selections dictionary: A dictionary of selections per dimension
+
+            ds.select({x: 3})
+
         Parameters
         ----------
-        selection_expr : holoviews.dim predicate expression
-            specifying selection.
+        selection_expr : A dim expression or dictionary of selections.
+            holoviews.dim predicate expression specifying selection or
+            a dictionary of selections (as an alternative to selecting
+            via keyword arguments).
         selection_specs : List of specs to match on
             A list of types, functions, or type[.group][.label]
             strings specifying which objects to apply the
@@ -629,6 +635,13 @@ class Dataset(Element, metaclass=PipelineMeta):
         or a scalar if a single value was selected
         """
         from ...util.transform import dim
+        if isinstance(selection_expr, dict):
+            if selection:
+                raise ValueError("""\
+                Selections may be supplied as keyword arguments or as a positional
+                argument, never both.""")
+            selection = selection_expr
+            selection_expr = None
         if selection_expr is not None and not isinstance(selection_expr, dim):
             raise ValueError("""\
             The first positional argument to the Dataset.select method is expected to be a
diff --git a/holoviews/core/data/pandas.py b/holoviews/core/data/pandas.py
index fdea717ba..2f2ee32a2 100644
--- a/holoviews/core/data/pandas.py
+++ b/holoviews/core/data/pandas.py
@@ -44,7 +44,7 @@ class PandasInterface(Interface, PandasAPI):
     def applies(cls, obj):
         if not cls.loaded():
             return False
-        return isinstance(obj, (pd.DataFrame, pd.Series))
+        return type(obj) is pd.DataFrame
 
     @classmethod
     def dimension_type(cls, dataset, dim):
@@ -426,7 +426,7 @@ class PandasInterface(Interface, PandasAPI):
                     index_sel = {}
             column_sel = {k: v for k, v in selection.items() if k not in index_sel}
             if column_sel:
-                selection_mask = cls.select_mask(dataset, column_sel)
+                selection_mask = cls.select_mask(dataset.clone(data=df), column_sel)
 
         indexed = cls.indexed(dataset, selection)
         if isinstance(selection_mask, pd.Series):
diff --git a/holoviews/element/comparison.py b/holoviews/element/comparison.py
index c5f70643a..3a23a975d 100644
--- a/holoviews/element/comparison.py
+++ b/holoviews/element/comparison.py
@@ -19,7 +19,8 @@ considered different.
 """
 import contextlib
 from functools import partial
-from unittest import TestCase
+from unittest import SkipTest, TestCase
+from unittest.mock import patch
 from unittest.util import safe_repr
 
 import numpy as np
@@ -787,20 +788,27 @@ class IPTestCase(ComparisonTestCase):
     """
 
     def setUp(self):
-        super().setUp()
         try:
             import IPython
             from IPython.display import HTML, SVG
-            self.ip = IPython.InteractiveShell()
-            if self.ip is None:
-                raise TypeError()
-        except Exception as e:
-            raise SkipTest("IPython could not be started") from e
+        except Exception:
+            raise SkipTest("IPython could not be imported") from None
 
-        self.ip.displayhook.flush = lambda: None  # To avoid gc.collect called in it
+        super().setUp()
+        self.exits = []
+        with patch('atexit.register', lambda x: self.exits.append(x)):
+            self.ip = IPython.InteractiveShell(history_length=0, history_load_length=0)
         self.addTypeEqualityFunc(HTML, self.skip_comparison)
         self.addTypeEqualityFunc(SVG,  self.skip_comparison)
 
+    def tearDown(self) -> None:
+        # self.ip.displayhook.flush calls gc.collect
+        with patch('gc.collect', lambda: None):
+            for ex in self.exits:
+                ex()
+        del self.ip
+        super().tearDown()
+
     def skip_comparison(self, obj1, obj2, msg): pass
 
     def get_object(self, name):
diff --git a/holoviews/element/graphs.py b/holoviews/element/graphs.py
index fd1757523..792cc6d6e 100644
--- a/holoviews/element/graphs.py
+++ b/holoviews/element/graphs.py
@@ -592,7 +592,7 @@ class TriMesh(Graph):
         """
         return self._initialize_edgepaths()
 
-    def select(self, selection_specs=None, **selection):
+    def select(self, selection_expr=None, selection_specs=None, **selection):
         """Allows selecting data by the slices, sets and scalar values
         along a particular dimension. The indices should be supplied as
         keywords mapping between the selected dimension and
@@ -603,9 +603,10 @@ class TriMesh(Graph):
 
         """
         self._initialize_edgepaths()
-        return super().select(selection_specs=None,
-                              selection_mode='nodes',
-                              **selection)
+        return super().select(
+            selection_expr=selection_expr, selection_specs=selection_specs,
+            selection_mode='nodes', **selection
+        )
 
 
 
diff --git a/holoviews/element/raster.py b/holoviews/element/raster.py
index b0e45f403..f1f7e1661 100644
--- a/holoviews/element/raster.py
+++ b/holoviews/element/raster.py
@@ -405,7 +405,7 @@ class Image(Selection2DExpr, Dataset, Raster, SheetCoordinateSystem):
         agg = super().aggregate(dimensions, function, spreadfn, **kwargs)
         return Curve(agg) if isinstance(agg, Dataset) and len(self.vdims) == 1 else agg
 
-    def select(self, selection_specs=None, **selection):
+    def select(self, selection_expr=None, selection_specs=None, **selection):
         """Allows selecting data by the slices, sets and scalar values
         along a particular dimension. The indices should be supplied as
         keywords mapping between the selected dimension and
@@ -415,6 +415,13 @@ class Image(Selection2DExpr, Dataset, Raster, SheetCoordinateSystem):
         specs match the selected object.
 
         """
+        if isinstance(selection_expr, dict):
+            if selection:
+                raise ValueError("""\
+                Selections may be supplied as keyword arguments or as a positional
+                argument, never both.""")
+            selection = selection_expr
+            selection_expr = None
         if selection_specs and not any(self.matches(sp) for sp in selection_specs):
             return self
 
diff --git a/holoviews/ipython/__init__.py b/holoviews/ipython/__init__.py
index f8d769d0d..12dcdf727 100644
--- a/holoviews/ipython/__init__.py
+++ b/holoviews/ipython/__init__.py
@@ -42,7 +42,9 @@ class notebook_extension(extension):
 
     css = param.String(default='', doc="Optional CSS rule set to apply to the notebook.")
 
-    logo = param.Boolean(default=True, doc="Toggles display of HoloViews logo")
+    logo = param.ClassSelector(default=True, class_=(bool, dict), doc="""
+        Controls logo display. Dictionary option must include the keys
+        `logo_link`, `logo_src`, and `logo_title`.""")
 
     inline = param.Boolean(default=False, doc="""
         Whether to inline JS and CSS resources.
@@ -213,7 +215,7 @@ class notebook_extension(extension):
         return resources
 
     @classmethod
-    def load_logo(cls, logo=False, bokeh_logo=False, mpl_logo=False, plotly_logo=False):
+    def load_logo(cls, logo: dict | bool = False, bokeh_logo=False, mpl_logo=False, plotly_logo=False):
         """Allow to display Holoviews' logo and the plotting extensions' logo.
 
         """
@@ -222,10 +224,41 @@ class notebook_extension(extension):
         templateLoader = jinja2.FileSystemLoader(os.path.dirname(os.path.abspath(__file__)))
         jinjaEnv = jinja2.Environment(loader=templateLoader)
         template = jinjaEnv.get_template('load_notebook.html')
+        if isinstance(logo, dict):
+            logo_src = logo['logo_src']
+            logo_link = logo['logo_link']
+            logo_title = logo['logo_title']
+        elif not logo:
+            logo_src = logo_link = logo_title = ''
+        else:
+            from .. import __version__
+
+            logo_src = None  # holoviews logo available in the template
+            logo_link = 'https://holoviews.org'
+            logo_title = f'HoloViews {__version__}'
+
+        bokeh_version = mpl_version = plotly_version = ''
+        # Backends are already imported at this stage.
+        if bokeh_logo:
+            import bokeh
+            bokeh_version = bokeh.__version__
+        if mpl_logo:
+            import matplotlib as mpl
+            mpl_version = mpl.__version__
+        if plotly_logo:
+            import plotly
+            plotly_version = plotly.__version__
+
         html = template.render({'logo':        logo,
+                                'logo_src':    logo_src,
+                                'logo_link':   logo_link,
+                                'logo_title':  logo_title,
                                 'bokeh_logo':  bokeh_logo,
                                 'mpl_logo':    mpl_logo,
-                                'plotly_logo': plotly_logo})
+                                'plotly_logo': plotly_logo,
+                                'bokeh_version':  bokeh_version,
+                                'mpl_version':    mpl_version,
+                                'plotly_version': plotly_version})
         publish_display_data(data={'text/html': html})
 
 
diff --git a/holoviews/ipython/display_hooks.py b/holoviews/ipython/display_hooks.py
index 1308cbaf4..7457f86c0 100644
--- a/holoviews/ipython/display_hooks.py
+++ b/holoviews/ipython/display_hooks.py
@@ -137,6 +137,56 @@ def option_state(element):
         dynamic_optstate(element, state=optstate)
         raise
 
+def _render_jupyter_exception(e):
+    import html
+
+    raw_tb = "\n".join(traceback.format_exception(e.etype, e.value, e.traceback)).strip()
+
+    escaped_tb = html.escape(raw_tb)
+    exc_name = e.etype.__name__
+    exc_msg = str(e.value).split("\n")[0]
+    if len(exc_msg) > 80:
+        exc_msg = exc_msg[:80].strip() + " ..."
+    exc_msg = html.escape(exc_msg)
+
+    output_html = f"""
+    <style>
+      .hv-jupyter-exc {{
+        font-family: monospace;
+        border-radius: 3px;
+        padding: 0.5em;
+        margin: 0.5em 0;
+      }}
+      .hv-jupyter-exc summary {{
+        cursor: pointer;
+        user-select: none;
+        list-style: none;
+        position: relative;
+        padding-left: 1.2em;
+      }}
+      .hv-jupyter-exc summary::after {{
+        content: "▶";
+        position: absolute;
+        left: 0;
+        top: 0;
+        transition: transform 0.2s ease;
+      }}
+      .hv-jupyter-exc[open] summary::after {{
+        transform: rotate(90deg);
+      }}
+      .hv-jupyter-full {{
+        padding: 0.5em 0 0 1em;
+        white-space: pre-wrap;
+        background-color: #fdd;
+        color: #000;
+      }}
+    </style>
+    <details class="hv-jupyter-exc">
+      <summary><b>{exc_name}:</b> {exc_msg}</summary>
+      <div class="hv-jupyter-full">{escaped_tb}</pre>
+    </details>"""
+    return {"text/html": output_html}
+
 
 def display_hook(fn):
     """A decorator to wrap display hooks that return a MIME bundle or None.
@@ -175,14 +225,8 @@ def display_hook(fn):
                 sys.stderr.write(str(e))
             return {}, {}
         except AbbreviatedException as e:
-            FULL_TRACEBACK = '\n'.join(traceback.format_exception(e.etype,
-                                                                  e.value,
-                                                                  e.traceback))
-            info = dict(name=e.etype.__name__,
-                        message=str(e.value).replace('\n','<br>'))
-            msg =  '<i> [Call holoviews.ipython.show_traceback() for details]</i>'
-            return {'text/html': "<b>{name}</b>{msg}<br>{message}".format(msg=msg, **info)}, {}
-
+            FULL_TRACEBACK = '\n'.join(traceback.format_exception(e.etype, e.value, e.traceback))
+            return _render_jupyter_exception(e), {}
         except Exception:
             raise
     return wrapped
diff --git a/holoviews/ipython/load_notebook.html b/holoviews/ipython/load_notebook.html
index 72deaecc8..9eb323d54 100644
--- a/holoviews/ipython/load_notebook.html
+++ b/holoviews/ipython/load_notebook.html
@@ -1,6 +1,6 @@
 {% if logo %}
-<div class="logo-block">
-<img src='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAABHNCSVQICAgIfAhkiAAAAAlwSFlz
+{% if not logo_src %}
+{% set logo_src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAABHNCSVQICAgIfAhkiAAAAAlwSFlz
 AAAB+wAAAfsBxc2miwAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAA6zSURB
 VHic7ZtpeFRVmsf/5966taWqUlUJ2UioBBJiIBAwCZtog9IOgjqACsogKtqirT2ttt069nQ/zDzt
 tI4+CrJIREFaFgWhBXpUNhHZQoKBkIUASchWla1S+3ar7r1nPkDaCAnZKoQP/D7mnPOe9/xy76n3
@@ -68,15 +68,23 @@ SLHujPOTPVW+rwY+sxskAAC2HrA8t2Vvc7ffP1r9o+vwR2dcr92InIAbKKC1FZ5tB1tf+/G8p8sv
 N/9Q5zd/XR34LYCwV5JdccMEAMDBk45DH243r/X4xGvqxFa/GNpS7n6rwOwNWwHVE26oAADYurf1
 zx/utOzt+DMKYM0p17YtZZ5VNzqfsB2HewG1WXE8PoZ7gOclbTIvynZf9JV+fqZtfgs/8F/Nu5rB
 EIBmJ+8QRMmpU7EzGRsf2FzuePqYRbzh/zE26EwdrT10f6r6o8HOYzCJB9Dpff8tbnGLG8L/A/WE
-roTBs2RqAAAAAElFTkSuQmCC'
-     style='height:25px; border-radius:12px; display: inline-block; float: left; vertical-align: middle'></img>
+roTBs2RqAAAAAElFTkSuQmCC' %}
+{% endif %}
+<div class="logo-block">
+<a href="{{ logo_link }}" target="_blank" title="{{ logo_title }}">
+  <img src='{{ logo_src }}' style='height:25px; border-radius:12px; display: inline-block; float: left; vertical-align: middle'></img>
+</a>
 
 {% if bokeh_logo %}
+<a href="https://bokeh.org" target="_blank" title="Bokeh {{ bokeh_version }}">
   <img src='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACMAAAAjCAYAAAAe2bNZAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAK6wAACusBgosNWgAAABx0RVh0U29mdHdhcmUAQWRvYmUgRmlyZXdvcmtzIENTNui8sowAAAf9SURBVFiFvZh7cFTVHcc/59y7793sJiFAwkvAYDRqFWwdraLVlj61diRYsDjqCFbFKrYo0CltlSq1tLaC2GprGIriGwqjFu10OlrGv8RiK/IICYECSWBDkt3s695zTv9IAtlHeOn0O7Mzu797z+/3Ob/z+p0VfBq9doNFljuABwAXw2PcvGHt6bgwxhz7Ls4YZNVXxxANLENwE2D1W9PAGmAhszZ0/X9gll5yCbHoOirLzmaQs0F6F8QMZq1v/8xgNm7DYwwjgXJLYL4witQ16+sv/U9HdDmV4WrKw6B06cZC/RMrM4MZ7xz61DAbtzEXmAvUAX4pMOVecg9/MFFu3j3Gz7gQBLygS2RGumBkL0cubiFRsR3LzVBV1UMk3IrW73PT9C2lYOwhQB4ClhX1AuKpjLcV27oEjyUpNUJCg1CvcejykWTCXyQgzic2HIIBjg3pS6+uRLKAhumZvD4U+tq0jTrgkVKQQtLekfTtxIPAkhTNF6G7kZm7aPp6M9myKVQEoaYaIhEQYvD781DML/RfBGNZXAl4irJiwBa07e/y7cQnBaJghIX6ENl2GR/fGCBoz6cm5qeyEqQA5ZYA5x5eeiV0Qph4gjFAUSwAr6QllQgcxS/Jm25Cr2Tmpsk03XI9NfI31FTZBEOgVOk51adqDBNPCNPSRlkiDXbBEwOU2WxH+I7itQZ62g56OjM33suq1YsZHVtGZSUI2QdyYgkgOthQNIF7BIGDnRAJgJSgj69cUx1gB8PkOGwL4E1gPrM27gIg7NlGKLQApc7BmEnAxP5g/rw4YqBrCDB5xHkw5rdR/1qTrN/hKNo6YUwVDNpFsnjYS8RbidBPcPXFP6R6yfExuOXmN4A3jv1+8ZUwgY9D2OWjUZE6lO88jDwHI8ZixGiMKSeYTBamCoDk6kDAb6y1OcH1a6KpD/fZesoFw5FlIXAVCIiH4PxrV+p2npVDToTBmtjY8t1swh2V61E9KqWiyuPEjM8dbfxuvfa49Zayf9R136Wr8mBSf/T7bNteA8zwaGEUbFpckWwq95n59dUIywKl2fbOIS5e8bWSu0tJ1a5redAYfqkdjesodFajcgaVNWhXo1C9SrkN3Usmv3UMJrc6/DDwkwEntkEJLe67tSLhvyzK8rHDQWleve5CGk4VZEB1r+5bg2E2si+Y0QatDK6jUVkX5eg2YYlp++ZM+rfMNYamAj8Y7MAVWFqaR1f/t2xzU4IHjybBtthzuiAASqv7jTF7jOqDMAakFHgDNsFyP+FhwZHBmH9F7cutIYkQCylYYv1AZSqsn1/+bX51OMMjPSl2nAnM7hnjOx2v53YgNWAzHM9Q/9l0lQWPSCBSyokAtOBC1Rj+w/1Xs+STDp4/E5g7Rs2zm2+oeVd7PUuHKDf6A4r5EsPT5K3gfCnBXNUYnvGzb+KcCczYYWOnLpy4eOXuG2oec0PBN8XQQAnpvS35AvAykr56rWhPBiV4MvtceGLxk5Mr6A1O8IfK7rl7xJ0r9kyumuP4fa0lMqTBLJIAJqEf1J3qE92lMBndlyfRD2YBghHC4hlny7ASqCeWo5zaoDdIWfnIefNGTb9fC73QDfhyBUCNOxrGPSUBfPem9us253YTV+3mcBbdkUYfzmHiLqZbYdIGHHON2ZlemXouaJUOO6TqtdHEQuXYY8Yt+EbDgmlS6RdzkaDTv2P9A3gICiq93sWhb5mc5wVhuU3Y7m5hOc3So7qFT3SLgOXHb/cyOfMn7xROegoC/PTcn3v8gbKPgDopJFk3R/uBPWQiwQ+2/GJevRMObLUzqe/saJjQUQTTftEVMW9tWxPgAocwcj9abNcZe7s+6t2R2xXZG7zyYLp8Q1PiRBBHym5bYuXi8Qt+/LvGu9f/5YDAxABsaRNPH6Xr4D4Sk87a897SOy9v/fKwjoF2eQel95yDESGEF6gEMwKhLwKus3wOVjTtes7qzgLdXTMnNCNoEpbcrtNuq6N7Xh/+eqcbj94xQkp7mdKpW5XbtbR8Z26kgMCAf2UU5YEovRUVRHbu2b3vK1UdDFkDCyMRQxbpdv8nhKAGIa7QaQedzT07fFPny53R738JoVYBdVrnsNx9XZ9v33UeGO+AA2MMUkgqQ5UcdDLZSFeVgONnXeHqSAC5Ew1BXwko0D1Zct3dT1duOjS3MzZnEUJtBuoQAq3SGOLR4ekjn9NC5nVOaYXf9lETrUkmOJy3pOz8OKIb2A1cWhJCCEzOxU2mUPror+2/L3yyM3pkM7jTjr1nBOgkGeyQ7erxpdJsMAS9wb2F9rzMxNY1K2PMU0WtZV82VU8Wp6vbKJVo9Lx/+4cydORdxCCQ/kDGTZCWsRpLu7VD7bfKqL8V2orKTp/PtzaXy42jr6TwAuisi+7JolUG4wY+8vyrISCMtRrLKWpvjAOqx/QGhp0rjRo5xD3x98CWQuOQN8qumRMmI7jKZPUEpzNVZsj4Zbaq1to5tZZsKIydLWojhIXrJnES79EaOzv3du2NytKuxzJKAA6wF8xqEE8s2jo/1wd/khslQGxd81Zg62Bbp31XBH+iETt7Y3ELA0iU6iGDlQ5mexe0VEx4a3x8V1AaYwFJgTiwaOsDmeK2J8nMUOqsnB1A+dcA04ucCYt0urkjmflk9iT2v30q/gZn5rQPvor4n9Ou634PeBzoznes/iot/7WnClKoM/+zCIjH5kwT8ChQjTHPIPTjFV3PpU/Hx+DM/A9U3IXI4SPCYAAAAABJRU5ErkJggg=='
-       style='height:15px; border-radius:12px; display: inline-block; float: left'></img>
-  {% endif %}
+       style='height:15px; border-radius:12px; display: inline-block; float: left'>
+  </img>
+</a>
+{% endif %}
 
 {% if mpl_logo %}
+<a href="https://matplotlib.org" target="_blank" title="Matplotlib {{ mpl_version }}">
   <img src='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABHNCSVQICAgIfAhkiAAAAAlwSFlz
 AAAFMAAABTABZarKtgAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAArNSURB
 VFiFnVd5VFNXGv/ee0kgGyQhbFoXIKCFYEXEDVErTucMoKUOWA/VLsNSLPQgFTOdyrHPiIp1lFIQ
@@ -129,10 +137,11 @@ mhrfO3fufECS5GHXnf8pAAAAHMfdURTdimGYPjExsTo0NHTyj2ynEplMxurs7HyHIAiKJMlSHMct
 U9k9N2vl5+cH0en0TRiGWX18fC65vnh+LxqNBq2oqFhgMpmi7XY7arVaj+zdu/fxn/l/4bSZl5fH
 5nK5CQAQMtXznCRJePpEbwOAZhzHX4ix/wHzzC/tu64gcwAAAABJRU5ErkJggg=='
        style='height:15px; border-radius:12px; display: inline-block; float: left'></img>
-  {% endif %}
+</a>
+{% endif %}
 
 {% if plotly_logo %}
-
+<a href="https://plotly.com/python" target="_blank" title="Plotly {{ plotly_version }}">
   <img src='data:image/png;base64,
 iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAABHNCSVQICAgIfAhkiAAAAAlwSFlz
 AAAUtgAAFLYBHNdcgwAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAHOSURB
@@ -146,6 +155,12 @@ XgLLFXD/2k1/F/UyqK2/w8EHvivg4lkee3q1QVH21wa6hxzIOgUkg/Oy2RaIDx5I7x0smPxvwOgD
 La/ILFfDtS00UmSdAtKzutT9A6mvz/M+Df6KDNsAGw+AbYCNB8A2wMYDYBtg4wGwDbCZfAAFMO2/
 z/8BE3VwbODCC/EAAAAASUVORK5CYII='
        style='height:15px; border-radius:12px; display: inline-block; float: left'></img>
-  {% endif %}
+</a>
+{% endif %}
+
+<span style="float: left; margin-left: 5px; line-height: 15px; cursor: pointer; opacity: 0.7;"
+      onmouseover="this.style.opacity='1'" 
+      onmouseout="this.style.opacity='0.7'"
+      title="Extension loaded. This cell output contains code that enables plot interactivity, it should not be removed.">ⓘ</span>
 </div>
 {% endif %}
diff --git a/holoviews/operation/_datashader_bundling.py b/holoviews/operation/_datashader_bundling.py
new file mode 100644
index 000000000..3ea8df5b7
--- /dev/null
+++ b/holoviews/operation/_datashader_bundling.py
@@ -0,0 +1,52 @@
+import param
+from datashader.bundling import (
+    directly_connect_edges as connect_edges,
+    hammer_bundle,
+)
+
+from ..core import Operation
+from .datashader import split_dataframe
+
+
+class _connect_edges(Operation):
+
+    split = param.Boolean(default=False, doc="""
+        Determines whether bundled edges will be split into individual edges
+        or concatenated with NaN separators.""")
+
+    def _bundle(self, position_df, edges_df):
+        raise NotImplementedError('_connect_edges is an abstract baseclass '
+                                  'and does not implement any actual bundling.')
+
+    def _process(self, element, key=None):
+        index = element.nodes.kdims[2].name
+        rename_edges = {d.name: v for d, v in zip(element.kdims[:2], ['source', 'target'], strict=True)}
+        rename_nodes = {d.name: v for d, v in zip(element.nodes.kdims[:2], ['x', 'y'], strict=True)}
+        position_df = element.nodes.redim(**rename_nodes).dframe([0, 1, 2]).set_index(index)
+        edges_df = element.redim(**rename_edges).dframe([0, 1])
+        paths = self._bundle(position_df, edges_df)
+        paths = paths.rename(columns={v: k for k, v in rename_nodes.items()})
+        paths = split_dataframe(paths) if self.p.split else [paths]
+        return element.clone((element.data, element.nodes, paths))
+
+
+class bundle_graph(_connect_edges, hammer_bundle):
+    """Iteratively group edges and return as paths suitable for datashading.
+
+    Breaks each edge into a path with multiple line segments, and
+    iteratively curves this path to bundle edges into groups.
+
+    """
+
+    def _bundle(self, position_df, edges_df):
+        from datashader.bundling import hammer_bundle
+        return hammer_bundle.__call__(self, position_df, edges_df, **self.p)
+
+
+class directly_connect_edges(_connect_edges, connect_edges):
+    """Given a Graph object will directly connect all nodes.
+
+    """
+
+    def _bundle(self, position_df, edges_df):
+        return connect_edges.__call__(self, position_df, edges_df)
diff --git a/holoviews/operation/datashader.py b/holoviews/operation/datashader.py
index 28c3e196d..9f4569a42 100644
--- a/holoviews/operation/datashader.py
+++ b/holoviews/operation/datashader.py
@@ -15,14 +15,6 @@ from datashader.colors import color_lookup
 from packaging.version import Version
 from param.parameterized import bothmethod
 
-try:
-    from datashader.bundling import (
-        directly_connect_edges as connect_edges,
-        hammer_bundle,
-    )
-except ImportError:
-    hammer_bundle, connect_edges = object, object
-
 from ..core import (
     CompositeOverlay,
     Dimension,
@@ -80,10 +72,22 @@ DATASHADER_GE_0_18_1 = DATASHADER_VERSION >= (0, 18, 1)
 
 if TYPE_CHECKING:
     import dask.dataframe as dd
+
+    from ._datashader_bundling import bundle_graph, directly_connect_edges  # noqa: F401
 else:
     dd = _LazyModule("dask.dataframe", bool_use_sys_modules=True)
 
 
+def __getattr__(attr):
+    if attr == "bundle_graph":
+        from ._datashader_bundling import bundle_graph
+        return bundle_graph
+    elif attr == "directly_connect_edges":
+        from ._datashader_bundling import directly_connect_edges
+        return directly_connect_edges
+    raise AttributeError(f"module {__name__!r} has no attribute {attr!r}")
+
+
 class AggregationOperation(ResampleOperation2D):
     """AggregationOperation extends the ResampleOperation2D defining an
     aggregator parameter used to define a datashader Reduction.
@@ -1779,8 +1783,11 @@ class SpreadingOperation(LinkableOperation):
                 for idx, k, in enumerate("RGBA"):
                     new_data[k].data = img[:, :, idx]
             elif isinstance(element, ImageStack):
-                for k in map(str, element.vdims):
-                    new_data[k].data = array.sel(z=k)
+                if len(element.vdims) == 1:
+                    new_data[element.vdims[0].name].data = array
+                else:
+                    for k in map(str, element.vdims):
+                        new_data[k].data = array.sel(z=k)
             elif isinstance(element, Image):
                 new_data[element.vdims[0].name].data = array
             else:
@@ -1866,50 +1873,6 @@ def split_dataframe(path_df):
     return [df for df in np.split(path_df, splits) if len(df) > 1]
 
 
-class _connect_edges(Operation):
-
-    split = param.Boolean(default=False, doc="""
-        Determines whether bundled edges will be split into individual edges
-        or concatenated with NaN separators.""")
-
-    def _bundle(self, position_df, edges_df):
-        raise NotImplementedError('_connect_edges is an abstract baseclass '
-                                  'and does not implement any actual bundling.')
-
-    def _process(self, element, key=None):
-        index = element.nodes.kdims[2].name
-        rename_edges = {d.name: v for d, v in zip(element.kdims[:2], ['source', 'target'], strict=None)}
-        rename_nodes = {d.name: v for d, v in zip(element.nodes.kdims[:2], ['x', 'y'], strict=None)}
-        position_df = element.nodes.redim(**rename_nodes).dframe([0, 1, 2]).set_index(index)
-        edges_df = element.redim(**rename_edges).dframe([0, 1])
-        paths = self._bundle(position_df, edges_df)
-        paths = paths.rename(columns={v: k for k, v in rename_nodes.items()})
-        paths = split_dataframe(paths) if self.p.split else [paths]
-        return element.clone((element.data, element.nodes, paths))
-
-
-class bundle_graph(_connect_edges, hammer_bundle):
-    """Iteratively group edges and return as paths suitable for datashading.
-
-    Breaks each edge into a path with multiple line segments, and
-    iteratively curves this path to bundle edges into groups.
-
-    """
-
-    def _bundle(self, position_df, edges_df):
-        from datashader.bundling import hammer_bundle
-        return hammer_bundle.__call__(self, position_df, edges_df, **self.p)
-
-
-class directly_connect_edges(_connect_edges, connect_edges):
-    """Given a Graph object will directly connect all nodes.
-
-    """
-
-    def _bundle(self, position_df, edges_df):
-        return connect_edges.__call__(self, position_df, edges_df)
-
-
 def identity(x): return x
 
 
diff --git a/holoviews/operation/element.py b/holoviews/operation/element.py
index 24f68b336..f4857e2aa 100644
--- a/holoviews/operation/element.py
+++ b/holoviews/operation/element.py
@@ -1245,10 +1245,36 @@ class dendrogram(Operation):
          between successive leaves is minimal. This results in a more intuitive
          tree structure when the data are visualized. defaults to False,
          because this algorithm can be slow, particularly on large datasets.
-         See for more information:
+         For more information:
          https://docs.scipy.org/doc/scipy/reference/generated/scipy.cluster.hierarchy.linkage.html#scipy.cluster.hierarchy.linkage
          """)
 
+    linkage_method = param.Selector(
+        default="complete",
+        objects=["single", "complete", "average", "centroid", "median", "ward", "weighted"],
+        doc="""
+        The linkage algorithm to use. For more information:
+        https://docs.scipy.org/doc/scipy/reference/generated/scipy.cluster.hierarchy.linkage.html#scipy.cluster.hierarchy.linkage
+        """
+
+    )
+    linkage_metric = param.Selector(
+        default="correlation",
+        objects=[
+            "braycurtis", "canberra", "chebyshev", "cityblock",
+            "correlation", "cosine", "dice", "euclidean", "hamming",
+            "jaccard", "jensenshannon", "kulczynski1",
+            "mahalanobis", "matching", "minkowski", "rogerstanimoto",
+            "russellrao", "seuclidean", "sokalmichener", "sokalsneath",
+            "sqeuclidean", "yule"
+        ],
+        doc="""
+        The distance metric to use in the case that y is a collection of observation vectors; ignored otherwise.
+        For more information:
+        https://docs.scipy.org/doc/scipy/reference/generated/scipy.cluster.hierarchy.linkage.html#scipy.cluster.hierarchy.linkage
+        """
+    )
+
     def _compute_linkage(self, dataset, dim, vdim):
         try:
             from scipy.cluster.hierarchy import dendrogram, linkage
@@ -1259,10 +1285,15 @@ class dendrogram(Operation):
         for k, v in dataset.groupby(dim, container_type=list, group_type=Dataset):
             labels.append(k)
             arrays.append(v.dimension_values(vdim))
+
         X = np.vstack(arrays)
-        Z = linkage(X, optimal_ordering=self.p.optimal_ordering)
-        ddata = dendrogram(Z, labels=labels, no_plot=True)
-        return ddata
+        Z = linkage(
+            X,
+            method=self.p.linkage_method,
+            metric=self.p.linkage_metric,
+            optimal_ordering=self.p.optimal_ordering
+        )
+        return dendrogram(Z, labels=labels, no_plot=True)
 
     def _process(self, element, key=None):
         element_kdims = element.kdims
@@ -1270,7 +1301,7 @@ class dendrogram(Operation):
         sort_dims, dendros = [], {}
         for d in self.p.adjoint_dims:
             ddata = self._compute_linkage(dataset, d, self.p.main_dim)
-            order = [ddata["ivl"].index(v) for v in dataset.dimension_values(d)][::-1]
+            order = [ddata["ivl"].index(v) for v in dataset.dimension_values(d)]
             sort_dim = f"sort_{d}"
             dataset = dataset.add_dimension(sort_dim, 0, order)
             sort_dims.append(sort_dim)
diff --git a/holoviews/plotting/bokeh/__init__.py b/holoviews/plotting/bokeh/__init__.py
index b6fbfa100..f5c828a14 100644
--- a/holoviews/plotting/bokeh/__init__.py
+++ b/holoviews/plotting/bokeh/__init__.py
@@ -243,7 +243,7 @@ options.Curve = Options('style', color=Cycle(), line_width=2)
 options.BoxWhisker = Options('style', box_fill_color=Cycle(), whisker_color='black',
                              box_line_color='black', outlier_color='black')
 options.Scatter = Options('style', color=Cycle(), size=point_size, cmap=dflt_cmap)
-options.Points = Options('style', color=Cycle(), size=point_size, cmap=dflt_cmap)
+options.Points = Options('style', color=Cycle(), size=point_size, cmap=dflt_cmap, radius_dimension="min")
 options.Points = Options('plot', show_frame=True)
 options.Histogram = Options('style', line_color='black', color=Cycle(), muted_alpha=0.2)
 options.ErrorBars = Options('style', color='black')
diff --git a/holoviews/plotting/bokeh/chart.py b/holoviews/plotting/bokeh/chart.py
index 870540dee..887fdef0e 100644
--- a/holoviews/plotting/bokeh/chart.py
+++ b/holoviews/plotting/bokeh/chart.py
@@ -1,3 +1,4 @@
+import math
 from collections import defaultdict
 
 import numpy as np
@@ -63,12 +64,23 @@ class PointPlot(LegendPlot, ColorbarPlot):
 
     style_opts = [
         "cmap", "palette", "marker", "size", "angle", "hit_dilation",
+        "radius", "radius_dimension",
         *base_properties, *line_properties, *fill_properties
     ]
 
     _plot_methods = dict(single='scatter', batched='scatter')
     _batched_style_opts = line_properties + fill_properties + ['size', 'marker', 'angle']
 
+    def _init_glyph(self, plot, mapping, properties):
+        if properties.get("radius") is not None:
+            self._plot_methods = dict(single='circle', batched='circle')
+            properties.pop("size", None)
+        else:
+            self._plot_methods = dict(single='scatter', batched='scatter')
+            properties.pop("radius_dimension", None)
+            properties.pop("radius", None)
+        return super()._init_glyph(plot, mapping, properties)
+
     def _get_size_data(self, element, ranges, style):
         data, mapping = {}, {}
         sdim = element.get_dimension(self.size_index)
@@ -430,11 +442,38 @@ class HistogramPlot(ColorbarPlot):
     _nonvectorized_styles = [*base_properties, "line_dash"]
     _plot_methods = dict(single='quad')
 
+    def initialize_plot(self, ranges=None, plot=None, plots=None, source=None):
+        plot = super().initialize_plot(ranges, plot, plots, source)
+        logx = self.logx and self.invert_axes
+        logy = self.logy and not self.invert_axes
+        if logx or logy:
+            if logy:
+                range_ = plot.y_range
+                pos = "end" if self.invert_yaxis else "start"
+            else:
+                range_ = plot.x_range
+                pos = "end" if self.invert_xaxis else "start"
+            source = self.handles["source"]
+            # Insert bottom to the lower value of the axis
+            source.data['bottom'] = [getattr(range_, pos)] * len(source.data['top'])
+            callback = CustomJS(
+                args=dict(source=source, range=range_, pos=pos),
+                code="""
+                source.data['bottom'].fill(range[pos]);
+                source.change.emit();
+            """,
+            )
+            range_.js_on_change(pos, callback)
+
+        return plot
+
     def get_data(self, element, ranges, style):
         if self.invert_axes:
-            mapping = dict(top='right', bottom='left', left=0, right='top')
+            left = 'bottom' if self.logx else 0
+            mapping = dict(top='right', bottom='left', left=left, right='top')
         else:
-            mapping = dict(top='top', bottom=0, left='left', right='right')
+            bottom = 'bottom' if self.logy else 0
+            mapping = dict(top='top', bottom=bottom, left='left', right='right')
         if self.static_source:
             data = dict(top=[], left=[], right=[])
         else:
@@ -445,7 +484,7 @@ class HistogramPlot(ColorbarPlot):
                 edges = edges.compute()
             data = dict(top=values, left=edges[:-1], right=edges[1:])
             self._get_hover_data(data, element)
-        return (data, mapping, style)
+        return data, mapping, style
 
     def get_extents(self, element, ranges, range_type='combined', **kwargs):
         ydim = element.get_dimension(1)
@@ -455,6 +494,12 @@ class HistogramPlot(ColorbarPlot):
         ranges[ydim.label]['soft'] = (s0, s1)
         return super().get_extents(element, ranges, range_type)
 
+    def _update_range(self, axis_range, low, high, factors, invert, shared, log, streaming=False):
+        # We allow zero values with histogram
+        if log and low == 0:
+            low = 0.01 if high > 0.01 else 10**(math.log10(high)-2)
+        return super()._update_range(axis_range, low, high, factors, invert, shared, log, streaming)
+
 
 class SideHistogramPlot(HistogramPlot):
 
diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py
index b23b0a915..baae2d641 100644
--- a/holoviews/plotting/bokeh/element.py
+++ b/holoviews/plotting/bokeh/element.py
@@ -989,15 +989,14 @@ class ElementPlot(BokehPlot, GenericElementPlot):
 
         properties.update(**self._plot_properties(key, element))
 
-        figure = bokeh.plotting.figure
-
         with warnings.catch_warnings():
             # Bokeh raises warnings about duplicate tools but these
             # are not really an issue
             warnings.simplefilter('ignore', UserWarning)
-            fig = figure(title=title, **properties)
+            fig = bokeh.plotting.figure(title=title, **properties)
         fig.xaxis[0].update(**axis_props['x'])
         fig.yaxis[0].update(**axis_props['y'])
+        fig.toolbar.autohide = self.autohide_toolbar
 
         # Set up handlers to configure following behavior on streaming plots
         if self.streaming:
@@ -1816,7 +1815,7 @@ class ElementPlot(BokehPlot, GenericElementPlot):
         new_style = dict(style)
         prefix = group+'_' if group else ''
         for k, v in dict(style).items():
-            if isinstance(v, str):
+            if isinstance(v, (Dimension, str)):
                 if validate(k, v) == True:
                     continue
                 elif v in element:
@@ -3077,7 +3076,7 @@ class OverlayPlot(GenericOverlayPlot, LegendPlot):
                           'min_height', 'max_height', 'min_width', 'min_height',
                           'margin', 'aspect', 'data_aspect', 'frame_width',
                           'frame_height', 'responsive', 'fontscale', 'subcoordinate_y',
-                          'subcoordinate_scale', 'autorange', 'default_tools']
+                          'subcoordinate_scale', 'autorange', 'default_tools', 'autohide_toolbar']
 
     def __init__(self, overlay, **kwargs):
         self._multi_y_propagation = self.lookup_options(overlay, 'plot').options.get('multi_y', False)
diff --git a/holoviews/plotting/bokeh/plot.py b/holoviews/plotting/bokeh/plot.py
index 02ca534ca..35ac9b69b 100644
--- a/holoviews/plotting/bokeh/plot.py
+++ b/holoviews/plotting/bokeh/plot.py
@@ -87,7 +87,15 @@ class BokehPlot(DimensionedPlot, CallbackPlot):
                                             "left", "right", None],
                                    doc="""
         The toolbar location, must be one of 'above', 'below',
-        'left', 'right', None.""")
+        'left', 'right', None.""",
+    )
+
+    autohide_toolbar = param.Boolean(
+        default=False,
+        doc="""
+        Whether to automatically hide the toolbar until the user hovers over the plot.
+        This parameter has no effect if the toolbar is disabled (toolbar=None).""",
+    )
 
     width = param.Integer(default=None, bounds=(0, None), doc="""
         The width of the component (in pixels). This can be either
@@ -671,7 +679,7 @@ class GridPlot(CompositePlot, GenericCompositePlot):
                 r1, r2 = r1[::-1], r2[::-1]
             plot = gridplot([r1, r2], merge_tools=False)
             if self.merge_tools:
-                plot.toolbar = merge_tools([r1, r2])
+                plot.toolbar = merge_tools([r1, r2], autohide=self.autohide_toolbar)
         elif y_axis:
             models = [y_axis, plot]
             if self.shared_yaxis: models = models[::-1]
@@ -978,7 +986,7 @@ class LayoutPlot(CompositePlot, GenericLayoutPlot):
                                         toolbar_location=self.toolbar,
                                         sizing_mode=sizing_mode)
                         if self.merge_tools:
-                            grid.toolbar = merge_tools(children)
+                            grid.toolbar = merge_tools(children, autohide=self.autohide_toolbar)
                     tab_plots.append((title, grid))
                     continue
 
@@ -1020,7 +1028,7 @@ class LayoutPlot(CompositePlot, GenericLayoutPlot):
             if self.sync_legends:
                 sync_legends(layout_plot)
             if self.merge_tools:
-                layout_plot.toolbar = merge_tools(plot_grid)
+                layout_plot.toolbar = merge_tools(plot_grid, autohide=self.autohide_toolbar)
 
         title = self._get_title_div(self.keys[-1])
         if title:
diff --git a/holoviews/plotting/bokeh/raster.py b/holoviews/plotting/bokeh/raster.py
index 3f9a94339..80e168b4a 100644
--- a/holoviews/plotting/bokeh/raster.py
+++ b/holoviews/plotting/bokeh/raster.py
@@ -1,5 +1,4 @@
 import sys
-import uuid
 
 import bokeh.core.properties as bp
 import numpy as np
@@ -12,18 +11,42 @@ from panel.io import hold
 from ...core.data import XArrayInterface
 from ...core.util import cartesian_product, dimension_sanitizer, isfinite
 from ...element import Raster
+from ...util.warnings import warn
 from ..util import categorical_legend
 from .chart import PointPlot
 from .element import ColorbarPlot, LegendPlot
 from .selection import BokehOverlaySelectionDisplay
 from .styles import base_properties, fill_properties, line_properties, mpl_to_bokeh
-from .util import BOKEH_GE_3_3_0, BOKEH_GE_3_4_0, BOKEH_GE_3_7_0, colormesh
+from .util import (
+    BOKEH_GE_3_3_0,
+    BOKEH_GE_3_4_0,
+    BOKEH_GE_3_7_0,
+    BOKEH_VERSION,
+    colormesh,
+)
 
 _EPOCH = np.datetime64("1970-01-01", "ns")
 
 
+class HoverModel(DataModel):
+    xy = bp.Any()
+    data = bp.Any()
+
+    code_js = """
+    export default ({hover_model, attr, fmt}) => {
+      const templating = Bokeh.require("core/util/templating");
+      const value = hover_model.data[attr];
+      if (fmt == "M") {
+        const formatter = templating.DEFAULT_FORMATTERS.datetime;
+        return formatter(value, "%Y-%m-%d %H:%M:%S")
+      } else {
+        const formatter = templating.get_formatter();
+        return formatter(value)
+      }
+    }; """
+
+
 class ServerHoverMixin(param.Parameterized):
-    _model_cache = {}
 
     selector_in_hovertool = param.Boolean(default=True, doc="""
         Whether to show the selector in HoverTool.""")
@@ -52,6 +75,13 @@ class ServerHoverMixin(param.Parameterized):
         ):
             return tools
 
+        if not BOKEH_GE_3_7_0:
+            bk_str = ".".join(map(str, BOKEH_VERSION))
+            msg = f"selector needs Bokeh 3.7 or greater, you are using version {bk_str}."
+            warn(msg, RuntimeWarning)
+            hover.tooltips = Div(children=[msg])
+            return tools
+
         self._hover_data = data
 
         # Get dimensions
@@ -69,34 +99,21 @@ class ServerHoverMixin(param.Parameterized):
                 if vdim in vars:
                     vars.remove(vdim)
 
+        hover_model = HoverModel(data={})
         dims = (*coords, *vars)
-
-        # Create a dynamic custom DataModel with the dims as attributes
-        # __xy__ is the cursor position
-        if dims in self._model_cache:
-            HoverModel = self._model_cache[dims]
-        else:
-            HoverModel = self._model_cache[dims] = type(
-                f"HoverModel_{uuid.uuid4().hex}",
-                (DataModel,),
-                {d: bp.Any() for d in ("__xy__", *dims)},
-            )
-
-        hover_model = HoverModel()
         dtypes = {**data.coords.dtypes, **data.data_vars.dtypes}
         is_datetime = [dtypes[c].kind == "M" for c in data.coords]
         def _create_row(attr):
-            kwargs = {}
-            if BOKEH_GE_3_7_0:
-                kind = dtypes[attr].kind
-                if kind in "uifO":
-                    kwargs["formatter"] = "basic"
-                elif kind == "M":
-                    kwargs["formatter"] = "datetime"
-                    kwargs["format"] = "%Y-%m-%d %H:%M:%S"
+            kwargs = {
+                "format": "@{custom}",
+                "formatter": CustomJS(
+                    args=dict(hover_model=hover_model, attr=attr, fmt=dtypes[attr].kind),
+                    code=HoverModel.code_js
+                )
+            }
             return (
                 Span(children=[f"{ht.get(attr, attr)}:"], style={"color": "#26aae1", "text_align": "right"}),
-                Span(children=[ValueOf(obj=hover_model, attr=attr, **kwargs)], style={"text_align": "left"}),
+                Span(children=[ValueOf(obj=hover_model, attr="data", **kwargs)], style={"text_align": "left"}),
             )
         children = [el for dim in dims for el in _create_row(dim)]
 
@@ -136,7 +153,7 @@ class ServerHoverMixin(param.Parameterized):
         hover.tooltips = grid
         hover.callback = CustomJS(
             args={"position": hover_model},
-            code="export default ({position}, _, {geometry: {x, y}}) => {position.__xy__ = [x, y]}",
+            code="export default ({position}, _, {geometry: {x, y}}) => {position.xy = [x, y]}",
         )
 
         def on_change(attr, old, new):
@@ -157,11 +174,11 @@ class ServerHoverMixin(param.Parameterized):
             data_coords = {dim: data_sel['coords'][dim]['data'] for dim in coords}
             data_vars = {dim: data_sel['data_vars'][dim]['data'] for dim in vars}
             with hold(self.document):
-                hover_model.update(**data_coords, **data_vars)
+                hover_model.update(data={**data_coords, **data_vars})
                 if self.comm:  # Jupyter Notebook
                     self.push()
 
-        hover_model.on_change("__xy__", on_change)
+        hover_model.on_change("xy", on_change)
 
         return tools
 
diff --git a/holoviews/plotting/bokeh/util.py b/holoviews/plotting/bokeh/util.py
index b7fe6480a..bc8c448ad 100644
--- a/holoviews/plotting/bokeh/util.py
+++ b/holoviews/plotting/bokeh/util.py
@@ -400,7 +400,7 @@ def compute_layout_properties(
     return aspect_info, dimension_info
 
 
-def merge_tools(plot_grid, *, disambiguation_properties=None, hide_toolbar=False):
+def merge_tools(plot_grid, *, disambiguation_properties=None, hide_toolbar=False, autohide=False):
     """Merges tools defined on a grid of plots into a single toolbar.
     All tools of the same type are merged unless they define one
     of the disambiguation properties. By default `name`, `icon`, `tags`
@@ -433,7 +433,10 @@ def merge_tools(plot_grid, *, disambiguation_properties=None, hide_toolbar=False
             if p not in disambiguation_properties:
                 ignore.add(p)
 
-    return Toolbar(tools=group_tools(tools, merge=merge, ignore=ignore)) if tools else Toolbar()
+    toolbar_kwargs = {"autohide": autohide}
+    if tools:
+        toolbar_kwargs["tools"] = group_tools(tools, merge=merge, ignore=ignore)
+    return Toolbar(**toolbar_kwargs)
 
 
 def sync_legends(bokeh_layout):
diff --git a/holoviews/plotting/mpl/element.py b/holoviews/plotting/mpl/element.py
index e80b8b3dc..6c9761f55 100644
--- a/holoviews/plotting/mpl/element.py
+++ b/holoviews/plotting/mpl/element.py
@@ -19,6 +19,7 @@ from ...core import (
     NdOverlay,
     util,
 )
+from ...core.dimension import Dimension
 from ...core.options import Keywords, abbreviated_exception
 from ...element import Graph, Path
 from ...streams import Stream
@@ -411,7 +412,7 @@ class ElementPlot(GenericElementPlot, MPLPlot):
         lims = {}
         valid_lim = lambda c: util.isnumeric(c) and not np.isnan(c)
         if not isinstance(low, util.datetime_types) and log and (low is None or low <= 0):
-            low = 0.01 if high < 0.01 else 10**(np.log10(high)-2)
+            low = 0.01 if high > 0.01 else 10**(np.log10(high)-2)
             self.param.warning(
                 "Logarithmic axis range encountered value less "
                 "than or equal to zero, please supply explicit "
@@ -614,11 +615,13 @@ class ElementPlot(GenericElementPlot, MPLPlot):
     def _apply_transforms(self, element, ranges, style):
         new_style = dict(style)
         for k, v in style.items():
-            if isinstance(v, str):
+            if isinstance(v, (Dimension, str)):
                 if validate(k, v) == True:
                     continue
-                elif v in element or (isinstance(element, Graph) and v in element.nodes):
-                    v = dim(v)
+                elif isinstance(element, Graph) and v in element.nodes:
+                    v = dim(element.nodes.get_dimension(v))
+                elif v in element:
+                    v = dim(element.get_dimension(v))
                 elif any(d==v for d in self.overlay_dims):
                     v = dim(next(d for d in self.overlay_dims if d==v))
 
diff --git a/holoviews/plotting/plotly/element.py b/holoviews/plotting/plotly/element.py
index 88b01d6f3..4fac398a5 100644
--- a/holoviews/plotting/plotly/element.py
+++ b/holoviews/plotting/plotly/element.py
@@ -6,6 +6,7 @@ import param
 
 from ... import Tiles
 from ...core import util
+from ...core.dimension import Dimension
 from ...core.element import Element
 from ...core.spaces import DynamicMap
 from ...streams import Stream
@@ -337,11 +338,11 @@ class ElementPlot(PlotlyPlot, GenericElementPlot):
     def _apply_transforms(self, element, ranges, style):
         new_style = dict(style)
         for k, v in dict(style).items():
-            if isinstance(v, str):
+            if isinstance(v, (Dimension, str)):
                 if k == 'marker' and v in 'xsdo':
                     continue
                 elif v in element:
-                    v = dim(v)
+                    v = dim(element.get_dimension(v))
                 elif any(d==v for d in self.overlay_dims):
                     v = dim(next(d for d in self.overlay_dims if d==v))
 
diff --git a/holoviews/streams.py b/holoviews/streams.py
index 233a4d4f2..14a625c9a 100644
--- a/holoviews/streams.py
+++ b/holoviews/streams.py
@@ -18,6 +18,7 @@ import param
 
 from .core import util
 from .core.ndmapping import UniformNdMapping
+from .util.warnings import deprecated
 
 if TYPE_CHECKING:
     import pandas as pd
@@ -540,10 +541,6 @@ class Buffer(Pipe):
     by the specified ``length``. The accumulated data is then made
     available via the ``data`` parameter.
 
-    A Buffer may also be instantiated with a streamz.StreamingDataFrame
-    or a streamz.StreamingSeries, it will automatically subscribe to
-    events emitted by a streamz object.
-
     When streaming a DataFrame will reset the DataFrame index by
     default making it available to HoloViews elements as dimensions,
     this may be disabled by setting index=False.
@@ -584,6 +581,10 @@ class Buffer(Pipe):
                     loaded = True
                 except ImportError:
                     loaded = False
+            if loaded:
+                # NOTE: there could still be some code in these classes which handles
+                # the streaming interface.
+                deprecated("1.23.0", "Buffer's streamz interface")
             if not loaded or not isinstance(data, (StreamingDataFrame, StreamingSeries)):
                 raise ValueError("Buffer must be initialized with pandas DataFrame, "
                                  "streamz.StreamingDataFrame or streamz.StreamingSeries.")
diff --git a/holoviews/util/__init__.py b/holoviews/util/__init__.py
index 3762f47aa..5acd9ce91 100644
--- a/holoviews/util/__init__.py
+++ b/holoviews/util/__init__.py
@@ -817,14 +817,18 @@ def save(obj, filename, fmt='auto', backend=None, resources='cdn', toolbar=None,
     """
     backend = backend or Store.current_backend
     renderer_obj = renderer(backend)
-    if (
-        not toolbar
-        and backend == "bokeh"
-        and (fmt == "png" or (isinstance(filename, str) and filename.endswith("png")))
-    ):
-        obj = obj.opts(toolbar=None, backend="bokeh", clone=True)
-    elif toolbar is not None and not toolbar:
-        obj = obj.opts(toolbar=None)
+    if backend == "bokeh":
+        if toolbar is not None:
+            if toolbar:
+                toolbar_location = obj.opts.get().kwargs.get('toolbar', 'right')
+                obj = obj.opts(toolbar=toolbar_location, autohide_toolbar=False, backend="bokeh", clone=True)
+            else:
+                obj = obj.opts(toolbar=None, backend="bokeh", clone=True)
+        elif (
+            not toolbar
+            and (fmt == "png" or (isinstance(filename, str) and filename.endswith("png")))
+        ):
+            obj = obj.opts(toolbar=None, backend="bokeh", clone=True)
     if kwargs:
         renderer_obj = renderer_obj.instance(**kwargs)
     if isinstance(filename, Path):
diff --git a/holoviews/util/_versions.py b/holoviews/util/_versions.py
index e6f45c5df..9fa4f321e 100644
--- a/holoviews/util/_versions.py
+++ b/holoviews/util/_versions.py
@@ -16,7 +16,6 @@ PACKAGES = [
     "pandas",
     "pyarrow",
     "spatialpandas",
-    "streamz",
     "xarray",
     # Processing
     "numba",
diff --git a/pixi.toml b/pixi.toml
index ec8de736a..45a6c125f 100644
--- a/pixi.toml
+++ b/pixi.toml
@@ -120,7 +120,6 @@ scipy = "*"
 selenium = "*"
 shapely = "*"
 spatialpandas = "*"
-streamz = ">=0.5.0"
 xarray = ">=0.10.4"
 xyzservices = "*"
 
diff --git a/pyproject.toml b/pyproject.toml
index 75d288d9c..6d4d450f2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -47,6 +47,8 @@ HoloViz = "https://holoviz.org/"
 
 [project.optional-dependencies]
 recommended = ["matplotlib >=3", "plotly >=4.0"]
+
+[dependency-groups]
 tests = ["pytest", "pytest-rerunfailures", "pytest-asyncio"]
 
 [project.scripts]
@@ -105,7 +107,6 @@ filterwarnings = [
     # 2023-09: `pkg_resources` is deprecated
     "ignore:Deprecated call to `pkg_resources.+?'mpl_toolkits:DeprecationWarning", # https://github.com/matplotlib/matplotlib/issues/25244
     "ignore:Deprecated call to `pkg_resources.+?'sphinxcontrib:DeprecationWarning", # https://github.com/mgaitan/sphinxcontrib-mermaid/issues/119
-    "ignore: pkg_resources is deprecated as an API:DeprecationWarning:streamz.plugins", # https://github.com/python-streamz/streamz/issues/460
     # 2023-10: Datetime's utctimestamp() and utcnow() is deprecated in Python 3.12
     "ignore:datetime.datetime.utcfromtimestamp():DeprecationWarning:dateutil.tz.tz", # https://github.com/dateutil/dateutil/pull/1285
     "ignore:datetime.datetime.utcfromtimestamp():DeprecationWarning:bokeh", # https://github.com/bokeh/bokeh/issues/13125


Test patch:
diff --git a/examples/conftest.py b/examples/conftest.py
index b318a52de..da8929232 100644
--- a/examples/conftest.py
+++ b/examples/conftest.py
@@ -17,8 +17,6 @@ PANDAS_GE_2_0_0 = Version(pd.__version__).release >= (2, 0, 0)
 collect_ignore_glob = [
     # Needs selenium, phantomjs, firefox, and geckodriver to save a png picture
     "user_guide/Plotting_with_Bokeh.ipynb",
-    # Streaming data use streamz which is no longer maintained
-    "user_guide/16-Streaming_Data.ipynb",
     # Possible timeout error
     "user_guide/17-Dashboards.ipynb",
     # Give file not found
diff --git a/holoviews/tests/conftest.py b/holoviews/tests/conftest.py
index 96530ae18..731429024 100644
--- a/holoviews/tests/conftest.py
+++ b/holoviews/tests/conftest.py
@@ -124,6 +124,14 @@ def serve_hv(page, port):  # noqa: F811
 
     return serve_and_return_page
 
+@pytest.fixture
+def serve_panel(page, port):  # noqa: F811
+    def serve_and_return_page(pn_obj):
+        serve_and_wait(pn.panel(pn_obj), port=port)
+        page.goto(f"http://localhost:{port}")
+        return page
+
+    return serve_and_return_page
 
 @pytest.fixture(autouse=True)
 def reset_store():
diff --git a/holoviews/tests/core/data/base.py b/holoviews/tests/core/data/base.py
index 69418e1ed..16849accb 100644
--- a/holoviews/tests/core/data/base.py
+++ b/holoviews/tests/core/data/base.py
@@ -693,6 +693,13 @@ class HeterogeneousColumnTests(HomogeneousColumnTests):
                           kdims=self.kdims, vdims=self.vdims)
         self.assertEqual(row, indexed)
 
+    def test_dataset_select_rows_gender_male_dict(self):
+        row = self.table.select({"Gender": 'M'})
+        indexed = Dataset({'Gender':['M', 'M'], 'Age':[10, 16],
+                           'Weight':[15,18], 'Height':[0.8,0.6]},
+                          kdims=self.kdims, vdims=self.vdims)
+        self.assertEqual(row, indexed)
+
     def test_dataset_select_rows_gender_male_expr(self):
         row = self.table.select(selection_expr=dim('Gender') == 'M')
         indexed = Dataset({'Gender': ['M', 'M'], 'Age': [10, 16],
@@ -709,6 +716,15 @@ class HeterogeneousColumnTests(HomogeneousColumnTests):
         self.assertEqual(row, indexed)
         self.assertEqual(alias_row, indexed)
 
+    def test_dataset_select_rows_gender_male_alias_dict(self):
+        row = self.alias_table.select({"Gender": 'M'})
+        alias_row = self.alias_table.select({"gender": 'M'})
+        indexed = Dataset({'gender':['M', 'M'], 'age':[10, 16],
+                           'weight':[15,18], 'height':[0.8,0.6]},
+                          kdims=self.alias_kdims, vdims=self.alias_vdims)
+        self.assertEqual(row, indexed)
+        self.assertEqual(alias_row, indexed)
+
     def test_dataset_index_row_age(self):
         indexed = Dataset({'Gender':['F'], 'Age':[12],
                            'Weight':[10], 'Height':[0.8]},
@@ -973,12 +989,24 @@ class GriddedInterfaceTests:
         )
         self.assertEqual(self.dataset_grid.select(y=slice(0, 0.25)), ds)
 
+    def test_select_slice_as_dict(self):
+        ds = self.element(
+            (self.grid_xs, self.grid_ys[:2], self.grid_zs[:2]), ['x', 'y'], ['z']
+        )
+        self.assertEqual(self.dataset_grid.select({"y": slice(0, 0.25)}), ds)
+
     def test_select_tuple(self):
         ds = self.element(
             (self.grid_xs, self.grid_ys[:2], self.grid_zs[:2]), ['x', 'y'], ['z']
         )
         self.assertEqual(self.dataset_grid.select(y=(0, 0.25)), ds)
 
+    def test_select_tuple_as_dict(self):
+        ds = self.element(
+            (self.grid_xs, self.grid_ys[:2], self.grid_zs[:2]), ['x', 'y'], ['z']
+        )
+        self.assertEqual(self.dataset_grid.select({"y": (0, 0.25)}), ds)
+
     def test_nodata_range(self):
         ds = self.dataset_grid.clone(vdims=[Dimension('z', nodata=0)])
         self.assertEqual(ds.range('z'), (1, 5))
diff --git a/holoviews/tests/core/data/test_pandasinterface.py b/holoviews/tests/core/data/test_pandasinterface.py
index 1c4022922..aeb4c180d 100644
--- a/holoviews/tests/core/data/test_pandasinterface.py
+++ b/holoviews/tests/core/data/test_pandasinterface.py
@@ -4,6 +4,7 @@ import pytest
 
 from holoviews.core.data import Dataset
 from holoviews.core.data.interface import DataError
+from holoviews.core.data.pandas import PandasInterface
 from holoviews.core.dimension import Dimension
 from holoviews.core.spaces import HoloMap
 from holoviews.element import Distribution, Points, Scatter
@@ -351,6 +352,16 @@ class PandasInterfaceMultiIndex(HeterogeneousColumnTests, InterfaceTests):
         expected = self.df.loc[[2]]
         pd.testing.assert_frame_equal(selected.data, expected)
 
+    def test_select_index_and_column(self):
+        # See https://github.com/holoviz/holoviews/issues/6578
+        frame = pd.DataFrame({"number": [1, 1, 2, 2], "color": ["red", "blue", "red", "blue"]})
+        index = pd.MultiIndex.from_frame(frame, names=("number", "color"))
+        df = pd.DataFrame({"cat": list("abab"), "values": range(4)}, index=index)
+        ds = Dataset(df, kdims=["number"], vdims=["cat", "values"])
+        selected = ds.select(number=[1], cat=["a"])
+        expected = df[(df.index.get_level_values(0) == 1) & (df["cat"] == "a")]
+        pd.testing.assert_frame_equal(selected.data, expected)
+
     def test_sample(self):
         ds = Dataset(self.df, kdims=["number", "color"])
         sample = ds.interface.sample(ds, [1])
@@ -404,3 +415,10 @@ class PandasInterfaceMultiIndex(HeterogeneousColumnTests, InterfaceTests):
 
         plot = Scatter(self.df, kdims="number")
         np.testing.assert_equal(plot.dimension_values('number'), self.df.index.get_level_values('number'))
+
+
+def test_no_subclasse_interface_applies():
+    spd = pytest.importorskip("spatialpandas")
+    square = spd.geometry.Polygon([(0, 0), (0, 1), (1, 1), (1, 0)])
+    sdf = spd.GeoDataFrame({"geometry": spd.GeoSeries([square, square]), "name": ["A", "B"]})
+    assert PandasInterface.applies(sdf) is False
diff --git a/holoviews/tests/ipython/test_displayhooks.py b/holoviews/tests/ipython/test_displayhooks.py
index 79ca8903c..33bfbf45d 100644
--- a/holoviews/tests/ipython/test_displayhooks.py
+++ b/holoviews/tests/ipython/test_displayhooks.py
@@ -18,7 +18,6 @@ class TestDisplayHooks(IPTestCase):
 
     def tearDown(self):
         self.ip.run_line_magic("unload_ext", "holoviews.ipython")
-        del self.ip
         Store.display_hooks = self.backup
         notebook_extension._loaded = False
         super().tearDown()
diff --git a/holoviews/tests/ipython/test_magics.py b/holoviews/tests/ipython/test_magics.py
index c0b5b49ee..64faf5ccf 100644
--- a/holoviews/tests/ipython/test_magics.py
+++ b/holoviews/tests/ipython/test_magics.py
@@ -22,7 +22,6 @@ class ExtensionTestCase(IPTestCase):
 
     def tearDown(self):
         self.ip.run_line_magic("unload_ext", "holoviews.ipython")
-        del self.ip
         super().tearDown()
 
 
diff --git a/holoviews/tests/operation/test_datashader.py b/holoviews/tests/operation/test_datashader.py
index e3425b880..cb609142c 100644
--- a/holoviews/tests/operation/test_datashader.py
+++ b/holoviews/tests/operation/test_datashader.py
@@ -1468,6 +1468,16 @@ def test_selector_datashade_bad_column_name(point_data):
         datashade(point_plot, selector=ds.min("val"), **inputs)
 
 
+@pytest.mark.usefixtures("bokeh_backend")
+def test_selector_single_categorical():
+    # Test for https://github.com/holoviz/holoviews/issues/6595
+    plot = Points(([0, 1], [0, 1], ["A", "A"]), ["X", "Y"], "C")
+    plot = rasterize(plot, aggregator=ds.count_cat("C"), selector=ds.first("X"))
+    plot = dynspread(plot)
+    # Should not fail
+    renderer("bokeh").get_plot(plot)
+
+
 class DatashaderSpreadTests(ComparisonTestCase):
 
     def test_spread_rgb_1px(self):
diff --git a/holoviews/tests/plotting/bokeh/test_elementplot.py b/holoviews/tests/plotting/bokeh/test_elementplot.py
index 395d0bbe7..57964cdc7 100644
--- a/holoviews/tests/plotting/bokeh/test_elementplot.py
+++ b/holoviews/tests/plotting/bokeh/test_elementplot.py
@@ -455,6 +455,12 @@ class TestElementPlot(LoggingComparisonTestCase, TestBokehPlot):
         x_range = plot.handles['x_range']
         self.assertEqual(x_range.factors, [])
 
+    def test_style_map_dimension_object(self):
+        x = Dimension('x')
+        y = Dimension('y')
+        scatter = Scatter([1, 2, 3], kdims=[x], vdims=[y]).opts(color=x)
+        self._test_colormapping(scatter, 'x', prefix='color_')
+
     #################################################################
     # Aspect tests
     #################################################################
diff --git a/holoviews/tests/plotting/bokeh/test_histogramplot.py b/holoviews/tests/plotting/bokeh/test_histogramplot.py
index 7bff1c46d..855ab6f96 100644
--- a/holoviews/tests/plotting/bokeh/test_histogramplot.py
+++ b/holoviews/tests/plotting/bokeh/test_histogramplot.py
@@ -132,7 +132,9 @@ class TestSideHistogramPlot(LoggingComparisonTestCase, TestBokehPlot):
         self.assertEqual(x_range.end, 3.8)
         self.assertEqual(y_range.start, 0.01)
         self.assertEqual(y_range.end, 3.3483695221017129)
-        self.log_handler.assertContains('WARNING', 'Logarithmic axis range encountered value less than')
+        # We should not have logged 'Logarithmic axis range encountered value less than'
+        last_line = self.log_handler.tail('WARNING', n=1)
+        assert last_line == []
 
     def test_histogram_padding_datetime_square(self):
         histogram = Histogram([(np.datetime64(f'2016-04-0{i}', 'ns'), i) for i in range(1, 4)]).opts(
diff --git a/holoviews/tests/plotting/bokeh/test_plot.py b/holoviews/tests/plotting/bokeh/test_plot.py
index 03c9761f2..867d17ba8 100644
--- a/holoviews/tests/plotting/bokeh/test_plot.py
+++ b/holoviews/tests/plotting/bokeh/test_plot.py
@@ -53,10 +53,10 @@ class TestBokehPlot(ComparisonTestCase):
         for plot, padding in self._padding.items():
             plot.padding = padding
 
-    def _test_colormapping(self, element, dim, log=False):
+    def _test_colormapping(self, element, dim, log=False, prefix=''):
         plot = bokeh_renderer.get_plot(element)
         plot.initialize_plot()
-        cmapper = plot.handles['color_mapper']
+        cmapper = plot.handles[f'{prefix}color_mapper']
         low, high = element.range(dim)
         self.assertEqual(cmapper.low, low)
         self.assertEqual(cmapper.high, high)
@@ -191,3 +191,59 @@ def test_span_not_cloned_crosshair():
     tool2 = next(t for t in fig2.tools if isinstance(t, CrosshairTool))
 
     assert tool1.overlay is tool2.overlay
+
+
+def test_autohide_toolbar_single_plot():
+    curve = Curve([1, 2, 3])
+
+    # Test with autohide_toolbar=False (default)
+    plot = bokeh_renderer.get_plot(curve)
+    assert plot.autohide_toolbar is False
+    assert plot.state.toolbar.autohide is False
+
+    # Test with autohide_toolbar=True
+    plot = bokeh_renderer.get_plot(curve.opts(autohide_toolbar=True))
+    assert plot.autohide_toolbar is True
+    assert plot.state.toolbar.autohide is True
+
+
+def test_autohide_toolbar_overlay():
+    overlay = Curve([1, 2, 3]) * Curve([2, 3, 4])
+
+    # Test with autohide_toolbar=False (default)
+    plot = bokeh_renderer.get_plot(overlay)
+    assert plot.autohide_toolbar is False
+    assert plot.state.toolbar.autohide is False
+
+    # Test with autohide_toolbar=True
+    plot = bokeh_renderer.get_plot(overlay.opts(autohide_toolbar=True))
+    assert plot.autohide_toolbar is True
+    assert plot.state.toolbar.autohide is True
+
+
+def test_autohide_toolbar_layout():
+    layout = Curve([1, 2, 3]) + Curve([2, 3, 4])
+
+    # Test with autohide_toolbar=False (default)
+    plot = bokeh_renderer.get_plot(layout)
+    assert plot.autohide_toolbar is False
+    grid = plot.handles['plot']
+    for child, *_ in grid.children:
+        assert child.toolbar.autohide is False
+
+    # Test with autohide_toolbar=True
+    plot = bokeh_renderer.get_plot(layout.opts(autohide_toolbar=True))
+    assert plot.autohide_toolbar is True
+    grid = plot.handles['plot']
+    assert grid.toolbar.autohide is True
+
+def test_autohide_toolbar_nested():
+    overlay = Curve([1, 2, 3]) * Curve([2, 3, 4])
+
+    # Test with different settings for components
+    mixed_layout = overlay.opts(autohide_toolbar=True) + Curve([3, 4, 5]).opts(autohide_toolbar=False)
+    plot = bokeh_renderer.get_plot(mixed_layout)
+    grid = plot.handles['plot']
+    child1, child2 = [child for child, *_ in grid.children]
+    assert child1.toolbar.autohide is True
+    assert child2.toolbar.autohide is False
diff --git a/holoviews/tests/plotting/bokeh/test_pointplot.py b/holoviews/tests/plotting/bokeh/test_pointplot.py
index aaced1d36..a94fe6ee6 100644
--- a/holoviews/tests/plotting/bokeh/test_pointplot.py
+++ b/holoviews/tests/plotting/bokeh/test_pointplot.py
@@ -2,8 +2,15 @@ import datetime as dt
 
 import numpy as np
 import pandas as pd
-from bokeh.models import CategoricalColorMapper, FactorRange, LinearColorMapper, Scatter
-
+from bokeh.models import (
+    CategoricalColorMapper,
+    Circle,
+    FactorRange,
+    LinearColorMapper,
+    Scatter,
+)
+
+import holoviews as hv
 from holoviews.core import NdOverlay
 from holoviews.core.options import Cycle
 from holoviews.element import Points
@@ -540,3 +547,37 @@ class TestPointPlot(TestBokehPlot):
             "size_index; ignoring the size_index.\n"
         )
         self.assertEqual(log_msg, warning)
+
+    def test_point_radius(self):
+        x, y = 4, 5
+        xs = np.arange(x)
+        ys = np.arange(y)
+        zs = np.arange(x * y).reshape(y, x)
+        plot = Points((xs, ys, zs,), kdims=["xs", "ys"], vdims="zs")
+        plot.opts(radius=hv.dim("zs").norm() / 2)
+
+        handles = bokeh_renderer.get_plot(plot).handles
+        glyph = handles["glyph"]
+        assert isinstance(glyph, Circle)
+        assert glyph.radius_dimension == "min"
+
+        norm = zs.T.ravel() / np.max(zs) / 2
+        np.testing.assert_array_equal(handles["cds"].data["radius"], norm)
+
+    def test_point_radius_then_size_then_radius(self):
+        plot = Points([1, 2, 3])
+        plot.opts(radius=1)
+
+        handles = bokeh_renderer.get_plot(plot).handles
+        glyph = handles["glyph"]
+        assert isinstance(glyph, Circle)
+
+        plot.opts(radius=None, size=1)
+        handles = bokeh_renderer.get_plot(plot).handles
+        glyph = handles["glyph"]
+        assert isinstance(glyph, Scatter)
+
+        plot.opts(radius=1)
+        handles = bokeh_renderer.get_plot(plot).handles
+        glyph = handles["glyph"]
+        assert isinstance(glyph, Circle)
diff --git a/holoviews/tests/plotting/matplotlib/test_areaplot.py b/holoviews/tests/plotting/matplotlib/test_areaplot.py
index b1bf4a259..6437ed39c 100644
--- a/holoviews/tests/plotting/matplotlib/test_areaplot.py
+++ b/holoviews/tests/plotting/matplotlib/test_areaplot.py
@@ -96,7 +96,7 @@ class TestAreaPlot(LoggingComparisonTestCase, TestMPLPlot):
         x_range, y_range = plot.handles['axis'].get_xlim(), plot.handles['axis'].get_ylim()
         self.assertEqual(x_range[0], 0.8)
         self.assertEqual(x_range[1], 3.2)
-        self.assertEqual(y_range[0], 0.03348369522101712)
+        self.assertEqual(y_range[0], 0.01)
         self.assertEqual(y_range[1], 3.3483695221017129)
         self.log_handler.assertContains('WARNING', 'Logarithmic axis range encountered value less than')
 
diff --git a/holoviews/tests/plotting/matplotlib/test_elementplot.py b/holoviews/tests/plotting/matplotlib/test_elementplot.py
index 0df1e7bd9..b5b6b1c24 100644
--- a/holoviews/tests/plotting/matplotlib/test_elementplot.py
+++ b/holoviews/tests/plotting/matplotlib/test_elementplot.py
@@ -3,6 +3,7 @@ from matplotlib import style
 from matplotlib.projections import PolarAxes
 from matplotlib.ticker import FormatStrFormatter, FuncFormatter, PercentFormatter
 
+from holoviews.core.dimension import Dimension
 from holoviews.core.spaces import DynamicMap
 from holoviews.element import Curve, HeatMap, Image, Scatter, Scatter3D
 from holoviews.streams import Stream
@@ -360,6 +361,14 @@ class TestColorbarPlot(TestMPLPlot):
         cbar_ax = plot.handles['cax']
         self.assertEqual(cbar_ax.get_ylabel(), 'color')
 
+    def test_style_map_dimension_object(self):
+        x = Dimension('x')
+        y = Dimension('y')
+        scatter = Scatter([1, 2, 3], kdims=[x], vdims=[y]).opts(color=x)
+        plot = mpl_renderer.get_plot(scatter)
+        artist = plot.handles['artist']
+        self.assertEqual(artist.get_clim(), (0, 2))
+
 
 class TestOverlayPlot(TestMPLPlot):
 
diff --git a/holoviews/tests/plotting/matplotlib/test_histogramplot.py b/holoviews/tests/plotting/matplotlib/test_histogramplot.py
index dab971f19..9ec36af50 100644
--- a/holoviews/tests/plotting/matplotlib/test_histogramplot.py
+++ b/holoviews/tests/plotting/matplotlib/test_histogramplot.py
@@ -76,7 +76,7 @@ class TestHistogramPlot(LoggingComparisonTestCase, TestMPLPlot):
         x_range, y_range = plot.handles['axis'].get_xlim(), plot.handles['axis'].get_ylim()
         self.assertEqual(x_range[0], 0.19999999999999996)
         self.assertEqual(x_range[1], 3.8)
-        self.assertEqual(y_range[0], 0.03348369522101712)
+        self.assertEqual(y_range[0], 0.01)
         self.assertEqual(y_range[1], 3.3483695221017129)
         self.log_handler.assertContains('WARNING', 'Logarithmic axis range encountered value less than')
 
diff --git a/holoviews/tests/plotting/plotly/test_elementplot.py b/holoviews/tests/plotting/plotly/test_elementplot.py
index fba246c78..457853cef 100644
--- a/holoviews/tests/plotting/plotly/test_elementplot.py
+++ b/holoviews/tests/plotting/plotly/test_elementplot.py
@@ -3,8 +3,9 @@ from collections import deque
 import numpy as np
 import pandas as pd
 
+from holoviews.core.dimension import Dimension
 from holoviews.core.spaces import DynamicMap
-from holoviews.element import Curve, Path3D, Scatter3D
+from holoviews.element import Curve, Path3D, Scatter, Scatter3D
 from holoviews.streams import PointerX
 
 from .test_plot import TestPlotlyPlot, plotly_renderer
@@ -232,6 +233,7 @@ class TestOverlayPlot(TestPlotlyPlot):
         self.assertEqual(state['layout']['scene']['zaxis']['title']['text'], 'Z-Axis')
 
 class TestColorbarPlot(TestPlotlyPlot):
+
     def test_base(self):
         df = pd.DataFrame(np.random.random((10, 4)), columns=list("XYZT"))
         scatter = Scatter3D(data=df)
@@ -255,3 +257,11 @@ class TestColorbarPlot(TestPlotlyPlot):
         state = self._get_plot_state(scatter)
         assert "colorbar" in state["data"][0]["marker"]
         assert state["data"][0]["marker"]["colorbar"]["title"]["text"] == "some-title"
+
+    def test_style_map_dimension_object(self):
+        x = Dimension("x")
+        y = Dimension("y")
+        scatter = Scatter([1, 2, 3], kdims=[x], vdims=[y]).opts(color=x)
+        state = self._get_plot_state(scatter)
+        assert state["data"][0]["marker"]["cmin"] == 0
+        assert state["data"][0]["marker"]["cmax"] == 2
diff --git a/holoviews/tests/ui/bokeh/test_histogram.py b/holoviews/tests/ui/bokeh/test_histogram.py
new file mode 100644
index 000000000..130742551
--- /dev/null
+++ b/holoviews/tests/ui/bokeh/test_histogram.py
@@ -0,0 +1,70 @@
+import pytest
+from bokeh.models import CustomJS
+
+from holoviews.element import Histogram
+
+from .. import expect, wait_until
+
+
+def watch_hook(dim, pos):
+    # Setting up monitoring, as I cannot get the data to sync back to Python
+    def create_hook(plot, element):
+        range_ = getattr(plot.handles["plot"], f"{dim}_range")
+        cjs = CustomJS(
+            args=dict(source=plot.handles["source"], range_=range_),
+            code=f"""
+                window.bokeh_cds_data = source.data['bottom']
+                window.bokeh_range = range_.{pos}
+            """,
+        )
+        range_.js_on_change("start", cjs)
+
+    return create_hook
+
+
+@pytest.mark.usefixtures("bokeh_backend")
+@pytest.mark.parametrize(
+    ["opts_kwargs", "hook", "drag_direction"],
+    [
+        ({"logy": True}, watch_hook("y", "start"), "down"),
+        ({"logx": True, "invert_axes": True}, watch_hook("x", "start"), "right"),
+        ({"logy": True, "invert_yaxis": True}, watch_hook("y", "end"), "up"),
+        ({"logx": True, "invert_axes": True, "invert_xaxis": True}, watch_hook("x", "end"), "left"),
+    ],
+    ids=["logy", "logx", "logy-invert", "logx-invert"],
+)
+def test_histogram_log_scaling(serve_hv, opts_kwargs, hook, drag_direction):
+    hist = Histogram(([1, 3, 6, 9], [1, 10, 0, 100])).opts(hooks=[hook], **opts_kwargs)
+
+    page = serve_hv(hist)
+    hv_plot = page.locator(".bk-events")
+    expect(hv_plot).to_have_count(1)
+    bbox = hv_plot.bounding_box()
+
+    center_x = bbox["x"] + bbox["width"] / 2
+    center_y = bbox["y"] + bbox["height"] / 2
+    page.mouse.move(center_x, center_y)
+
+    match drag_direction:
+        case "down":
+            target_x, target_y = center_x, bbox["y"] - bbox["height"]
+        case "up":
+            target_x, target_y = center_x, bbox["y"] + bbox["height"]
+        case "right":
+            target_x, target_y = bbox["x"] + bbox["width"], center_y
+        case "left":
+            target_x, target_y = bbox["x"] - bbox["width"], center_y
+        case _:
+            raise ValueError(f"Unknown drag direction: {drag_direction}")
+
+    page.mouse.down()
+    page.mouse.move(target_x, target_y)
+    page.mouse.up()
+
+    def f():
+        cds_value = page.evaluate("() => window.bokeh_cds_data")[0]
+        range_value = page.evaluate("() => window.bokeh_range")
+        assert cds_value != 0.01  # default value
+        assert cds_value == range_value
+
+    wait_until(f, page=page)
diff --git a/holoviews/tests/ui/bokeh/test_hover.py b/holoviews/tests/ui/bokeh/test_hover.py
index e6140b52e..8840dc853 100644
--- a/holoviews/tests/ui/bokeh/test_hover.py
+++ b/holoviews/tests/ui/bokeh/test_hover.py
@@ -1,5 +1,6 @@
 import numpy as np
 import pandas as pd
+import panel as pn
 import pytest
 
 import holoviews as hv
@@ -363,3 +364,67 @@ def test_hover_tooltips_rasterize_server_datetime_axis(serve_hv, rng, convert_x,
         expect(page.locator(".bk-Tooltip")).to_contain_text('x:2020-01-01')
     if convert_y:
         expect(page.locator(".bk-Tooltip")).to_contain_text('y:2020-01-01')
+
+
+@pytest.mark.usefixtures("bokeh_backend")
+def test_hover_tooltips_selector_update_plot(serve_panel):
+    import datashader as ds
+
+    from holoviews.operation.datashader import rasterize
+
+    N_OBS = 1000
+    x_data = np.random.random((N_OBS, N_OBS))
+
+    def get_plot(color_by):
+        if color_by == 'option1':
+            color_data = np.random.choice(['A', 'B', 'C', 'D'], size=N_OBS)
+        else:
+            color_data = np.random.choice(['a', 'b', 'c', 'd'], size=N_OBS)
+
+        dataset = hv.Dataset(
+            (x_data[:, 0], x_data[:, 1], color_data),
+            ['X', 'Y'],
+            color_by,
+        )
+        plot = dataset.to(hv.Points)
+        plot = rasterize(
+            plot,
+            aggregator=ds.count_cat(color_by),
+            selector=ds.first('X'),
+        )
+        plot = plot.opts(tools=["hover"], title=color_by)
+        return plot
+
+    scb = pn.widgets.Select(name="Color By", options=['option1', 'option2'])
+    layout = pn.Row(scb, pn.bind(get_plot, scb))
+
+    page = serve_panel(layout)
+    page.wait_for_timeout(500)
+
+    # Locate the plot and move mouse over it
+    hv_plot = page.locator(".bk-events")
+    expect(hv_plot).to_have_count(1)
+    bbox = hv_plot.bounding_box()
+    page.mouse.move(bbox["x"] + bbox["width"] / 2, bbox["y"] + bbox["height"] / 2)
+    page.wait_for_timeout(200)
+
+    tooltip = page.locator(".bk-Tooltip")
+    expect(tooltip).to_have_count(1)
+    expect(tooltip).to_contain_text('A:')
+    expect(tooltip).to_contain_text('B:')
+    expect(tooltip).to_contain_text('C:')
+    expect(tooltip).to_contain_text('D:')
+
+    # Change the selector to 'option2'
+    scb.value = "option2"
+    page.wait_for_timeout(1000)
+
+    # Move the mouse again to trigger updated tooltip
+    page.mouse.move(bbox["x"] + bbox["width"] / 4, bbox["y"] + bbox["height"] / 4)
+    page.wait_for_timeout(200)
+    tooltip = page.locator(".bk-Tooltip")
+    expect(tooltip).to_have_count(1)
+    expect(tooltip).to_contain_text('a:')
+    expect(tooltip).to_contain_text('b:')
+    expect(tooltip).to_contain_text('c:')
+    expect(tooltip).to_contain_text('d:')