From e26df61ce57162e35b3168ec282c6e695e7432fb Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Sat, 4 Apr 2020 15:57:53 +0200 Subject: [PATCH 01/27] Add Lasso stream --- holoviews/plotting/bokeh/callbacks.py | 36 +++++++++++++++++++++------ holoviews/streams.py | 10 ++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/holoviews/plotting/bokeh/callbacks.py b/holoviews/plotting/bokeh/callbacks.py index 596b8416d9..658086b4a9 100644 --- a/holoviews/plotting/bokeh/callbacks.py +++ b/holoviews/plotting/bokeh/callbacks.py @@ -22,13 +22,13 @@ from ...core.options import CallbackError from ...core.util import dimension_sanitizer, isscalar, dt64_to_dt from ...element import Table -from ...streams import (Stream, PointerXY, RangeXY, Selection1D, RangeX, - RangeY, PointerX, PointerY, BoundsX, BoundsY, - Tap, SingleTap, DoubleTap, MouseEnter, MouseLeave, - PressUp, PanEnd, - PlotSize, Draw, BoundsXY, PlotReset, BoxEdit, - PointDraw, PolyDraw, PolyEdit, CDSStream, - FreehandDraw, CurveEdit, SelectionXY) +from ...streams import ( + Stream, PointerXY, RangeXY, Selection1D, RangeX, RangeY, PointerX, + PointerY, BoundsX, BoundsY, Tap, SingleTap, DoubleTap, MouseEnter, + MouseLeave, PressUp, PanEnd, PlotSize, Draw, BoundsXY, PlotReset, + BoxEdit, PointDraw, PolyDraw, PolyEdit, CDSStream, FreehandDraw, + CurveEdit, SelectionXY, Lasso, +) from ..links import Link, RectanglesTableLink, DataLink, RangeToolLink, SelectionLink, VertexTableLink from ..plot import GenericElementPlot, GenericOverlayPlot from .util import convert_timestamp @@ -1035,6 +1035,27 @@ def _process_msg(self, msg): return {} +class LassoCallback(Callback): + + attributes = {'xs': 'cb_obj.geometry.x', 'ys': 'cb_obj.geometry.y'} + models = ['plot'] + extra_models = ['lasso_select'] + on_events = ['selectiongeometry'] + skip = ["(cb_obj.geometry.type != 'poly') || (!cb_obj.final)"] + + def _process_msg(self, msg): + if not all(c in msg for c in ('xs', 'ys')): + return {} + xs, ys = msg['xs'], msg['ys'] + if isinstance(xs, dict): + xs = ((int(i), x) for i, x in xs.items()) + xs = [x for _, x in sorted(xs)] + if isinstance(ys, dict): + ys = ((int(i), y) for i, y in ys.items()) + ys = [y for _, y in sorted(ys)] + return {'geometry': np.column_stack([xs, ys])} + + class Selection1DCallback(Callback): """ Returns the current selection on a ColumnDataSource. @@ -1382,6 +1403,7 @@ def initialize(self, plot_id=None): callbacks[BoundsXY] = BoundsCallback callbacks[BoundsX] = BoundsXCallback callbacks[BoundsY] = BoundsYCallback +callbacks[Lasso] = LassoCallback callbacks[Selection1D] = Selection1DCallback callbacks[PlotSize] = PlotSizeCallback callbacks[SelectionXY] = SelectionXYCallback diff --git a/holoviews/streams.py b/holoviews/streams.py index 2007637d15..b333f3d4ed 100644 --- a/holoviews/streams.py +++ b/holoviews/streams.py @@ -1023,6 +1023,16 @@ class BoundsXY(LinkedStream): Bounds defined as (left, bottom, right, top) tuple.""") +class Lasso(LinkedStream): + """ + A stream representing a lasso selection in 2D space as a two-column + array of coordinates. + """ + + geometry = param.Array(doc=""" + The coordinates of the lasso geometry as a two-column array.""") + + class SelectionXY(BoundsXY): """ A stream representing the selection along the x-axis and y-axis. From 06b0fddd49816f606e2470395732ff1975d7cea8 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Sat, 4 Apr 2020 15:58:28 +0200 Subject: [PATCH 02/27] Add support for Lasso selection in Selection2DExpr --- holoviews/element/selection.py | 78 ++++++++++++++++++++------- holoviews/plotting/bokeh/selection.py | 24 ++++++--- 2 files changed, 75 insertions(+), 27 deletions(-) diff --git a/holoviews/element/selection.py b/holoviews/element/selection.py index 1bc5f181b1..70779bf417 100644 --- a/holoviews/element/selection.py +++ b/holoviews/element/selection.py @@ -6,7 +6,7 @@ import numpy as np from ..core import util, NdOverlay -from ..streams import SelectionXY, Selection1D +from ..streams import SelectionXY, Selection1D, Lasso from ..util.transform import dim from .annotation import HSpan, VSpan @@ -37,6 +37,23 @@ def _merge_regions(region1, region2, operation): return None +def spatial_select(xvals, yvals, geometry): + try: + from spatialpandas.geometry import Polygon, PointArray + points = PointArray((xvals, yvals)) + poly = Polygon([np.concatenate([geometry, geometry[:1]]).flatten()]) + return points.intersects(poly) + except Exception: + pass + try: + from shapely.geometry import Point, Polygon + points = (Point(x, y) for x, y in zip(xvals, yvals)) + poly = Polygon(geometry) + return np.array([poly.contains(p) for p in points]) + except ImportError: + raise ImportError("Lasso selection requires either spatialpandas or shapely to be available.") + + class Selection2DExpr(object): """ Mixin class for Cartesian 2D elements to add basic support for @@ -45,7 +62,7 @@ class Selection2DExpr(object): _selection_dims = 2 - _selection_streams = (SelectionXY,) + _selection_streams = (SelectionXY, Lasso) def _get_selection(self, **kwargs): xcats, ycats = None, None @@ -78,27 +95,12 @@ def _get_index_expr(self, index_cols, bbox): contains = dim(index_cols[0], util.lzip, *index_cols[1:]).isin(vals, object=True) return dim(contains, np.reshape, get_shape) - def _get_selection_expr_for_stream_value(self, **kwargs): + def _get_bounds_selection(self, xdim, ydim, **kwargs): from .geom import Rectangles - from .graphs import Graph - - invert_axes = self.opts.get('plot').kwargs.get('invert_axes', False) - - if kwargs.get('bounds') is None and kwargs.get('x_selection') is None: - return None, None, Rectangles([]) - (x0, x1), xcats, (y0, y1), ycats = self._get_selection(**kwargs) xsel = xcats or (x0, x1) ysel = ycats or (y0, y1) - if isinstance(self, Graph): - xdim, ydim = self.nodes.dimensions()[:2] - else: - xdim, ydim = self.dimensions()[:2] - - if invert_axes: - xdim, ydim = ydim, xdim - bbox = {xdim.name: xsel, ydim.name: ysel} index_cols = kwargs.get('index_cols') if index_cols: @@ -117,11 +119,49 @@ def _get_selection_expr_for_stream_value(self, **kwargs): region_element = Rectangles([(x0, y0, x1, y1)]) return selection_expr, bbox, region_element + def _get_lasso_selection(self, xdim, ydim, geometry, **kwargs): + from .path import Path + bbox = {xdim.name: geometry[:, 0], ydim.name: geometry[:, 1]} + expr = dim.pipe(spatial_select, xdim, dim(ydim), geometry=geometry) + return expr, bbox, Path([np.concatenate([geometry, geometry[:1]])]) + + def _get_selection_expr_for_stream_value(self, **kwargs): + from .geom import Rectangles + from .path import Path + from .graphs import Graph + + invert_axes = self.opts.get('plot').kwargs.get('invert_axes', False) + + if (kwargs.get('bounds') is None and kwargs.get('x_selection') is None + and kwargs.get('geometry') is None): + return None, None, Rectangles([]) * Path([]) + + if isinstance(self, Graph): + xdim, ydim = self.nodes.dimensions()[:2] + else: + xdim, ydim = self.dimensions()[:2] + + if invert_axes: + xdim, ydim = ydim, xdim + + if 'bounds' in kwargs: + expr, bbox, region = self._get_bounds_selection(xdim, ydim, **kwargs) + return expr, bbox, region * Path([]) + elif 'geometry' in kwargs: + expr, bbox, region = self._get_lasso_selection(xdim, ydim, **kwargs) + return expr, bbox, Rectangles([]) * region + @staticmethod def _merge_regions(region1, region2, operation): if region1 is None or operation == "overwrite": return region2 - return region1.clone(region1.interface.concatenate([region1, region2])) + rect1 = region1.get(0) + rect2 = region2.get(0) + rects = rect1.clone(rect1.interface.concatenate([rect1, rect2])) + poly1 = region1.get(1) + poly2 = region2.get(1) + polys = poly1.clone([poly1, poly2]) + return rects * polys class SelectionGeomExpr(Selection2DExpr): diff --git a/holoviews/plotting/bokeh/selection.py b/holoviews/plotting/bokeh/selection.py index 1763be53e5..09c27e3ca7 100644 --- a/holoviews/plotting/bokeh/selection.py +++ b/holoviews/plotting/bokeh/selection.py @@ -1,7 +1,7 @@ import numpy as np from ...core.options import Store -from ...core.overlay import NdOverlay +from ...core.overlay import NdOverlay, Overlay from ...selection import OverlaySelectionDisplay, SelectionDisplay @@ -57,28 +57,32 @@ def _build_element_layer(self, element, layer_color, layer_alpha, **opts): def _style_region_element(self, region_element, unselected_color): from ..util import linear_gradient backend_options = Store.options(backend="bokeh") + el2_name = None if isinstance(region_element, NdOverlay): - element_name = type(region_element.last).name + el1_name = type(region_element.last).name + elif isinstance(region_element, Overlay): + el1_name = type(region_element.get(0)).name + el2_name = type(region_element.get(1)).name else: - element_name = type(region_element).name - style_options = backend_options[(element_name,)]['style'] + el1_name = type(region_element).name + style_options = backend_options[(el1_name,)]['style'] allowed = style_options.allowed_keywords options = {} for opt_name in allowed: if 'alpha' in opt_name: options[opt_name] = 1.0 - if element_name != "Histogram": + if el1_name != "Histogram": # Darken unselected color if unselected_color: region_color = linear_gradient(unselected_color, "#000000", 9)[3] options["color"] = region_color - if element_name == 'Rectangles': + if el1_name == 'Rectangles': options["line_width"] = 1 options["fill_alpha"] = 0 options["selection_fill_alpha"] = 0 options["nonselection_fill_alpha"] = 0 - elif "Span" in element_name: + elif "Span" in el1_name: unselected_color = unselected_color or "#e6e9ec" region_color = linear_gradient(unselected_color, "#000000", 9)[1] options["color"] = region_color @@ -92,4 +96,8 @@ def _style_region_element(self, region_element, unselected_color): options["fill_color"] = region_color options["color"] = region_color - return region_element.opts(element_name, backend='bokeh', clone=True, **options) + + region = region_element.opts(el1_name, clone=True, **options) + if el2_name and el2_name == 'Path': + region = region.opts(el2_name, backend='bokeh', color='black') + return region From d72b96d5b890d61ad68e162785e3a635eb312e18 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Sat, 4 Apr 2020 16:41:50 +0200 Subject: [PATCH 03/27] Add support for gridded data --- holoviews/element/selection.py | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/holoviews/element/selection.py b/holoviews/element/selection.py index 70779bf417..bc963221e4 100644 --- a/holoviews/element/selection.py +++ b/holoviews/element/selection.py @@ -36,8 +36,26 @@ def _get_selection_expr_for_stream_value(self, **kwargs): def _merge_regions(region1, region2, operation): return None - -def spatial_select(xvals, yvals, geometry): +def spatial_select_gridded(xvals, yvals, geometry): + rectilinear = (np.diff(xvals, axis=0) == 0).all() + if rectilinear: + from .raster import Image + from .path import Polygons + try: + from ..operation.datashader import rasterize + except ImportError: + raise ImportError("Lasso selection on gridded data requires " + "datashader to be available.") + xs, ys = xvals[0], yvals[:, 0] + target = Image((xs, ys, np.empty(ys.shape+xs.shape))) + poly = Polygons([geometry]) + mask = rasterize(poly, target=target, dynamic=False, aggregator='any') + return mask.dimension_values(2, flat=False) + else: + mask = spatial_select_columnar(xvals.flatten(), yvals.flatten(), geometry) + return mask.reshape(xvals.shape) + +def spatial_select_columnar(xvals, yvals, geometry): try: from spatialpandas.geometry import Polygon, PointArray points = PointArray((xvals, yvals)) @@ -51,8 +69,14 @@ def spatial_select(xvals, yvals, geometry): poly = Polygon(geometry) return np.array([poly.contains(p) for p in points]) except ImportError: - raise ImportError("Lasso selection requires either spatialpandas or shapely to be available.") + raise ImportError("Lasso selection on tabular data requires " + "either spatialpandas or shapely to be available.") +def spatial_select(xvals, yvals, geometry): + if xvals.ndim > 1: + return spatial_select_gridded(xvals, yvals, geometry) + else: + return spatial_select_columnar(xvals, yvals, geometry) class Selection2DExpr(object): """ From 59ad59f021394c266fd674169f54dcf74f44f518 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Sun, 5 Apr 2020 02:48:37 +0200 Subject: [PATCH 04/27] Add cache for linked selections --- holoviews/selection.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/holoviews/selection.py b/holoviews/selection.py index 84ddc82bf6..35eef2bd01 100644 --- a/holoviews/selection.py +++ b/holoviews/selection.py @@ -381,6 +381,7 @@ def __init__(self, color_prop='color', is_cmap=False, supports_region=True): self.color_props = color_prop self.is_cmap = is_cmap self.supports_region = supports_region + self._cache = {} def _get_color_kwarg(self, color): return {color_prop: [color] if self.is_cmap else color @@ -450,7 +451,7 @@ def update_region(element, region_element, colors, **kwargs): return Overlay(layers).collate() def _build_layer_callback(self, element, exprs, layer_number, **kwargs): - return self._select(element, exprs[layer_number]) + return self._select(element, exprs[layer_number], self._cache) def _apply_style_callback(self, element, layer_number, colors, cmap, alpha, **kwargs): opts = {} @@ -470,20 +471,32 @@ def _style_region_element(self, region_element, unselected_cmap): raise NotImplementedError() @staticmethod - def _select(element, selection_expr): + def _select(element, selection_expr, cache={}): from .element import Curve, Spread from .util.transform import dim if isinstance(selection_expr, dim): dataset = element.dataset + mask = None + if dataset._plot_id in cache: + ds_cache = cache[dataset._plot_id] + if selection_expr in ds_cache: + mask = ds_cache[selection_expr] + else: + ds_cache.clear() + else: + ds_cache = cache[dataset._plot_id] = {} try: if dataset.interface.gridded: - mask = selection_expr.apply(dataset, expanded=True, flat=False, strict=True) + if mask is None: + mask = selection_expr.apply(dataset, expanded=True, flat=False, strict=True) selection = dataset.clone(dataset.interface.mask(dataset, ~mask)) elif isinstance(element, (Curve, Spread)) and hasattr(dataset.interface, 'mask'): - mask = selection_expr.apply(dataset, compute=False, strict=True) + if mask is None: + mask = selection_expr.apply(dataset, compute=False, strict=True) selection = dataset.clone(dataset.interface.mask(dataset, ~mask)) else: - mask = selection_expr.apply(dataset, compute=False, keep_index=True, strict=True) + if mask is None: + mask = selection_expr.apply(dataset, compute=False, keep_index=True, strict=True) selection = dataset.select(selection_mask=mask) element = element.pipeline(selection) element._dataset = dataset @@ -495,6 +508,7 @@ def _select(element, selection_expr): except Exception as e: raise CallbackError("linked_selection aborted because it could not " "display selection for all elements: %s." % e) + ds_cache[selection_expr] = mask return element From e0b6f01ce83e4510adaaa3f43bf2e7201b70a90b Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Sun, 5 Apr 2020 14:55:50 +0200 Subject: [PATCH 05/27] Implement geom and poly brushing --- holoviews/element/path.py | 14 +---- holoviews/element/selection.py | 112 ++++++++++++++++++++++++++------- holoviews/selection.py | 4 ++ 3 files changed, 95 insertions(+), 35 deletions(-) diff --git a/holoviews/element/path.py b/holoviews/element/path.py index a40bffc228..e983d612f3 100644 --- a/holoviews/element/path.py +++ b/holoviews/element/path.py @@ -13,10 +13,10 @@ from ..core.dimension import Dimension, asdim from ..core.util import OrderedDict, disable_constant from .geom import Geometry -from .selection import SelectionIndexExpr +from .selection import SelectionIndexExpr, SelectionPolyExpr -class Path(Geometry): +class Path(SelectionPolyExpr, Geometry): """ The Path element represents one or more of path geometries with associated values. Each path geometry may be split into @@ -205,7 +205,7 @@ def __setstate__(self, state): -class Contours(SelectionIndexExpr, Path): +class Contours(Path): """ The Contours element is a subtype of a Path which is characterized by the fact that each path geometry may only be associated with @@ -248,14 +248,6 @@ class Contours(SelectionIndexExpr, Path): _level_vdim = Dimension('Level') # For backward compatibility - def _get_selection_expr_for_stream_value(self, **kwargs): - expr, _, _ = super(Contours, self)._get_selection_expr_for_stream_value(**kwargs) - if expr: - region = self.pipeline(self.dataset.select(expr)) - else: - region = self.iloc[:0] - return expr, _, region - def __init__(self, data, kdims=None, vdims=None, **params): data = [] if data is None else data if params.get('level') is not None: diff --git a/holoviews/element/selection.py b/holoviews/element/selection.py index bc963221e4..cb8d2b7233 100644 --- a/holoviews/element/selection.py +++ b/holoviews/element/selection.py @@ -36,6 +36,7 @@ def _get_selection_expr_for_stream_value(self, **kwargs): def _merge_regions(region1, region2, operation): return None + def spatial_select_gridded(xvals, yvals, geometry): rectilinear = (np.diff(xvals, axis=0) == 0).all() if rectilinear: @@ -54,11 +55,11 @@ def spatial_select_gridded(xvals, yvals, geometry): else: mask = spatial_select_columnar(xvals.flatten(), yvals.flatten(), geometry) return mask.reshape(xvals.shape) - + def spatial_select_columnar(xvals, yvals, geometry): try: from spatialpandas.geometry import Polygon, PointArray - points = PointArray((xvals, yvals)) + points = PointArray((xvals.astype('float'), yvals.astype('float'))) poly = Polygon([np.concatenate([geometry, geometry[:1]]).flatten()]) return points.intersects(poly) except Exception: @@ -78,6 +79,34 @@ def spatial_select(xvals, yvals, geometry): else: return spatial_select_columnar(xvals, yvals, geometry) +def spatial_geom_select(x0vals, y0vals, x1vals, y1vals, geometry): + try: + from shapely.geometry import box, Polygon + boxes = (box(x0, y0, x1, y1) for x0, y0, x1, y1 in + zip(x0vals, y0vals, x1vals, y1vals)) + poly = Polygon(geometry) + return np.array([poly.contains(p) for p in boxes]) + except ImportError: + raise ImportError("Lasso selection on geometry data requires " + "shapely to be available.") + +def spatial_poly_select(xvals, yvals, geometry): + try: + from shapely.geometry import Polygon + boxes = (Polygon(np.column_stack([xs, ys])) for xs, ys in zip(xvals, yvals)) + poly = Polygon(geometry) + return np.array([poly.contains(p) for p in boxes]) + except ImportError: + raise ImportError("Lasso selection on geometry data requires " + "shapely to be available.") + +def spatial_bounds_select(xvals, yvals, bounds): + x0, y0, x1, y1 = bounds + return np.array([((x0<=np.nanmin(xs)) & (y0<=np.nanmin(ys)) & + (x1>=np.nanmax(xs)) & (y1>=np.nanmax(ys))) + for xs, ys in zip(xvals, yvals)]) + + class Selection2DExpr(object): """ Mixin class for Cartesian 2D elements to add basic support for @@ -149,30 +178,33 @@ def _get_lasso_selection(self, xdim, ydim, geometry, **kwargs): expr = dim.pipe(spatial_select, xdim, dim(ydim), geometry=geometry) return expr, bbox, Path([np.concatenate([geometry, geometry[:1]])]) - def _get_selection_expr_for_stream_value(self, **kwargs): - from .geom import Rectangles - from .path import Path + def _get_selection_dims(self): from .graphs import Graph - - invert_axes = self.opts.get('plot').kwargs.get('invert_axes', False) - - if (kwargs.get('bounds') is None and kwargs.get('x_selection') is None - and kwargs.get('geometry') is None): - return None, None, Rectangles([]) * Path([]) - if isinstance(self, Graph): xdim, ydim = self.nodes.dimensions()[:2] else: xdim, ydim = self.dimensions()[:2] + invert_axes = self.opts.get('plot').kwargs.get('invert_axes', False) if invert_axes: xdim, ydim = ydim, xdim + return (xdim, ydim) + + def _get_selection_expr_for_stream_value(self, **kwargs): + from .geom import Rectangles + from .path import Path + + if (kwargs.get('bounds') is None and kwargs.get('x_selection') is None + and kwargs.get('geometry') is None): + return None, None, Rectangles([]) * Path([]) + + dims = self._get_selection_dims() if 'bounds' in kwargs: - expr, bbox, region = self._get_bounds_selection(xdim, ydim, **kwargs) + expr, bbox, region = self._get_bounds_selection(*dims, **kwargs) return expr, bbox, region * Path([]) elif 'geometry' in kwargs: - expr, bbox, region = self._get_lasso_selection(xdim, ydim, **kwargs) + expr, bbox, region = self._get_lasso_selection(*dims, **kwargs) return expr, bbox, Rectangles([]) * region @staticmethod @@ -190,22 +222,20 @@ def _merge_regions(region1, region2, operation): class SelectionGeomExpr(Selection2DExpr): - def _get_selection_expr_for_stream_value(self, **kwargs): - from .geom import Rectangles - - if kwargs.get('bounds') is None and kwargs.get('x_selection') is None: - return None, None, Rectangles([]) - + def _get_selection_dims(self): + x0dim, y0dim, x1dim, y1dim = self.kdims invert_axes = self.opts.get('plot').kwargs.get('invert_axes', False) + if invert_axes: + x0dim, x1dim, y0dim, y1dim = y0dim, y1dim, x0dim, x1dim + return (x0dim, y0dim, x1dim, y1dim) + + def _get_bounds_selection(self, x0dim, y0dim, x1dim, y1dim, **kwargs): + from .geom import Rectangles (x0, x1), xcats, (y0, y1), ycats = self._get_selection(**kwargs) xsel = xcats or (x0, x1) ysel = ycats or (y0, y1) - x0dim, y0dim, x1dim, y1dim = self.kdims - if invert_axes: - x0dim, x1dim, y0dim, y1dim = y0dim, y1dim, x0dim, x1dim - bbox = {x0dim.name: xsel, y0dim.name: ysel, x1dim.name: xsel, y1dim.name: ysel} index_cols = kwargs.get('index_cols') if index_cols: @@ -220,6 +250,40 @@ def _get_selection_expr_for_stream_value(self, **kwargs): region_element = Rectangles([(x0, y0, x1, y1)]) return selection_expr, bbox, region_element + def _get_lasso_selection(self, x0dim, y0dim, x1dim, y1dim, geometry, **kwargs): + from .path import Path + + bbox = { + x0dim.name: geometry[:, 0], y0dim.name: geometry[:, 1], + x1dim.name: geometry[:, 0], y1dim.name: geometry[:, 1] + } + expr = dim.pipe(spatial_geom_select, x0dim, dim(y0dim), dim(x1dim), dim(y1dim), geometry=geometry) + return expr, bbox, Path([np.concatenate([geometry, geometry[:1]])]) + + +class SelectionPolyExpr(Selection2DExpr): + + def _get_bounds_selection(self, xdim, ydim, **kwargs): + from .geom import Rectangles + (x0, x1), _, (y0, y1), _ = self._get_selection(**kwargs) + + bbox = {xdim.name: (x0, x1), ydim.name: (y0, y1)} + index_cols = kwargs.get('index_cols') + if index_cols: + selection_expr = self._get_index_expr(index_cols, bbox) + region_element = None + else: + selection_expr = dim.pipe(spatial_bounds_select, xdim, dim(ydim), + bounds=(x0, y0, x1, y1)) + region_element = Rectangles([(x0, y0, x1, y1)]) + return selection_expr, bbox, region_element + + def _get_lasso_selection(self, xdim, ydim, geometry, **kwargs): + from .path import Path + bbox = {xdim.name: geometry[:, 0], ydim.name: geometry[:, 1]} + expr = dim.pipe(spatial_poly_select, xdim, dim(ydim), geometry=geometry) + return expr, bbox, Path([np.concatenate([geometry, geometry[:1]])]) + class Selection1DExpr(Selection2DExpr): """ diff --git a/holoviews/selection.py b/holoviews/selection.py index 35eef2bd01..6a8f0529a3 100644 --- a/holoviews/selection.py +++ b/holoviews/selection.py @@ -490,6 +490,10 @@ def _select(element, selection_expr, cache={}): if mask is None: mask = selection_expr.apply(dataset, expanded=True, flat=False, strict=True) selection = dataset.clone(dataset.interface.mask(dataset, ~mask)) + elif dataset.interface.multi: + if mask is None: + mask = selection_expr.apply(dataset, expanded=False, flat=False, strict=True) + selection = dataset.clone(dataset.iloc[np.where(mask)[0]]) elif isinstance(element, (Curve, Spread)) and hasattr(dataset.interface, 'mask'): if mask is None: mask = selection_expr.apply(dataset, compute=False, strict=True) From a4eb0c5131b960e3fecd3211f7fb67a025f68fdd Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Mon, 6 Apr 2020 00:46:24 +0200 Subject: [PATCH 06/27] Correctly propagate function kwargs --- holoviews/util/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/holoviews/util/__init__.py b/holoviews/util/__init__.py index 838ab629f7..ecfb1973c7 100644 --- a/holoviews/util/__init__.py +++ b/holoviews/util/__init__.py @@ -986,12 +986,14 @@ def dynamic_operation(*key, **kwargs): return apply(obj, *key, **kwargs) operation = self.p.operation + op_kwargs = self.p.kwargs if not isinstance(operation, Operation): operation = function.instance(fn=apply) + op_kwargs = {'kwargs': op_kwargs} return OperationCallable(dynamic_operation, inputs=[map_obj], link_inputs=self.p.link_inputs, operation=operation, - operation_kwargs=self.p.kwargs) + operation_kwargs=op_kwargs) def _make_dynamic(self, hmap, dynamic_fn, streams): From ab554a30fbafd580f8431cf1967a229f8ae3ad13 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Mon, 6 Apr 2020 00:48:45 +0200 Subject: [PATCH 07/27] Ensure pipeline and transforms are intact --- holoviews/core/data/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/holoviews/core/data/__init__.py b/holoviews/core/data/__init__.py index f8cac365b6..bcaf292642 100644 --- a/holoviews/core/data/__init__.py +++ b/holoviews/core/data/__init__.py @@ -357,6 +357,8 @@ def __init__(self, data, kdims=None, vdims=None, **kwargs): if input_pipeline is None: input_pipeline = chain_op.instance() + kwargs['kdims'] = self.kdims + kwargs['vdims'] = self.vdims init_op = factory.instance( output_type=type(self), args=[], @@ -1164,9 +1166,8 @@ def clone(self, data=None, shared_data=True, new_type=None, link=True, data = self if link: overrides['plot_id'] = self._plot_id - elif self._in_method: - if 'dataset' not in overrides: - overrides['dataset'] = self.dataset + elif self._in_method and 'dataset' not in overrides: + overrides['dataset'] = self.dataset new_dataset = super(Dataset, self).clone( data, shared_data, new_type, *args, **overrides From 4f6fb0ff6ed265d0abe2c5b75188eb412190b6f4 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Mon, 6 Apr 2020 00:58:48 +0200 Subject: [PATCH 08/27] Fixed index selections --- holoviews/element/selection.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/holoviews/element/selection.py b/holoviews/element/selection.py index cb8d2b7233..504091a581 100644 --- a/holoviews/element/selection.py +++ b/holoviews/element/selection.py @@ -202,10 +202,10 @@ def _get_selection_expr_for_stream_value(self, **kwargs): if 'bounds' in kwargs: expr, bbox, region = self._get_bounds_selection(*dims, **kwargs) - return expr, bbox, region * Path([]) + return expr, bbox, None if region is None else region * Path([]) elif 'geometry' in kwargs: expr, bbox, region = self._get_lasso_selection(*dims, **kwargs) - return expr, bbox, Rectangles([]) * region + return expr, bbox, None if region is None else Rectangles([]) * region @staticmethod def _merge_regions(region1, region2, operation): From 38ff653307d1d9d4546bda0e3cd60be671b8643d Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Mon, 6 Apr 2020 02:39:52 +0200 Subject: [PATCH 09/27] Avoid replaying both dynamic callbacks and pipeline --- holoviews/core/operation.py | 5 ++- holoviews/selection.py | 75 +++++++++++++++---------------------- 2 files changed, 34 insertions(+), 46 deletions(-) diff --git a/holoviews/core/operation.py b/holoviews/core/operation.py index a445ffebd6..a03e9d9bcc 100644 --- a/holoviews/core/operation.py +++ b/holoviews/core/operation.py @@ -127,15 +127,16 @@ def _apply(self, element, key=None): for hook in self._preprocess_hooks: kwargs.update(hook(self, element)) - element_pipeline = getattr(element, '_pipeline', None) + element_pipeline = getattr(element, '_pipeline', None) ret = self._process(element, key) + for hook in self._postprocess_hooks: ret = hook(self, ret, **kwargs) if (self._propagate_dataset and isinstance(ret, Dataset) and isinstance(element, Dataset)): - ret._dataset = element.dataset + ret._dataset = element.dataset.clone() ret._pipeline = element_pipeline.instance( operations=element_pipeline.operations + [ self.instance(**self.p) diff --git a/holoviews/selection.py b/holoviews/selection.py index 6a8f0529a3..bf092f892a 100644 --- a/holoviews/selection.py +++ b/holoviews/selection.py @@ -11,7 +11,7 @@ from .core.overlay import NdOverlay, Overlay from .core.spaces import GridSpace from .streams import SelectionExpr, PlotReset, Stream -from .operation.element import function +from .operation.element import function, method from .util import DynamicMap from .util.transform import dim @@ -99,19 +99,10 @@ def _selection_transform(self, hvobj, operations=()): from .plotting.util import initialize_dynamic if isinstance(hvobj, DynamicMap): callback = hvobj.callback - ninputs = len(callback.inputs) - if ninputs == 1: - child_hvobj = callback.inputs[0] - if callback.operation: - next_op = {'op': callback.operation, 'kwargs': callback.operation_kwargs} - else: - fn = function.instance(fn=callback.callable) - next_op = {'op': fn, 'kwargs': callback.operation_kwargs} - new_operations = (next_op,) + operations - return self._selection_transform(child_hvobj, new_operations) - elif ninputs == 2: - return Overlay([self._selection_transform(el) - for el in hvobj.callback.inputs]).collate() + if len(callback.inputs) == 2: + return Overlay([ + self._selection_transform(el) for el in callback.inputs + ]).collate() initialize_dynamic(hvobj) if issubclass(hvobj.type, Element): @@ -398,32 +389,13 @@ def build_selection(self, selection_streams, hvobj, operations, region_stream=No for layer_number in range(num_layers): streams = [selection_streams.exprs_stream] obj = hvobj.clone(link=False) if layer_number == 1 else hvobj + cmap_stream = selection_streams.cmap_streams[layer_number] layer = obj.apply( - self._build_layer_callback, streams=streams, + self._build_layer_callback, streams=[cmap_stream]+streams, layer_number=layer_number, per_element=True ) layers.append(layer) - # Wrap in operations - for op in operations: - op, kws = op['op'], op['kwargs'] - for layer_number in range(num_layers): - streams = list(op.streams) - cmap_stream = selection_streams.cmap_streams[layer_number] - kwargs = dict(kws) - - # Handle cmap as an operation parameter - if 'cmap' in op.param or 'cmap' in kwargs: - if layer_number == 0 or (op.cmap is None and kwargs.get('cmap') is None): - streams += [cmap_stream] - else: - @param.depends(cmap=cmap_stream.param.cmap) - def update_cmap(cmap, default=op.cmap, kw=kwargs.get('cmap')): - return cmap or kw or default - kwargs['cmap'] = update_cmap - new_op = op.instance(streams=streams) - layers[layer_number] = new_op(layers[layer_number], **kwargs) - for layer_number in range(num_layers): layer = layers[layer_number] cmap_stream = selection_streams.cmap_streams[layer_number] @@ -450,8 +422,23 @@ def update_region(element, region_element, colors, **kwargs): layers.append(region) return Overlay(layers).collate() - def _build_layer_callback(self, element, exprs, layer_number, **kwargs): - return self._select(element, exprs[layer_number], self._cache) + @classmethod + def _inject_cmap_in_pipeline(cls, pipeline, cmap): + operations = [] + for op in pipeline.operations: + if hasattr(op, 'cmap'): + op = op.instance(cmap=cmap) + operations.append(op) + return pipeline.instance(operations=operations) + + def _build_layer_callback(self, element, exprs, layer_number, cmap, **kwargs): + selection = self._select(element, exprs[layer_number], self._cache) + pipeline = element.pipeline + if cmap is not None: + pipeline = self._inject_cmap_in_pipeline(pipeline, cmap) + if element is not selection: + return pipeline(selection) + return element def _apply_style_callback(self, element, layer_number, colors, cmap, alpha, **kwargs): opts = {} @@ -488,22 +475,20 @@ def _select(element, selection_expr, cache={}): try: if dataset.interface.gridded: if mask is None: - mask = selection_expr.apply(dataset, expanded=True, flat=False, strict=True) + mask = selection_expr.apply(dataset, expanded=True, flat=False, strict=False) selection = dataset.clone(dataset.interface.mask(dataset, ~mask)) elif dataset.interface.multi: if mask is None: - mask = selection_expr.apply(dataset, expanded=False, flat=False, strict=True) + mask = selection_expr.apply(dataset, expanded=False, flat=False, strict=False) selection = dataset.clone(dataset.iloc[np.where(mask)[0]]) elif isinstance(element, (Curve, Spread)) and hasattr(dataset.interface, 'mask'): if mask is None: - mask = selection_expr.apply(dataset, compute=False, strict=True) + mask = selection_expr.apply(dataset, compute=False, strict=False) selection = dataset.clone(dataset.interface.mask(dataset, ~mask)) else: if mask is None: - mask = selection_expr.apply(dataset, compute=False, keep_index=True, strict=True) + mask = selection_expr.apply(dataset, compute=False, keep_index=True, strict=False) selection = dataset.select(selection_mask=mask) - element = element.pipeline(selection) - element._dataset = dataset except KeyError as e: key_error = str(e).replace('"', '').replace('.', '') raise CallbackError("linked_selection aborted because it could not " @@ -513,7 +498,9 @@ def _select(element, selection_expr, cache={}): raise CallbackError("linked_selection aborted because it could not " "display selection for all elements: %s." % e) ds_cache[selection_expr] = mask - return element + else: + selection = element + return selection class ColorListSelectionDisplay(SelectionDisplay): From 2736046fc922a15941adb8fa203139e070caa260 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Mon, 6 Apr 2020 03:46:44 +0200 Subject: [PATCH 10/27] Ensure dataset providence even if not explicitly declared --- holoviews/core/accessors.py | 19 ++++++++++++++----- holoviews/util/__init__.py | 19 +++++++++++++++++-- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/holoviews/core/accessors.py b/holoviews/core/accessors.py index 921c688d09..ef7cf90a9b 100644 --- a/holoviews/core/accessors.py +++ b/holoviews/core/accessors.py @@ -97,8 +97,8 @@ class Apply(object): def __init__(self, obj, mode=None): self._obj = obj - def __call__(self, apply_function, streams=[], link_inputs=True, dynamic=None, - per_element=False, **kwargs): + def __call__(self, apply_function, streams=[], link_inputs=True, + link_dataset=True, dynamic=None, per_element=False, **kwargs): """Applies a function to all (Nd)Overlay or Element objects. Any keyword arguments are passed through to the function. If @@ -118,6 +118,8 @@ def __call__(self, apply_function, streams=[], link_inputs=True, dynamic=None, link_inputs (bool, optional): Whether to link the inputs Determines whether Streams and Links attached to original object will be inherited. + link_dataset (bool, optional): Whether to link the dataset + Determines whether the dataset will be inherited. dynamic (bool, optional): Whether to make object dynamic By default object is made dynamic if streams are supplied, an instance parameter is supplied as a @@ -135,6 +137,7 @@ def __call__(self, apply_function, streams=[], link_inputs=True, dynamic=None, A new object where the function was applied to all contained (Nd)Overlay or Element objects. """ + from .data import Dataset from .dimension import ViewableElement from .element import Element from .spaces import HoloMap, DynamicMap @@ -190,17 +193,23 @@ def apply_function(object, **kwargs): if (applies or isinstance(self._obj, HoloMap)) and is_dynamic: return Dynamic(self._obj, operation=apply_function, streams=streams, - kwargs=kwargs, link_inputs=link_inputs) + kwargs=kwargs, link_inputs=link_inputs, + link_dataset=link_dataset) elif applies: inner_kwargs = util.resolve_dependent_kwargs(kwargs) if hasattr(apply_function, 'dynamic'): inner_kwargs['dynamic'] = False - return apply_function(self._obj, **inner_kwargs) + new_obj = apply_function(self._obj, **inner_kwargs) + if (link_dataset and isinstance(self._obj, Dataset) and + isinstance(new_obj, Dataset) and new_obj._dataset is None): + new_obj._dataset = self._obj.dataset + return new_obj elif self._obj._deep_indexable: mapped = [] for k, v in self._obj.data.items(): new_val = v.apply(apply_function, dynamic=dynamic, streams=streams, - link_inputs=link_inputs, **kwargs) + link_inputs=link_inputs, link_dataset=link_dataset, + **kwargs) if new_val is not None: mapped.append((k, new_val)) return self._obj.clone(mapped, link=link_inputs) diff --git a/holoviews/util/__init__.py b/holoviews/util/__init__.py index ecfb1973c7..5d045e504d 100644 --- a/holoviews/util/__init__.py +++ b/holoviews/util/__init__.py @@ -12,7 +12,10 @@ import param from pyviz_comms import extension as _pyviz_extension -from ..core import DynamicMap, HoloMap, Dimensioned, ViewableElement, StoreOptions, Store +from ..core import ( + Dataset, DynamicMap, HoloMap, Dimensioned, ViewableElement, + StoreOptions, Store +) from ..core.options import options_policy, Keywords, Options from ..core.operation import Operation from ..core.util import basestring, merge_options_to_dict, OrderedDict @@ -870,6 +873,14 @@ class Dynamic(param.ParameterizedFunction): corresponding visualization should update this stream with range changes originating from the newly generated axes.""") + link_dataset = param.Boolean(default=True, doc=""" + Determines whether the output of the operation should inherit + the .dataset property of the input to the operation. Helpful + for tracking data providence for user supplied functions, + which do not make use of the clone method. Should be disabled + for operations where the output is not derived from the input + and instead depends on some external state.""") + shared_data = param.Boolean(default=False, doc=""" Whether the cloned DynamicMap will share the same cache.""") @@ -979,7 +990,11 @@ def resolve(key, kwargs): def apply(element, *key, **kwargs): kwargs = dict(util.resolve_dependent_kwargs(self.p.kwargs), **kwargs) - return self._process(element, key, kwargs) + processed = self._process(element, key, kwargs) + if (self.p.link_dataset and isinstance(element, Dataset) and + isinstance(processed, Dataset) and processed._dataset is None): + processed._dataset = element.dataset + return processed def dynamic_operation(*key, **kwargs): key, obj = resolve(key, kwargs) From 15d377b94786f73a147952069ceb12012aebede9 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Mon, 6 Apr 2020 03:47:20 +0200 Subject: [PATCH 11/27] Fixed region indicators on DynamicMap linked selections --- holoviews/element/chart.py | 3 +++ holoviews/element/selection.py | 16 ++++++++++++++++ holoviews/selection.py | 15 +++++++++++---- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/holoviews/element/chart.py b/holoviews/element/chart.py index b32aeebf35..c28db9ed2e 100644 --- a/holoviews/element/chart.py +++ b/holoviews/element/chart.py @@ -182,6 +182,9 @@ class Histogram(Chart): _selection_streams = (SelectionXY,) + def _empty_region(self): + return self.clone([]) + def __init__(self, data, edges=None, **params): if data is None: data = [] diff --git a/holoviews/element/selection.py b/holoviews/element/selection.py index 504091a581..07ecf5ea29 100644 --- a/holoviews/element/selection.py +++ b/holoviews/element/selection.py @@ -17,6 +17,9 @@ class SelectionIndexExpr(object): _selection_streams = (Selection1D,) + def _empty_region(self): + return None + def _get_selection_expr_for_stream_value(self, **kwargs): index = kwargs.get('index') index_cols = kwargs.get('index_cols') @@ -117,6 +120,11 @@ class Selection2DExpr(object): _selection_streams = (SelectionXY, Lasso) + def _empty_region(self): + from .geom import Rectangles + from .path import Path + return Rectangles([]) * Path([]) + def _get_selection(self, **kwargs): xcats, ycats = None, None x0, y0, x1, y1 = kwargs['bounds'] @@ -295,6 +303,14 @@ class Selection1DExpr(Selection2DExpr): _inverted_expr = False + def _empty_region(self): + invert_axes = self.opts.get('plot').kwargs.get('invert_axes', False) + if ((invert_axes and not self._inverted_expr) or (not invert_axes and self._inverted_expr)): + region_el = HSpan + else: + region_el = VSpan + return NdOverlay({0: region_el()}) + def _get_selection_expr_for_stream_value(self, **kwargs): invert_axes = self.opts.get('plot').kwargs.get('invert_axes', False) if ((invert_axes and not self._inverted_expr) or (not invert_axes and self._inverted_expr)): diff --git a/holoviews/selection.py b/holoviews/selection.py index bf092f892a..38a0b85ead 100644 --- a/holoviews/selection.py +++ b/holoviews/selection.py @@ -65,7 +65,12 @@ def _register(self, hvobj): selection expressions in response to user interaction events """ # Create stream that produces element that displays region of selection - if getattr(hvobj, "_selection_streams", ()): + if isinstance(hvobj, DynamicMap): + eltype = hvobj.type + else: + eltype = type(hvobj) + + if getattr(eltype, "_selection_streams", ()): self._region_streams[hvobj] = _RegionElement() # Create SelectionExpr stream @@ -411,12 +416,14 @@ def build_selection(self, selection_streams, hvobj, operations, region_stream=No def update_region(element, region_element, colors, **kwargs): unselected_color = colors[0] if region_element is None: - region_element = element._get_selection_expr_for_stream_value()[2] + region_element = element._empty_region() return self._style_region_element(region_element, unselected_color) streams = [region_stream, selection_streams.style_stream] - region = hvobj.clone(link=False).apply(update_region, streams) - if getattr(hvobj, '_selection_dims', None) == 1 or isinstance(hvobj, Histogram): + region = hvobj.clone(link=False).apply(update_region, streams, link_dataset=False) + + eltype = hvobj.type if isinstance(hvobj, DynamicMap) else hvobj + if getattr(eltype, '_selection_dims', None) == 1 or issubclass(eltype, Histogram): layers.insert(1, region) else: layers.append(region) From 3bf0ad998eb02d02b2c298c433549f79e804d154 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Mon, 6 Apr 2020 03:52:36 +0200 Subject: [PATCH 12/27] Correctly orient xarray mask --- holoviews/core/data/xarray.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/holoviews/core/data/xarray.py b/holoviews/core/data/xarray.py index 845ac34e1b..e5762b94d8 100644 --- a/holoviews/core/data/xarray.py +++ b/holoviews/core/data/xarray.py @@ -506,8 +506,15 @@ def mask(cls, dataset, mask, mask_val=np.nan): else: orig_mask = mask for vd in dataset.vdims: - data_coords = list(dataset.data[vd.name].dims) + dims = list(dataset.data[vd.name].dims) + if any(cls.irregular(dataset, kd) for kd in dataset.kdims): + data_coords = dims + else: + data_coords = [kd.name for kd in dataset.kdims][::-1] mask = cls.canonicalize(dataset, orig_mask, data_coords) + if data_coords != dims: + inds = [dims.index(d) for d in data_coords] + mask = mask.transpose(inds) masked[vd.name] = marr = masked[vd.name].astype('float') marr.values[mask] = mask_val return masked From d95d6a0a36660a6cff5b4c9c7027bfec995a78f1 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 8 Apr 2020 11:44:59 +0200 Subject: [PATCH 13/27] Fixed bug applying link_selections to element --- holoviews/selection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/holoviews/selection.py b/holoviews/selection.py index 38a0b85ead..28810bbfe1 100644 --- a/holoviews/selection.py +++ b/holoviews/selection.py @@ -422,7 +422,7 @@ def update_region(element, region_element, colors, **kwargs): streams = [region_stream, selection_streams.style_stream] region = hvobj.clone(link=False).apply(update_region, streams, link_dataset=False) - eltype = hvobj.type if isinstance(hvobj, DynamicMap) else hvobj + eltype = hvobj.type if isinstance(hvobj, DynamicMap) else type(hvobj) if getattr(eltype, '_selection_dims', None) == 1 or issubclass(eltype, Histogram): layers.insert(1, region) else: From 606a4d7fb4bb819755258f64a9d8bb4c0035711c Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 16 Apr 2020 13:01:21 +0200 Subject: [PATCH 14/27] Fix warning message --- holoviews/util/transform.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/holoviews/util/transform.py b/holoviews/util/transform.py index 3672a172c8..710267ffb5 100644 --- a/holoviews/util/transform.py +++ b/holoviews/util/transform.py @@ -633,7 +633,7 @@ def apply(self, dataset, flat=False, expanded=None, ranges={}, all_values=False, if not self.applies(dataset) and (not isinstance(dataset, Graph) or not self.applies(dataset.nodes)): raise KeyError("One or more dimensions in the expression %r " "could not resolve on '%s'. Ensure all " - "dimensions referenced by the expression are" + "dimensions referenced by the expression are " "present on the supplied object." % (self, dataset)) if not self.interface_applies(dataset, coerce=self.coerce): if self.coerce: From c3ee93c2c8d792c6c794b495db10c137e4a53c22 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 22 Apr 2020 12:56:49 +0200 Subject: [PATCH 15/27] Fixed bug not linking single DynamicMap --- holoviews/selection.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/holoviews/selection.py b/holoviews/selection.py index 28810bbfe1..f6f7666049 100644 --- a/holoviews/selection.py +++ b/holoviews/selection.py @@ -92,9 +92,7 @@ def __call__(self, hvobj, **kwargs): self.param.set_param(**kwargs) # Perform transform - hvobj_selection = self._selection_transform(hvobj.clone(link=False)) - - return hvobj_selection + return self._selection_transform(hvobj.clone()) def _selection_transform(self, hvobj, operations=()): """ From b46ec35207afa2e4890baf80f11d5402b02b6462 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 22 Apr 2020 12:57:05 +0200 Subject: [PATCH 16/27] Fixed StatsElement.dataset --- holoviews/element/stats.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/holoviews/element/stats.py b/holoviews/element/stats.py index 5f3b82f2dd..cd0a309860 100644 --- a/holoviews/element/stats.py +++ b/holoviews/element/stats.py @@ -45,10 +45,12 @@ def dataset(self): from . import Dataset if self._dataset is None: datatype = list(unique_iterator(self.datatype+Dataset.datatype)) - dataset = Dataset(self, vdims=[], datatype=datatype) + dataset = Dataset(self, dataset=None, pipeline=None, transforms=None, + vdims=[], datatype=datatype) return dataset - else: - return self._dataset + elif not isinstance(self._dataset, Dataset): + return Dataset(self, _validate_vdims=False, **self._dataset) + return self._dataset def range(self, dim, data_range=True, dimension_range=True): From 1b306dce08d04afd5d070eea0695bd6554b4018a Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 22 Apr 2020 13:09:01 +0200 Subject: [PATCH 17/27] Updated linked brushing docs to talk about lasso_select --- examples/user_guide/Linked_Brushing.ipynb | 126 +++++++++++++++------- 1 file changed, 87 insertions(+), 39 deletions(-) diff --git a/examples/user_guide/Linked_Brushing.ipynb b/examples/user_guide/Linked_Brushing.ipynb index b825c2623d..8954bdae11 100644 --- a/examples/user_guide/Linked_Brushing.ipynb +++ b/examples/user_guide/Linked_Brushing.ipynb @@ -135,7 +135,15 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The same `box_select` tool should now work as for the `gridmatrix` plot, but this time by calling back to Python. There are now many more options and capabilities available, as described below, but by default you can now also select additional regions in different elements, and the selected points will be those that match _all_ of the selections, so that you can precisely specify the data points of interest with constraints on _all_ dimensions at once. A bounding box will be shown for each selection, but only the overall selected points (across all selection dimensions) will be highlighted in each plot. You can use the reset tool to clear all the selections and start over." + "The same `box_select` and `lasso_select` tools should now work as for the `gridmatrix` plot, but this time by calling back to Python. There are now many more options and capabilities available, as described below, but by default you can now also select additional regions in different elements, and the selected points will be those that match _all_ of the selections, so that you can precisely specify the data points of interest with constraints on _all_ dimensions at once. A bounding box will be shown for each selection, but only the overall selected points (across all selection dimensions) will be highlighted in each plot. You can use the reset tool to clear all the selections and start over.\n", + "\n", + "## Box-select vs Lasso-select\n", + "\n", + "Since HoloViews version 1.13.3 linked brushing supports both the `box_select` and `lasso_select` tools. The lasso selection provides more fine-grained control about the exact region to include in the selection, however it is a much more expensive operation and will not scale as well to very large columnar datasets. Additionally lasso select has a number of dependencies:\n", + "\n", + "* Lasso-select on tabular data requires either `spatialpandas` or `shapely`\n", + "* Lasso-select on gridded data requires `datashader`\n", + "* Lasso-select on geometry data requires `shapely`" ] }, { @@ -501,67 +509,107 @@ "Display selections\n", "Index-based selections\n", "Range-based selections\n", + "Lasso-based selections\n", "Notes\n", "\n", "\n", "\n", - "AreaYesYesYes\n", + "AreaYesYesYes\n", + " No \n", "BarsNo No No \n", - " Complicated to support stacked and multi-level bars\n", - "BivariateYesYesYes\n", - "BoxWhiskerYesYesYes\n", + " No Complicated to support stacked and multi-level bars\n", + "BivariateYesYesYes\n", + " Yes \n", + "BoxWhiskerYesYesYes\n", + " No \n", "ChordNo No No \n", - " Complicated to support composite elements\n", - "ContoursYesYesNo \n", - "CurveYesYesYes\n", - "DistributionYesYesYes\n", - "ErrorBarsYesNo No \n", + " No Complicated to support composite elements\n", + "ContoursYesYesNo \n", + " Yes \n", + "CurveYesYesYes\n", + " No \n", + "DistributionYesYesYes\n", + " No \n", + "ErrorBarsYesNo No \n", + " No \n", "GraphNo No No \n", - " Complicated to support composite elements\n", - "HeatMapYesYesYes\n", - "HexTilesYesYesYes\n", - "HistogramYesYesYes\n", - "HSVYesYesYes\n", - "ImageYesYesYes\n", - "LabelsYesNo No \n", + " Yes Complicated to support composite elements\n", + "HeatMapYesYesYes\n", + " Yes \n", + "HexTilesYesYesYes\n", + " Yes \n", + "HistogramYesYesYes\n", + " No \n", + "HSVYesYesYes\n", + " Yes \n", + "ImageYesYesYes\n", + " Yes \n", + "LabelsYesNo No \n", + " Yes \n", "Path3DNo No No \n", - " Complicated to support masking partial paths; no 3D selections\n", + " No Complicated to support masking partial paths; no 3D selections\n", "PathNo No No \n", - " Complicated to support masking partial paths\n", - "PointsYesYesYes\n", - "PolygonsYesYesNo \n", - "QuadMeshYesYesYes\n", - "RadialHeatMapYesNo No \n", + " No Complicated to support masking partial paths\n", + "PointsYesYesYes\n", + " Yes \n", + "PolygonsYesYesNo \n", + " Yes \n", + "QuadMeshYesYesYes\n", + " Yes \n", + "RadialHeatMapYesNo No \n", + " No \n", "RasterNo No No \n", - " Special-case code that is difficult to replace\n", - "RectanglesYesYesYes\n", - "RGBYesYesYes\n", + " No Special-case code that is difficult to replace\n", + "RectanglesYesYesYes\n", + " Yes \n", + "RGBYesYesYes\n", + " Yes \n", "SankeyNo No No \n", - " Complicated to support composite elements\n", - "ScatterYesYesYes\n", + " No Complicated to support composite elements\n", + "ScatterYesYesYes\n", + " Yes \n", "Scatter3DYesNo No \n", - " 3D selections not yet available\n", - "SegmentsYesYesYes\n", - "SpikesYesYesYes\n", - "SpreadYesYesYes\n", + " No 3D selections not yet available\n", + "SegmentsYesYesYes\n", + " Yes \n", + "SpikesYesYesYes\n", + " No \n", + "SpreadYesYesYes\n", + " No \n", "SurfaceYesNo No \n", - " 3D selections not yet available\n", - "TableYesYesNo \n", + " No 3D selections not yet available\n", + "TableYesYesNo \n", + " No \n", "TriMeshNo No No \n", - " Complicated to support composite elements\n", + " No Complicated to support composite elements\n", "TriSurfaceYesNo No \n", - " 3D selections not yet available\n", - "VectorFieldYesYesYes\n", - "ViolinYesYesYes\n", + " No 3D selections not yet available\n", + "VectorFieldYesYesYes\n", + " Yes \n", + "ViolinYesYesYes\n", + " No \n", "\n", "" ] } ], "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", "name": "python", - "pygments_lexer": "ipython3" + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.5" } }, "nbformat": 4, From 5123ed6a03c220c3c34909b3a25d86ee4bd99a5d Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 22 Apr 2020 14:41:06 +0200 Subject: [PATCH 18/27] Fix minor bugs in core --- holoviews/core/accessors.py | 4 +++- holoviews/core/data/__init__.py | 4 +--- holoviews/core/data/interface.py | 8 ++++---- holoviews/core/operation.py | 1 + holoviews/selection.py | 2 +- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/holoviews/core/accessors.py b/holoviews/core/accessors.py index ef7cf90a9b..e19d8c9a3d 100644 --- a/holoviews/core/accessors.py +++ b/holoviews/core/accessors.py @@ -153,7 +153,9 @@ def __call__(self, apply_function, streams=[], link_inputs=True, if not len(samples): return self._obj[samples] return HoloMap(self._obj[samples]).apply( - apply_function, streams, link_inputs, dynamic, **kwargs) + apply_function, streams, link_inputs, link_dataset, + dynamic, per_element, **kwargs + ) if isinstance(apply_function, util.basestring): args = kwargs.pop('_method_args', ()) diff --git a/holoviews/core/data/__init__.py b/holoviews/core/data/__init__.py index bcaf292642..29f43e9b66 100644 --- a/holoviews/core/data/__init__.py +++ b/holoviews/core/data/__init__.py @@ -1169,12 +1169,10 @@ def clone(self, data=None, shared_data=True, new_type=None, link=True, elif self._in_method and 'dataset' not in overrides: overrides['dataset'] = self.dataset - new_dataset = super(Dataset, self).clone( + return super(Dataset, self).clone( data, shared_data, new_type, *args, **overrides ) - return new_dataset - # Overrides of superclass methods that are needed so that PipelineMeta # will find them to wrap with pipeline support def options(self, *args, **kwargs): diff --git a/holoviews/core/data/interface.py b/holoviews/core/data/interface.py index c8a0697980..aa50731560 100644 --- a/holoviews/core/data/interface.py +++ b/holoviews/core/data/interface.py @@ -83,6 +83,7 @@ class iloc(Accessor): integer indices, slices, lists and arrays of values. For more information see the ``Dataset.iloc`` property docstring. """ + @classmethod def _perform_getitem(cls, dataset, index): index = util.wrap_tuple(index) @@ -113,12 +114,11 @@ def _perform_getitem(cls, dataset, index): kdims = [d for d in dims if d in kdims] vdims = [d for d in dims if d in vdims] - datatype = [dt for dt in dataset.datatype - if dt in Interface.interfaces and + datatypes = util.unique_iterator([dataset.interface.datatype]+dataset.datatype) + datatype = [dt for dt in datatypes if dt in Interface.interfaces and not Interface.interfaces[dt].gridded] if not datatype: datatype = ['dataframe', 'dictionary'] - return dataset.clone(data, kdims=kdims, vdims=vdims, - datatype=datatype) + return dataset.clone(data, kdims=kdims, vdims=vdims, datatype=datatype) class ndloc(Accessor): diff --git a/holoviews/core/operation.py b/holoviews/core/operation.py index a03e9d9bcc..d3aa0b50ac 100644 --- a/holoviews/core/operation.py +++ b/holoviews/core/operation.py @@ -195,6 +195,7 @@ def __call__(self, element, **kwargs): elif 'streams' not in kwargs: kwargs['streams'] = self.p.streams kwargs['per_element'] = self._per_element + kwargs['link_dataset'] = self._propagate_dataset return element.apply(self, **kwargs) diff --git a/holoviews/selection.py b/holoviews/selection.py index f6f7666049..2687b6b977 100644 --- a/holoviews/selection.py +++ b/holoviews/selection.py @@ -485,7 +485,7 @@ def _select(element, selection_expr, cache={}): elif dataset.interface.multi: if mask is None: mask = selection_expr.apply(dataset, expanded=False, flat=False, strict=False) - selection = dataset.clone(dataset.iloc[np.where(mask)[0]]) + selection = dataset.iloc[mask] elif isinstance(element, (Curve, Spread)) and hasattr(dataset.interface, 'mask'): if mask is None: mask = selection_expr.apply(dataset, compute=False, strict=False) From e7fa1fb416a21f83dff7f7db98a08b695cd037a3 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 22 Apr 2020 14:42:17 +0200 Subject: [PATCH 19/27] Fixed flakes --- holoviews/element/path.py | 2 +- holoviews/selection.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/holoviews/element/path.py b/holoviews/element/path.py index e983d612f3..57d18ef19d 100644 --- a/holoviews/element/path.py +++ b/holoviews/element/path.py @@ -13,7 +13,7 @@ from ..core.dimension import Dimension, asdim from ..core.util import OrderedDict, disable_constant from .geom import Geometry -from .selection import SelectionIndexExpr, SelectionPolyExpr +from .selection import SelectionPolyExpr class Path(SelectionPolyExpr, Geometry): diff --git a/holoviews/selection.py b/holoviews/selection.py index 2687b6b977..b0d7b4fa79 100644 --- a/holoviews/selection.py +++ b/holoviews/selection.py @@ -11,7 +11,6 @@ from .core.overlay import NdOverlay, Overlay from .core.spaces import GridSpace from .streams import SelectionExpr, PlotReset, Stream -from .operation.element import function, method from .util import DynamicMap from .util.transform import dim From 217cfb8a8d3a8079e32878826f1e64a4c6baa959 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 29 Apr 2020 03:35:14 +0200 Subject: [PATCH 20/27] Implement PathPlot --- holoviews/plotting/plotly/__init__.py | 1 + holoviews/plotting/plotly/shapes.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/holoviews/plotting/plotly/__init__.py b/holoviews/plotting/plotly/__init__.py index 7d70e3ae0d..0ce6e965cf 100644 --- a/holoviews/plotting/plotly/__init__.py +++ b/holoviews/plotting/plotly/__init__.py @@ -79,6 +79,7 @@ Ellipse: PathShapePlot, Rectangles: BoxShapePlot, Segments: SegmentShapePlot, + Path: PathsPlot, HLine: HVLinePlot, VLine: HVLinePlot, HSpan: HVSpanPlot, diff --git a/holoviews/plotting/plotly/shapes.py b/holoviews/plotting/plotly/shapes.py index b39feeff5f..46da4afe89 100644 --- a/holoviews/plotting/plotly/shapes.py +++ b/holoviews/plotting/plotly/shapes.py @@ -65,6 +65,20 @@ def get_data(self, element, ranges, style): return [dict(path=path, xref='x', yref='y')] +class PathsPlot(ShapePlot): + _shape_type = 'path' + + def get_data(self, element, ranges, style): + paths = [] + for el in element.split(): + xdim, ydim = (1, 0) if self.invert_axes else (0, 1) + xs = el.dimension_values(xdim) + ys = el.dimension_values(ydim) + path = ShapePlot.build_path(xs, ys) + paths.append(dict(path=path, xref='x', yref='y')) + return paths + + class HVLinePlot(ShapePlot): apply_ranges = param.Boolean(default=False, doc=""" From 2bde8b895d1aaa8e8d7c663758733be67b197d26 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 29 Apr 2020 03:35:28 +0200 Subject: [PATCH 21/27] Fixed tests --- holoviews/plotting/plotly/images.py | 9 ++- holoviews/plotting/plotly/selection.py | 24 ++++++-- holoviews/selection.py | 7 ++- holoviews/streams.py | 2 +- holoviews/tests/element/test_selection.py | 38 ++++++------ holoviews/tests/testselection.py | 75 +++++++++++++---------- holoviews/tests/teststreams.py | 25 ++++---- holoviews/util/__init__.py | 6 +- 8 files changed, 113 insertions(+), 73 deletions(-) diff --git a/holoviews/plotting/plotly/images.py b/holoviews/plotting/plotly/images.py index e190ec0a50..5c71128c3f 100644 --- a/holoviews/plotting/plotly/images.py +++ b/holoviews/plotting/plotly/images.py @@ -1,10 +1,13 @@ from __future__ import absolute_import, division, unicode_literals -from holoviews.core.util import VersionError -from .element import ElementPlot import numpy as np + from plotly.graph_objs.layout import Image as _Image +from ...core.util import VersionError +from .element import ElementPlot +from .selection import PlotlyOverlaySelectionDisplay + class RGBPlot(ElementPlot): @@ -12,6 +15,8 @@ class RGBPlot(ElementPlot): apply_ranges = True + selection_display = PlotlyOverlaySelectionDisplay() + def init_graph(self, datum, options, index=0): image = dict(datum, **options) diff --git a/holoviews/plotting/plotly/selection.py b/holoviews/plotting/plotly/selection.py index 22d6b0d46c..850076b812 100644 --- a/holoviews/plotting/plotly/selection.py +++ b/holoviews/plotting/plotly/selection.py @@ -1,4 +1,7 @@ from __future__ import absolute_import + +from ...core.overlay import NdOverlay, Overlay + from ...selection import OverlaySelectionDisplay from ...core.options import Store @@ -20,6 +23,8 @@ def _build_element_layer(self, element, layer_color, layer_alpha, **opts): merged_opts = dict(shared_opts) + print(layer_color) + if 'opacity' in allowed: merged_opts['opacity'] = layer_alpha elif 'alpha' in allowed: @@ -45,12 +50,20 @@ def _build_element_layer(self, element, layer_color, layer_alpha, **opts): def _style_region_element(self, region_element, unselected_color): from ..util import linear_gradient backend_options = Store.options(backend="plotly") - element_name = type(region_element).name - style_options = backend_options[(type(region_element).name,)]['style'] + + el2_name = None + if isinstance(region_element, NdOverlay): + el1_name = type(region_element.last).name + elif isinstance(region_element, Overlay): + el1_name = type(region_element.get(0)).name + el2_name = type(region_element.get(1)).name + else: + el1_name = type(region_element).name + style_options = backend_options[(el1_name,)]['style'] allowed_keywords = style_options.allowed_keywords options = {} - if element_name != "Histogram": + if el1_name != "Histogram": # Darken unselected color if unselected_color: region_color = linear_gradient(unselected_color, "#000000", 9)[3] @@ -69,4 +82,7 @@ def _style_region_element(self, region_element, unselected_color): if "selectedpoints" in allowed_keywords: options["selectedpoints"] = False - return region_element.opts(clone=True, backend='plotly', **options) + region = region_element.opts(el1_name, clone=True, backend='plotly', **options) + if el2_name and el2_name == 'Path': + region = region.opts(el2_name, backend='plotly', line_color='black') + return region diff --git a/holoviews/selection.py b/holoviews/selection.py index b0d7b4fa79..87e43e6166 100644 --- a/holoviews/selection.py +++ b/holoviews/selection.py @@ -101,7 +101,7 @@ def _selection_transform(self, hvobj, operations=()): from .plotting.util import initialize_dynamic if isinstance(hvobj, DynamicMap): callback = hvobj.callback - if len(callback.inputs) == 2: + if len(callback.inputs) > 1: return Overlay([ self._selection_transform(el) for el in callback.inputs ]).collate() @@ -440,9 +440,10 @@ def _build_layer_callback(self, element, exprs, layer_number, cmap, **kwargs): pipeline = element.pipeline if cmap is not None: pipeline = self._inject_cmap_in_pipeline(pipeline, cmap) - if element is not selection: + if element is selection: + return pipeline(element.dataset) + else: return pipeline(selection) - return element def _apply_style_callback(self, element, layer_number, colors, cmap, alpha, **kwargs): opts = {} diff --git a/holoviews/streams.py b/holoviews/streams.py index b333f3d4ed..23b98af257 100644 --- a/holoviews/streams.py +++ b/holoviews/streams.py @@ -1029,7 +1029,7 @@ class Lasso(LinkedStream): array of coordinates. """ - geometry = param.Array(doc=""" + geometry = param.Array(constant=True, doc=""" The coordinates of the lasso geometry as a two-column array.""") diff --git a/holoviews/tests/element/test_selection.py b/holoviews/tests/element/test_selection.py index 71864e0e54..72e36bae04 100644 --- a/holoviews/tests/element/test_selection.py +++ b/holoviews/tests/element/test_selection.py @@ -10,7 +10,7 @@ from holoviews.core.options import Store from holoviews.element import ( Area, BoxWhisker, Curve, Distribution, HSpan, Image, Points, - Rectangles, RGB, Scatter, Segments, Violin, VSpan + Rectangles, RGB, Scatter, Segments, Violin, VSpan, Path ) from holoviews.element.comparison import ComparisonTestCase @@ -216,14 +216,14 @@ def test_points_selection_numeric(self): expr, bbox, region = points._get_selection_expr_for_stream_value(bounds=(1, 0, 3, 2)) self.assertEqual(bbox, {'x': (1, 3), 'y': (0, 2)}) self.assertEqual(expr.apply(points), np.array([False, True, True, False, False])) - self.assertEqual(region, Rectangles([(1, 0, 3, 2)])) + self.assertEqual(region, Rectangles([(1, 0, 3, 2)]) * Path([])) def test_points_selection_numeric_inverted(self): points = Points([3, 2, 1, 3, 4]).opts(invert_axes=True) expr, bbox, region = points._get_selection_expr_for_stream_value(bounds=(0, 1, 2, 3)) self.assertEqual(bbox, {'x': (1, 3), 'y': (0, 2)}) self.assertEqual(expr.apply(points), np.array([False, True, True, False, False])) - self.assertEqual(region, Rectangles([(0, 1, 2, 3)])) + self.assertEqual(region, Rectangles([(0, 1, 2, 3)]) * Path([])) def test_points_selection_categorical(self): points = Points((['B', 'A', 'C', 'D', 'E'], [3, 2, 1, 3, 4])) @@ -232,7 +232,7 @@ def test_points_selection_categorical(self): ) self.assertEqual(bbox, {'x': ['B', 'A', 'C'], 'y': (1, 3)}) self.assertEqual(expr.apply(points), np.array([True, True, True, False, False])) - self.assertEqual(region, Rectangles([(0, 1, 2, 3)])) + self.assertEqual(region, Rectangles([(0, 1, 2, 3)]) * Path([])) def test_points_selection_numeric_index_cols(self): points = Points([3, 2, 1, 3, 2]) @@ -248,14 +248,14 @@ def test_scatter_selection_numeric(self): expr, bbox, region = scatter._get_selection_expr_for_stream_value(bounds=(1, 0, 3, 2)) self.assertEqual(bbox, {'x': (1, 3), 'y': (0, 2)}) self.assertEqual(expr.apply(scatter), np.array([False, True, True, False, False])) - self.assertEqual(region, Rectangles([(1, 0, 3, 2)])) + self.assertEqual(region, Rectangles([(1, 0, 3, 2)]) * Path([])) def test_scatter_selection_numeric_inverted(self): scatter = Scatter([3, 2, 1, 3, 4]).opts(invert_axes=True) expr, bbox, region = scatter._get_selection_expr_for_stream_value(bounds=(0, 1, 2, 3)) self.assertEqual(bbox, {'x': (1, 3), 'y': (0, 2)}) self.assertEqual(expr.apply(scatter), np.array([False, True, True, False, False])) - self.assertEqual(region, Rectangles([(0, 1, 2, 3)])) + self.assertEqual(region, Rectangles([(0, 1, 2, 3)]) * Path([])) def test_scatter_selection_categorical(self): scatter = Scatter((['B', 'A', 'C', 'D', 'E'], [3, 2, 1, 3, 4])) @@ -264,7 +264,7 @@ def test_scatter_selection_categorical(self): ) self.assertEqual(bbox, {'x': ['B', 'A', 'C'], 'y': (1, 3)}) self.assertEqual(expr.apply(scatter), np.array([True, True, True, False, False])) - self.assertEqual(region, Rectangles([(0, 1, 2, 3)])) + self.assertEqual(region, Rectangles([(0, 1, 2, 3)]) * Path([])) def test_scatter_selection_numeric_index_cols(self): scatter = Scatter([3, 2, 1, 3, 2]) @@ -285,7 +285,7 @@ def test_image_selection_numeric(self): [False, True, True], [False, True, True] ])) - self.assertEqual(region, Rectangles([(0.5, 1.5, 2.1, 3.1)])) + self.assertEqual(region, Rectangles([(0.5, 1.5, 2.1, 3.1)]) * Path([])) def test_image_selection_numeric_inverted(self): img = Image(([0, 1, 2], [0, 1, 2, 3], np.random.rand(4, 3))).opts(invert_axes=True) @@ -297,7 +297,7 @@ def test_image_selection_numeric_inverted(self): [False, True, True], [False, True, True] ])) - self.assertEqual(region, Rectangles([(1.5, 0.5, 3.1, 2.1)])) + self.assertEqual(region, Rectangles([(1.5, 0.5, 3.1, 2.1)]) * Path([])) def test_rgb_selection_numeric(self): img = RGB(([0, 1, 2], [0, 1, 2, 3], np.random.rand(4, 3, 3))) @@ -309,7 +309,7 @@ def test_rgb_selection_numeric(self): [False, True, True], [False, True, True] ])) - self.assertEqual(region, Rectangles([(0.5, 1.5, 2.1, 3.1)])) + self.assertEqual(region, Rectangles([(0.5, 1.5, 2.1, 3.1)]) * Path([])) def test_rgb_selection_numeric_inverted(self): img = RGB(([0, 1, 2], [0, 1, 2, 3], np.random.rand(4, 3, 3))).opts(invert_axes=True) @@ -321,7 +321,7 @@ def test_rgb_selection_numeric_inverted(self): [False, True, True], [False, True, True] ])) - self.assertEqual(region, Rectangles([(1.5, 0.5, 3.1, 2.1)])) + self.assertEqual(region, Rectangles([(1.5, 0.5, 3.1, 2.1)]) * Path([])) @@ -344,41 +344,41 @@ def test_rect_selection_numeric(self): expr, bbox, region = rect._get_selection_expr_for_stream_value(bounds=(0.5, 0.9, 3.4, 4.9)) self.assertEqual(bbox, {'x0': (0.5, 3.4), 'y0': (0.9, 4.9), 'x1': (0.5, 3.4), 'y1': (0.9, 4.9)}) self.assertEqual(expr.apply(rect), np.array([False, True, False])) - self.assertEqual(region, Rectangles([(0.5, 0.9, 3.4, 4.9)])) + self.assertEqual(region, Rectangles([(0.5, 0.9, 3.4, 4.9)]) * Path([])) expr, bbox, region = rect._get_selection_expr_for_stream_value(bounds=(0, 0.9, 3.5, 4.9)) self.assertEqual(bbox, {'x0': (0, 3.5), 'y0': (0.9, 4.9), 'x1': (0, 3.5), 'y1': (0.9, 4.9)}) self.assertEqual(expr.apply(rect), np.array([True, True, True])) - self.assertEqual(region, Rectangles([(0, 0.9, 3.5, 4.9)])) + self.assertEqual(region, Rectangles([(0, 0.9, 3.5, 4.9)]) * Path([])) def test_rect_selection_numeric_inverted(self): rect = Rectangles([(0, 1, 2, 3), (1, 3, 1.5, 4), (2.5, 4.2, 3.5, 4.8)]).opts(invert_axes=True) expr, bbox, region = rect._get_selection_expr_for_stream_value(bounds=(0.9, 0.5, 4.9, 3.4)) self.assertEqual(bbox, {'x0': (0.5, 3.4), 'y0': (0.9, 4.9), 'x1': (0.5, 3.4), 'y1': (0.9, 4.9)}) self.assertEqual(expr.apply(rect), np.array([False, True, False])) - self.assertEqual(region, Rectangles([(0.9, 0.5, 4.9, 3.4)])) + self.assertEqual(region, Rectangles([(0.9, 0.5, 4.9, 3.4)]) * Path([])) expr, bbox, region = rect._get_selection_expr_for_stream_value(bounds=(0.9, 0, 4.9, 3.5)) self.assertEqual(bbox, {'x0': (0, 3.5), 'y0': (0.9, 4.9), 'x1': (0, 3.5), 'y1': (0.9, 4.9)}) self.assertEqual(expr.apply(rect), np.array([True, True, True])) - self.assertEqual(region, Rectangles([(0.9, 0, 4.9, 3.5)])) + self.assertEqual(region, Rectangles([(0.9, 0, 4.9, 3.5)]) * Path([])) def test_segments_selection_numeric(self): segs = Segments([(0, 1, 2, 3), (1, 3, 1.5, 4), (2.5, 4.2, 3.5, 4.8)]) expr, bbox, region = segs._get_selection_expr_for_stream_value(bounds=(0.5, 0.9, 3.4, 4.9)) self.assertEqual(bbox, {'x0': (0.5, 3.4), 'y0': (0.9, 4.9), 'x1': (0.5, 3.4), 'y1': (0.9, 4.9)}) self.assertEqual(expr.apply(segs), np.array([False, True, False])) - self.assertEqual(region, Rectangles([(0.5, 0.9, 3.4, 4.9)])) + self.assertEqual(region, Rectangles([(0.5, 0.9, 3.4, 4.9)]) * Path([])) expr, bbox, region = segs._get_selection_expr_for_stream_value(bounds=(0, 0.9, 3.5, 4.9)) self.assertEqual(bbox, {'x0': (0, 3.5), 'y0': (0.9, 4.9), 'x1': (0, 3.5), 'y1': (0.9, 4.9)}) self.assertEqual(expr.apply(segs), np.array([True, True, True])) - self.assertEqual(region, Rectangles([(0, 0.9, 3.5, 4.9)])) + self.assertEqual(region, Rectangles([(0, 0.9, 3.5, 4.9)]) * Path([])) def test_segs_selection_numeric_inverted(self): segs = Segments([(0, 1, 2, 3), (1, 3, 1.5, 4), (2.5, 4.2, 3.5, 4.8)]).opts(invert_axes=True) expr, bbox, region = segs._get_selection_expr_for_stream_value(bounds=(0.9, 0.5, 4.9, 3.4)) self.assertEqual(bbox, {'x0': (0.5, 3.4), 'y0': (0.9, 4.9), 'x1': (0.5, 3.4), 'y1': (0.9, 4.9)}) self.assertEqual(expr.apply(segs), np.array([False, True, False])) - self.assertEqual(region, Rectangles([(0.9, 0.5, 4.9, 3.4)])) + self.assertEqual(region, Rectangles([(0.9, 0.5, 4.9, 3.4)]) * Path([])) expr, bbox, region = segs._get_selection_expr_for_stream_value(bounds=(0.9, 0, 4.9, 3.5)) self.assertEqual(bbox, {'x0': (0, 3.5), 'y0': (0.9, 4.9), 'x1': (0, 3.5), 'y1': (0.9, 4.9)}) self.assertEqual(expr.apply(segs), np.array([True, True, True])) - self.assertEqual(region, Rectangles([(0.9, 0, 4.9, 3.5)])) + self.assertEqual(region, Rectangles([(0.9, 0, 4.9, 3.5)]) * Path([])) diff --git a/holoviews/tests/testselection.py b/holoviews/tests/testselection.py index 57925b843d..470369e763 100644 --- a/holoviews/tests/testselection.py +++ b/holoviews/tests/testselection.py @@ -4,7 +4,7 @@ import pandas as pd from holoviews.core.util import unicode, basestring -from holoviews.core.options import Store +from holoviews.core.options import Cycle, Store from holoviews.element import ErrorBars, Points, Rectangles, Table from holoviews.plotting.util import linear_gradient from holoviews.selection import link_selections @@ -25,11 +25,9 @@ class TestLinkSelections(ComparisonTestCase): - def setUp(self): - if type(self) is TestLinkSelections: - # Only run tests in subclasses - raise SkipTest("Not supported") + __test__ = False + def setUp(self): self.data = pd.DataFrame( {'x': [1, 2, 3], 'y': [0, 3, 2], @@ -80,7 +78,7 @@ def test_points_selection(self, dynamic=False, show_regions=True): # Check initial state of linked dynamic map self.assertIsInstance(current_obj, hv.Overlay) - unselected, selected, region = current_obj.values() + unselected, selected, region, region2 = current_obj.values() # Check initial base layer self.check_base_points_like(unselected, lnk_sel) @@ -92,7 +90,7 @@ def test_points_selection(self, dynamic=False, show_regions=True): boundsxy = lnk_sel._selection_expr_streams[0]._source_streams[0] self.assertIsInstance(boundsxy, hv.streams.SelectionXY) boundsxy.event(bounds=(0, 1, 5, 5)) - unselected, selected, region = linked[()].values() + unselected, selected, region, region2 = linked[()].values() # Check that base layer is unchanged self.check_base_points_like(unselected, lnk_sel) @@ -167,6 +165,7 @@ def test_overlay_points_errorbars(self, dynamic=False): error = ErrorBars(self.data, kdims='x', vdims=['y', 'e']) lnk_sel = link_selections.instance(unselected_color='#ff0000') overlay = points * error + if dynamic: overlay = hv.util.Dynamic(overlay) @@ -184,6 +183,7 @@ def test_overlay_points_errorbars(self, dynamic=False): # Select first and third point boundsxy = lnk_sel._selection_expr_streams[0]._source_streams[0] boundsxy.event(bounds=(0, 0, 4, 2)) + current_obj = linked[()] # Check base layers haven't changed @@ -196,9 +196,6 @@ def test_overlay_points_errorbars(self, dynamic=False): self.check_overlay_points_like(current_obj.ErrorBars.II, lnk_sel, self.data.iloc[[0, 2]]) - def test_overlay_points_errorbars_dynamic(self): - self.test_overlay_points_errorbars(dynamic=True) - @ds_skip def test_datashade_selection(self): points = Points(self.data) @@ -212,8 +209,7 @@ def test_datashade_selection(self): self.check_base_points_like(current_obj[0][()].Points.I, lnk_sel) # Check selection layer - self.check_overlay_points_like(current_obj[0][()].Points.II, lnk_sel, - self.data) + self.check_overlay_points_like(current_obj[0][()].Points.II, lnk_sel, self.data) # Check RGB base layer self.assertEqual( @@ -297,11 +293,10 @@ def test_points_selection_streaming(self): self.data.iloc[[0, 2]]) def do_crossfilter_points_histogram( - self, selection_mode, cross_filter_mode, - selected1, selected2, selected3, selected4, - points_region1, points_region2, points_region3, points_region4, - hist_region2, hist_region3, hist_region4, show_regions=True, dynamic=False - ): + self, selection_mode, cross_filter_mode, selected1, selected2, + selected3, selected4, points_region1, points_region2, + points_region3, points_region4, hist_region2, hist_region3, + hist_region4, show_regions=True, dynamic=False): points = Points(self.data) hist = points.hist('x', adjoin=False, normed=False, num_bins=5) @@ -344,7 +339,7 @@ def do_crossfilter_points_histogram( # No selection region region_hist = current_obj[1][()].Histogram.II - self.assertEqual(region_hist.data, hist_orig.pipeline(hist_orig.dataset.iloc[:0]).data) + self.assertEqual(region_hist, base_hist.clone([])) # Check initial selection overlay Histogram selection_hist = current_obj[1][()].Histogram.III @@ -393,7 +388,7 @@ def do_crossfilter_points_histogram( self.assertIsInstance(hist_boundsxy, SelectionXY) hist_boundsxy.event(bounds=(0, 0, 2.5, 2)) - points_unsel, points_sel, points_region = current_obj[0][()].values() + points_unsel, points_sel, points_region, points_region_poly = current_obj[0][()].values() # Check points selection overlay self.check_overlay_points_like(points_sel, lnk_sel, self.data.iloc[selected2]) @@ -408,9 +403,12 @@ def do_crossfilter_points_histogram( # Check selection region covers first and second bar if show_regions: self.assertEqual(self.element_color(region_hist), hist_region_color) - self.assertEqual( - region_hist.data, hist_orig.pipeline(hist_orig.dataset.iloc[hist_region2]).data - ) + if not len(hist_region2) and lnk_sel.selection_mode != 'inverse': + self.assertEqual(len(region_hist), 0) + else: + self.assertEqual( + region_hist.data, hist_orig.pipeline(hist_orig.dataset.iloc[hist_region2]).data + ) # Check histogram selection overlay self.assertEqual( @@ -442,10 +440,13 @@ def do_crossfilter_points_histogram( # Check selection region covers first and second bar region_hist = current_obj[1][()].Histogram.II - self.assertEqual( - region_hist.data, - hist_orig.pipeline(hist_orig.dataset.iloc[hist_region3]).data - ) + if not len(hist_region3) and lnk_sel.selection_mode != 'inverse': + self.assertEqual(len(region_hist), 0) + else: + self.assertEqual( + region_hist.data, + hist_orig.pipeline(hist_orig.dataset.iloc[hist_region3]).data + ) # (4) Perform selection of bars [1, 2] hist_boundsxy = lnk_sel._selection_expr_streams[1]._source_streams[0] @@ -454,7 +455,7 @@ def do_crossfilter_points_histogram( # Check points selection overlay self.check_overlay_points_like(current_obj[0][()].Points.II, lnk_sel, - self.data.iloc[selected4]) + self.data.iloc[selected4]) # Check points region bounds region_bounds = current_obj[0][()].Rectangles.I @@ -466,10 +467,13 @@ def do_crossfilter_points_histogram( self.assertEqual( self.element_color(region_hist), hist_region_color ) - self.assertEqual( - region_hist.data, - hist_orig.pipeline(hist_orig.dataset.iloc[hist_region4]).data - ) + if not len(hist_region4) and lnk_sel.selection_mode != 'inverse': + self.assertEqual(len(region_hist), 0) + else: + self.assertEqual( + region_hist.data, + hist_orig.pipeline(hist_orig.dataset.iloc[hist_region4]).data + ) # Check bar selection overlay selection_hist = current_obj[1][()].Histogram.III @@ -606,6 +610,9 @@ def test_points_histogram_inverse_intersect_dynamic(self): # Backend implementations class TestLinkSelectionsPlotly(TestLinkSelections): + + __test__ = True + def setUp(self): try: import holoviews.plotting.plotly # noqa @@ -619,6 +626,7 @@ def tearDown(self): Store.current_backend = self._backend def element_color(self, element, color_prop=None): + if isinstance(element, Table): color = element.opts.get('style').kwargs['fill'] elif isinstance(element, Rectangles): @@ -626,13 +634,16 @@ def element_color(self, element, color_prop=None): else: color = element.opts.get('style').kwargs['color'] - if isinstance(color, (basestring, unicode)): + if isinstance(color, (Cycle, basestring, unicode)): return color else: return list(color) class TestLinkSelectionsBokeh(TestLinkSelections): + + __test__ = True + def setUp(self): try: import holoviews.plotting.bokeh # noqa diff --git a/holoviews/tests/teststreams.py b/holoviews/tests/teststreams.py index bf9b65c7bc..6afa986565 100644 --- a/holoviews/tests/teststreams.py +++ b/holoviews/tests/teststreams.py @@ -889,8 +889,9 @@ def test_selection_expr_stream_scatter_points(self): expr_stream = SelectionExpr(element) # Check stream properties - self.assertEqual(len(expr_stream._source_streams), 1) - self.assertIsInstance(expr_stream._source_streams[0], BoundsXY) + self.assertEqual(len(expr_stream._source_streams), 2) + self.assertIsInstance(expr_stream._source_streams[0], SelectionXY) + self.assertIsInstance(expr_stream._source_streams[1], Lasso) self.assertIsNone(expr_stream.bbox) self.assertIsNone(expr_stream.selection_expr) @@ -914,8 +915,9 @@ def test_selection_expr_stream_invert_axes(self): expr_stream = SelectionExpr(element) # Check stream properties - self.assertEqual(len(expr_stream._source_streams), 1) - self.assertIsInstance(expr_stream._source_streams[0], BoundsXY) + self.assertEqual(len(expr_stream._source_streams), 2) + self.assertIsInstance(expr_stream._source_streams[0], SelectionXY) + self.assertIsInstance(expr_stream._source_streams[1], Lasso) self.assertIsNone(expr_stream.bbox) self.assertIsNone(expr_stream.selection_expr) @@ -943,8 +945,9 @@ def test_selection_expr_stream_invert_xaxis_yaxis(self): expr_stream = SelectionExpr(element) # Check stream properties - self.assertEqual(len(expr_stream._source_streams), 1) - self.assertIsInstance(expr_stream._source_streams[0], BoundsXY) + self.assertEqual(len(expr_stream._source_streams), 2) + self.assertIsInstance(expr_stream._source_streams[0], SelectionXY) + self.assertIsInstance(expr_stream._source_streams[1], Lasso) self.assertIsNone(expr_stream.bbox) self.assertIsNone(expr_stream.selection_expr) @@ -968,7 +971,7 @@ def test_selection_expr_stream_hist(self): # Check stream properties self.assertEqual(len(expr_stream._source_streams), 1) - self.assertIsInstance(expr_stream._source_streams[0], BoundsXY) + self.assertIsInstance(expr_stream._source_streams[0], SelectionXY) self.assertIsNone(expr_stream.bbox) self.assertIsNone(expr_stream.selection_expr) @@ -1001,7 +1004,7 @@ def test_selection_expr_stream_hist_invert_axes(self): # Check stream properties self.assertEqual(len(expr_stream._source_streams), 1) - self.assertIsInstance(expr_stream._source_streams[0], BoundsXY) + self.assertIsInstance(expr_stream._source_streams[0], SelectionXY) self.assertIsNone(expr_stream.bbox) self.assertIsNone(expr_stream.selection_expr) @@ -1035,7 +1038,7 @@ def test_selection_expr_stream_hist_invert_xaxis_yaxis(self): # Check stream properties self.assertEqual(len(expr_stream._source_streams), 1) - self.assertIsInstance(expr_stream._source_streams[0], BoundsXY) + self.assertIsInstance(expr_stream._source_streams[0], SelectionXY) self.assertIsNone(expr_stream.bbox) self.assertIsNone(expr_stream.selection_expr) @@ -1066,8 +1069,8 @@ def test_selection_expr_stream_dynamic_map(self): expr_stream = SelectionExpr(dmap) # Check stream properties - self.assertEqual(len(expr_stream._source_streams), 1) - self.assertIsInstance(expr_stream._source_streams[0], BoundsXY) + self.assertEqual(len(expr_stream._source_streams), 2) + self.assertIsInstance(expr_stream._source_streams[0], SelectionXY) self.assertIsNone(expr_stream.bbox) self.assertIsNone(expr_stream.selection_expr) diff --git a/holoviews/util/__init__.py b/holoviews/util/__init__.py index 5d045e504d..a03dc2f27a 100644 --- a/holoviews/util/__init__.py +++ b/holoviews/util/__init__.py @@ -18,6 +18,7 @@ ) from ..core.options import options_policy, Keywords, Options from ..core.operation import Operation +from ..core.overlay import Overlay from ..core.util import basestring, merge_options_to_dict, OrderedDict from ..core.operation import OperationCallable from ..core import util @@ -1017,7 +1018,10 @@ def _make_dynamic(self, hmap, dynamic_fn, streams): an equivalent DynamicMap from the HoloMap. """ if isinstance(hmap, ViewableElement): - return DynamicMap(dynamic_fn, streams=streams) + dmap = DynamicMap(dynamic_fn, streams=streams) + if isinstance(hmap, Overlay): + dmap.callback.inputs[:] = list(hmap) + return dmap dim_values = zip(*hmap.data.keys()) params = util.get_param_values(hmap) kdims = [d.clone(values=list(util.unique_iterator(values))) for d, values in From 60ad4284d33b4b07c92996726a8e4a4621563cef Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 29 Apr 2020 14:47:43 +0200 Subject: [PATCH 22/27] Add tests --- holoviews/element/selection.py | 4 +- holoviews/selection.py | 7 + holoviews/tests/element/test_selection.py | 227 +++++++++++++++++++++- 3 files changed, 233 insertions(+), 5 deletions(-) diff --git a/holoviews/element/selection.py b/holoviews/element/selection.py index 07ecf5ea29..411bd256a2 100644 --- a/holoviews/element/selection.py +++ b/holoviews/element/selection.py @@ -109,7 +109,7 @@ def spatial_bounds_select(xvals, yvals, bounds): (x1>=np.nanmax(xs)) & (y1>=np.nanmax(ys))) for xs, ys in zip(xvals, yvals)]) - + class Selection2DExpr(object): """ Mixin class for Cartesian 2D elements to add basic support for @@ -341,7 +341,7 @@ def _get_selection_expr_for_stream_value(self, **kwargs): cat_kwarg = 'y_selection' else: cat_kwarg = 'x_selection' - + if self._inverted_expr: if ydim is not None: xdim = ydim x0, x1 = y0, y1 diff --git a/holoviews/selection.py b/holoviews/selection.py index 87e43e6166..bc0a6ba28a 100644 --- a/holoviews/selection.py +++ b/holoviews/selection.py @@ -90,6 +90,13 @@ def __call__(self, hvobj, **kwargs): # Apply kwargs as params self.param.set_param(**kwargs) + if Store.current_backend not in Store.renderers: + raise RuntimeError("Cannot peform link_selections operation " + "since the selected backend %r is not " + "loaded. Load the plotting extension with " + "hv.extension or import the plotting " + "backend explicitly." % Store.current_backend) + # Perform transform return self._selection_transform(hvobj.clone()) diff --git a/holoviews/tests/element/test_selection.py b/holoviews/tests/element/test_selection.py index 72e36bae04..68fb5ebb81 100644 --- a/holoviews/tests/element/test_selection.py +++ b/holoviews/tests/element/test_selection.py @@ -2,7 +2,7 @@ Test cases for the Comparisons class over the Chart elements """ -from unittest import SkipTest +from unittest import SkipTest, skipIf import numpy as np @@ -10,10 +10,32 @@ from holoviews.core.options import Store from holoviews.element import ( Area, BoxWhisker, Curve, Distribution, HSpan, Image, Points, - Rectangles, RGB, Scatter, Segments, Violin, VSpan, Path + Rectangles, RGB, Scatter, Segments, Violin, VSpan, Path, + QuadMesh, Polygons ) from holoviews.element.comparison import ComparisonTestCase +try: + import datashader as ds +except: + ds = None + +try: + import spatialpandas as spd +except: + spd = None + +try: + import shapely +except: + shapely = None + + +shapelib_available = skipIf(shapely is None and spatialpandas is None, + 'Neither shapely nor spatialpandas are available') +shapely_available = skipIf(shapely is None, 'shapely is not available') +ds_available = skipIf(ds is None, 'datashader not available') + class TestSelection1DExpr(ComparisonTestCase): @@ -225,6 +247,26 @@ def test_points_selection_numeric_inverted(self): self.assertEqual(expr.apply(points), np.array([False, True, True, False, False])) self.assertEqual(region, Rectangles([(0, 1, 2, 3)]) * Path([])) + @shapelib_available + def test_points_selection_geom(self): + points = Points([3, 2, 1, 3, 4]) + geom = np.array([(-0.1, -0.1), (1.4, 0), (1.4, 2.2), (-0.1, 2.2)]) + expr, bbox, region = points._get_selection_expr_for_stream_value(geometry=geom) + self.assertEqual(bbox, {'x': np.array([-0.1, 1.4, 1.4, -0.1]), + 'y': np.array([-0.1, 0, 2.2, 2.2])}) + self.assertEqual(expr.apply(points), np.array([False, True, False, False, False])) + self.assertEqual(region, Rectangles([]) * Path([list(geom)+[(-0.1, -0.1)]])) + + @shapelib_available + def test_points_selection_geom_inverted(self): + points = Points([3, 2, 1, 3, 4]).opts(invert_axes=True) + geom = np.array([(-0.1, -0.1), (1.4, 0), (1.4, 2.2), (-0.1, 2.2)]) + expr, bbox, region = points._get_selection_expr_for_stream_value(geometry=geom) + self.assertEqual(bbox, {'y': np.array([-0.1, 1.4, 1.4, -0.1]), + 'x': np.array([-0.1, 0, 2.2, 2.2])}) + self.assertEqual(expr.apply(points), np.array([False, False, True, False, False])) + self.assertEqual(region, Rectangles([]) * Path([list(geom)+[(-0.1, -0.1)]])) + def test_points_selection_categorical(self): points = Points((['B', 'A', 'C', 'D', 'E'], [3, 2, 1, 3, 4])) expr, bbox, region = points._get_selection_expr_for_stream_value( @@ -299,6 +341,36 @@ def test_image_selection_numeric_inverted(self): ])) self.assertEqual(region, Rectangles([(1.5, 0.5, 3.1, 2.1)]) * Path([])) + @ds_available + def test_img_selection_geom(self): + img = Image(([0, 1, 2], [0, 1, 2, 3], np.random.rand(4, 3))) + geom = np.array([(-0.4, -0.1), (0.6, -0.1), (0.4, 1.7), (-0.1, 1.7)]) + expr, bbox, region = img._get_selection_expr_for_stream_value(geometry=geom) + self.assertEqual(bbox, {'x': np.array([-0.4, 0.6, 0.4, -0.1]), + 'y': np.array([-0.1, -0.1, 1.7, 1.7])}) + self.assertEqual(expr.apply(img, expanded=True, flat=False), np.array([ + [ True, False, False], + [ True, False, False], + [ False, False, False], + [False, False, False] + ])) + self.assertEqual(region, Rectangles([]) * Path([list(geom)+[(-0.4, -0.1)]])) + + @ds_available + def test_img_selection_geom_inverted(self): + img = Image(([0, 1, 2], [0, 1, 2, 3], np.random.rand(4, 3))).opts(invert_axes=True) + geom = np.array([(-0.4, -0.1), (0.6, -0.1), (0.4, 1.7), (-0.1, 1.7)]) + expr, bbox, region = img._get_selection_expr_for_stream_value(geometry=geom) + self.assertEqual(bbox, {'y': np.array([-0.4, 0.6, 0.4, -0.1]), + 'x': np.array([-0.1, -0.1, 1.7, 1.7])}) + self.assertEqual(expr.apply(img, expanded=True, flat=False), np.array([ + [ True, True, False], + [ False, False, False], + [ False, False, False], + [False, False, False] + ])) + self.assertEqual(region, Rectangles([]) * Path([list(geom)+[(-0.4, -0.1)]])) + def test_rgb_selection_numeric(self): img = RGB(([0, 1, 2], [0, 1, 2, 3], np.random.rand(4, 3, 3))) expr, bbox, region = img._get_selection_expr_for_stream_value(bounds=(0.5, 1.5, 2.1, 3.1)) @@ -323,6 +395,42 @@ def test_rgb_selection_numeric_inverted(self): ])) self.assertEqual(region, Rectangles([(1.5, 0.5, 3.1, 2.1)]) * Path([])) + def test_quadmesh_selection(self): + n = 4 + coords = np.linspace(-1.5,1.5,n) + X,Y = np.meshgrid(coords, coords); + Qx = np.cos(Y) - np.cos(X) + Qy = np.sin(Y) + np.sin(X) + Z = np.sqrt(X**2 + Y**2) + qmesh = QuadMesh((Qx, Qy, Z)) + expr, bbox, region = qmesh._get_selection_expr_for_stream_value(bounds=(0, -0.5, 0.7, 1.5)) + self.assertEqual(bbox, {'x': (0, 0.7), 'y': (-0.5, 1.5)}) + self.assertEqual(expr.apply(qmesh, expanded=True, flat=False), np.array([ + [False, False, False, True], + [False, False, True, False], + [False, True, True, False], + [True, False, False, False] + ])) + self.assertEqual(region, Rectangles([(0, -0.5, 0.7, 1.5)]) * Path([])) + + def test_quadmesh_selection_inverted(self): + n = 4 + coords = np.linspace(-1.5,1.5,n) + X,Y = np.meshgrid(coords, coords); + Qx = np.cos(Y) - np.cos(X) + Qy = np.sin(Y) + np.sin(X) + Z = np.sqrt(X**2 + Y**2) + qmesh = QuadMesh((Qx, Qy, Z)).opts(invert_axes=True) + expr, bbox, region = qmesh._get_selection_expr_for_stream_value(bounds=(0, -0.5, 0.7, 1.5)) + self.assertEqual(bbox, {'x': (-0.5, 1.5), 'y': (0, 0.7)}) + self.assertEqual(expr.apply(qmesh, expanded=True, flat=False), np.array([ + [False, False, False, True], + [False, False, True, True], + [False, True, False, False], + [True, False, False, False] + ])) + self.assertEqual(region, Rectangles([(0, -0.5, 0.7, 1.5)]) * Path([])) + class TestSelectionGeomExpr(ComparisonTestCase): @@ -349,7 +457,7 @@ def test_rect_selection_numeric(self): self.assertEqual(bbox, {'x0': (0, 3.5), 'y0': (0.9, 4.9), 'x1': (0, 3.5), 'y1': (0.9, 4.9)}) self.assertEqual(expr.apply(rect), np.array([True, True, True])) self.assertEqual(region, Rectangles([(0, 0.9, 3.5, 4.9)]) * Path([])) - + def test_rect_selection_numeric_inverted(self): rect = Rectangles([(0, 1, 2, 3), (1, 3, 1.5, 4), (2.5, 4.2, 3.5, 4.8)]).opts(invert_axes=True) expr, bbox, region = rect._get_selection_expr_for_stream_value(bounds=(0.9, 0.5, 4.9, 3.4)) @@ -361,6 +469,30 @@ def test_rect_selection_numeric_inverted(self): self.assertEqual(expr.apply(rect), np.array([True, True, True])) self.assertEqual(region, Rectangles([(0.9, 0, 4.9, 3.5)]) * Path([])) + @shapely_available + def test_rect_geom_selection(self): + rect = Rectangles([(0, 1, 2, 3), (1, 3, 1.5, 4), (2.5, 4.2, 3.5, 4.8)]) + geom = np.array([(-0.4, -0.1), (2.2, -0.1), (2.2, 4.1), (-0.1, 4.2)]) + expr, bbox, region = rect._get_selection_expr_for_stream_value(geometry=geom) + self.assertEqual(bbox, {'x0': np.array([-0.4, 2.2, 2.2, -0.1]), + 'y0': np.array([-0.1, -0.1, 4.1, 4.2]), + 'x1': np.array([-0.4, 2.2, 2.2, -0.1]), + 'y1': np.array([-0.1, -0.1, 4.1, 4.2])}) + self.assertEqual(expr.apply(rect), np.array([True, True, False])) + self.assertEqual(region, Rectangles([]) * Path([list(geom)+[(-0.4, -0.1)]])) + + @shapely_available + def test_rect_geom_selection_inverted(self): + rect = Rectangles([(0, 1, 2, 3), (1, 3, 1.5, 4), (2.5, 4.2, 3.5, 4.8)]).opts(invert_axes=True) + geom = np.array([(-0.4, -0.1), (3.2, -0.1), (3.2, 4.1), (-0.1, 4.2)]) + expr, bbox, region = rect._get_selection_expr_for_stream_value(geometry=geom) + self.assertEqual(bbox, {'y0': np.array([-0.4, 3.2, 3.2, -0.1]), + 'x0': np.array([-0.1, -0.1, 4.1, 4.2]), + 'y1': np.array([-0.4, 3.2, 3.2, -0.1]), + 'x1': np.array([-0.1, -0.1, 4.1, 4.2])}) + self.assertEqual(expr.apply(rect), np.array([True, False, False])) + self.assertEqual(region, Rectangles([]) * Path([list(geom)+[(-0.4, -0.1)]])) + def test_segments_selection_numeric(self): segs = Segments([(0, 1, 2, 3), (1, 3, 1.5, 4), (2.5, 4.2, 3.5, 4.8)]) expr, bbox, region = segs._get_selection_expr_for_stream_value(bounds=(0.5, 0.9, 3.4, 4.9)) @@ -382,3 +514,92 @@ def test_segs_selection_numeric_inverted(self): self.assertEqual(bbox, {'x0': (0, 3.5), 'y0': (0.9, 4.9), 'x1': (0, 3.5), 'y1': (0.9, 4.9)}) self.assertEqual(expr.apply(segs), np.array([True, True, True])) self.assertEqual(region, Rectangles([(0.9, 0, 4.9, 3.5)]) * Path([])) + + @shapely_available + def test_segs_geom_selection(self): + rect = Segments([(0, 1, 2, 3), (1, 3, 1.5, 4), (2.5, 4.2, 3.5, 4.8)]) + geom = np.array([(-0.4, -0.1), (2.2, -0.1), (2.2, 4.1), (-0.1, 4.2)]) + expr, bbox, region = rect._get_selection_expr_for_stream_value(geometry=geom) + self.assertEqual(bbox, {'x0': np.array([-0.4, 2.2, 2.2, -0.1]), + 'y0': np.array([-0.1, -0.1, 4.1, 4.2]), + 'x1': np.array([-0.4, 2.2, 2.2, -0.1]), + 'y1': np.array([-0.1, -0.1, 4.1, 4.2])}) + self.assertEqual(expr.apply(rect), np.array([True, True, False])) + self.assertEqual(region, Rectangles([]) * Path([list(geom)+[(-0.4, -0.1)]])) + + @shapely_available + def test_segs_geom_selection_inverted(self): + rect = Segments([(0, 1, 2, 3), (1, 3, 1.5, 4), (2.5, 4.2, 3.5, 4.8)]).opts(invert_axes=True) + geom = np.array([(-0.4, -0.1), (3.2, -0.1), (3.2, 4.1), (-0.1, 4.2)]) + expr, bbox, region = rect._get_selection_expr_for_stream_value(geometry=geom) + self.assertEqual(bbox, {'y0': np.array([-0.4, 3.2, 3.2, -0.1]), + 'x0': np.array([-0.1, -0.1, 4.1, 4.2]), + 'y1': np.array([-0.4, 3.2, 3.2, -0.1]), + 'x1': np.array([-0.1, -0.1, 4.1, 4.2])}) + self.assertEqual(expr.apply(rect), np.array([True, False, False])) + self.assertEqual(region, Rectangles([]) * Path([list(geom)+[(-0.4, -0.1)]])) + + +class TestSelectionPolyExpr(ComparisonTestCase): + + def setUp(self): + try: + import holoviews.plotting.bokeh # noqa + except: + raise SkipTest("Bokeh selection tests require bokeh.") + super(TestSelectionPolyExpr, self).setUp() + self._backend = Store.current_backend + Store.set_current_backend('bokeh') + + def tearDown(self): + Store.current_backend = self._backend + + def test_poly_selection_numeric(self): + poly = Polygons([ + [(0, 0), (0.2, 0.1), (0.3, 0.4), (0.1, 0.2)], + [(0.25, -.1), (0.4, 0.2), (0.6, 0.3), (0.5, 0.1)], + [(0.3, 0.3), (0.5, 0.4), (0.6, 0.5), (0.35, 0.45)] + ]) + expr, bbox, region = poly._get_selection_expr_for_stream_value(bounds=(0.2, -0.2, 0.6, 0.6)) + self.assertEqual(bbox, {'x': (0.2, 0.6), 'y': (-0.2, 0.6)}) + self.assertEqual(expr.apply(poly, expanded=False), np.array([False, True, True])) + self.assertEqual(region, Rectangles([(0.2, -0.2, 0.6, 0.6)]) * Path([])) + + def test_poly_selection_numeric_inverted(self): + poly = Polygons([ + [(0, 0), (0.2, 0.1), (0.3, 0.4), (0.1, 0.2)], + [(0.25, -.1), (0.4, 0.2), (0.6, 0.3), (0.5, 0.1)], + [(0.3, 0.3), (0.5, 0.4), (0.6, 0.5), (0.35, 0.45)] + ]).opts(invert_axes=True) + expr, bbox, region = poly._get_selection_expr_for_stream_value(bounds=(0.2, -0.2, 0.6, 0.6)) + self.assertEqual(bbox, {'y': (0.2, 0.6), 'x': (-0.2, 0.6)}) + self.assertEqual(expr.apply(poly, expanded=False), np.array([False, False, True])) + self.assertEqual(region, Rectangles([(0.2, -0.2, 0.6, 0.6)]) * Path([])) + + @shapely_available + def test_poly_geom_selection(self): + poly = Polygons([ + [(0, 0), (0.2, 0.1), (0.3, 0.4), (0.1, 0.2)], + [(0.25, -.1), (0.4, 0.2), (0.6, 0.3), (0.5, 0.1)], + [(0.3, 0.3), (0.5, 0.4), (0.6, 0.5), (0.35, 0.45)] + ]) + geom = np.array([(0.2, -0.15), (0.5, 0), (0.75, 0.6), (0.1, 0.45)]) + expr, bbox, region = poly._get_selection_expr_for_stream_value(geometry=geom) + self.assertEqual(bbox, {'x': np.array([0.2, 0.5, 0.75, 0.1]), + 'y': np.array([-0.15, 0, 0.6, 0.45])}) + self.assertEqual(expr.apply(poly, expanded=False), np.array([False, True, True])) + self.assertEqual(region, Rectangles([]) * Path([list(geom)+[(0.2, -0.15)]])) + + @shapely_available + def test_poly_geom_selection_inverted(self): + poly = Polygons([ + [(0, 0), (0.2, 0.1), (0.3, 0.4), (0.1, 0.2)], + [(0.25, -.1), (0.4, 0.2), (0.6, 0.3), (0.5, 0.1)], + [(0.3, 0.3), (0.5, 0.4), (0.6, 0.5), (0.35, 0.45)] + ]).opts(invert_axes=True) + geom = np.array([(0.2, -0.15), (0.5, 0), (0.75, 0.6), (0.1, 0.6)]) + expr, bbox, region = poly._get_selection_expr_for_stream_value(geometry=geom) + self.assertEqual(bbox, {'y': np.array([0.2, 0.5, 0.75, 0.1]), + 'x': np.array([-0.15, 0, 0.6, 0.6])}) + self.assertEqual(expr.apply(poly, expanded=False), np.array([False, False, True])) + self.assertEqual(region, Rectangles([]) * Path([list(geom)+[(0.2, -0.15)]])) From dd4460799629a1b56986fef58a6605a72d5a8dd5 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 29 Apr 2020 15:08:55 +0200 Subject: [PATCH 23/27] Fixed index based selections --- holoviews/element/selection.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/holoviews/element/selection.py b/holoviews/element/selection.py index 411bd256a2..3235baea1f 100644 --- a/holoviews/element/selection.py +++ b/holoviews/element/selection.py @@ -146,10 +146,9 @@ def _get_selection(self, **kwargs): return (x0, x1), xcats, (y0, y1), ycats - def _get_index_expr(self, index_cols, bbox): + def _get_index_expr(self, index_cols, sel): get_shape = dim(self.dataset.get_dimension(index_cols[0]), np.shape) index_cols = [dim(self.dataset.get_dimension(c), np.ravel) for c in index_cols] - sel = self.dataset.clone(datatype=['dataframe', 'dictionary']).select(**bbox) vals = dim(index_cols[0], util.unique_zip, *index_cols[1:]).apply( sel, expanded=True, flat=True ) @@ -165,7 +164,8 @@ def _get_bounds_selection(self, xdim, ydim, **kwargs): bbox = {xdim.name: xsel, ydim.name: ysel} index_cols = kwargs.get('index_cols') if index_cols: - selection_expr = self._get_index_expr(index_cols, bbox) + selection = self.dataset.clone(datatype=['dataframe', 'dictionary']).select(**bbox) + selection_expr = self._get_index_expr(index_cols, selection) region_element = None else: if xcats: @@ -184,6 +184,11 @@ def _get_lasso_selection(self, xdim, ydim, geometry, **kwargs): from .path import Path bbox = {xdim.name: geometry[:, 0], ydim.name: geometry[:, 1]} expr = dim.pipe(spatial_select, xdim, dim(ydim), geometry=geometry) + index_cols = kwargs.get('index_cols') + if index_cols: + selection = self[expr.apply(self)] + selection_expr = self._get_index_expr(index_cols, selection) + return selection_expr, bbox, None return expr, bbox, Path([np.concatenate([geometry, geometry[:1]])]) def _get_selection_dims(self): @@ -247,7 +252,8 @@ def _get_bounds_selection(self, x0dim, y0dim, x1dim, y1dim, **kwargs): bbox = {x0dim.name: xsel, y0dim.name: ysel, x1dim.name: xsel, y1dim.name: ysel} index_cols = kwargs.get('index_cols') if index_cols: - selection_expr = self._get_index_expr(index_cols, bbox) + selection = self.dataset.clone(datatype=['dataframe', 'dictionary']).select(**bbox) + selection_expr = self._get_index_expr(index_cols, selection) region_element = None else: x0expr = (dim(x0dim) >= x0) & (dim(x0dim) <= x1) @@ -266,6 +272,11 @@ def _get_lasso_selection(self, x0dim, y0dim, x1dim, y1dim, geometry, **kwargs): x1dim.name: geometry[:, 0], y1dim.name: geometry[:, 1] } expr = dim.pipe(spatial_geom_select, x0dim, dim(y0dim), dim(x1dim), dim(y1dim), geometry=geometry) + index_cols = kwargs.get('index_cols') + if index_cols: + selection = self[expr.apply(self)] + selection_expr = self._get_index_expr(index_cols, selection) + return selection_expr, bbox, None return expr, bbox, Path([np.concatenate([geometry, geometry[:1]])]) @@ -290,6 +301,11 @@ def _get_lasso_selection(self, xdim, ydim, geometry, **kwargs): from .path import Path bbox = {xdim.name: geometry[:, 0], ydim.name: geometry[:, 1]} expr = dim.pipe(spatial_poly_select, xdim, dim(ydim), geometry=geometry) + index_cols = kwargs.get('index_cols') + if index_cols: + selection = self[expr.apply(self, expanded=False)] + selection_expr = self._get_index_expr(index_cols, selection) + return selection_expr, bbox, None return expr, bbox, Path([np.concatenate([geometry, geometry[:1]])]) From 56157006091b43f5c814e165f67c37b71b9a7b22 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 29 Apr 2020 15:09:06 +0200 Subject: [PATCH 24/27] Removed stray print --- holoviews/plotting/plotly/selection.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/holoviews/plotting/plotly/selection.py b/holoviews/plotting/plotly/selection.py index 850076b812..1e61b0bc3e 100644 --- a/holoviews/plotting/plotly/selection.py +++ b/holoviews/plotting/plotly/selection.py @@ -23,8 +23,6 @@ def _build_element_layer(self, element, layer_color, layer_alpha, **opts): merged_opts = dict(shared_opts) - print(layer_color) - if 'opacity' in allowed: merged_opts['opacity'] = layer_alpha elif 'alpha' in allowed: From da067f9220c2d198293b28d649c445b8837ceca2 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 29 Apr 2020 15:18:26 +0200 Subject: [PATCH 25/27] Fixed flake --- holoviews/tests/element/test_selection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/holoviews/tests/element/test_selection.py b/holoviews/tests/element/test_selection.py index 68fb5ebb81..d4dcf4bffd 100644 --- a/holoviews/tests/element/test_selection.py +++ b/holoviews/tests/element/test_selection.py @@ -31,7 +31,7 @@ shapely = None -shapelib_available = skipIf(shapely is None and spatialpandas is None, +shapelib_available = skipIf(shapely is None and spd is None, 'Neither shapely nor spatialpandas are available') shapely_available = skipIf(shapely is None, 'shapely is not available') ds_available = skipIf(ds is None, 'datashader not available') From 31acd9ac3518eb2e7fe5aedb0dcd9aa6c9f6b879 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 29 Apr 2020 15:19:04 +0200 Subject: [PATCH 26/27] Removed metadata --- examples/user_guide/Linked_Brushing.ipynb | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/examples/user_guide/Linked_Brushing.ipynb b/examples/user_guide/Linked_Brushing.ipynb index 8954bdae11..43e12476a3 100644 --- a/examples/user_guide/Linked_Brushing.ipynb +++ b/examples/user_guide/Linked_Brushing.ipynb @@ -594,22 +594,9 @@ } ], "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.7.5" + "pygments_lexer": "ipython3" } }, "nbformat": 4, From dc96dad18a8fcd3cef96c5fcfedddb9f553558ca Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 29 Apr 2020 15:52:10 +0200 Subject: [PATCH 27/27] Fixed selection issue --- holoviews/element/selection.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/holoviews/element/selection.py b/holoviews/element/selection.py index 3235baea1f..38c6588e31 100644 --- a/holoviews/element/selection.py +++ b/holoviews/element/selection.py @@ -369,7 +369,8 @@ def _get_selection_expr_for_stream_value(self, **kwargs): bbox[self.kdims[0].name] = cats index_cols = kwargs.get('index_cols') if index_cols: - selection_expr = self._get_index_expr(index_cols, bbox) + selection = self.dataset.clone(datatype=['dataframe', 'dictionary']).select(**bbox) + selection_expr = self._get_index_expr(index_cols, selection) region_element = None else: if isinstance(cats, list) and xdim in self.kdims[:1]: