From a9e4ccbb9278a2d39dbef03d3e94f88e27bf8aae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Mon, 1 Dec 2025 11:59:33 +0100 Subject: [PATCH 1/6] fix: Support hovertool across multiple elements --- holoviews/plotting/bokeh/element.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py index 7dec0a48fb..5a43a67018 100644 --- a/holoviews/plotting/bokeh/element.py +++ b/holoviews/plotting/bokeh/element.py @@ -648,14 +648,20 @@ def _get_hover_data(self, data, element, dimensions=None): If empty initializes with no data. """ - if 'hover' not in self.handles or self.static_source: + if 'hover' not in self.handles and not self.overlay_dims: return - for d in (dimensions or element.dimensions()): - dim = util.dimension_sanitizer(d.name) - if dim not in data: - data[dim] = element.dimension_values(d) + if 'hover' in self.handles and not self.static_source: + for d in (dimensions or element.dimensions()): + dim = util.dimension_sanitizer(d.name) + if dim not in data: + data[dim] = element.dimension_values(d) + + if not data: + return + # Always add overlay_dims to data, as these identify the element in the overlay + # and are needed for hover tooltips even when the subplot doesn't have its own hover tool for k, v in self.overlay_dims.items(): dim = util.dimension_sanitizer(k.name) if dim not in data: @@ -3596,6 +3602,14 @@ def update_frame(self, key, ranges=None, element=None): if not self.tabs: init_kwargs['plot'] = self.handles['plot'] self._create_dynamic_subplots(key, items, ranges, **init_kwargs) + if overlay_hover := self.handles.get('hover'): + if overlay_hover.renderers == 'auto': + overlay_hover.renderers = [] + for k, _ in items: + if k in self.subplots and 'glyph_renderer' in self.subplots[k].handles: + renderer = self.subplots[k].handles['glyph_renderer'] + if renderer not in overlay_hover.renderers: + overlay_hover.renderers.append(renderer) if not self.overlaid and not self.tabs: self._process_legend(element) From 2a17a2cdefa9a2bb1704c8fbdf7ae13c93405707 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Tue, 2 Dec 2025 10:45:15 +0100 Subject: [PATCH 2/6] Add ui test --- holoviews/tests/ui/bokeh/test_hover.py | 33 ++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/holoviews/tests/ui/bokeh/test_hover.py b/holoviews/tests/ui/bokeh/test_hover.py index 9c81957234..436fe4e843 100644 --- a/holoviews/tests/ui/bokeh/test_hover.py +++ b/holoviews/tests/ui/bokeh/test_hover.py @@ -515,3 +515,36 @@ def test_hover_heatmap_image(serve_hv, x_axis_type, y_axis_type): expect(tooltip).to_contain_text("x: 10.100" if is_lambda(x_axis_type) else "x: 10") expect(tooltip).to_contain_text("y: 4.100" if is_lambda(y_axis_type) else "y: 4") expect(tooltip).to_contain_text("z: 53") + + +@pytest.mark.usefixtures("bokeh_backend") +def test_hover_across_dynamicmaps(serve_panel): + data = pd.DataFrame({"x": range(2), "y": range(2), "category": ["A", "B"]}) + + + def inner(value): + el = ( + hv.Dataset(data) + .to(hv.Points, kdims=["x", "y"], groupby="category") + .opts(tools=["hover"], size=10) + ) + el = el.get(value) + el.opts(show_legend=False, xlim=(-1, 3), ylim=(-1, 3), hit_dilation=100) + return el.overlay() + + + widget = pn.widgets.MultiSelect(value=["A"], options=["A", "B"]) + col = pn.Column(widget, hv.DynamicMap(inner, streams=[widget.param.value])) + page = serve_panel(col) + + hv_plot = page.locator(".bk-events") + expect(hv_plot).to_have_count(1) + bbox = hv_plot.bounding_box() + + for i, cat in enumerate(("A", "B")): + widget.value = [cat] + page.mouse.move(bbox["x"] + bbox["width"] / 2, bbox["y"] + bbox["height"] / 2) + page.mouse.up() + tooltip = page.locator(".bk-Tooltip") + expect(tooltip).to_have_count(1) + expect(tooltip.first).to_contain_text(f"category: {cat} x: {i} y: {i}") From 7deab8f86758a8c5bd996cdfc39c4e0d58868348 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Tue, 2 Dec 2025 10:59:18 +0100 Subject: [PATCH 3/6] clean up --- holoviews/plotting/bokeh/element.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py index 5a43a67018..9619c0ebf5 100644 --- a/holoviews/plotting/bokeh/element.py +++ b/holoviews/plotting/bokeh/element.py @@ -648,10 +648,11 @@ def _get_hover_data(self, data, element, dimensions=None): If empty initializes with no data. """ - if 'hover' not in self.handles and not self.overlay_dims: + has_hover = 'hover' in self.handles + if not has_hover and not self.overlay_dims: return - if 'hover' in self.handles and not self.static_source: + if has_hover and not self.static_source: for d in (dimensions or element.dimensions()): dim = util.dimension_sanitizer(d.name) if dim not in data: @@ -660,8 +661,6 @@ def _get_hover_data(self, data, element, dimensions=None): if not data: return - # Always add overlay_dims to data, as these identify the element in the overlay - # and are needed for hover tooltips even when the subplot doesn't have its own hover tool for k, v in self.overlay_dims.items(): dim = util.dimension_sanitizer(k.name) if dim not in data: From d913917358aa8c967bf88c8a47a919ad5a3158ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Tue, 2 Dec 2025 11:11:45 +0100 Subject: [PATCH 4/6] add test for both categories --- holoviews/tests/ui/bokeh/test_hover.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/holoviews/tests/ui/bokeh/test_hover.py b/holoviews/tests/ui/bokeh/test_hover.py index 436fe4e843..08fcc85abe 100644 --- a/holoviews/tests/ui/bokeh/test_hover.py +++ b/holoviews/tests/ui/bokeh/test_hover.py @@ -548,3 +548,11 @@ def inner(value): tooltip = page.locator(".bk-Tooltip") expect(tooltip).to_have_count(1) expect(tooltip.first).to_contain_text(f"category: {cat} x: {i} y: {i}") + + widget.value = ["A", "B"] + page.mouse.move(bbox["x"] + bbox["width"] / 2, bbox["y"] + bbox["height"] / 2) + page.mouse.up() + tooltip = page.locator(".bk-Tooltip") + expect(tooltip).to_have_count(2) + expect(tooltip.first).to_contain_text("category: A x: 0 y: 0") + expect(tooltip.last).to_contain_text("category: B x: 1 y: 1") From fc1518b6bcd4e3209373e62d132aa4fe55959f38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Wed, 3 Dec 2025 07:47:21 +0100 Subject: [PATCH 5/6] updates --- holoviews/tests/ui/bokeh/test_hover.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/holoviews/tests/ui/bokeh/test_hover.py b/holoviews/tests/ui/bokeh/test_hover.py index 08fcc85abe..b7849bfc62 100644 --- a/holoviews/tests/ui/bokeh/test_hover.py +++ b/holoviews/tests/ui/bokeh/test_hover.py @@ -520,21 +520,17 @@ def test_hover_heatmap_image(serve_hv, x_axis_type, y_axis_type): @pytest.mark.usefixtures("bokeh_backend") def test_hover_across_dynamicmaps(serve_panel): data = pd.DataFrame({"x": range(2), "y": range(2), "category": ["A", "B"]}) - - - def inner(value): - el = ( - hv.Dataset(data) - .to(hv.Points, kdims=["x", "y"], groupby="category") - .opts(tools=["hover"], size=10) - ) - el = el.get(value) - el.opts(show_legend=False, xlim=(-1, 3), ylim=(-1, 3), hit_dilation=100) - return el.overlay() - - + el = ( + hv.Dataset(data) + .to(hv.Points, kdims=["x", "y"], groupby="category") + .opts(tools=["hover"], size=10, show_legend=False, xlim=(-1, 3), ylim=(-1, 3), hit_dilation=100) + ) widget = pn.widgets.MultiSelect(value=["A"], options=["A", "B"]) - col = pn.Column(widget, hv.DynamicMap(inner, streams=[widget.param.value])) + dmap = hv.DynamicMap( + lambda value: el.get(value).overlay(), + streams=[widget.param.value] + ) + col = pn.Column(widget, dmap) page = serve_panel(col) hv_plot = page.locator(".bk-events") From c6f9d3756ac3563d393ef8e98860c921e65aae52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Fri, 5 Dec 2025 10:57:58 +0100 Subject: [PATCH 6/6] fix lint --- holoviews/tests/ui/bokeh/test_hover.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/holoviews/tests/ui/bokeh/test_hover.py b/holoviews/tests/ui/bokeh/test_hover.py index df85cca9e9..d42a34398d 100644 --- a/holoviews/tests/ui/bokeh/test_hover.py +++ b/holoviews/tests/ui/bokeh/test_hover.py @@ -554,8 +554,8 @@ def test_hover_across_dynamicmaps(serve_panel): expect(tooltip).to_have_count(2) expect(tooltip.first).to_contain_text("category: A x: 0 y: 0") expect(tooltip.last).to_contain_text("category: B x: 1 y: 1") - - + + @pytest.mark.usefixtures("bokeh_backend") def test_hover_heatmap_categorical_outside_plot_area(serve_hv, caplog): # Test for https://github.com/holoviz/holoviews/pull/6438