diff --git a/holoviews/plotting/bokeh/chart.py b/holoviews/plotting/bokeh/chart.py index 8d1e744513..fa806ac467 100644 --- a/holoviews/plotting/bokeh/chart.py +++ b/holoviews/plotting/bokeh/chart.py @@ -8,6 +8,8 @@ from bokeh.models.tools import BoxSelectTool from bokeh.transform import jitter +from ...plotting.bokeh.selection import BokehOverlaySelectionDisplay +from ...selection import NoOpSelectionDisplay from ...core.data import Dataset from ...core.dimension import dimension_name from ...core.util import ( @@ -58,6 +60,8 @@ class PointPlot(LegendPlot, ColorbarPlot): _plot_methods = dict(single='scatter', batched='scatter') _batched_style_opts = line_properties + fill_properties + ['size', 'marker', 'angle'] + selection_display = BokehOverlaySelectionDisplay() + def _get_size_data(self, element, ranges, style): data, mapping = {}, {} sdim = element.get_dimension(self.size_index) @@ -396,6 +400,8 @@ class HistogramPlot(ColorbarPlot): _nonvectorized_styles = ['line_dash', 'visible'] + selection_display = BokehOverlaySelectionDisplay() + def get_data(self, element, ranges, style): if self.invert_axes: mapping = dict(top='right', bottom='left', left=0, right='top') @@ -505,6 +511,10 @@ class ErrorPlot(ColorbarPlot): _plot_methods = dict(single=Whisker) + # selection_display should be changed to BokehOverlaySelectionDisplay + # when #3950 is fixed + selection_display = NoOpSelectionDisplay() + def get_data(self, element, ranges, style): mapping = dict(self._mapping) if self.static_source: @@ -664,6 +674,8 @@ class SpikesPlot(ColorbarPlot): _plot_methods = dict(single='segment') + selection_display = BokehOverlaySelectionDisplay() + def get_extents(self, element, ranges, range_type='combined'): if len(element.dimensions()) > 1: ydim = element.get_dimension(1) @@ -779,6 +791,8 @@ class BarPlot(ColorbarPlot, LegendPlot): # Declare that y-range should auto-range if not bounded _y_range_type = Range1d + selection_display = BokehOverlaySelectionDisplay() + def get_extents(self, element, ranges, range_type='combined'): """ Make adjustments to plot extents by computing diff --git a/holoviews/plotting/bokeh/plot.py b/holoviews/plotting/bokeh/plot.py index 726483df27..88f447c51d 100644 --- a/holoviews/plotting/bokeh/plot.py +++ b/holoviews/plotting/bokeh/plot.py @@ -10,6 +10,7 @@ from bokeh.models import (ColumnDataSource, Column, Row, Div) from bokeh.models.widgets import Panel, Tabs +from ...selection import NoOpSelectionDisplay from ...core import ( OrderedDict, Store, AdjointLayout, NdLayout, Layout, Empty, GridSpace, HoloMap, Element @@ -77,6 +78,8 @@ class BokehPlot(DimensionedPlot, CallbackPlot): backend = 'bokeh' + selection_display = NoOpSelectionDisplay() + @property def id(self): return self.root.ref['id'] if self.root else None diff --git a/holoviews/plotting/bokeh/selection.py b/holoviews/plotting/bokeh/selection.py new file mode 100644 index 0000000000..35bdd58180 --- /dev/null +++ b/holoviews/plotting/bokeh/selection.py @@ -0,0 +1,30 @@ +from ...selection import OverlaySelectionDisplay +from ...core.options import Store + + +class BokehOverlaySelectionDisplay(OverlaySelectionDisplay): + """ + Overlay selection display subclass for use with bokeh backend + """ + def _build_element_layer( + self, element, layer_color, selection_expr=True + ): + element, visible = self._select(element, selection_expr) + + backend_options = Store.options(backend='bokeh') + style_options = backend_options[(type(element).name,)]['style'] + + def alpha_opts(alpha): + options = dict() + + for opt_name in style_options.allowed_keywords: + if 'alpha' in opt_name: + options[opt_name] = alpha + + return options + + layer_alpha = 1.0 if visible else 0.0 + merged_opts = dict(self._get_color_kwarg(layer_color), **alpha_opts(layer_alpha)) + layer_element = element.options(tools=['box_select'], **merged_opts) + + return layer_element diff --git a/holoviews/plotting/bokeh/stats.py b/holoviews/plotting/bokeh/stats.py index 08f3707694..eb83e8a6e0 100644 --- a/holoviews/plotting/bokeh/stats.py +++ b/holoviews/plotting/bokeh/stats.py @@ -8,6 +8,7 @@ from bokeh.models import FactorRange, Circle, VBar, HBar +from .selection import BokehOverlaySelectionDisplay from ...core.dimension import Dimension, Dimensioned from ...core.ndmapping import sorted_context from ...core.util import (basestring, dimension_sanitizer, wrap_tuple, @@ -34,6 +35,8 @@ class DistributionPlot(AreaPlot): filled = param.Boolean(default=True, doc=""" Whether the bivariate contours should be filled.""") + selection_display = BokehOverlaySelectionDisplay() + class BivariatePlot(PolygonPlot): """ @@ -55,7 +58,7 @@ class BivariatePlot(PolygonPlot): levels = param.ClassSelector(default=10, class_=(list, int), doc=""" A list of scalar values used to specify the contour levels.""") - + selection_display = BokehOverlaySelectionDisplay(color_prop='cmap', is_cmap=True) class BoxWhiskerPlot(CompositeElementPlot, ColorbarPlot, LegendPlot): @@ -84,6 +87,8 @@ class BoxWhiskerPlot(CompositeElementPlot, ColorbarPlot, LegendPlot): _stream_data = False # Plot does not support streaming data + selection_display = BokehOverlaySelectionDisplay() + def get_extents(self, element, ranges, range_type='combined'): return super(BoxWhiskerPlot, self).get_extents( element, ranges, range_type, 'categorical', element.vdims[0] @@ -332,6 +337,8 @@ class ViolinPlot(BoxWhiskerPlot): _stat_fns = [partial(np.percentile, q=q) for q in [25, 50, 75]] + selection_display = BokehOverlaySelectionDisplay(color_prop='violin_fill_color') + def _kde_data(self, el, key, **kwargs): vdim = el.vdims[0] values = el.dimension_values(vdim) diff --git a/holoviews/plotting/plot.py b/holoviews/plotting/plot.py index 20c6970fc9..576c88d7ec 100644 --- a/holoviews/plotting/plot.py +++ b/holoviews/plotting/plot.py @@ -17,6 +17,7 @@ from panel.io.notebook import push from panel.io.state import state +from ..selection import NoOpSelectionDisplay from ..core import OrderedDict from ..core import util, traversal from ..core.element import Element, Element3D @@ -947,6 +948,8 @@ class GenericElementPlot(DimensionedPlot): _propagate_options = [] v17_option_propagation = True + _selection_display = NoOpSelectionDisplay() + def __init__(self, element, keys=None, ranges=None, dimensions=None, batched=False, overlaid=0, cyclic_index=0, zorder=0, style=None, overlay_dims={}, stream_sources=[], streams=None, **params): diff --git a/holoviews/plotting/plotly/chart.py b/holoviews/plotting/plotly/chart.py index bf2349ad3a..563253e9d6 100644 --- a/holoviews/plotting/plotly/chart.py +++ b/holoviews/plotting/plotly/chart.py @@ -3,6 +3,7 @@ import param import numpy as np +from .selection import PlotlyOverlaySelectionDisplay from ...core.data import Dataset from ...core import util from ...element import Bars @@ -44,6 +45,8 @@ class ScatterPlot(ChartPlot, ColorbarPlot): _style_key = 'marker' + selection_display = PlotlyOverlaySelectionDisplay() + def graph_options(self, element, ranges, style): opts = super(ScatterPlot, self).graph_options(element, ranges, style) cdim = element.get_dimension(self.color_index) @@ -148,6 +151,8 @@ class ErrorBarsPlot(ChartPlot, ColorbarPlot): _style_key = 'error_y' + selection_display = PlotlyOverlaySelectionDisplay() + def get_data(self, element, ranges, style): x, y = ('y', 'x') if self.invert_axes else ('x', 'y') error_k = 'error_' + x if element.horizontal else 'error_' + y @@ -188,6 +193,8 @@ class BarPlot(ElementPlot): trace_kwargs = {'type': 'bar'} + selection_display = PlotlyOverlaySelectionDisplay() + def get_extents(self, element, ranges, range_type='combined'): """ Make adjustments to plot extents by computing @@ -297,10 +304,14 @@ class HistogramPlot(ElementPlot): trace_kwargs = {'type': 'bar'} - style_opts = ['visible', 'color', 'line_color', 'line_width', 'opacity'] + style_opts = [ + 'visible', 'color', 'line_color', 'line_width', 'opacity', 'selectedpoints' + ] _style_key = 'marker' + selection_display = PlotlyOverlaySelectionDisplay() + def get_data(self, element, ranges, style): xdim = element.kdims[0] ydim = element.vdims[0] diff --git a/holoviews/plotting/plotly/chart3d.py b/holoviews/plotting/plotly/chart3d.py index 25c5b9330b..ac30b7f54d 100644 --- a/holoviews/plotting/plotly/chart3d.py +++ b/holoviews/plotting/plotly/chart3d.py @@ -6,6 +6,7 @@ from plotly import colors from plotly.figure_factory._trisurf import trisurf as trisurface +from .selection import PlotlyOverlaySelectionDisplay from ...core.options import SkipRendering from .element import ElementPlot, ColorbarPlot from .chart import ScatterPlot, CurvePlot @@ -58,6 +59,12 @@ class Scatter3DPlot(Chart3DPlot, ScatterPlot): trace_kwargs = {'type': 'scatter3d', 'mode': 'markers'} + style_opts = [ + 'visible', 'marker', 'color', 'cmap', 'alpha', 'opacity', 'size', 'sizemin' + ] + + selection_display = PlotlyOverlaySelectionDisplay() + class Path3DPlot(Chart3DPlot, CurvePlot): diff --git a/holoviews/plotting/plotly/selection.py b/holoviews/plotting/plotly/selection.py new file mode 100644 index 0000000000..ad3904086d --- /dev/null +++ b/holoviews/plotting/plotly/selection.py @@ -0,0 +1,26 @@ +from __future__ import absolute_import +from ...selection import OverlaySelectionDisplay +from ...core.options import Store + + +class PlotlyOverlaySelectionDisplay(OverlaySelectionDisplay): + """ + Overlay selection display subclass for use with plotly backend + """ + def _build_element_layer( + self, element, layer_color, selection_expr=True + ): + element, visible = self._select(element, selection_expr) + + backend_options = Store.options(backend='plotly') + style_options = backend_options[(type(element).name,)]['style'] + + if 'selectedpoints' in style_options.allowed_keywords: + shared_opts = dict(selectedpoints=False) + else: + shared_opts = dict() + + merged_opts = dict(self._get_color_kwarg(layer_color), **shared_opts) + layer_element = element.options(visible=visible, **merged_opts) + + return layer_element diff --git a/holoviews/plotting/plotly/stats.py b/holoviews/plotting/plotly/stats.py index 872ff2d5b4..b17e93b740 100644 --- a/holoviews/plotting/plotly/stats.py +++ b/holoviews/plotting/plotly/stats.py @@ -2,6 +2,7 @@ import param +from .selection import PlotlyOverlaySelectionDisplay from .chart import ChartPlot from .element import ElementPlot, ColorbarPlot @@ -18,6 +19,8 @@ class BivariatePlot(ChartPlot, ColorbarPlot): _style_key = 'contours' + selection_display = PlotlyOverlaySelectionDisplay() + def graph_options(self, element, ranges, style): opts = super(BivariatePlot, self).graph_options(element, ranges, style) copts = self.get_color_opts(element.vdims[0], element, ranges, style) @@ -44,6 +47,9 @@ def graph_options(self, element, ranges, style): opts['showscale'] = copts.get('showscale', False) + # Add visible + opts['visible'] = style.get('visible', True) + return opts @@ -64,6 +70,8 @@ class DistributionPlot(ElementPlot): _style_key = 'line' + selection_display = PlotlyOverlaySelectionDisplay() + class MultiDistributionPlot(ElementPlot): @@ -118,6 +126,8 @@ class BoxWhiskerPlot(MultiDistributionPlot): _style_key = 'marker' + selection_display = PlotlyOverlaySelectionDisplay() + def graph_options(self, element, ranges, style): options = super(BoxWhiskerPlot, self).graph_options(element, ranges, style) options['boxmean'] = self.mean diff --git a/holoviews/plotting/plotly/tabular.py b/holoviews/plotting/plotly/tabular.py index 9caaf5f874..099b2d7452 100644 --- a/holoviews/plotting/plotly/tabular.py +++ b/holoviews/plotting/plotly/tabular.py @@ -2,6 +2,7 @@ import param +from ...selection import ColorListSelectionDisplay from .element import ElementPlot @@ -17,12 +18,26 @@ class TablePlot(ElementPlot): _style_key = 'cells' + selection_display = ColorListSelectionDisplay(color_prop='fill') + def get_data(self, element, ranges, style): header = dict(values=[d.pprint_label for d in element.dimensions()]) cells = dict(values=[[d.pprint_value(v) for v in element.dimension_values(d)] for d in element.dimensions()]) return [{'header': header, 'cells': cells}] + def graph_options(self, element, ranges, style): + opts = super(TablePlot, self).graph_options(element, ranges, style) + + # Transpose fill_color array so values apply by rows not column + if 'fill' in opts.get('cells', {}): + opts['cells']['fill_color'] = [opts['cells'].pop('fill')] + + if 'line' in opts.get('cells', {}): + opts['cells']['line_color'] = [opts['cells']['line']] + + return opts + def init_layout(self, key, element, ranges): return dict(width=self.width, height=self.height, title=self._format_title(key, separator=' '), diff --git a/holoviews/selection.py b/holoviews/selection.py new file mode 100644 index 0000000000..1e19a563d8 --- /dev/null +++ b/holoviews/selection.py @@ -0,0 +1,458 @@ +from collections import namedtuple +import copy +import numpy as np +import param + +from param.parameterized import bothmethod + +from . import Overlay +from .core import OperationCallable +from .streams import SelectionExpr, Stream +from .core.element import Element, Layout +from .util import Dynamic, DynamicMap +from .core.options import Store +from .plotting.util import initialize_dynamic, linear_gradient + +_Cmap = Stream.define('Cmap', cmap=[]) +_Alpha = Stream.define('Alpha', alpha=1.0) +_Exprs = Stream.define('Exprs', exprs=[]) +_Colors = Stream.define('Colors', colors=[]) + +_SelectionStreams = namedtuple( + 'SelectionStreams', 'colors_stream exprs_stream cmap_streams alpha_streams' +) + + +class _base_link_selections(param.ParameterizedFunction): + """ + Baseclass for linked selection functions. + + Subclasses override the _build_selection_streams class method to construct + a _SelectionStreams namedtuple instance that includes the required streams + for implementing linked selections. + + Subclasses also override the _expr_stream_updated method. This allows + subclasses to control whether new selections override prior selections or + whether they are combined with prior selections + """ + @bothmethod + def instance(self_or_cls, **params): + inst = super(_base_link_selections, self_or_cls).instance(**params) + + # Init private properties + inst._selection_expr_streams = [] + + # Init selection streams + inst._selection_streams = self_or_cls._build_selection_streams(inst) + + return inst + + def _register(self, hvobj): + """ + Register an Element of DynamicMap that may be capable of generating + selection expressions in response to user interaction events + """ + expr_stream = SelectionExpr(source=hvobj) + expr_stream.add_subscriber( + lambda **kwargs: self._expr_stream_updated(hvobj, **kwargs) + ) + self._selection_expr_streams.append(expr_stream) + + def __call__(self, hvobj, **kwargs): + # Apply kwargs as params + self.param.set_param(**kwargs) + + # Perform transform + hvobj_selection = self._selection_transform(hvobj.clone(link=False)) + + return hvobj_selection + + def _selection_transform( + self, + hvobj, + operations=(), + ): + """ + Transform an input HoloViews object into a dynamic object with linked + selections enabled. + """ + if isinstance(hvobj, DynamicMap): + initialize_dynamic(hvobj) + + if (isinstance(hvobj.callback, OperationCallable) and + len(hvobj.callback.inputs) == 1): + + child_hvobj = hvobj.callback.inputs[0] + next_op = hvobj.callback.operation + new_operations = (next_op,) + operations + + # Recurse on child with added operation + return self._selection_transform( + hvobj=child_hvobj, + operations=new_operations, + ) + elif hvobj.type == Overlay and not hvobj.streams: + # Process overlay inputs individually and then overlay again + overlay_elements = hvobj.callback.inputs + new_hvobj = self._selection_transform(overlay_elements[0]) + for overlay_element in overlay_elements[1:]: + new_hvobj = new_hvobj * self._selection_transform(overlay_element) + + return new_hvobj + elif issubclass(hvobj.type, Element): + self._register(hvobj) + + chart = Store.registry[Store.current_backend][hvobj.type] + return chart.selection_display.build_selection( + self._selection_streams, hvobj, operations + ) + else: + # This is a DynamicMap that we don't know how to recurse into. + return hvobj + + elif isinstance(hvobj, Element): + element = hvobj.clone(link=False) + + # Register hvobj to receive selection expression callbacks + self._register(element) + + chart = Store.registry[Store.current_backend][type(element)] + try: + return chart.selection_display.build_selection( + self._selection_streams, element, operations + ) + except AttributeError: + # In case chart doesn't have selection_display defined + return element + + elif isinstance(hvobj, (Layout, Overlay)): + new_hvobj = hvobj.clone(shared_data=False) + for k, v in hvobj.items(): + new_hvobj[k] = self._selection_transform( + v, operations + ) + + # collate if available. Needed for Overlay + try: + new_hvobj = new_hvobj.collate() + except AttributeError: + pass + + return new_hvobj + else: + # Unsupported object + return hvobj + + @classmethod + def _build_selection_streams(cls, inst): + """ + Subclasses should override this method to return a _SelectionStreams + instance + """ + raise NotImplementedError() + + def _expr_stream_updated(self, hvobj, selection_expr, bbox): + """ + Called when one of the registered HoloViews objects produces a new + selection expression. Subclasses should override this method, and + they should use the input expression to update the `exprs_stream` + property of the _SelectionStreams instance that was produced by + the _build_selection_streams. + + Subclasses have the flexibility to control whether the new selection + express overrides previous selections, or whether it is combined with + previous selections. + """ + raise NotImplementedError() + + +class link_selections(_base_link_selections): + selection_expr = param.Parameter(default=None) + unselected_color = param.Color(default="#99a6b2") # LightSlateGray - 65% + selected_color = param.Color(default="#DC143C") # Crimson + + @classmethod + def _build_selection_streams(cls, inst): + # Colors stream + colors_stream = _Colors( + colors=[inst.unselected_color, inst.selected_color] + ) + + # Cmap streams + cmap_streams = [ + _Cmap(cmap=inst.unselected_cmap), + _Cmap(cmap=inst.selected_cmap), + ] + + def update_colors(*_): + colors_stream.event( + colors=[inst.unselected_color, inst.selected_color] + ) + cmap_streams[0].event(cmap=inst.unselected_cmap) + cmap_streams[1].event(cmap=inst.selected_cmap) + + inst.param.watch( + update_colors, + parameter_names=['unselected_color', 'selected_color'] + ) + + # Exprs stream + exprs_stream = _Exprs(exprs=[True, None]) + + def update_exprs(*_): + exprs_stream.event(exprs=[True, inst.selection_expr]) + + inst.param.watch( + update_exprs, + parameter_names=['selection_expr'] + ) + + # Alpha streams + alpha_streams = [ + _Alpha(alpha=255), + _Alpha(alpha=inst._selected_alpha), + ] + + def update_alphas(*_): + alpha_streams[1].event(alpha=inst._selected_alpha) + + inst.param.watch(update_alphas, parameter_names=['selection_expr']) + + return _SelectionStreams( + colors_stream=colors_stream, + exprs_stream=exprs_stream, + alpha_streams=alpha_streams, + cmap_streams=cmap_streams, + ) + + @property + def unselected_cmap(self): + """ + The datashader colormap for unselected data + """ + return _color_to_cmap(self.unselected_color) + + @property + def selected_cmap(self): + """ + The datashader colormap for selected data + """ + return _color_to_cmap(self.selected_color) + + @property + def _selected_alpha(self): + if self.selection_expr: + return 255 + else: + return 0 + + def _expr_stream_updated(self, hvobj, selection_expr, bbox): + if selection_expr: + self.selection_expr = selection_expr + + +class SelectionDisplay(object): + """ + Base class for selection display classes. Selection display classes are + responsible for transforming an element (or DynamicMap that produces an + element) into a HoloViews object that represents the current selection + state. + """ + def build_selection(self, selection_streams, hvobj, operations): + raise NotImplementedError() + + +class NoOpSelectionDisplay(SelectionDisplay): + """ + Selection display class that returns input element unchanged. For use with + elements that don't support displaying selections. + """ + def build_selection(self, selection_streams, hvobj, operations): + return hvobj + + +class OverlaySelectionDisplay(SelectionDisplay): + """ + Selection display base class that represents selections by overlaying + colored subsets on top of the original element in an Overlay container. + """ + def __init__(self, color_prop='color', is_cmap=False): + self.color_prop = color_prop + self.is_cmap = is_cmap + + def _get_color_kwarg(self, color): + return {self.color_prop: [color] if self.is_cmap else color} + + def build_selection(self, selection_streams, hvobj, operations): + layers = [] + num_layers = len(selection_streams.colors_stream.colors) + if not num_layers: + return Overlay(items=[]) + + for layer_number in range(num_layers): + build_layer = self._build_layer_callback(layer_number) + sel_streams = [selection_streams.colors_stream, + selection_streams.exprs_stream] + + if isinstance(hvobj, DynamicMap): + def apply_map( + obj, + build_layer=build_layer, + colors=None, + exprs=None, + **kwargs + ): + return obj.map( + lambda el: build_layer(el, colors, exprs), + specs=Element, + clone=True, + ) + + layer = Dynamic( + hvobj, + operation=apply_map, + streams=hvobj.streams + sel_streams, + link_inputs=True, + ) + else: + layer = Dynamic( + hvobj, + operation=build_layer, + streams=sel_streams, + ) + + layers.append(layer) + + # Wrap in operations + for op in operations: + for layer_number in range(num_layers): + streams = copy.copy(op.streams) + + if 'cmap' in op.param: + streams += [selection_streams.cmap_streams[layer_number]] + + if 'alpha' in op.param: + streams += [selection_streams.alpha_streams[layer_number]] + + new_op = op.instance(streams=streams) + layers[layer_number] = new_op(layers[layer_number]) + + # build overlay + result = layers[0] + for layer in layers[1:]: + result *= layer + return result + + def _build_layer_callback(self, layer_number): + def _build_layer(element, colors, exprs, **_): + layer_element = self._build_element_layer( + element, colors[layer_number], exprs[layer_number] + ) + + return layer_element + + return _build_layer + + def _build_element_layer( + self, element, layer_color, selection_expr=True + ): + raise NotImplementedError() + + @staticmethod + def _select(element, selection_expr): + from .util.transform import dim + if isinstance(selection_expr, dim): + try: + element = element.pipeline( + element.dataset.select(selection_expr=selection_expr) + ) + except Exception as e: + print(e) + raise + visible = True + else: + visible = bool(selection_expr) + return element, visible + + +class ColorListSelectionDisplay(SelectionDisplay): + """ + Selection display class for elements that support coloring by a + vectorized color list. + """ + def __init__(self, color_prop='color'): + self.color_prop = color_prop + + def build_selection(self, selection_streams, hvobj, operations): + def _build_selection(el, colors, exprs, **_): + + selection_exprs = exprs[1:] + unselected_color = colors[0] + selected_colors = colors[1:] + + n = len(el.dimension_values(0)) + + if not any(selection_exprs): + colors = [unselected_color] * n + else: + clrs = np.array( + [unselected_color] + list(selected_colors)) + + color_inds = np.zeros(n, dtype='int8') + + for i, expr, color in zip( + range(1, len(clrs)), + selection_exprs, + selected_colors + ): + color_inds[expr.apply(el)] = i + + colors = clrs[color_inds] + + return el.options(**{self.color_prop: colors}) + + sel_streams = [selection_streams.colors_stream, + selection_streams.exprs_stream] + + if isinstance(hvobj, DynamicMap): + def apply_map( + obj, + colors=None, + exprs=None, + **kwargs + ): + return obj.map( + lambda el: _build_selection(el, colors, exprs), + specs=Element, + clone=True, + ) + + hvobj = Dynamic( + hvobj, + operation=apply_map, + streams=hvobj.streams + sel_streams, + link_inputs=True, + ) + else: + hvobj = Dynamic( + hvobj, + operation=_build_selection, + streams=sel_streams + ) + + for op in operations: + hvobj = op(hvobj) + + return hvobj + + +def _color_to_cmap(color): + """ + Create a light to dark cmap list from a base color + """ + # Lighten start color by interpolating toward white + start_color = linear_gradient("#ffffff", color, 7)[2] + + # Darken end color by interpolating toward black + end_color = linear_gradient(color, "#000000", 7)[2] + return [start_color, end_color] diff --git a/holoviews/tests/testselection.py b/holoviews/tests/testselection.py new file mode 100644 index 0000000000..191e78a45c --- /dev/null +++ b/holoviews/tests/testselection.py @@ -0,0 +1,335 @@ +from unittest import skip, SkipTest + +import holoviews as hv +from holoviews.operation.datashader import datashade, dynspread +from holoviews.selection import link_selections +from holoviews.element.comparison import ComparisonTestCase + +import pandas as pd + + +class TestLinkSelections(ComparisonTestCase): + + def setUp(self): + if type(self) is TestLinkSelections: + # Only run tests in subclasses + raise SkipTest("Not supported") + + self.data = pd.DataFrame( + {'x': [1, 2, 3], + 'y': [0, 3, 2], + 'e': [1, 1.5, 2], + }, + columns=['x', 'y', 'e'] + ) + + def element_color(self, element): + raise NotImplementedError + + def element_visible(self, element): + raise NotImplementedError + + def check_base_scatter_like(self, base_scatter, lnk_sel, data=None): + if data is None: + data = self.data + + self.assertEqual( + self.element_color(base_scatter), + lnk_sel.unselected_color + ) + self.assertTrue(self.element_visible(base_scatter)) + self.assertEqual(base_scatter.data, data) + + def check_overlay_scatter_like(self, overlay_scatter, lnk_sel, data): + self.assertEqual( + self.element_color(overlay_scatter), + lnk_sel.selected_color + ) + self.assertEqual( + self.element_visible(overlay_scatter), + len(data) != len(self.data) + ) + + self.assertEqual(overlay_scatter.data, data) + + def test_scatter_selection(self, dynamic=False): + scatter = hv.Scatter(self.data, kdims='x', vdims='y') + if dynamic: + # Convert scatter to DynamicMap that returns the element + scatter = hv.util.Dynamic(scatter) + + lnk_sel = link_selections.instance() + linked = lnk_sel(scatter) + current_obj = linked[()] + + # Check initial state of linked dynamic map + self.assertIsInstance(current_obj, hv.Overlay) + + # Check initial base layer + self.check_base_scatter_like(current_obj.Scatter.I, lnk_sel) + + # Check selection layer + self.check_overlay_scatter_like(current_obj.Scatter.II, lnk_sel, self.data) + + # Perform selection of second and third point + boundsxy = lnk_sel._selection_expr_streams[0]._source_streams[0] + self.assertIsInstance(boundsxy, hv.streams.BoundsXY) + boundsxy.event(bounds=(0, 1, 5, 5)) + current_obj = linked[()] + + # Check that base layer is unchanged + self.check_base_scatter_like(current_obj.Scatter.I, lnk_sel) + + # Check selection layer + self.check_overlay_scatter_like(current_obj.Scatter.II, lnk_sel, self.data.iloc[1:]) + + def test_scatter_selection_dynamic(self): + self.test_scatter_selection(dynamic=True) + + def test_layout_selection_scatter_table(self): + scatter = hv.Scatter(self.data, kdims='x', vdims='y') + table = hv.Table(self.data) + lnk_sel = link_selections.instance() + linked = lnk_sel(scatter + table) + + current_obj = linked[()] + + # Check initial base scatter + self.check_base_scatter_like( + current_obj[0][()].Scatter.I, + lnk_sel + ) + + # Check initial selection scatter + self.check_overlay_scatter_like( + current_obj[0][()].Scatter.II, + lnk_sel, + self.data + ) + + # Check initial table + self.assertEqual( + self.element_color(current_obj[1][()]), + [lnk_sel.unselected_color] * len(self.data) + ) + + # 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 scatter + self.check_base_scatter_like( + current_obj[0][()].Scatter.I, + lnk_sel + ) + + # Check selection scatter + self.check_overlay_scatter_like( + current_obj[0][()].Scatter.II, + lnk_sel, + self.data.iloc[[0, 2]] + ) + + # Check selected table + self.assertEqual( + self.element_color(current_obj[1][()]), + [ + lnk_sel.selected_color, + lnk_sel.unselected_color, + lnk_sel.selected_color, + ] + ) + + def test_overlay_scatter_errorbars(self, dynamic=False): + scatter = hv.Scatter(self.data, kdims='x', vdims='y') + error = hv.ErrorBars(self.data, kdims='x', vdims=['y', 'e']) + lnk_sel = link_selections.instance() + overlay = scatter * error + if dynamic: + overlay = hv.util.Dynamic(overlay) + + linked = lnk_sel(overlay) + current_obj = linked[()] + + # Check initial base layers + self.check_base_scatter_like(current_obj.Scatter.I, lnk_sel) + self.check_base_scatter_like(current_obj.ErrorBars.I, lnk_sel) + + # Check initial selection layers + self.check_overlay_scatter_like( + current_obj.Scatter.II, lnk_sel, self.data + ) + self.check_overlay_scatter_like( + current_obj.ErrorBars.II, lnk_sel, self.data + ) + + # 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 + self.check_base_scatter_like(current_obj.Scatter.I, lnk_sel) + self.check_base_scatter_like(current_obj.ErrorBars.I, lnk_sel) + + # Check selected layers + self.check_overlay_scatter_like( + current_obj.Scatter.II, lnk_sel, self.data.iloc[[0, 2]] + ) + self.check_overlay_scatter_like( + current_obj.ErrorBars.II, lnk_sel, self.data.iloc[[0, 2]] + ) + + def test_overlay_scatter_errorbars_dynamic(self): + self.test_overlay_scatter_errorbars(dynamic=True) + + def test_datashade_selection(self): + scatter = hv.Scatter(self.data, kdims='x', vdims='y') + layout = scatter + dynspread(datashade(scatter)) + + lnk_sel = link_selections.instance() + linked = lnk_sel(layout) + current_obj = linked[()] + + # Check base scatter layer + self.check_base_scatter_like(current_obj[0][()].Scatter.I, lnk_sel) + + # Check selection layer + self.check_overlay_scatter_like( + current_obj[0][()].Scatter.II, lnk_sel, self.data + ) + + # Check RGB base layer + self.assertEqual( + current_obj[1][()].RGB.I, + dynspread( + datashade(scatter, cmap=lnk_sel.unselected_cmap, alpha=255) + )[()] + ) + + # Check RGB selection layer + self.assertEqual( + current_obj[1][()].RGB.II, + dynspread( + datashade(scatter, cmap=lnk_sel.selected_cmap, alpha=0) + )[()] + ) + + # Perform selection of second and third point + boundsxy = lnk_sel._selection_expr_streams[0]._source_streams[0] + self.assertIsInstance(boundsxy, hv.streams.BoundsXY) + boundsxy.event(bounds=(0, 1, 5, 5)) + current_obj = linked[()] + + # Check that base scatter layer is unchanged + self.check_base_scatter_like(current_obj[0][()].Scatter.I, lnk_sel) + + # Check scatter selection layer + self.check_overlay_scatter_like( + current_obj[0][()].Scatter.II, lnk_sel, self.data.iloc[1:] + ) + + # Check that base RGB layer is unchanged + self.assertEqual( + current_obj[1][()].RGB.I, + dynspread( + datashade(scatter, cmap=lnk_sel.unselected_cmap, alpha=255) + )[()] + ) + + # Check selection RGB layer + self.assertEqual( + current_obj[1][()].RGB.II, + dynspread( + datashade( + scatter.iloc[1:], cmap=lnk_sel.selected_cmap, alpha=255 + ) + )[()] + ) + + def test_scatter_selection_streaming(self): + buffer = hv.streams.Buffer(self.data.iloc[:2], index=False) + scatter = hv.DynamicMap(hv.Scatter, streams=[buffer]) + lnk_sel = link_selections.instance() + linked = lnk_sel(scatter) + + # Perform selection of first and (future) third point + boundsxy = lnk_sel._selection_expr_streams[0]._source_streams[0] + self.assertIsInstance(boundsxy, hv.streams.BoundsXY) + boundsxy.event(bounds=(0, 0, 4, 2)) + current_obj = linked[()] + + # Check initial base layer + self.check_base_scatter_like( + current_obj.Scatter.I, lnk_sel, self.data.iloc[:2] + ) + + # Check selection layer + self.check_overlay_scatter_like( + current_obj.Scatter.II, lnk_sel, self.data.iloc[[0]] + ) + + # Now stream third point to the DynamicMap + buffer.send(self.data.iloc[[2]]) + current_obj = linked[()] + + # Check initial base layer + self.check_base_scatter_like( + current_obj.Scatter.I, lnk_sel, self.data + ) + + # Check selection layer + self.check_overlay_scatter_like( + current_obj.Scatter.II, lnk_sel, self.data.iloc[[0, 2]] + ) + + +# Backend implementations +class TestLinkSelectionsPlotly(TestLinkSelections): + def setUp(self): + super(TestLinkSelectionsPlotly, self).setUp() + hv.extension('plotly') + + def element_color(self, element): + if isinstance(element, hv.Table): + color = element.opts.get('style').kwargs['fill'] + else: + color = element.opts.get('style').kwargs['color'] + + if isinstance(color, str): + return color + else: + return list(color) + + def element_visible(self, element): + return element.opts.get('style').kwargs['visible'] + + +class TestLinkSelectionsBokeh(TestLinkSelections): + def setUp(self): + super(TestLinkSelectionsBokeh, self).setUp() + hv.extension('bokeh') + + def element_color(self, element): + color = element.opts.get('style').kwargs['color'] + + if isinstance(color, str): + return color + else: + return list(color) + + def element_visible(self, element): + return element.opts.get('style').kwargs['alpha'] > 0 + + @skip("Coloring Bokeh table not yet supported") + def test_layout_selection_scatter_table(self): + pass + + @skip("Bokeh ErrorBars selection not yet supported") + def test_overlay_scatter_errorbars(self): + pass + + @skip("Bokeh ErrorBars selection not yet supported") + def test_overlay_scatter_errorbars_dynamic(self): + pass