Skip to content

Commit

Permalink
Add crossfilter mode to link_selections (#4119)
Browse files Browse the repository at this point in the history
  • Loading branch information
jonmmease authored Mar 2, 2020
1 parent 48aba7d commit 6f9ed6a
Show file tree
Hide file tree
Showing 62 changed files with 2,936 additions and 730 deletions.
4 changes: 4 additions & 0 deletions doc/user_guide/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ These guides provide detail about specific additional features in HoloViews:
`Working with renderers and plots <Plots_and_Renderers.html>`_
Using the ``Renderer`` and ``Plot`` classes for access to the plotting machinery.

`Using linked brushing to cross-filter complex datasets <Linked_Brushing.html>`_
Explains how to use the `link_selections` helper to cross-filter multiple elements.

`Using Annotators to edit and label data <Annotators.html>`_
Explains how to use the `annotate` helper to edit and annotate elements with the help of drawing tools and editable tables.

Expand Down Expand Up @@ -148,6 +151,7 @@ These guides provide detail about specific additional features in HoloViews:
Plotting with matplotlib <Plotting_with_Matplotlib>
Plotting with plotly <Plotting_with_Plotly>
Working with Plot and Renderers <Plots_and_Renderers>
Linked Brushing <Linked_Brushing>
Annotators <Annotators>
Exporting and Archiving <Exporting_and_Archiving>
Continuous Coordinates <Continuous_Coordinates>
Expand Down
531 changes: 531 additions & 0 deletions examples/user_guide/Linked_Brushing.ipynb

Large diffs are not rendered by default.

57 changes: 53 additions & 4 deletions holoviews/core/accessors.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ class Apply(object):
def __init__(self, obj, mode=None):
self._obj = obj

def __call__(self, function, streams=[], link_inputs=True, dynamic=None, **kwargs):
def __call__(self, function, streams=[], link_inputs=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
Expand All @@ -122,6 +123,10 @@ def __call__(self, function, streams=[], link_inputs=True, dynamic=None, **kwarg
supplied, an instance parameter is supplied as a
keyword argument, or the supplied function is a
parameterized method.
per_element (bool, optional): Whether to apply per element
By default apply works on the leaf nodes, which
includes both elements and overlays. If set it will
apply directly to elements.
kwargs (dict, optional): Additional keyword arguments
Keyword arguments which will be supplied to the
function.
Expand All @@ -131,6 +136,7 @@ def __call__(self, function, streams=[], link_inputs=True, dynamic=None, **kwarg
contained (Nd)Overlay or Element objects.
"""
from .dimension import ViewableElement
from .element import Element
from .spaces import HoloMap, DynamicMap
from ..util import Dynamic

Expand Down Expand Up @@ -164,7 +170,8 @@ def function(object, **kwargs):
kwargs = {k: v.param.value if isinstance(v, Widget) else v
for k, v in kwargs.items()}

applies = isinstance(self._obj, ViewableElement)
spec = Element if per_element else ViewableElement
applies = isinstance(self._obj, spec)
params = {p: val for p, val in kwargs.items()
if isinstance(val, param.Parameter)
and isinstance(val.owner, param.Parameterized)}
Expand Down Expand Up @@ -299,6 +306,46 @@ def _filter_cache(self, dmap, kdims):
filtered.append((key, value))
return filtered

def _transform_dimension(self, kdims, vdims, dimension):
if dimension in kdims:
idx = kdims.index(dimension)
dimension = self._obj.kdims[idx]
elif dimension in vdims:
idx = vdims.index(dimension)
dimension = self._obj.vdims[idx]
return dimension

def _create_expression_transform(self, kdims, vdims, exclude=[]):
from .dimension import dimension_name
from ..util.transform import dim

def _transform_expression(expression):
if dimension_name(expression.dimension) in exclude:
dimension = expression.dimension
else:
dimension = self._transform_dimension(
kdims, vdims, expression.dimension
)
expression = expression.clone(dimension)
ops = []
for op in expression.ops:
new_op = dict(op)
new_args = []
for arg in op['args']:
if isinstance(arg, dim):
arg = _transform_expression(arg)
new_args.append(arg)
new_op['args'] = tuple(new_args)
new_kwargs = {}
for kw, kwarg in op['kwargs'].items():
if isinstance(kwarg, dim):
kwarg = _transform_expression(kwarg)
new_kwargs[kw] = kwarg
new_op['kwargs'] = new_kwargs
ops.append(new_op)
expression.ops = ops
return expression
return _transform_expression

def __call__(self, specs=None, **dimensions):
"""
Expand All @@ -324,13 +371,15 @@ def __call__(self, specs=None, **dimensions):
kdims = self.replace_dimensions(obj.kdims, dimensions)
vdims = self.replace_dimensions(obj.vdims, dimensions)
zipped_dims = zip(obj.kdims+obj.vdims, kdims+vdims)
renames = {pk.name: nk for pk, nk in zipped_dims if pk != nk}
renames = {pk.name: nk for pk, nk in zipped_dims if pk.name != nk.name}

if self.mode == 'dataset':
data = obj.data
if renames:
data = obj.interface.redim(obj, renames)
clone = obj.clone(data, kdims=kdims, vdims=vdims)
transform = self._create_expression_transform(kdims, vdims, list(renames.values()))
transforms = obj._transforms + [transform]
clone = obj.clone(data, kdims=kdims, vdims=vdims, transforms=transforms)
if self._obj.dimensions(label='name') == clone.dimensions(label='name'):
# Ensure that plot_id is inherited as long as dimension
# name does not change
Expand Down
17 changes: 12 additions & 5 deletions holoviews/core/data/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,14 +283,21 @@ def __init__(self, data, kdims=None, vdims=None, **kwargs):
input_data = data
dataset_provided = 'dataset' in kwargs
input_dataset = kwargs.pop('dataset', None)
input_pipeline = kwargs.pop(
'pipeline', None
)
input_pipeline = kwargs.pop('pipeline', None)
input_transforms = kwargs.pop('transforms', None)

if isinstance(data, Element):
pvals = util.get_param_values(data)
kwargs.update([(l, pvals[l]) for l in ['group', 'label']
if l in pvals and l not in kwargs])
if isinstance(data, Dataset):
if not dataset_provided and data._dataset is not None:
input_dataset = data._dataset
if input_pipeline is None:
input_pipeline = data.pipeline
if input_transforms is None:
input_transforms = data._transforms

kwargs.update(process_dimensions(kdims, vdims))
kdims, vdims = kwargs.get('kdims'), kwargs.get('vdims')

Expand All @@ -316,6 +323,7 @@ def __init__(self, data, kdims=None, vdims=None, **kwargs):
operations=input_pipeline.operations + [init_op],
output_type=type(self),
)
self._transforms = input_transforms or []

# Handle initializing the dataset property.
self._dataset = None
Expand All @@ -331,7 +339,6 @@ def dataset(self):
"""
The Dataset that this object was created from
"""
from . import Dataset
if self._dataset is None:
datatype = list(util.unique_iterator(self.datatype+Dataset.datatype))
dataset = Dataset(self, _validate_vdims=False, datatype=datatype)
Expand Down Expand Up @@ -526,7 +533,7 @@ def select(self, selection_expr=None, selection_specs=None, **selection):
selection = {dim_name: sel for dim_name, sel in selection.items()
if dim_name in self.dimensions()+['selection_mask']}
if (selection_specs and not any(self.matches(sp) for sp in selection_specs)
or (not selection and not selection_expr)):
or (not selection and not selection_expr)):
return self

# Handle selection dim expression
Expand Down
13 changes: 10 additions & 3 deletions holoviews/core/data/array.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ class ArrayInterface(Interface):

datatype = 'array'

named = False

@classmethod
def dimension_type(cls, dataset, dim):
return dataset.data.dtype.type
Expand Down Expand Up @@ -123,9 +125,7 @@ def sort(cls, dataset, by=[], reverse=False):


@classmethod
def values(
cls, dataset, dim, expanded=True, flat=True, compute=True, keep_index=False
):
def values(cls, dataset, dim, expanded=True, flat=True, compute=True, keep_index=False):
data = dataset.data
dim_idx = dataset.get_dimension_index(dim)
if data.ndim == 1:
Expand All @@ -136,6 +136,13 @@ def values(
return values


@classmethod
def mask(cls, dataset, mask, mask_value=np.nan):
masked = np.copy(dataset.data)
masked[mask] = mask_value
return masked


@classmethod
def reindex(cls, dataset, kdims=None, vdims=None):
# DataFrame based tables don't need to be reindexed
Expand Down
10 changes: 1 addition & 9 deletions holoviews/core/data/dask.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,15 +90,7 @@ def sort(cls, dataset, by=[], reverse=False):
return dataset.data

@classmethod
def values(
cls,
dataset,
dim,
expanded=True,
flat=True,
compute=True,
keep_index=False,
):
def values(cls, dataset, dim, expanded=True, flat=True, compute=True, keep_index=False):
dim = dataset.get_dimension(dim)
data = dataset.data[dim.name]
if not expanded:
Expand Down
16 changes: 12 additions & 4 deletions holoviews/core/data/dictionary.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,16 @@ def concat(cls, datasets, dimensions, vdims):
return OrderedDict([(d.name, np.concatenate(columns[d.name])) for d in dims])


@classmethod
def mask(cls, dataset, mask, mask_value=np.nan):
masked = OrderedDict()
for k, v in dataset.data.items():
new_array = np.copy(dataset.data[k])
new_array[mask] = mask_value
masked[k] = new_array
return masked


@classmethod
def sort(cls, dataset, by=[], reverse=False):
by = [dataset.get_dimension(d).name for d in by]
Expand All @@ -246,10 +256,8 @@ def range(cls, dataset, dimension):


@classmethod
def values(
cls, dataset, dim, expanded=True, flat=True, compute=True, keep_index=False
):
dim = dataset.get_dimension(dim).name
def values(cls, dataset, dim, expanded=True, flat=True, compute=True, keep_index=False):
dim = dataset.get_dimension(dim, strict=True).name
values = dataset.data.get(dim)
if isscalar(values):
if not expanded:
Expand Down
64 changes: 51 additions & 13 deletions holoviews/core/data/grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ def init(cls, eltype, data, kdims, vdims):
ndims = len(kdims)
dimensions = [dimension_name(d) for d in kdims+vdims]
vdim_tuple = tuple(dimension_name(vd) for vd in vdims)

if isinstance(data, tuple):
if (len(data) != len(dimensions) and len(data) == (ndims+1) and
len(data[-1].shape) == (ndims+1)):
Expand All @@ -66,18 +67,34 @@ def init(cls, eltype, data, kdims, vdims):
data[vdim_tuple] = value_array
else:
data = {d: v for d, v in zip(dimensions, data)}
elif isinstance(data, list) and data == []:
data = OrderedDict([(d, []) for d in dimensions])
elif (isinstance(data, list) and data == []):
if len(kdims) == 1:
data = OrderedDict([(d, []) for d in dimensions])
else:
data = OrderedDict([(d.name, np.array([])) for d in kdims])
if len(vdims) == 1:
data[vdims[0].name] = np.zeros((0, 0))
else:
data[vdim_tuple] = np.zeros((0, 0, len(vdims)))
elif not any(isinstance(data, tuple(t for t in interface.types if t is not None))
for interface in cls.interfaces.values()):
data = {k: v for k, v in zip(dimensions, zip(*data))}
elif isinstance(data, np.ndarray):
if data.ndim == 1:
if eltype._auto_indexable_1d and len(kdims)+len(vdims)>1:
data = np.column_stack([np.arange(len(data)), data])
else:
data = np.atleast_2d(data).T
data = {k: data[:,i] for i,k in enumerate(dimensions)}
if data.shape == (0, 0) and len(vdims) == 1:
array = data
data = OrderedDict([(d.name, np.array([])) for d in kdims])
data[vdims[0].name] = array
elif data.shape == (0, 0, len(vdims)):
array = data
data = OrderedDict([(d.name, np.array([])) for d in kdims])
data[vdim_tuple] = array
else:
if data.ndim == 1:
if eltype._auto_indexable_1d and len(kdims)+len(vdims)>1:
data = np.column_stack([np.arange(len(data)), data])
else:
data = np.atleast_2d(data).T
data = {k: data[:, i] for i, k in enumerate(dimensions)}
elif isinstance(data, list) and data == []:
data = {d: np.array([]) for d in dimensions[:ndims]}
data.update({d: np.empty((0,) * ndims) for d in dimensions[ndims:]})
Expand Down Expand Up @@ -245,9 +262,9 @@ def _infer_interval_breaks(cls, coord, axis=0):
if sys.version_info.major == 2 and len(coord) and isinstance(coord[0], (dt.datetime, dt.date)):
# np.diff does not work on datetimes in python 2
coord = coord.astype('datetime64')
if len(coord) == 0:
if coord.shape[axis] == 0:
return np.array([], dtype=coord.dtype)
if len(coord) > 1:
if coord.shape[axis] > 1:
deltas = 0.5 * np.diff(coord, axis=axis)
else:
deltas = np.array([0.5])
Expand Down Expand Up @@ -393,9 +410,7 @@ def ndloc(cls, dataset, indices):


@classmethod
def values(
cls, dataset, dim, expanded=True, flat=True, compute=True, keep_index=False
):
def values(cls, dataset, dim, expanded=True, flat=True, compute=True, keep_index=False):
dim = dataset.get_dimension(dim, strict=True)
if dim in dataset.vdims or dataset.data[dim.name].ndim > 1:
vdim_tuple = cls.packed(dataset)
Expand Down Expand Up @@ -598,6 +613,29 @@ def select(cls, dataset, selection_mask=None, **selection):
return data


@classmethod
def mask(cls, dataset, mask, mask_val=np.nan):
mask = cls.canonicalize(dataset, mask)
packed = cls.packed(dataset)
masked = OrderedDict(dataset.data)
if packed:
masked = dataset.data[packed].copy()
try:
masked[mask] = mask_val
except ValueError:
masked = masked.astype('float')
masked[mask] = mask_val
else:
for vd in dataset.vdims:
masked[vd.name] = marr = masked[vd.name].copy()
try:
marr[mask] = mask_val
except ValueError:
masked[vd.name] = marr = marr.astype('float')
marr[mask] = mask_val
return masked


@classmethod
def sample(cls, dataset, samples=[]):
"""
Expand Down
9 changes: 9 additions & 0 deletions holoviews/core/data/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ class ImageInterface(GridInterface):

datatype = 'image'

named = False

@classmethod
def init(cls, eltype, data, kdims, vdims):
if kdims is None:
Expand Down Expand Up @@ -199,6 +201,13 @@ def values(
return None


@classmethod
def mask(cls, dataset, mask, mask_val=np.nan):
masked = dataset.data.copy().astype('float')
masked[np.flipud(mask)] = mask_val
return masked


@classmethod
def select(cls, dataset, selection_mask=None, **selection):
"""
Expand Down
5 changes: 4 additions & 1 deletion holoviews/core/data/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,9 @@ class Interface(param.Parameterized):
# Denotes whether the interface expects ragged data
multi = False

# Whether the interface stores the names of the underlying dimensions
named = True

@classmethod
def loaded(cls):
"""
Expand Down Expand Up @@ -229,7 +232,7 @@ def initialize(cls, eltype, data, kdims, vdims, datatype=None):
datatype = eltype.datatype

interface = data.interface
if interface.datatype in datatype and interface.datatype in eltype.datatype:
if interface.datatype in datatype and interface.datatype in eltype.datatype and interface.named:
data = data.data
elif interface.multi and any(cls.interfaces[dt].multi for dt in datatype if dt in cls.interfaces):
data = [d for d in data.interface.split(data, None, None, 'columns')]
Expand Down
Loading

0 comments on commit 6f9ed6a

Please sign in to comment.