Repository: holoviz/holoviews
Pull request #6584 (2025-05-22 12:49:04)

fix: hover selector in overlay
This line was raising an error `tooltips = tuple(tool.tooltips) if tool.tooltips else ()` as `tool.tooltips` is a `Div`. With these changes this example works fine.

```python
import datashader as ds
import holoviews as hv
from holoviews.operation.datashader import rasterize
import numpy as np
import pandas as pd

hv.extension('bokeh')
num = 100
np.random.seed(1)

dists = {
    cat: pd.DataFrame(
        {
            "x": np.random.normal(x, s, num),
            "y": np.random.normal(y, s, num),
            "s": s,
            "val": val,
            "cat": cat,
        }
    )
    for x, y, s, val, cat in [
        (2, 2, 0.03, 0, "d1"),
        (2, -2, 0.10, 1, "d2"),
        (-2, -2, 0.50, 2, "d3"),
        (-2, 2, 1.00, 3, "d4"),
        (0, 0, 3.00, 4, "d5"),
    ]
}
df = pd.concat(dists, ignore_index=True)

layout = rasterize(hv.Points(df), selector=ds.first('val')).opts(tools=['hover']) * hv.Points([])
layout
```

<img width="793" alt="image" src="https://github.com/user-attachments/assets/4a35f947-2aef-430d-9a88-cc34e9448a33" />


hoxbro: How does the `mergetool` function work if the other plot has a hover tool too?
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/holoviz/holoviews/pull/6584?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 88.81%. Comparing base [(`f6cce46`)](https://app.codecov.io/gh/holoviz/holoviews/commit/f6cce46d4bd5b17a194c07df636e9d6104fd7840?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) to head [(`78abba1`)](https://app.codecov.io/gh/holoviz/holoviews/commit/78abba18fb510de07cb4927b5b3fbb18fb9ba8a2?dropdown=coverage&el=desc&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    #6584      +/-   ##
==========================================
- Coverage   88.81%   88.81%   -0.01%     
==========================================
  Files         326      326              
  Lines       69465    69474       +9     
==========================================
+ Hits        61696    61703       +7     
- Misses       7769     7771       +2     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/holoviz/holoviews/pull/6584?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>
maximlt: You get a sort of overlay of hover tooltips.

<img width="347" alt="image" src="https://github.com/user-attachments/assets/406fdffd-4f88-4471-af6a-3ae0855d03dd" />

hoxbro: I think that is OK 

Patch:
diff --git a/holoviews/core/util/__init__.py b/holoviews/core/util/__init__.py
index db5bcf6b0..8e0c7e6d0 100644
--- a/holoviews/core/util/__init__.py
+++ b/holoviews/core/util/__init__.py
@@ -31,6 +31,7 @@ from .dependencies import (  # noqa: F401
     PANDAS_VERSION,
     PARAM_VERSION,
     VersionError,
+    _LazyModule,
 )
 from .types import (
     arraylike_types,
@@ -43,6 +44,14 @@ from .types import (
     timedelta_types,
 )
 
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+    import pandas as pd
+    import polars as pl
+else:
+    pd = _LazyModule("pandas", bool_use_sys_modules=True)
+    pl = _LazyModule("polars", bool_use_sys_modules=True)
+
 # Python 2 builtins
 basestring = str
 long = int
@@ -800,10 +809,21 @@ label_sanitizer = sanitize_identifier_fn.instance()
 dimension_sanitizer = sanitize_identifier_fn.instance(capitalize=False)
 
 def isscalar(val):
-    """Value is scalar or None
+    """Value is scalar or nullable
 
     """
-    return val is None or np.isscalar(val) or isinstance(val, datetime_types)
+    return is_null_or_na_scalar(val) or np.isscalar(val) or isinstance(val, datetime_types)
+
+
+def is_null_or_na_scalar(val):
+    if hasattr(val, "__len__"):
+        return False
+    return bool(
+        val is None
+        or (pd and (val is pd.NA or val is pd.NaT))
+        or (pl and val is pl.Null)
+        or (np.isscalar(val) and np.isnan(val))
+    )
 
 
 def isnumeric(val):
diff --git a/holoviews/core/util/dependencies.py b/holoviews/core/util/dependencies.py
index 29350d7de..b4575e5ed 100644
--- a/holoviews/core/util/dependencies.py
+++ b/holoviews/core/util/dependencies.py
@@ -18,6 +18,8 @@ class VersionError(Exception):
 
 @lru_cache
 def _is_installed(module_name):
+    # So we don't accidentally import it
+    module_name, *_ = module_name.split(".")
     return find_spec(module_name) is not None
 
 
diff --git a/holoviews/operation/datashader.py b/holoviews/operation/datashader.py
index d1fe2d1b7..28c3e196d 100644
--- a/holoviews/operation/datashader.py
+++ b/holoviews/operation/datashader.py
@@ -1789,12 +1789,7 @@ class SpreadingOperation(LinkableOperation):
 
             for k, v in sel_data.items():
                 new_data[k].data = v
-
-            # TODO: Investigate why this does not work
-            # element = element.clone(data=new_data, kdims=element.vdims.copy(), vdims=element.vdims.copy())
-            element = element.clone()
-            element.data = new_data
-            return element
+            return element.clone(data=new_data)
 
         kwargs = {}
         if isinstance(element, RGB):
diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py
index e76a1d4c7..b23b0a915 100644
--- a/holoviews/plotting/bokeh/element.py
+++ b/holoviews/plotting/bokeh/element.py
@@ -3237,7 +3237,10 @@ class OverlayPlot(GenericOverlayPlot, LegendPlot):
                     else:
                         tool_type = type(tool)
                     if isinstance(tool, tools.HoverTool):
-                        tooltips = tuple(tool.tooltips) if tool.tooltips else ()
+                        if isinstance(tool.tooltips, bokeh.models.dom.Div):
+                            tooltips = tool.tooltips
+                        else:
+                            tooltips = tuple(tool.tooltips) if tool.tooltips else ()
                         if tooltips in hover_tools:
                             continue
                         else:
@@ -3267,7 +3270,9 @@ class OverlayPlot(GenericOverlayPlot, LegendPlot):
             self.handles['hover'] = subplot.handles['hover']
         elif 'hover' in subplot.handles and 'hover_tools' in self.handles:
             hover = subplot.handles['hover']
-            if hover.tooltips and not isinstance(hover.tooltips, str):
+            if hover.tooltips and isinstance(hover.tooltips, bokeh.models.dom.Div):
+                tooltips = hover.tooltips
+            elif hover.tooltips and not isinstance(hover.tooltips, str):
                 tooltips = tuple((name, spec.replace('{%F %T}', '')) for name, spec in hover.tooltips)
             else:
                 tooltips = ()


Test patch:
diff --git a/holoviews/tests/core/test_utils.py b/holoviews/tests/core/test_utils.py
index 47bc751d8..cf954f382 100644
--- a/holoviews/tests/core/test_utils.py
+++ b/holoviews/tests/core/test_utils.py
@@ -8,6 +8,7 @@ from itertools import product
 
 import numpy as np
 import pandas as pd
+import pytest
 
 from holoviews import Dimension, Element
 from holoviews.core.util import (
@@ -22,6 +23,7 @@ from holoviews.core.util import (
     find_range,
     get_path,
     is_nan,
+    is_null_or_na_scalar,
     isfinite,
     make_path_unique,
     max_range,
@@ -817,3 +819,30 @@ def test_is_nan():
     assert is_nan([1, 1]) == False
     assert is_nan([np.nan]) == True
     assert is_nan([np.nan, np.nan]) == False
+
+
+def test_is_null_or_na_scalar():
+    assert is_null_or_na_scalar(np.nan)
+    assert is_null_or_na_scalar(pd.NA)
+    assert is_null_or_na_scalar(pd.NaT)
+    assert is_null_or_na_scalar(None)
+    assert is_null_or_na_scalar(np.datetime64("NAT"))
+
+    assert not is_null_or_na_scalar(datetime.datetime.today())
+    assert not is_null_or_na_scalar(pd.Timestamp.now())
+    assert not is_null_or_na_scalar("AAAA")
+    assert not is_null_or_na_scalar(...)
+    assert not is_null_or_na_scalar([1, 2])
+    assert not is_null_or_na_scalar((1, 2))
+    assert not is_null_or_na_scalar({1, 2})
+    assert not is_null_or_na_scalar({"a": 1, "b": 2})
+    assert not is_null_or_na_scalar(slice(None))
+    assert not is_null_or_na_scalar(np.array([1, 2]))
+    assert not is_null_or_na_scalar(pd.DataFrame([1, 2]))
+
+
+def test_is_null_or_na_scalar_polars():
+    pl = pytest.importorskip("polars")
+    assert is_null_or_na_scalar(pl.Null)
+    assert not is_null_or_na_scalar(pl.DataFrame([1, 2]))
+    assert not is_null_or_na_scalar(pl.LazyFrame([1, 2]))
diff --git a/holoviews/tests/operation/test_datashader.py b/holoviews/tests/operation/test_datashader.py
index cf08006ec..e3425b880 100644
--- a/holoviews/tests/operation/test_datashader.py
+++ b/holoviews/tests/operation/test_datashader.py
@@ -31,6 +31,7 @@ from holoviews import (
     Spikes,
     Spread,
     TriMesh,
+    renderer,
 )
 from holoviews.element.comparison import ComparisonTestCase
 from holoviews.operation import apply_when
@@ -1390,6 +1391,12 @@ def test_selector_rasterize(point_plot, sel_fn):
     img_count = rasterize(point_plot, **inputs)
     np.testing.assert_array_equal(img["Count"], img_count["Count"])
 
+@pytest.mark.usefixtures("bokeh_backend")
+def test_selector_hover_in_overlay(point_plot):
+    inputs = dict(dynamic=False,  x_range=(-1, 1), y_range=(-1, 1), width=10, height=10)
+    overlay = rasterize(point_plot, selector=ds.first("val"), **inputs).opts(tools=["hover"]) * Points([])
+    renderer("bokeh").get_plot(overlay)
+
 @pytest.mark.parametrize("sel_fn", (ds.first, ds.last, ds.min, ds.max))
 def test_selector_datashade(point_plot, sel_fn):
     inputs = dict(dynamic=False,  x_range=(-1, 1), y_range=(-1, 1), width=10, height=10)