diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py index 28155ad127..8cbc4ea2f2 100644 --- a/holoviews/plotting/bokeh/element.py +++ b/holoviews/plotting/bokeh/element.py @@ -88,6 +88,7 @@ get_scale, get_tab_title, get_ticker_axis_props, + get_tool_id, glyph_order, hold_policy, hold_render, @@ -3090,6 +3091,8 @@ class OverlayPlot(GenericOverlayPlot, LegendPlot): multiple_legends = param.Boolean(default=False, doc=""" Whether to split the legend for subplots into multiple legends.""") + _zoom_tool_types = (tools.WheelZoomTool, tools.ZoomInTool, tools.ZoomOutTool) + _propagate_options = ['width', 'height', 'xaxis', 'yaxis', 'labelled', 'bgcolor', 'fontsize', 'invert_axes', 'show_frame', 'show_grid', 'logx', 'logy', 'xticks', 'toolbar', @@ -3250,40 +3253,59 @@ def _init_tools(self, element, callbacks=None): callbacks = [] hover_tools = {} zooms_subcoordy = {} - _zoom_types = (tools.WheelZoomTool, tools.ZoomInTool, tools.ZoomOutTool) - init_tools, tool_types = [], [] + init_tools, tool_ids = [], set() + + def process_tool(tool, is_overlay_tool=False): + tool_id = get_tool_id(tool) + + if (is_overlay_tool and self.subcoordinate_y and + tool_id[0] in self._zoom_tool_types and isinstance(tool, str) and + not tool.startswith('x') and tool.replace('_tool', '') in zooms_subcoordy): + return False + + # Handle HoverTool deduplication by tooltips + if isinstance(tool, tools.HoverTool): + 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: + return False + hover_tools[tooltips] = tool + return True + + if ( + self.subcoordinate_y and isinstance(tool, self._zoom_tool_types) + and 'hv_created' in tool.tags and len(tool.tags) == 2 + ): + if tool.tags[1] in zooms_subcoordy: + return False + zooms_subcoordy[tool.tags[1]] = tool + self.handles['zooms_subcoordy'] = zooms_subcoordy + tool_ids.add(tool_id) + return True + + if tool_id in tool_ids: + return False + + tool_ids.add(tool_id) + return True + + # Collect tools from subplots for key, subplot in self.subplots.items(): el = element.get(key) if el is not None: el_tools = subplot._init_tools(el, self.callbacks) for tool in el_tools: - if isinstance(tool, str): - tool_type = TOOL_TYPES.get(tool) - else: - tool_type = type(tool) - if isinstance(tool, tools.HoverTool): - 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: - hover_tools[tooltips] = tool - elif ( - self.subcoordinate_y and isinstance(tool, _zoom_types) - and 'hv_created' in tool.tags and len(tool.tags) == 2 - ): - if tool.tags[1] in zooms_subcoordy: - continue - else: - zooms_subcoordy[tool.tags[1]] = tool - self.handles['zooms_subcoordy'] = zooms_subcoordy - elif tool_type in tool_types: - continue - else: - tool_types.append(tool_type) - init_tools.append(tool) + if process_tool(tool): + init_tools.append(tool) + + # Add tools specified directly on the overlay + overlay_tools = self.default_tools + self.tools + for tool in overlay_tools: + if process_tool(tool, is_overlay_tool=True): + init_tools.append(tool) + self.handles['hover_tools'] = hover_tools return init_tools diff --git a/holoviews/plotting/bokeh/util.py b/holoviews/plotting/bokeh/util.py index 3a9c43ca38..a730997137 100644 --- a/holoviews/plotting/bokeh/util.py +++ b/holoviews/plotting/bokeh/util.py @@ -1263,3 +1263,107 @@ def get_ticker_axis_props(ticker): if labels is not None: axis_props['major_label_overrides'] = dict(zip(ticks, labels, strict=None)) return axis_props + + +_DIRECTIONAL_TOOL_BASES = { + 'wheel_zoom': 'both', + 'pan': 'both', + 'zoom_in': 'both', + 'zoom_out': 'both', + 'box_zoom': 'both', + 'wheel_pan': 'both', + 'box_select': 'both', + 'crosshair': 'both', +} + +_TOOL_ALIAS_IDS = { + 'tap': 'tap', + 'click': 'inspect', + 'doubletap': 'doubletap', + 'auto_box_zoom': 'auto', +} + + +def _get_tool_id_from_str( + tool: str, + tool_type: type[tools.Tool], +) -> tuple[type[tools.Tool], str | None]: + """Return (tool_type, identifier) for a string tool name.""" + if tool in _DIRECTIONAL_TOOL_BASES: + return tool_type, _DIRECTIONAL_TOOL_BASES[tool] + if tool.startswith(('x', 'y')) and tool[1:] in _DIRECTIONAL_TOOL_BASES: + dimension = 'width' if tool.startswith('x') else 'height' + return tool_type, dimension + if tool in _TOOL_ALIAS_IDS: + return tool_type, _TOOL_ALIAS_IDS[tool] + return tool_type, None + + +def get_tool_id( + tool: str | tools.Tool, + *, + properties: tuple[str, ...] = ( + 'dimensions', + 'dimension', + 'tags', + 'name', + 'description', + 'icon', + ), + skip_tags: set[str] | None = None, +) -> tuple[type[tools.Tool], str | tuple | None]: + """ + Returns the tool type and an identifier for a given tool. + + The identifier allows distinguishing tools of the same type but with + different properties. This function checks all disambiguation properties + and returns a composite identifier if multiple properties are present. + + Parameters + ---------- + tool : str or Bokeh Tool class + Tool specification as string name or Tool class instance + properties : tuple of str, optional + Properties to check for disambiguation (default: dimensions, dimension, + tags, name, description, icon) + skip_tags : set of str or None, optional + Tag values to ignore during identification (default: {'hv_created'}) + + Returns + ------- + tuple[type[tools.Tool], str | tuple | None] + Tuple of (tool_type, identifier). The identifier can be: + - str: Single property value (e.g., 'both', 'width') + - tuple: Multiple property values as tuple of tuples + - None: No distinguishing properties + """ + if skip_tags is None: + skip_tags = {'hv_created'} + + if isinstance(tool, str): + tool_type = TOOL_TYPES.get(tool) + return _get_tool_id_from_str(tool, tool_type) + + tool_type = type(tool) + identifiers = {} + for prop_name in properties: + value = getattr(tool, prop_name, None) + if value is None: + continue + # Filter out internal tags; skip if nothing user-visible remains + if prop_name == 'tags': + visible = [t for t in value if t not in skip_tags] + if not visible: + continue + value = tuple(visible) + # Convert lists to tuples for hashability + elif isinstance(value, list): + value = tuple(value) + + identifiers[prop_name] = value + + if not identifiers: + return tool_type, None + if len(identifiers) == 1: + return tool_type, next(iter(identifiers.values())) + return tool_type, tuple(identifiers.items()) diff --git a/holoviews/tests/plotting/bokeh/test_overlayplot.py b/holoviews/tests/plotting/bokeh/test_overlayplot.py index c8056e780e..c9f6090a7b 100644 --- a/holoviews/tests/plotting/bokeh/test_overlayplot.py +++ b/holoviews/tests/plotting/bokeh/test_overlayplot.py @@ -1,8 +1,17 @@ +from collections import defaultdict + import numpy as np import pandas as pd import panel as pn import pytest -from bokeh.models import FactorRange, FixedTicker, HoverTool, Range1d, Span +from bokeh.models import ( + FactorRange, + FixedTicker, + HoverTool, + Range1d, + Span, + tools as bk_tools, +) import holoviews as hv from holoviews.plotting.bokeh.util import property_to_dict @@ -75,7 +84,7 @@ def test_hover_tool_instance_renderer_association(self): assert plot.handles['hover'].tooltips == tooltips # def test_hover_tool_overlay_renderers(self): - # overlay = Curve(range(2)).opts(tools=['hover']) * ErrorBars([]).opts(tools=['hover']) + # overlay = hv.Curve(range(2)).opts(tools=['hover']) * ErrorBars([]).opts(tools=['hover']) # plot = bokeh_renderer.get_plot(overlay) # assert len(plot.handles['hover'].renderers) == 1 # assert plot.handles['hover'].tooltips == [('x', '@{x}'), ('y', '@{y}')] @@ -411,6 +420,147 @@ def test_ndoverlay_categorical_y_ranges(order): expected = sorted(map(str, df.values.ravel())) assert output == expected +@pytest.mark.parametrize(("tools", "new_tools"), + [ + (None, []), + (["zoom_in"], ["ZoomInTool"]), + (["zoom_in", "zoom_out"], ["ZoomInTool", "ZoomOutTool"]) + ], ids=["none", "zoom_in", "zoom_in_and_zoom_out"]) +def test_overlay_opts_tools(tools, new_tools): + overlay = hv.Curve([1, 2, 3]) *hv.Curve([2, 3, 4]) + if tools is not None: + overlay.opts(tools=tools) + plot = bokeh_renderer.get_plot(overlay) + + tool_types = [type(tool).__name__ for tool in plot.state.tools] + defaults = ['WheelZoomTool', 'SaveTool', 'PanTool', 'BoxZoomTool', 'ResetTool'] + assert tool_types == [*defaults, *new_tools] + + +def test_overlay_opts_tools_with_element_tools(): + overlay = hv.Curve([1, 2, 3]).opts(tools=['zoom_out']) *hv.Curve([2, 3, 4]).opts(tools=['zoom_in']) + plot = bokeh_renderer.get_plot(overlay) + + tool_types = [type(tool).__name__ for tool in plot.state.tools] + defaults = ['WheelZoomTool', 'SaveTool', 'PanTool', 'BoxZoomTool', 'ResetTool'] + assert tool_types == [defaults[0], "ZoomOutTool", "ZoomInTool", *defaults[1:]] + +def test_overlay_default_tools_not_duplicated(): + overlay = hv.Curve([1, 2, 3]) *hv.Curve([2, 3, 4]) + plot = bokeh_renderer.get_plot(overlay) + + # Count each tool type + tool_type_counts = defaultdict(int) + for tool in plot.state.tools: + tool_type_counts[type(tool).__name__] += 1 + + # Each default tool should appear exactly once + assert tool_type_counts['PanTool'] == 1 + assert tool_type_counts['WheelZoomTool'] == 1 + assert tool_type_counts['SaveTool'] == 1 + assert tool_type_counts['BoxZoomTool'] == 1 + assert tool_type_counts['ResetTool'] == 1 + + +@pytest.mark.parametrize( + ("tool_strings", "expected_dimensions", "expected_count"), + [ + (["xpan", "ypan"], ["both", "width", "height"], 3), + (["pan", "xpan", "ypan"], ["both", "width", "height"], 3), + ], + ids=["directional_only", "generic_and_directional"], +) +def test_overlay_opts_directional_pan_tools(tool_strings, expected_dimensions, expected_count): + overlay = (hv.Curve([1, 2, 3]) * hv.Curve([2, 3, 4])).opts(tools=tool_strings) + plot = bokeh_renderer.get_plot(overlay) + + tools = [tool for tool in plot.state.tools if type(tool).__name__ == "PanTool"] + assert len(tools) == expected_count + + dimensions = [tool.dimensions for tool in tools] + for dimension in expected_dimensions: + assert dimension in dimensions + + +@pytest.mark.parametrize( + ("tool_strings", "expected_dimensions", "expected_count"), + [ + (["xzoom_in", "yzoom_in"], ["width", "height"], 2), + (["zoom_in", "xzoom_in", "yzoom_in"], ["both", "width", "height"], 3), + ], + ids=["directional_only", "generic_and_directional"], +) +def test_overlay_opts_directional_zoom_in_tools(tool_strings, expected_dimensions, expected_count): + overlay = (hv.Curve([1, 2, 3]) * hv.Curve([2, 3, 4])).opts(tools=tool_strings) + plot = bokeh_renderer.get_plot(overlay) + + tools = [tool for tool in plot.state.tools if type(tool).__name__ == "ZoomInTool"] + assert len(tools) == expected_count + + dimensions = [tool.dimensions for tool in tools] + for dimension in expected_dimensions: + assert dimension in dimensions + + +@pytest.mark.parametrize( + ("tool_strings", "expected_dimensions", "expected_count"), + [ + (["xbox_zoom", "ybox_zoom"], ["auto", "width", "height"], 3), + (["box_zoom", "xbox_zoom", "ybox_zoom"], ["auto", "both", "width", "height"], 4), + ], + ids=["directional_only", "generic_and_directional"], +) +def test_overlay_opts_directional_box_zoom_tools(tool_strings, expected_dimensions, expected_count): + overlay = (hv.Curve([1, 2, 3]) * hv.Curve([2, 3, 4])).opts(tools=tool_strings) + plot = bokeh_renderer.get_plot(overlay) + + tools = [tool for tool in plot.state.tools if type(tool).__name__ == "BoxZoomTool"] + assert len(tools) == expected_count + + dimensions = [tool.dimensions for tool in tools] + for dimension in expected_dimensions: + assert dimension in dimensions + +def test_overlay_opts_mixed_tools_no_duplicates(): + overlay = ( + hv.Curve([1, 2, 3]).opts(tools=['xpan']) * + hv.Curve([2, 3, 4]).opts(tools=['xpan']) + ).opts(tools=['xpan']) + plot = bokeh_renderer.get_plot(overlay) + + pan_tools = [tool for tool in plot.state.tools if type(tool).__name__ == 'PanTool'] + # Should only have one xpan tool despite being specified multiple times + xpan_tools = [tool for tool in pan_tools if tool.dimensions == 'width'] + assert len(xpan_tools) == 1 + + +def test_overlay_opts_xwheel_pan_ywheel_pan_distinct(): + overlay = (hv.Curve([1, 2, 3]) * hv.Curve([2, 3, 4])).opts(tools=['xwheel_pan', 'ywheel_pan']) + plot = bokeh_renderer.get_plot(overlay) + + wheel_pan_tools = [t for t in plot.state.tools if isinstance(t, bk_tools.WheelPanTool)] + assert len(wheel_pan_tools) == 2 + dimensions = {t.dimension for t in wheel_pan_tools} + assert dimensions == {'width', 'height'} + + +def test_overlay_opts_tap_doubletap_distinct(): + overlay = (hv.Curve([1, 2, 3]) * hv.Curve([2, 3, 4])).opts(tools=['tap', 'doubletap']) + plot = bokeh_renderer.get_plot(overlay) + + tap_tools = [t for t in plot.state.tools if isinstance(t, bk_tools.TapTool)] + assert len(tap_tools) == 2 + + +def test_overlay_hover_string_not_blocked_by_subplot_hover(): + curve1 = hv.Curve([1, 2, 3]).opts(tools=[HoverTool(tooltips=[('x', '@x')])]) + curve2 = hv.Curve([2, 3, 4]).opts(tools=[HoverTool(tooltips=[('y', '@y')])]) + overlay = curve1 * curve2 + plot = bokeh_renderer.get_plot(overlay) + + hover_tools = [t for t in plot.state.tools if isinstance(t, bk_tools.HoverTool)] + assert len(hover_tools) == 2 + class TestLegends(TestBokehPlot): diff --git a/holoviews/tests/plotting/bokeh/test_utils.py b/holoviews/tests/plotting/bokeh/test_utils.py index 6ec7e5e9e1..531bc40089 100644 --- a/holoviews/tests/plotting/bokeh/test_utils.py +++ b/holoviews/tests/plotting/bokeh/test_utils.py @@ -1,11 +1,12 @@ import pytest -from bokeh.models import Tool +from bokeh.models import Tool, tools as bk_tools import holoviews as hv from holoviews.plotting.bokeh.styles import expand_batched_style from holoviews.plotting.bokeh.util import ( TOOL_TYPES, filter_batched_data, + get_tool_id, glyph_order, select_legends, ) @@ -109,9 +110,94 @@ def test_select_legends_figure_index(figure_index, expected): def test_bokeh_tools_types(): - bk_tools = Tool._known_aliases - assert len(bk_tools) == len(TOOL_TYPES) - assert sorted(bk_tools) == sorted(TOOL_TYPES) - - for key in bk_tools: - assert isinstance(bk_tools[key](), TOOL_TYPES[key]) + bk_tools_aliases = Tool._known_aliases + assert len(bk_tools_aliases) == len(TOOL_TYPES) + assert sorted(bk_tools_aliases) == sorted(TOOL_TYPES) + + for key in bk_tools_aliases: + assert isinstance(bk_tools_aliases[key](), TOOL_TYPES[key]) + +class TestGetToolId: + + def test_string_wheel_zoom(self): + tool_type, ident = get_tool_id('wheel_zoom') + assert tool_type is bk_tools.WheelZoomTool + assert ident == 'both' + + def test_string_xwheel_zoom(self): + tool_type, ident = get_tool_id('xwheel_zoom') + assert tool_type is bk_tools.WheelZoomTool + assert ident == 'width' + + def test_string_ywheel_zoom(self): + tool_type, ident = get_tool_id('ywheel_zoom') + assert tool_type is bk_tools.WheelZoomTool + assert ident == 'height' + + def test_string_xwheel_pan(self): + tool_type, ident = get_tool_id('xwheel_pan') + assert tool_type is bk_tools.WheelPanTool + assert ident == 'width' + + def test_string_ywheel_pan(self): + tool_type, ident = get_tool_id('ywheel_pan') + assert tool_type is bk_tools.WheelPanTool + assert ident == 'height' + + def test_string_tap(self): + tool_type, ident = get_tool_id('tap') + assert tool_type is bk_tools.TapTool + assert ident == 'tap' + + def test_string_click(self): + tool_type, ident = get_tool_id('click') + assert tool_type is bk_tools.TapTool + assert ident == 'inspect' + + def test_string_doubletap(self): + tool_type, ident = get_tool_id('doubletap') + assert tool_type is bk_tools.TapTool + assert ident == 'doubletap' + + def test_instance_wheel_zoom_default(self): + tool = bk_tools.WheelZoomTool() + tool_type, ident = get_tool_id(tool) + assert tool_type is bk_tools.WheelZoomTool + assert ident == 'both' + + def test_instance_wheel_zoom_width(self): + tool = bk_tools.WheelZoomTool(dimensions='width') + tool_type, ident = get_tool_id(tool) + assert tool_type is bk_tools.WheelZoomTool + assert ident == 'width' + + def test_instance_wheel_pan_width(self): + tool = bk_tools.WheelPanTool(dimension='width') + tool_type, ident = get_tool_id(tool) + assert tool_type is bk_tools.WheelPanTool + assert ident == 'width' + + def test_instance_save_tool_no_tags(self): + tool = bk_tools.SaveTool(tags=['hv_created']) + tool_type, ident = get_tool_id(tool) + assert tool_type is bk_tools.SaveTool + assert ident is None + + def test_instance_save_tool_user_tag(self): + tool = bk_tools.SaveTool(tags=['my_tag']) + tool_type, ident = get_tool_id(tool) + assert tool_type is bk_tools.SaveTool + assert ident == ('my_tag',) + + def test_instance_save_tool_hv_and_user_tag(self): + tool = bk_tools.SaveTool(tags=['hv_created', 'my_tag']) + tool_type, ident = get_tool_id(tool) + assert tool_type is bk_tools.SaveTool + assert ident == ('my_tag',) + + def test_instance_wheel_zoom_hv_created_tag(self): + tool = bk_tools.WheelZoomTool(tags=['hv_created']) + tool_type, ident = get_tool_id(tool) + assert tool_type is bk_tools.WheelZoomTool + # dimensions takes precedence over tags + assert ident == 'both'