diff --git a/holoviews/operation/normalization.py b/holoviews/operation/normalization.py index ffa6bac238..e685ad6d69 100644 --- a/holoviews/operation/normalization.py +++ b/holoviews/operation/normalization.py @@ -12,13 +12,15 @@ normalizations result in transformations to the stored data within each element. """ +from collections import defaultdict +import numpy as np import param from ..core import Overlay from ..core.operation import Operation from ..core.util import match_spec -from ..element import Raster +from ..element import Chart, Raster class Normalization(Operation): @@ -175,3 +177,61 @@ def _normalize_raster(self, raster, key): if range: norm_raster.data[:,:,depth] /= range return norm_raster + + +class subcoordinate_group_ranges(Operation): + """ + Compute the data range group-wise in a subcoordinate_y overlay, + and set the dimension range of each Chart element based on the + value computed for its group. + + This operation is useful to visually apply a group-wise min-max + normalisation. + """ + + def _process(self, overlay, key=None): + # 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 + ): + self.param.warning( + 'The subcoordinate_y overlay contains elements with a defined group, each ' + 'subcoordinate_y element in the overlay must have a defined group.' + ) + + vmins = defaultdict(list) + vmaxs = defaultdict(list) + include_chart = False + for el in overlay: + # Only applies to Charts. + # `group` is the Element type per default (e.g. Curve, Spike). + if not isinstance(el, Chart) or el.group == type(el).__name__: + continue + if not el.opts.get('plot').kwargs.get('subcoordinate_y', False): + self.param.warning( + f"All elements in group {el.group!r} must set the option " + f"'subcoordinate_y=True'. Not found for: {el}" + ) + vmin, vmax = el.range(1) + vmins[el.group].append(vmin) + vmaxs[el.group].append(vmax) + include_chart = True + + if not include_chart or not vmins: + return overlay + + minmax = { + group: (np.min(vmins[group]), np.max(vmaxs[group])) + for group in vmins + } + new = [] + for el in overlay: + if not isinstance(el, Chart): + new.append(el) + continue + y_dimension = el.vdims[0] + y_dimension = y_dimension.clone(range=minmax[el.group]) + new.append(el.redim(**{y_dimension.name: y_dimension})) + return overlay.clone(data=new) diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py index 91a5c808da..b4e6fec90a 100644 --- a/holoviews/plotting/bokeh/element.py +++ b/holoviews/plotting/bokeh/element.py @@ -632,10 +632,15 @@ def _axis_props(self, plots, subplots, element, ranges, pos, *, dim=None, range_el = el if self.batched and not isinstance(self, OverlayPlot) else element + if pos == 1 and 'subcoordinate_y' in range_tags_extras and dim and dim.range != (None, None): + dims = [dim] + v0, v1 = dim.range + axis_label = str(dim) + specs = ((dim.name, dim.label, dim.unit),) # For y-axes check if we explicitly passed in a dimension. # This is used by certain plot types to create an axis from # a synthetic dimension and exclusively supported for y-axes. - if pos == 1 and dim: + elif pos == 1 and dim: dims = [dim] v0, v1 = util.max_range([ elrange.get(dim.name, {'combined': (None, None)})['combined'] diff --git a/holoviews/tests/plotting/bokeh/test_subcoordy.py b/holoviews/tests/plotting/bokeh/test_subcoordy.py index 4bdf13c52e..0920c195e1 100644 --- a/holoviews/tests/plotting/bokeh/test_subcoordy.py +++ b/holoviews/tests/plotting/bokeh/test_subcoordy.py @@ -5,6 +5,7 @@ from holoviews.core import Overlay from holoviews.element import Curve from holoviews.element.annotation import VSpan +from holoviews.operation.normalization import subcoordinate_group_ranges from .test_plot import TestBokehPlot, bokeh_renderer @@ -391,3 +392,33 @@ def test_missing_group_error(self): ) ): bokeh_renderer.get_plot(Overlay(curves)) + + def test_norm_subcoordinate_group_ranges(self): + x = np.linspace(0, 10 * np.pi, 21) + curves = [] + j = 0 + for group in ['A', 'B']: + for i in range(2): + yvals = j * np.sin(x) + curves.append( + Curve((x + np.pi/2, yvals), label=f'{group}{i}', group=group).opts(subcoordinate_y=True) + ) + j += 1 + + overlay = Overlay(curves) + noverlay = subcoordinate_group_ranges(overlay) + + expected = [ + (-1.0, 1.0), + (-1.0, 1.0), + (-3.0, 3.0), + (-3.0, 3.0), + ] + for i, el in enumerate(noverlay): + assert el.get_dimension('y').range == expected[i] + + plot = bokeh_renderer.get_plot(noverlay) + + for i, sp in enumerate(plot.subplots.values()): + y_source = sp.handles['glyph_renderer'].coordinates.y_source + assert (y_source.start, y_source.end) == expected[i]