Skip to content
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
22 changes: 22 additions & 0 deletions holoviews/plotting/bokeh/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -1262,3 +1262,25 @@ 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:
# TODO(Azaya): More way to identify? Take a look at merge_tools
for name in ("dimensions", "description"):
if identifier := getattr(tool, name, None):
return tool_type, identifier
return tool_type, None
134 changes: 134 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,138 @@ 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']
# INFO(Azaya): Can this really be right?
Copy link
Contributor Author

@Azaya89 Azaya89 Nov 28, 2025

Choose a reason for hiding this comment

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

Suggested change
# INFO(Azaya): Can this really be right?

Hmm, I found this interesting...

The test passes because it validates the internal tool ordering (i.e, checking plot.state.tools) but visually when you actually plot the same code, the ordering is now different (['PanTool', 'BoxZoomTool', 'WheelZoomTool', 'ZoomOutTool', 'ZoomInTool', 'SaveTool', 'ResetTool']). Maybe this is a result of how Bokeh handles the visual arrangement of the tools?

Copy link
Member

Choose a reason for hiding this comment

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

I don't know why this is the case at the top of my head. Try to see if you can understand by looking at the code and trying to use pure bokeh examples. Maybe also related to merge_tools.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think it's definitely from bokeh

from bokeh.plotting import figure, show
from bokeh.io import output_notebook

output_notebook()

x = [1, 2, 3, 4, 5]
y = [4, 5, 5, 7, 2]

default_tools = ['wheel_zoom', 'save', 'pan', 'box_zoom', 'reset']
extra_tools = ['zoom_out', 'zoom_in']

# create a plot
p = figure(
    title="Toolbars",
    tools=extra_tools+default_tools,
    width=400,
    height=300,
)

p.line(x, y)
show(p)
Screenshot 2025-12-02 at 9 43 14 AM
p.toolbar.tools
[ZoomOutTool(id='p1475', ...),
 ZoomInTool(id='p1476', ...),
 WheelZoomTool(id='p1477', ...),
 SaveTool(id='p1478', ...),
 PanTool(id='p1479', ...),
 BoxZoomTool(id='p1480', ...),
 ResetTool(id='p1488', ...)]

So while plot.toolbar.tools respects the set order of the tools, visually it still follows the other way (i.e, ['PanTool', 'BoxZoomTool', 'WheelZoomTool', 'ZoomOutTool', 'ZoomInTool', 'SaveTool', '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

#TODO(Azaya): Make these test more parameterize
def test_overlay_opts_directional_pan_tools():
overlay = (Curve([1, 2, 3]) * Curve([2, 3, 4])).opts(tools=['xpan', 'ypan'])
plot = bokeh_renderer.get_plot(overlay)

pan_tools = [tool for tool in plot.state.tools if type(tool).__name__ == 'PanTool']
# Should have 3 PanTools: default 'pan' (both) + xpan (width) + ypan (height)
assert len(pan_tools) == 3

dimensions = [tool.dimensions for tool in pan_tools]
assert 'both' in dimensions
assert 'width' in dimensions
assert 'height' in dimensions

def test_overlay_opts_generic_and_directional_pan_tools():
overlay = (Curve([1, 2, 3]) * Curve([2, 3, 4])).opts(tools=['pan', 'xpan', 'ypan'])
plot = bokeh_renderer.get_plot(overlay)

# Default 'pan' tool should not be duplicated
pan_tools = [tool for tool in plot.state.tools if type(tool).__name__ == 'PanTool']
assert len(pan_tools) == 3

dimensions = [tool.dimensions for tool in pan_tools]
assert 'both' in dimensions
assert 'width' in dimensions
assert 'height' in dimensions

def test_overlay_opts_directional_zoom_tools():
overlay = (Curve([1, 2, 3]) * Curve([2, 3, 4])).opts(tools=['xzoom_in', 'yzoom_in'])
plot = bokeh_renderer.get_plot(overlay)

zoom_tools = [tool for tool in plot.state.tools if type(tool).__name__ == 'ZoomInTool']
assert len(zoom_tools) == 2

dimensions = [tool.dimensions for tool in zoom_tools]
assert 'width' in dimensions
assert 'height' in dimensions

def test_overlay_opts_generic_and_directional_zoom_tools():
overlay = (Curve([1, 2, 3]) * Curve([2, 3, 4])).opts(tools=['zoom_in', 'xzoom_in', 'yzoom_in'])
plot = bokeh_renderer.get_plot(overlay)

zoom_tools = [tool for tool in plot.state.tools if type(tool).__name__ == 'ZoomInTool']
assert len(zoom_tools) == 3

dimensions = [tool.dimensions for tool in zoom_tools]
assert 'both' in dimensions
assert 'width' in dimensions
assert 'height' in dimensions

def test_overlay_opts_directional_box_zoom_tools():
overlay = (Curve([1, 2, 3]) * Curve([2, 3, 4])).opts(tools=['xbox_zoom', 'ybox_zoom'])
plot = bokeh_renderer.get_plot(overlay)

box_zoom_tools = [tool for tool in plot.state.tools if type(tool).__name__ == 'BoxZoomTool']
# Should have 3 BoxZoomTools: default 'auto_box_zoom' (auto) + xbox_zoom (width) + ybox_zoom (height)
assert len(box_zoom_tools) == 3

# Check that we have auto_box_zoom (auto), xbox_zoom (width), and ybox_zoom (height)
dimensions = [tool.dimensions for tool in box_zoom_tools]
assert 'auto' in dimensions
assert 'width' in dimensions
assert 'height' in dimensions

def test_overlay_opts_generic_and_directional_box_zoom_tools():
overlay = (Curve([1, 2, 3]) * Curve([2, 3, 4])).opts(tools=['box_zoom', 'xbox_zoom', 'ybox_zoom'])
plot = bokeh_renderer.get_plot(overlay)

box_zoom_tools = [tool for tool in plot.state.tools if type(tool).__name__ == 'BoxZoomTool']
# Should have 4 BoxZoomTools: default 'auto_box_zoom' + 3 added
assert len(box_zoom_tools) == 4

dimensions = [tool.dimensions for tool in box_zoom_tools]
assert 'auto' in dimensions
assert 'both' in dimensions
assert 'width' in dimensions
assert 'height' 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