Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
74 changes: 45 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 @@ -3244,40 +3245,55 @@ def _init_tools(self, element, callbacks=None):
callbacks = []
hover_tools = {}
zooms_subcoordy = {}
_zoom_types = (tools.WheelZoomTool, tools.ZoomInTool, tools.ZoomOutTool)
init_tools, tool_types = [], []
zoom_types = (tools.WheelZoomTool, tools.ZoomInTool, tools.ZoomOutTool)
init_tools, tool_ids = [], set()

def process_tool(tool, skip_subcoordy_overlay_check=False):
tool_id = get_tool_id(tool)

if (skip_subcoordy_overlay_check and self.subcoordinate_y and
tool_id[0] in zoom_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
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:
return False
zooms_subcoordy[tool.tags[1]] = tool
self.handles['zooms_subcoordy'] = zooms_subcoordy
elif 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, skip_subcoordy_overlay_check=True):
init_tools.append(tool)

self.handles['hover_tools'] = hover_tools
return init_tools

Expand Down
24 changes: 24 additions & 0 deletions holoviews/plotting/bokeh/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -1262,3 +1262,27 @@ 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


def get_tool_id(tool: str | tools.Tool) -> tuple[type[tools.Tool], str | None]:
"""Returns the tool type and an identifier for a given tool."""
is_str = isinstance(tool, str)
tool_type = TOOL_TYPES.get(tool) if is_str else type(tool)

if is_str:
directional_tools = ('wheel_zoom', 'pan', 'zoom_in', 'zoom_out', 'box_zoom')
if tool in directional_tools:
return tool_type, 'both'
elif tool.startswith(('x', 'y')) and tool[1:] in directional_tools:
dimension = 'width' if tool.startswith('x') else 'height'
return tool_type, dimension
elif tool == 'auto_box_zoom':
return tool_type, 'auto'
else:
for name in ("dimensions", "tags", "name", "description", "icon"):
if identifier := getattr(tool, name, None):
# Convert lists/tuples to tuples (hashable)
if isinstance(identifier, list | tuple):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
if isinstance(identifier, list | tuple):
if isinstance(identifier, list):

Don't use pipe for isinstance and no need to check for tuple.

identifier = tuple(identifier)
return tool_type, identifier
return tool_type, None
115 changes: 115 additions & 0 deletions holoviews/tests/plotting/bokeh/test_overlayplot.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from collections import defaultdict

import numpy as np
import pandas as pd
import panel as pn
Expand Down Expand Up @@ -422,6 +424,119 @@ 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=["0", "1", "2"])
def test_overlay_opts_tools(tools, new_tools):
overlay = Curve([1, 2, 3]) * Curve([2, 3, 4])
if tools:
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 = Curve([1, 2, 3]).opts(tools=['zoom_out']) * 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 = Curve([1, 2, 3]) * 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 = (Curve([1, 2, 3]) * 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 = (Curve([1, 2, 3]) * 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 = (Curve([1, 2, 3]) * 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 = (
Curve([1, 2, 3]).opts(tools=['xpan']) *
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


class TestLegends(TestBokehPlot):

Expand Down
Loading