From 307b1fb563fe1100f81ec233fce8030f8a9682b1 Mon Sep 17 00:00:00 2001 From: Maxime Liquet <35924738+maximlt@users.noreply.github.com> Date: Mon, 15 Jan 2024 15:05:02 +0100 Subject: [PATCH] Zoom tools automatically vertically scaled on subcoordinate_y overlays (#6051) --- holoviews/plotting/bokeh/element.py | 61 +++++++++++++++++-- .../tests/plotting/bokeh/test_subcoordy.py | 48 +++++++++++++++ 2 files changed, 104 insertions(+), 5 deletions(-) diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py index 3d54ac005a..2d49588b7e 100644 --- a/holoviews/plotting/bokeh/element.py +++ b/holoviews/plotting/bokeh/element.py @@ -322,16 +322,32 @@ def _init_tools(self, element, callbacks=None): tool = tools.HoverTool( tooltips=tooltips, tags=['hv_created'], mode=tool, **hover_opts ) - elif bokeh32 and tool in ['wheel_zoom', 'xwheel_zoom', 'ywheel_zoom']: - if tool.startswith('x'): + elif bokeh32 and isinstance(tool, str) and tool.endswith( + ('wheel_zoom', 'zoom_in', 'zoom_out') + ): + zoom_kwargs = {} + tags = ['hv_created'] + if self.subcoordinate_y and not tool.startswith('x'): + zoom_dims = 'height' + zoom_kwargs['level'] = 1 + tags.append(tool) + elif tool.startswith('x'): zoom_dims = 'width' elif tool.startswith('y'): zoom_dims = 'height' else: zoom_dims = 'both' - tool = tools.WheelZoomTool( - zoom_together='none', dimensions=zoom_dims, tags=['hv_created'] - ) + zoom_kwargs['dimensions'] = zoom_dims + zoom_kwargs['tags'] = tags + if tool.endswith('wheel_zoom'): + # Setting `zoom_together` for multi-y axis support. + zoom_kwargs['zoom_together'] = 'none' + zoom_type = tools.WheelZoomTool + elif tool.endswith('zoom_in'): + zoom_type = tools.ZoomInTool + elif tool.endswith('zoom_out'): + zoom_type = tools.ZoomOutTool + tool = zoom_type(**zoom_kwargs) tool_list.append(tool) copied_tools = [] @@ -350,6 +366,15 @@ def _init_tools(self, element, callbacks=None): if hover: self.handles['hover'] = hover + if self.subcoordinate_y: + zoom_tools = {} + _zoom_types = (tools.WheelZoomTool, tools.ZoomInTool, tools.ZoomOutTool) + for t in copied_tools: + if isinstance(t, _zoom_types) and 'hv_created' in t.tags and len(t.tags) == 2: + zoom_tools[t.tags[1]] = t + if zoom_tools: + self.handles['zooms_subcoordy'] = zoom_tools + box_tools = [t for t in copied_tools if isinstance(t, tools.BoxSelectTool)] if box_tools: self.handles['box_select'] = box_tools[0] @@ -1828,6 +1853,14 @@ def _init_glyphs(self, plot, element, ranges, source): self._postprocess_hover(renderer, source) + zooms_subcoordy = self.handles.get('zooms_subcoordy') + if zooms_subcoordy is not None: + for zoom in zooms_subcoordy.values(): + # The default renderer is 'auto', instead we want to + # store the subplot renderer to aggregate them and set + # the final tool with a list of all the renderers. + zoom.renderers = [renderer] + # Update plot, source and glyph with abbreviated_exception(): self._update_glyph(renderer, properties, mapping, glyph, source, source.data) @@ -2777,6 +2810,8 @@ def _init_tools(self, element, callbacks=None): if callbacks is None: callbacks = [] hover_tools = {} + zooms_subcoordy = {} + _zoom_types = (tools.WheelZoomTool, tools.ZoomInTool, tools.ZoomOutTool) init_tools, tool_types = [], [] for key, subplot in self.subplots.items(): el = element.get(key) @@ -2793,6 +2828,15 @@ def _init_tools(self, element, callbacks=None): 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: @@ -2822,6 +2866,13 @@ def _merge_tools(self, subplot): tool.renderers = list(util.unique_iterator(renderers)) if 'hover' not in self.handles: self.handles['hover'] = tool + if 'zooms_subcoordy' in subplot.handles and 'zooms_subcoordy' in self.handles: + for subplot_zoom, overlay_zoom in zip( + subplot.handles['zooms_subcoordy'].values(), + self.handles['zooms_subcoordy'].values(), + ): + renderers = list(util.unique_iterator(subplot_zoom.renderers + overlay_zoom.renderers)) + overlay_zoom.renderers = renderers def _get_dimension_factors(self, overlay, ranges, dimension): factors = [] diff --git a/holoviews/tests/plotting/bokeh/test_subcoordy.py b/holoviews/tests/plotting/bokeh/test_subcoordy.py index a6af6a4664..0009d00950 100644 --- a/holoviews/tests/plotting/bokeh/test_subcoordy.py +++ b/holoviews/tests/plotting/bokeh/test_subcoordy.py @@ -1,5 +1,6 @@ import numpy as np import pytest +from bokeh.models.tools import WheelZoomTool, ZoomInTool, ZoomOutTool from holoviews.core import Overlay from holoviews.element import Curve @@ -202,3 +203,50 @@ def test_same_label_error(self): match='Elements wrapped in a subcoordinate_y overlay must all have a unique label', ): bokeh_renderer.get_plot(overlay) + + def test_tools_default_wheel_zoom_configured(self): + overlay = Overlay([Curve(range(10), label=f'Data {i}').opts(subcoordinate_y=True) for i in range(2)]) + plot = bokeh_renderer.get_plot(overlay) + zoom_subcoordy = plot.handles['zooms_subcoordy']['wheel_zoom'] + assert len(zoom_subcoordy.renderers) == 2 + assert len(set(zoom_subcoordy.renderers)) == 2 + assert zoom_subcoordy.dimensions == 'height' + assert zoom_subcoordy.level == 1 + + def test_tools_string_zoom_in_out_configured(self): + for zoom in ['zoom_in', 'zoom_out', 'yzoom_in', 'yzoom_out', 'ywheel_zoom']: + overlay = Overlay([Curve(range(10), label=f'Data {i}').opts(subcoordinate_y=True, tools=[zoom]) for i in range(2)]) + plot = bokeh_renderer.get_plot(overlay) + zoom_subcoordy = plot.handles['zooms_subcoordy'][zoom] + assert len(zoom_subcoordy.renderers) == 2 + assert len(set(zoom_subcoordy.renderers)) == 2 + assert zoom_subcoordy.dimensions == 'height' + assert zoom_subcoordy.level == 1 + + def test_tools_string_x_zoom_untouched(self): + for zoom, zoom_type in [ + ('xzoom_in', ZoomInTool), + ('xzoom_out', ZoomOutTool), + ('xwheel_zoom', WheelZoomTool), + ]: + overlay = Overlay([Curve(range(10), label=f'Data {i}').opts(subcoordinate_y=True, tools=[zoom]) for i in range(2)]) + plot = bokeh_renderer.get_plot(overlay) + for tool in plot.state.tools: + if isinstance(tool, zoom_type) and tool.tags == ['hv_created']: + assert tool.level == 0 + assert tool.dimensions == 'width' + break + else: + raise AssertionError('Provided zoom not found.') + + def test_tools_instance_zoom_untouched(self): + for zoom in [WheelZoomTool(), ZoomInTool(), ZoomOutTool()]: + overlay = Overlay([Curve(range(10), label=f'Data {i}').opts(subcoordinate_y=True, tools=[zoom]) for i in range(2)]) + plot = bokeh_renderer.get_plot(overlay) + for tool in plot.state.tools: + if isinstance(tool, type(zoom)) and 'hv_created' not in tool.tags: + assert tool.level == 0 + assert tool.dimensions == 'both' + break + else: + raise AssertionError('Provided zoom not found.')