Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 51 additions & 29 deletions holoviews/plotting/bokeh/element.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
get_scale,
get_tab_title,
get_ticker_axis_props,
get_tool_id,
glyph_order,
hold_policy,
hold_render,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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

Expand Down
104 changes: 104 additions & 0 deletions holoviews/plotting/bokeh/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added more tools and tool aliases that were missed before.

'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(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the str check into a separate helper fxn

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',

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added 'dimension' for WheenPanTool specifically

'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())
154 changes: 152 additions & 2 deletions holoviews/tests/plotting/bokeh/test_overlayplot.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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}')]
Expand Down Expand Up @@ -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):

Expand Down
Loading