diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py index 05972603ec..727c1192bb 100644 --- a/holoviews/plotting/bokeh/element.py +++ b/holoviews/plotting/bokeh/element.py @@ -1,4 +1,5 @@ import warnings +from collections import defaultdict from itertools import chain from types import FunctionType @@ -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. @@ -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(): @@ -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, diff --git a/holoviews/tests/plotting/bokeh/test_subcoordy.py b/holoviews/tests/plotting/bokeh/test_subcoordy.py index 0009d00950..2a02430b6b 100644 --- a/holoviews/tests/plotting/bokeh/test_subcoordy.py +++ b/holoviews/tests/plotting/bokeh/test_subcoordy.py @@ -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))