From 38e0b39ac51445c837a75cc00dfe047a4454ca6b Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 26 Sep 2023 18:01:25 +0200 Subject: [PATCH] Implement support for subcoordinate systems in the y-axis (#5840) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Simon Høxbro Hansen Co-authored-by: maximlt Co-authored-by: Demetris Roumis --- examples/gallery/demos/bokeh/eeg_viewer.ipynb | 172 ++++++++++++++++ examples/user_guide/Customizing_Plots.ipynb | 28 +++ holoviews/plotting/bokeh/element.py | 144 +++++++++++-- .../tests/plotting/bokeh/test_subcoordy.py | 190 ++++++++++++++++++ 4 files changed, 515 insertions(+), 19 deletions(-) create mode 100644 examples/gallery/demos/bokeh/eeg_viewer.ipynb create mode 100644 holoviews/tests/plotting/bokeh/test_subcoordy.py diff --git a/examples/gallery/demos/bokeh/eeg_viewer.ipynb b/examples/gallery/demos/bokeh/eeg_viewer.ipynb new file mode 100644 index 0000000000..01f3cb98c1 --- /dev/null +++ b/examples/gallery/demos/bokeh/eeg_viewer.ipynb @@ -0,0 +1,172 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "549b47a4", + "metadata": {}, + "source": [ + "This example demonstrates advanced visualization techniques using HoloViews with the Bokeh plotting backend. You'll learn how to:\n", + "\n", + "1. Display multiple timeseries in a single plot using `subcoordinate_y`.\n", + "2. Create and link a minimap to the main plot with `RangeToolLink`.\n", + "\n", + "Specifically, we'll simulate [Electroencephalography](https://en.wikipedia.org/wiki/Electroencephalography) (EEG) data, plot it, and then create a minimap based on the [z-score](https://en.wikipedia.org/wiki/Standard_score) of the data for easier navigation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8109537b-5fba-4f07-aba4-91a56f7e95c7", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import holoviews as hv\n", + "from bokeh.models import HoverTool\n", + "from holoviews.plotting.links import RangeToolLink\n", + "from scipy.stats import zscore\n", + "\n", + "hv.extension('bokeh')" + ] + }, + { + "cell_type": "markdown", + "id": "1c95f241-2314-42b0-b6cb-2c0baf332686", + "metadata": {}, + "source": [ + "## Generating EEG data\n", + "\n", + "Let's start by simulating some EEG data. We'll create a timeseries for each channel using sine waves with varying frequencies." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5f4a9dbe", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "N_CHANNELS = 10\n", + "N_SECONDS = 5\n", + "SAMPLING_RATE = 200\n", + "INIT_FREQ = 2 # Initial frequency in Hz\n", + "FREQ_INC = 5 # Frequency increment\n", + "AMPLITUDE = 1\n", + "\n", + "# Generate time and channel labels\n", + "total_samples = N_SECONDS * SAMPLING_RATE\n", + "time = np.linspace(0, N_SECONDS, total_samples)\n", + "channels = [f'EEG {i}' for i in range(N_CHANNELS)]\n", + "\n", + "# Generate sine wave data\n", + "data = np.array([AMPLITUDE * np.sin(2 * np.pi * (INIT_FREQ + i * FREQ_INC) * time)\n", + " for i in range(N_CHANNELS)])" + ] + }, + { + "cell_type": "markdown", + "id": "ec9e71b8-a995-4c0f-bdbb-5d148d8fa138", + "metadata": {}, + "source": [ + "## Visualizing EEG Data\n", + "\n", + "Next, let's dive into visualizing the EEG data. We construct each timeseries using a `Curve` element, assigning it a `label` and setting `subcoordinate_y=True`. All these curves are then aggregated into a list, which serves as the input for an `Overlay` element. Rendering this `Overlay` produces a plot where the timeseries are stacked vertically.\n", + "\n", + "Additionally, we'll enhance user interaction by implementing a custom hover tool. This will display key information—channel, time, and amplitude—when you hover over any of the curves." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9476769f-3935-4236-b010-1511d1a1e77f", + "metadata": {}, + "outputs": [], + "source": [ + "hover = HoverTool(tooltips=[\n", + " (\"Channel\", \"@channel\"),\n", + " (\"Time\", \"$x s\"),\n", + " (\"Amplitude\", \"$y µV\")\n", + "])\n", + "\n", + "channel_curves = []\n", + "for channel, channel_data in zip(channels, data):\n", + " ds = hv.Dataset((time, channel_data, channel), [\"Time\", \"Amplitude\", \"channel\"])\n", + " curve = hv.Curve(ds, \"Time\", [\"Amplitude\", \"channel\"], label=channel)\n", + " curve.opts(\n", + " subcoordinate_y=True, color=\"black\", line_width=1, tools=[hover],\n", + " )\n", + " channel_curves.append(curve)\n", + "\n", + "eeg = hv.Overlay(channel_curves, kdims=\"Channel\").opts(\n", + " xlabel=\"Time (s)\", ylabel=\"Channel\", show_legend=False, aspect=3, responsive=True,\n", + ")\n", + "eeg" + ] + }, + { + "cell_type": "markdown", + "id": "b4f603e2-039d-421a-ba9a-ed9e77efab99", + "metadata": {}, + "source": [ + "## Creating the Minimap\n", + "\n", + "A minimap can provide a quick overview of the data and help you navigate through it. We'll compute the z-score for each channel and represent it as an image; the z-score will normalize the data and bring out the patterns more clearly. To enable linking in the next step between the EEG `Overlay` and the minimap `Image`, we ensure they share the same y-axis range." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "40fa2198-c3b5-41e1-944f-f8b812612168", + "metadata": {}, + "outputs": [], + "source": [ + "y_positions = range(N_CHANNELS)\n", + "yticks = [(i , ich) for i, ich in enumerate(channels)]\n", + "\n", + "z_data = zscore(data, axis=1)\n", + "\n", + "minimap = hv.Image((time, y_positions , z_data), [\"Time (s)\", \"Channel\"], \"Amplitude (uV)\")\n", + "minimap = minimap.opts(\n", + " cmap=\"RdBu_r\", xlabel='Time (s)', alpha=.5, yticks=[yticks[0], yticks[-1]],\n", + " height=150, responsive=True, default_tools=[], clim=(-z_data.std(), z_data.std())\n", + ")\n", + "minimap" + ] + }, + { + "cell_type": "markdown", + "id": "a5b77970-342f-4428-bd1c-4dbef1e6a2b5", + "metadata": {}, + "source": [ + "## Building the dashboard\n", + "\n", + "Finally, we use [`RangeToolLink`](../../../user_guide/Linking_Plots.ipynb) to connect the minimap `Image` and the EEG `Overlay`, setting bounds for the initial viewable area. Once the plots are linked and assembled into a unified dashboard, you can interact with it. Experiment by dragging the selection box on the minimap or resizing it by clicking and dragging its edges." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "260489eb-2dbf-4c88-ba83-dd1cba0e547b", + "metadata": {}, + "outputs": [], + "source": [ + "RangeToolLink(\n", + " minimap, eeg, axes=[\"x\", \"y\"],\n", + " boundsx=(None, 2), boundsy=(None, 6.5)\n", + ")\n", + "\n", + "dashboard = (eeg + minimap).opts(merge_tools=False).cols(1)\n", + "dashboard" + ] + } + ], + "metadata": { + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/user_guide/Customizing_Plots.ipynb b/examples/user_guide/Customizing_Plots.ipynb index 24c5b8d915..5fea84edbe 100644 --- a/examples/user_guide/Customizing_Plots.ipynb +++ b/examples/user_guide/Customizing_Plots.ipynb @@ -745,6 +745,34 @@ "\n", "Note that as of HoloViews 1.17.0, `multi_y` does not have streaming plot support, extra axis labels are not dynamic and only the `RangeXY` linked stream is aware of additional y-axes." ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Subcoordinate y-axis\n", + "*(Available in HoloViews >= 1.18)*\n", + "\n", + "HoloViews enables you to create overlays where each element has its own distinct y-axis subcoordinate system. To activate this feature, set the `subcoordinate_y` keyword to True for **each** overlay element; the default is False. When using `subcoordinate_y=True`, setting a `label` for each element is required for proper rendering and identification.This will automatically distribute overlay elements along the y-axis.\n", + "\n", + "For more fine-grained control over y-axis positioning, you can specify a numerical 2-tuple for subcoordinate_y with values ranging from 0 to 1. Additionally, the `subcoordinate_scale` keyword, which defaults to 1, allows you to adjust the vertical scale of each element. This option is only applicable when `subcoordinate_y=True`. For example, setting a single Curve's `subcoordinate_scale` to 2 will result in it overlapping 50% with its adjacent elements." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 10*np.pi)\n", + "\n", + "curves = [\n", + " hv.Curve((x + i*np.pi/2, np.sin(x)), label=f'Line {i}').opts(subcoordinate_y=True, subcoordinate_scale=1.2)\n", + " for i in range(3)\n", + "]\n", + "\n", + "hv.Overlay(curves).opts(show_legend=False)" + ] } ], "metadata": { diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py index 079311b93e..c1d20faa20 100644 --- a/holoviews/plotting/bokeh/element.py +++ b/holoviews/plotting/bokeh/element.py @@ -154,6 +154,14 @@ class ElementPlot(BokehPlot, GenericElementPlot): elements and the overlay container, allowing customization on a per-axis basis.""") + subcoordinate_y = param.ClassSelector(default=False, class_=(bool, tuple), doc=""" + Enables sub-coordinate systems for this plot. Accepts also a numerical + two-tuple that must be a range between 0 and 1, the plot will be + rendered on this vertical range of the axis.""") + + subcoordinate_scale = param.Number(default=1, bounds=(0, None), inclusive_bounds=(False, True), doc=""" + Scale factor for subcoordinate ranges to control the level of overlap.""") + responsive = param.ObjectSelector(default=False, objects=[False, True, 'width', 'height']) fontsize = param.Parameter(default={'title': '12pt'}, allow_None=True, doc=""" @@ -229,6 +237,7 @@ class ElementPlot(BokehPlot, GenericElementPlot): _stream_data = True def __init__(self, element, plot=None, **params): + self._subcoord_standalone_ = None self.current_ranges = None super().__init__(element, **params) self.handles = {} if plot is None else self.handles['plot'] @@ -401,6 +410,21 @@ def _shared_axis_range(self, plots, specs, range_type, axis_type, pos): return dim_range + @property + def _subcoord_overlaid(self): + """ + Indicates when the context is a subcoordinate plot, either from within + the overlay rendering or one of its subplots. Used to skip code paths + when rendering an element outside of an overlay. + """ + if self._subcoord_standalone_ is not None: + return self._subcoord_standalone_ + self._subcoord_standalone_ = ( + (isinstance(self, OverlayPlot) and self.subcoordinate_y) or + (not isinstance(self, OverlayPlot) and self.overlaid and self.subcoordinate_y) + ) + return self._subcoord_standalone_ + def _axis_props(self, plots, subplots, element, ranges, pos, *, dim=None, range_tags_extras=[], extra_range_name=None): @@ -416,7 +440,10 @@ def _axis_props(self, plots, subplots, element, ranges, pos, *, dim=None, # a synthetic dimension and exclusively supported for y-axes. if pos == 1 and dim: dims = [dim] - v0, v1 = util.max_range([elrange.get(dim, {'combined': (None, None)})['combined'] for elrange in ranges.values()]) + v0, v1 = util.max_range([ + elrange.get(dim.name, {'combined': (None, None)})['combined'] + for elrange in ranges.values() + ]) axis_label = str(dim) specs = ((dim.name, dim.label, dim.unit),) else: @@ -428,7 +455,17 @@ def _axis_props(self, plots, subplots, element, ranges, pos, *, dim=None, l, b, r, t = self.get_extents(range_el, ranges) if self.invert_axes: l, b, r, t = b, l, t, r - v0, v1 = (l, r) if pos == 0 else (b, t) + if pos == 1 and self._subcoord_overlaid: + if isinstance(self.subcoordinate_y, bool): + offset = self.subcoordinate_scale / 2. + # This sum() is equal to n+1, n being the number of elements contained + # in the overlay with subcoordinate_y=True, as the traversal goes through + # the root overlay that has subcoordinate_y=True too since it's propagated. + v0, v1 = 0-offset, sum(self.traverse(lambda p: p.subcoordinate_y))-2+offset + else: + v0, v1 = 0, 1 + else: + v0, v1 = (l, r) if pos == 0 else (b, t) axis_dims = list(self._get_axis_dims(el)) if self.invert_axes: @@ -452,7 +489,9 @@ def _axis_props(self, plots, subplots, element, ranges, pos, *, dim=None, dims = dims[:2][::-1] categorical = any(self.traverse(lambda plot: plot._categorical)) - if dims is not None and any(dim.name in ranges and 'factors' in ranges[dim.name] for dim in dims): + if self.subcoordinate_y: + categorical = False + elif dims is not None and any(dim.name in ranges and 'factors' in ranges[dim.name] for dim in dims): categorical = True else: categorical = any(isinstance(v, (str, bytes)) for v in (v0, v1)) @@ -474,12 +513,12 @@ def _axis_props(self, plots, subplots, element, ranges, pos, *, dim=None, norm_opts = self.lookup_options(el, 'norm').options shared_name = extra_range_name or ('x-main-range' if pos == 0 else 'y-main-range') - if plots and self.shared_axes and not norm_opts.get('axiswise', False): + if plots and self.shared_axes and not norm_opts.get('axiswise', False) and not dim: dim_range = self._shared_axis_range(plots, specs, range_type, axis_type, pos) if dim_range: self._shared[shared_name] = True - if self._shared.get(shared_name): + if self._shared.get(shared_name) and not dim: pass elif categorical: axis_type = 'auto' @@ -507,16 +546,24 @@ def _create_extra_axes(self, plots, subplots, element, ranges): axpos0, axpos1 = 'left', 'right' ax_specs, yaxes, dimensions = {}, {}, {} + subcoordinate_axes = 0 for el, sp in zip(element, self.subplots.values()): ax_dims = sp._get_axis_dims(el)[:2] if sp.invert_axes: ax_dims[::-1] yd = ax_dims[1] - dimensions[yd.name] = yd opts = el.opts.get('plot', backend='bokeh').kwargs if not isinstance(yd, Dimension) or yd.name in yaxes: continue - yaxes[yd.name] = { + if self._subcoord_overlaid: + if opts.get('subcoordinate_y') is None: + continue + ax_name = el.label + subcoordinate_axes += 1 + else: + ax_name = yd.name + dimensions[ax_name] = yd + yaxes[ax_name] = { 'position': opts.get('yaxis', axpos1 if len(yaxes) else axpos0), 'autorange': opts.get('autorange', None), 'logx': opts.get('logx', False), @@ -528,11 +575,14 @@ def _create_extra_axes(self, plots, subplots, element, ranges): 'fontsize': { 'axis_label_text_font_size': sp._fontsize('ylabel').get('fontsize'), 'major_label_text_font_size': sp._fontsize('yticks').get('fontsize') - } + }, + 'subcoordinate_y': subcoordinate_axes-1 if self._subcoord_overlaid else None } for ydim, info in yaxes.items(): range_tags_extras = {'invert_yaxis': info['invert_yaxis']} + if info['subcoordinate_y'] is not None: + range_tags_extras['subcoordinate_y'] = info['subcoordinate_y'] if info['autorange'] == 'y': range_tags_extras['autorange'] = True lowerlim, upperlim = info['ylim'][0], info['ylim'][1] @@ -542,7 +592,6 @@ def _create_extra_axes(self, plots, subplots, element, ranges): range_tags_extras['y-upperlim'] = upperlim else: range_tags_extras['autorange'] = False - ax_props = self._axis_props( plots, subplots, element, ranges, pos=1, dim=dimensions[ydim], range_tags_extras=range_tags_extras, @@ -582,18 +631,23 @@ def _init_plot(self, key, element, plots, ranges=None): else: range_tags_extras['autorange'] = False axis_specs['y']['y'] = self._axis_props( - plots, subplots, element, ranges, pos=1, range_tags_extras = range_tags_extras + plots, subplots, element, ranges, pos=1, range_tags_extras=range_tags_extras ) + (self.yaxis, {}) + if self._subcoord_overlaid: + _, extra_axis_specs = self._create_extra_axes(plots, subplots, element, ranges) + axis_specs['y'].update(extra_axis_specs) + properties, axis_props = {}, {'x': {}, 'y': {}} for axis, axis_spec in axis_specs.items(): for (axis_dim, (axis_type, axis_label, axis_range, axis_position, fontsize)) in axis_spec.items(): scale = get_scale(axis_range, axis_type) if f'{axis}_range' in properties: properties[f'extra_{axis}_ranges'] = extra_ranges = properties.get(f'extra_{axis}_ranges', {}) - properties[f'extra_{axis}_scales'] = extra_scales = properties.get(f'extra_{axis}_scales', {}) extra_ranges[axis_dim] = axis_range - extra_scales[axis_dim] = scale + if not self.subcoordinate_y: + properties[f'extra_{axis}_scales'] = extra_scales = properties.get(f'extra_{axis}_scales', {}) + extra_scales[axis_dim] = scale else: properties[f'{axis}_range'] = axis_range properties[f'{axis}_scale'] = scale @@ -642,6 +696,10 @@ def _init_plot(self, key, element, plots, ranges=None): fig.xaxis[0].update(**axis_props['x']) fig.yaxis[0].update(**axis_props['y']) + # Do not add the extra axes to the layout if subcoordinates are used + if self._subcoord_overlaid: + return fig + multi_ax = 'x' if self.invert_axes else 'y' for axis_dim, range_obj in properties.get(f'extra_{multi_ax}_ranges', {}).items(): axis_type, axis_label, _, axis_position, fontsize = axis_specs[multi_ax][axis_dim] @@ -805,6 +863,17 @@ def _axis_properties(self, axis, key, plot, dimension=None, axis_props['ticker'] = FixedTicker(ticks=ticks) if labels is not None: axis_props['major_label_overrides'] = dict(zip(ticks, labels)) + elif self._subcoord_overlaid and axis == 'y': + ticks, labels = [], [] + for i, (el, sp) in enumerate(zip(self.current_frame, self.subplots.values())): + if not sp.subcoordinate_y: + continue + ycenter = i if isinstance(sp.subcoordinate_y, bool) else 0.5 * sum(sp.subcoordinate_y) + ticks.append(ycenter) + labels.append(el.label) + axis_props['ticker'] = FixedTicker(ticks=ticks) + if labels is not None: + axis_props['major_label_overrides'] = dict(zip(ticks, labels)) formatter = self.xformatter if axis == 'x' else self.yformatter if formatter: formatter = wrap_formatter(formatter, axis) @@ -956,6 +1025,9 @@ def _update_ranges(self, element, ranges): self._update_main_ranges(element, x_range, y_range, ranges) + if self._subcoord_overlaid: + return + # ALERT: stream handling not handled streaming = False multi_dim = 'x' if self.invert_axes else 'y' @@ -1002,8 +1074,7 @@ def _update_main_ranges(self, element, x_range, y_range, ranges): and (framewise or streaming)) or xfactors is not None) yupdate = ((not (self.model_changed(x_range) or self.model_changed(plot)) - and (framewise or streaming)) - or yfactors is not None) + and (framewise or streaming) or yfactors is not None) and not self.subcoordinate_y) options = self._traverse_options(element, 'plot', ['width', 'height'], defaults=False) fixed_width = (self.frame_width or options.get('width')) @@ -1115,7 +1186,7 @@ def _update_main_ranges(self, element, x_range, y_range, ranges): if not self.drawn or xupdate: self._update_range(x_range, l, r, xfactors, self.invert_xaxis, self._shared['x-main-range'], self.logx, streaming) - if not self.drawn or yupdate: + if not (self.drawn or self.subcoordinate_y) or yupdate: self._update_range( y_range, b, t, yfactors, self._get_tag(y_range, 'invert_yaxis'), self._shared['y-main-range'], self.logy, streaming @@ -1394,13 +1465,28 @@ def _init_glyph(self, plot, mapping, properties): if 'legend_field' in properties and 'legend_label' in properties: del properties['legend_label'] - if self.handles['x_range'].name in plot.extra_x_ranges: - properties['x_range_name'] = self.handles['y_range'].name - if self.handles['y_range'].name in plot.extra_y_ranges: + if self.handles['x_range'].name in plot.extra_x_ranges and not self.subcoordinate_y: + properties['x_range_name'] = self.handles['x_range'].name + if self.handles['y_range'].name in plot.extra_y_ranges and not self.subcoordinate_y: properties['y_range_name'] = self.handles['y_range'].name if "name" not in properties: properties["name"] = properties.get("legend_label") or properties.get("legend_field") + + if self._subcoord_overlaid: + y_source_range = self.handles['y_range'] + if isinstance(self.subcoordinate_y, bool): + center = y_source_range.tags[1]['subcoordinate_y'] + offset = self.subcoordinate_scale/2. + ytarget_range = dict(start=center-offset, end=center+offset) + else: + ytarget_range = dict(start=self.subcoordinate_y[0], end=self.subcoordinate_y[1]) + plot = plot.subplot( + x_source=plot.x_range, + x_target=plot.x_range, + y_source=y_source_range, + y_target=Range1d(**ytarget_range), + ) renderer = getattr(plot, plot_method)(**dict(properties, **mapping)) return renderer, renderer.glyph @@ -1773,6 +1859,9 @@ def initialize_plot(self, ranges=None, plot=None, plots=None, source=None): axes, plot_ranges = self._find_axes(plot, element) self.handles['xaxis'], self.handles['yaxis'] = axes self.handles['x_range'], self.handles['y_range'] = plot_ranges + if self._subcoord_overlaid: + if style_element.label in plot.extra_y_ranges: + self.handles['y_range'] = plot.extra_y_ranges.pop(style_element.label) self.handles['plot'] = plot if self.autorange: @@ -2526,7 +2615,8 @@ class OverlayPlot(GenericOverlayPlot, LegendPlot): 'xformatter', 'yformatter', 'active_tools', 'min_height', 'max_height', 'min_width', 'min_height', 'margin', 'aspect', 'data_aspect', 'frame_width', - 'frame_height', 'responsive', 'fontscale'] + 'frame_height', 'responsive', 'fontscale', 'subcoordinate_y', + 'subcoordinate_scale'] def __init__(self, overlay, **kwargs): self._multi_y_propagation = self.lookup_options(overlay, 'plot').options.get('multi_y', False) @@ -2750,6 +2840,22 @@ def _get_axis_dims(self, element): return super()._get_axis_dims(element) def initialize_plot(self, ranges=None, plot=None, plots=None): + if self.multi_y and self.subcoordinate_y: + raise ValueError('multi_y and subcoordinate_y are not supported together.') + if self.subcoordinate_y: + labels = self.hmap.last.traverse(lambda x: x.label, [ + lambda el: isinstance(el, Element) and el.opts.get('plot').kwargs.get('subcoordinate_y', False) + ]) + if any(not label for label in labels): + raise ValueError( + 'Every element wrapped in a subcoordinate_y overlay must have ' + 'a label.' + ) + if len(set(labels)) == 1: + raise ValueError( + 'Elements wrapped in a subcoordinate_y overlay must all have ' + 'a unique label.' + ) key = util.wrap_tuple(self.hmap.last_key) nonempty = [(k, el) for k, el in self.hmap.data.items() if el] if not nonempty: diff --git a/holoviews/tests/plotting/bokeh/test_subcoordy.py b/holoviews/tests/plotting/bokeh/test_subcoordy.py new file mode 100644 index 0000000000..d36ae6447c --- /dev/null +++ b/holoviews/tests/plotting/bokeh/test_subcoordy.py @@ -0,0 +1,190 @@ +import numpy as np +import pytest + +from holoviews.core import Overlay +from holoviews.element import Curve +from holoviews.element.annotation import VSpan + +from .test_plot import TestBokehPlot, bokeh_renderer + + +class TestSubcoordinateY(TestBokehPlot): + + # With subcoordinate_y set to True + + def test_bool_base(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) + # 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 ('Curve', 'Data_0') in plot.subplots + assert ('Curve', 'Data_1') in plot.subplots + # the range per subplots are correctly computed + sp1 = plot.subplots[('Curve', '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[('Curve', '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_bool_scale(self): + test_data = [ + (0.5, (-0.25, 0.25), (0.75, 1.25), (-0.25, 1.25)), + (1, (-0.5, 0.5), (0.5, 1.5), (-0.5, 1.5)), + (2, (-1, 1), (0, 2), (-1, 2)), + (5, (-2.5, 2.5), (-1.5, 3.5), (-2.5, 3.5)), + ] + for scale, ytarget1, ytarget2, ytarget in test_data: + overlay = Overlay([ + Curve(range(10), label=f'Data {i}').opts(subcoordinate_y=True, subcoordinate_scale=scale) + for i in range(2) + ]) + plot = bokeh_renderer.get_plot(overlay) + # the range per subplots are correctly computed + sp1 = plot.subplots[('Curve', 'Data_0')] + assert sp1.handles['glyph_renderer'].coordinates.y_target.start == ytarget1[0] + assert sp1.handles['glyph_renderer'].coordinates.y_target.end == ytarget1[1] + sp2 = plot.subplots[('Curve', 'Data_1')] + assert sp2.handles['glyph_renderer'].coordinates.y_target.start == ytarget2[0] + assert sp2.handles['glyph_renderer'].coordinates.y_target.end == ytarget2[1] + # y_range is correctly computed + assert plot.handles['y_range'].start == ytarget[0] + assert plot.handles['y_range'].end == ytarget[1] + + def test_no_label(self): + overlay = Overlay([Curve(range(10)).opts(subcoordinate_y=True) for i in range(2)]) + with pytest.raises( + ValueError, + match='Every element wrapped in a subcoordinate_y overlay must have a label' + ): + bokeh_renderer.get_plot(overlay) + + def test_overlaid_without_label_no_error(self): + overlay = Overlay([Curve(range(10), label=f'Data {i}').opts(subcoordinate_y=True) for i in range(2)]) + with_span = overlay * VSpan(1, 2) + bokeh_renderer.get_plot(with_span) + + def test_custom_ylabel(self): + overlay = Overlay([Curve(range(10), label=f'Data {i}').opts(subcoordinate_y=True) for i in range(2)]) + overlay.opts(ylabel='Y label') + plot = bokeh_renderer.get_plot(overlay) + # the figure axis has the label set + assert plot.state.yaxis.axis_label == 'Y label' + # 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_legend_label(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) + legend_labels = [l.label['value'] for l in plot.state.legend[0].items] + # the legend displays the labels + assert legend_labels == ['Data 0', 'Data 1'] + + def test_shared_multi_axes(self): + overlay1 = Overlay([Curve(np.arange(10), label=f'Data {i}').opts(subcoordinate_y=True) for i in range(2)]) + overlay2 = Overlay([Curve(np.arange(10) + 5, label=f'Data {i}').opts(subcoordinate_y=True) for i in range(2)]) + + plot = bokeh_renderer.get_plot(overlay1 + overlay2) + + oplot1 = plot.subplots[(0, 0)].subplots['main'] + oplot2 = plot.subplots[(0, 1)].subplots['main'] + assert (oplot1.handles['y_range'].start, oplot1.handles['y_range'].end) == (-0.5, 1.5) + assert oplot1.handles['extra_y_ranges'] == {} + assert (oplot2.handles['y_range'].start, oplot2.handles['y_range'].end) == (-0.5, 1.5) + assert oplot2.handles['extra_y_ranges'] == {} + + def test_invisible_yaxis(self): + overlay = Overlay([Curve(range(10), label=f'Data {i}').opts(subcoordinate_y=True) for i in range(2)]) + overlay.opts(yaxis=None) + plot = bokeh_renderer.get_plot(overlay) + assert not plot.state.yaxis.visible + + def test_axis_labels(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) + + assert plot.state.xaxis.axis_label == 'x' + assert plot.state.yaxis.axis_label == 'y' + + def test_only_x_axis_labels(self): + overlay = Overlay([Curve(range(10), label=f'Data {i}').opts(subcoordinate_y=True) for i in range(2)]) + overlay.opts(labelled=['x']) + plot = bokeh_renderer.get_plot(overlay) + + assert plot.state.xaxis.axis_label == 'x' + assert plot.state.yaxis.axis_label == '' + + def test_none_x_axis_labels(self): + overlay = Overlay([Curve(range(10), vdims=['A'], label=f'Data {i}').opts(subcoordinate_y=True) for i in range(2)]) + plot = bokeh_renderer.get_plot(overlay) + + assert plot.state.xaxis.axis_label == 'x' + assert plot.state.yaxis.axis_label == 'A' + + # With subcoordinate_y set to a range + + def test_range_base(self): + overlay = Overlay([ + Curve(range(10), label='Data 0').opts(subcoordinate_y=(0, 0.5)), + Curve(range(10), label='Data 1').opts(subcoordinate_y=(0.5, 1)), + ]) + plot = bokeh_renderer.get_plot(overlay) + # subcoordinate_y is propagated to the overlay, just the first one though :( + assert plot.subcoordinate_y == (0, 0.5) + # the figure has only one yaxis + assert len(plot.state.yaxis) == 1 + # the overlay has two subplots + assert len(plot.subplots) == 2 + assert ('Curve', 'Data_0') in plot.subplots + assert ('Curve', 'Data_1') in plot.subplots + # the range per subplots are correctly computed + sp1 = plot.subplots[('Curve', 'Data_0')] + assert sp1.handles['glyph_renderer'].coordinates.y_target.start == 0 + assert sp1.handles['glyph_renderer'].coordinates.y_target.end == 0.5 + sp2 = plot.subplots[('Curve', 'Data_1')] + assert sp2.handles['glyph_renderer'].coordinates.y_target.start == 0.5 + assert sp2.handles['glyph_renderer'].coordinates.y_target.end == 1 + # y_range is correctly computed + assert plot.handles['y_range'].start == 0 + assert plot.handles['y_range'].end == 1 + # extra_y_range is empty + assert plot.handles['extra_y_ranges'] == {} + # the ticks show the labels + assert plot.state.yaxis.ticker.ticks == [0.25, 0.75] + assert plot.state.yaxis.major_label_overrides == {0.25: 'Data 0', 0.75: 'Data 1'} + + def test_plot_standalone(self): + standalone = Curve(range(10), label='Data 0').opts(subcoordinate_y=True) + plot = bokeh_renderer.get_plot(standalone) + assert (plot.state.x_range.start, plot.state.x_range.end) == (0, 9) + assert (plot.state.y_range.start, plot.state.y_range.end) == (0, 9) + + def test_multi_y_error(self): + overlay = Overlay([Curve(range(10), label=f'Data {i}').opts(subcoordinate_y=True) for i in range(2)]) + overlay.opts(multi_y=True) + with pytest.raises( + ValueError, + match='multi_y and subcoordinate_y are not supported together' + ): + bokeh_renderer.get_plot(overlay) + + def test_same_label_error(self): + overlay = Overlay([Curve(range(10), label='Same').opts(subcoordinate_y=True) for _ in range(2)]) + with pytest.raises( + ValueError, + match='Elements wrapped in a subcoordinate_y overlay must all have a unique label', + ): + bokeh_renderer.get_plot(overlay)