Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a zoom tool per subcoordinate_y group #6122

Merged
merged 4 commits into from
Apr 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
75 changes: 73 additions & 2 deletions holoviews/plotting/bokeh/element.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import warnings
from collections import defaultdict
from itertools import chain
from types import FunctionType

Expand Down Expand Up @@ -2843,7 +2844,6 @@ def _init_tools(self, element, callbacks=None):
self.handles['hover_tools'] = hover_tools
return init_tools


def _merge_tools(self, subplot):
"""
Merges tools on the overlay with those on the subplots.
Expand All @@ -2869,9 +2869,77 @@ def _merge_tools(self, subplot):
subplot.handles['zooms_subcoordy'].values(),
self.handles['zooms_subcoordy'].values(),
):
renderers = list(util.unique_iterator(subplot_zoom.renderers + overlay_zoom.renderers))
renderers = list(util.unique_iterator(overlay_zoom.renderers + subplot_zoom.renderers))
overlay_zoom.renderers = renderers

def _postprocess_subcoordinate_y_groups(self, overlay, plot):
"""
Add a zoom tool per group to the overlay.
"""
# First, just process and validate the groups and their content.
groups = defaultdict(list)

# If there are groups AND there are subcoordinate_y elements without a group.
if any(el.group != type(el).__name__ for el in overlay) and any(
el.opts.get('plot').kwargs.get('subcoordinate_y', False)
and el.group == type(el).__name__
for el in overlay
):
raise ValueError(
'The subcoordinate_y overlay contains elements with a defined group, each '
'subcoordinate_y element in the overlay must have a defined group.'
)

for el in overlay:
# group is the Element type per default (e.g. Curve, Spike).
if el.group == type(el).__name__:
continue
if not el.opts.get('plot').kwargs.get('subcoordinate_y', False):
raise ValueError(
f"All elements in group {el.group!r} must set the option "
f"'subcoordinate_y=True'. Not found for: {el}"
)
groups[el.group].append(el)

# No need to go any further if there's just one group.
if len(groups) <= 1:
return

# At this stage, there's only one zoom tool (e.g. 1 wheel_zoom) that
# has all the renderers (e.g. all the curves in the overlay).
# We want to create as many zoom tools as groups, for each group
# the zoom tool must have the renderers of the elements of the group.
zoom_tools = self.handles['zooms_subcoordy']
for zoom_tool_name, zoom_tool in zoom_tools.items():
renderers_per_group = defaultdict(list)
# We loop through each overlay sub-elements and empty the list of
# renderers of the initial tool.
for el in overlay:
if el.group not in groups:
continue
renderers_per_group[el.group].append(zoom_tool.renderers.pop(0))

if zoom_tool.renderers:
raise RuntimeError(f'Found unexpected zoom renderers {zoom_tool.renderers}')

new_ztools = []
# Create a new tool per group with the right renderers and a custom description.
for grp, grp_renderers in renderers_per_group.items():
new_tool = zoom_tool.clone()
new_tool.renderers = grp_renderers
new_tool.description = f"{zoom_tool_name.replace('_', ' ').title()} ({grp})"
new_ztools.append(new_tool)
# Revert tool order so the upper tool in the toolbar corresponds to the
# upper group in the overlay.
new_ztools = new_ztools[::-1]

# Update the handle for good measure.
zoom_tools[zoom_tool_name] = new_ztools

# Replace the original tool by the new ones
idx = plot.tools.index(zoom_tool)
plot.tools[idx:idx+1] = new_ztools

def _get_dimension_factors(self, overlay, ranges, dimension):
factors = []
for k, sp in self.subplots.items():
Expand Down Expand Up @@ -2960,6 +3028,9 @@ def initialize_plot(self, ranges=None, plot=None, plots=None):
panels.append(TabPanel(child=child, title=title))
self._merge_tools(subplot)

if self.subcoordinate_y:
self._postprocess_subcoordinate_y_groups(element, plot)

if self.tabs:
self.handles['plot'] = Tabs(
tabs=panels, width=self.width, height=self.height,
Expand Down
130 changes: 130 additions & 0 deletions holoviews/tests/plotting/bokeh/test_subcoordy.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,3 +250,133 @@ def test_tools_instance_zoom_untouched(self):
break
else:
raise AssertionError('Provided zoom not found.')

def test_single_group(self):
# Same as test_bool_base, to check nothing is affected by defining
# a single group.

overlay = Overlay([Curve(range(10), label=f'Data {i}', group='Group').opts(subcoordinate_y=True) for i in range(2)])
plot = bokeh_renderer.get_plot(overlay)
# subcoordinate_y is propagated to the overlay
assert plot.subcoordinate_y is True
# the figure has only one yaxis
assert len(plot.state.yaxis) == 1
# the overlay has two subplots
assert len(plot.subplots) == 2
assert ('Group', 'Data_0') in plot.subplots
assert ('Group', 'Data_1') in plot.subplots
# the range per subplots are correctly computed
sp1 = plot.subplots[('Group', 'Data_0')]
assert sp1.handles['glyph_renderer'].coordinates.y_target.start == -0.5
assert sp1.handles['glyph_renderer'].coordinates.y_target.end == 0.5
sp2 = plot.subplots[('Group', 'Data_1')]
assert sp2.handles['glyph_renderer'].coordinates.y_target.start == 0.5
assert sp2.handles['glyph_renderer'].coordinates.y_target.end == 1.5
# y_range is correctly computed
assert plot.handles['y_range'].start == -0.5
assert plot.handles['y_range'].end == 1.5
# extra_y_range is empty
assert plot.handles['extra_y_ranges'] == {}
# the ticks show the labels
assert plot.state.yaxis.ticker.ticks == [0, 1]
assert plot.state.yaxis.major_label_overrides == {0: 'Data 0', 1: 'Data 1'}

def test_multiple_groups(self):
overlay = Overlay([
Curve(range(10), label=f'{group} / {i}', group=group).opts(subcoordinate_y=True)
for group in ['A', 'B']
for i in range(2)
])
plot = bokeh_renderer.get_plot(overlay)
# subcoordinate_y is propagated to the overlay
assert plot.subcoordinate_y is True
# the figure has only one yaxis
assert len(plot.state.yaxis) == 1
# the overlay has two subplots
assert len(plot.subplots) == 4
assert ('A', 'A_over_0') in plot.subplots
assert ('A', 'A_over_1') in plot.subplots
assert ('B', 'B_over_0') in plot.subplots
assert ('B', 'B_over_1') in plot.subplots
# the range per subplots are correctly computed
sp1 = plot.subplots[('A', 'A_over_0')]
assert sp1.handles['glyph_renderer'].coordinates.y_target.start == -0.5
assert sp1.handles['glyph_renderer'].coordinates.y_target.end == 0.5
sp2 = plot.subplots[('A', 'A_over_1')]
assert sp2.handles['glyph_renderer'].coordinates.y_target.start == 0.5
assert sp2.handles['glyph_renderer'].coordinates.y_target.end == 1.5
sp3 = plot.subplots[('B', 'B_over_0')]
assert sp3.handles['glyph_renderer'].coordinates.y_target.start == 1.5
assert sp3.handles['glyph_renderer'].coordinates.y_target.end == 2.5
sp4 = plot.subplots[('B', 'B_over_1')]
assert sp4.handles['glyph_renderer'].coordinates.y_target.start == 2.5
assert sp4.handles['glyph_renderer'].coordinates.y_target.end == 3.5
# y_range is correctly computed
assert plot.handles['y_range'].start == -0.5
assert plot.handles['y_range'].end == 3.5
# extra_y_range is empty
assert plot.handles['extra_y_ranges'] == {}
# the ticks show the labels
assert plot.state.yaxis.ticker.ticks == [0, 1, 2, 3]
assert plot.state.yaxis.major_label_overrides == {
0: 'A / 0', 1: 'A / 1',
2: 'B / 0', 3: 'B / 1',
}

def test_multiple_groups_wheel_zoom_configured(self):
# Same as test_tools_default_wheel_zoom_configured

groups = ['A', 'B']
overlay = Overlay([
Curve(range(10), label=f'{group} / {i}', group=group).opts(subcoordinate_y=True)
for group in groups
for i in range(2)
])
plot = bokeh_renderer.get_plot(overlay)
zoom_tools = [tool for tool in plot.state.tools if isinstance(tool, WheelZoomTool)]
assert zoom_tools == plot.handles['zooms_subcoordy']['wheel_zoom']
assert len(zoom_tools) == len(groups)
for zoom_tool, group in zip(zoom_tools, reversed(groups)):
assert len(zoom_tool.renderers) == 2
assert len(set(zoom_tool.renderers)) == 2
assert zoom_tool.dimensions == 'height'
assert zoom_tool.level == 1
assert zoom_tool.description == f'Wheel Zoom ({group})'

def test_single_group_overlaid_no_error(self):
overlay = Overlay([Curve(range(10), label=f'Data {i}', group='Group').opts(subcoordinate_y=True) for i in range(2)])
with_span = VSpan(1, 2) * overlay * VSpan(3, 4)
bokeh_renderer.get_plot(with_span)

def test_multiple_groups_overlaid_no_error(self):
overlay = Overlay([
Curve(range(10), label=f'{group} / {i}', group=group).opts(subcoordinate_y=True)
for group in ['A', 'B']
for i in range(2)
])
with_span = VSpan(1, 2) * overlay * VSpan(3, 4)
bokeh_renderer.get_plot(with_span)

def test_missing_group_error(self):
curves = []
for i, group in enumerate(['A', 'B', 'C']):
for i in range(2):
label = f'{group}{i}'
if group == "B":
curve = Curve(range(10), label=label, group=group).opts(
subcoordinate_y=True
)
else:
curve = Curve(range(10), label=label).opts(
subcoordinate_y=True
)
curves.append(curve)

with pytest.raises(
ValueError,
match=(
'The subcoordinate_y overlay contains elements with a defined group, each '
'subcoordinate_y element in the overlay must have a defined group.'
)
):
bokeh_renderer.get_plot(Overlay(curves))