Skip to content

Commit

Permalink
Add a zoom tool per subcoordinate_y group (#6122)
Browse files Browse the repository at this point in the history
  • Loading branch information
maximlt authored Apr 17, 2024
1 parent ffb1293 commit 4042923
Show file tree
Hide file tree
Showing 2 changed files with 203 additions and 1 deletion.
74 changes: 73 additions & 1 deletion 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 @@ -3025,9 +3026,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 @@ -3132,6 +3201,9 @@ def initialize_plot(self, ranges=None, plot=None, plots=None):
reordered.append(reversed_renderers.pop(0))
plot.renderers = reordered

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 @@ -261,3 +261,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))

0 comments on commit 4042923

Please sign in to comment.