diff --git a/holoviews/core/data/__init__.py b/holoviews/core/data/__init__.py index 02e86d533a..77ccd5aa0f 100644 --- a/holoviews/core/data/__init__.py +++ b/holoviews/core/data/__init__.py @@ -252,7 +252,9 @@ def range(self, dim, data_range=True): lower, upper = self.interface.range(self, dim) else: lower, upper = (np.NaN, np.NaN) - return dimension_range(lower, upper, dim) + if data_range == 'exclusive': + return (lower, upper) + return dimension_range(lower, upper, dim.range, dim.soft_range) def add_dimension(self, dimension, dim_pos, dim_val, vdim=False, **kwargs): diff --git a/holoviews/core/dimension.py b/holoviews/core/dimension.py index 18290fe054..26128066f5 100644 --- a/holoviews/core/dimension.py +++ b/holoviews/core/dimension.py @@ -1067,7 +1067,9 @@ def range(self, dimension, data_range=True): lower, upper = max_range(ranges) else: lower, upper = (np.NaN, np.NaN) - return dimension_range(lower, upper, dimension) + if data_range == 'exclusive': + return (lower, upper) + return dimension_range(lower, upper, dimension.range, dimension.soft_range) def __repr__(self): diff --git a/holoviews/core/util.py b/holoviews/core/util.py index 3494517891..5c09ec9fb6 100644 --- a/holoviews/core/util.py +++ b/holoviews/core/util.py @@ -703,6 +703,20 @@ def find_range(values, soft_range=[]): return (None, None) +def is_finite(value): + """ + Safe check whether a value is finite, only None and NaN values are + considered non-finite and allows checking all types not restricted + to numeric types. + """ + if value is None: + return False + try: + return np.isfinite(value) + except: + return True + + def max_range(ranges): """ Computes the maximal lower and upper bounds from a list bounds. @@ -710,27 +724,34 @@ def max_range(ranges): try: with warnings.catch_warnings(): warnings.filterwarnings('ignore', r'All-NaN (slice|axis) encountered') - values = [r for r in ranges for v in r if v is not None] - if pd and all(isinstance(v, pd.Timestamp) for r in values for v in r): - values = [(v1.to_datetime64(), v2.to_datetime64()) for v1, v2 in values] - arr = np.array(values) - if arr.dtype.kind in 'OSU': - arr = np.sort([v for v in arr.flat if not is_nan(v)]) + values = [v for r in ranges for v in r if is_finite(v)] + if pd: + if all(isinstance(v, pd.Timestamp) for v in values): + values = [v.to_datetime64() for v in values] + values = np.array(values) + if not len(values): + return (np.NaN, np.NaN) + elif values.dtype.kind in 'OSU': + arr = np.sort(values) return arr[0], arr[-1] - if arr.dtype.kind in 'M': - return arr[:, 0].min(), arr[:, 1].max() - return (np.nanmin(arr[:, 0]), np.nanmax(arr[:, 1])) + elif lo_values.dtype.kind in 'M': + return values.min(), values.max() + return (np.nanmin(values), np.nanmax(values)) except: return (np.NaN, np.NaN) -def dimension_range(lower, upper, dimension): +def dimension_range(lower, upper, hard_range, soft_range, padding=0): """ Computes the range along a dimension by combining the data range with the Dimension soft_range and range. """ - lower, upper = max_range([(lower, upper), dimension.soft_range]) - dmin, dmax = dimension.range + if is_number(lower) and is_number(upper) and padding != 0: + pad = (upper - lower)*padding + lower -= pad + upper += pad + lower, upper = max_range([(lower, upper), soft_range]) + dmin, dmax = hard_range lower = lower if dmin is None or not np.isfinite(dmin) else dmin upper = upper if dmax is None or not np.isfinite(dmax) else dmax return lower, upper @@ -928,6 +949,7 @@ def dimension_sort(odict, kdims, vdims, key_index): # Copied from param should make param version public def is_number(obj): if isinstance(obj, numbers.Number): return True + elif isinstance(obj, (np.str_, np.unicode_)): return False # The extra check is for classes that behave like numbers, such as those # found in numpy, gmpy, etc. elif (hasattr(obj, '__int__') and hasattr(obj, '__add__')): return True diff --git a/holoviews/element/chart.py b/holoviews/element/chart.py index 08d1ae7c64..2fa2a9a193 100644 --- a/holoviews/element/chart.py +++ b/holoviews/element/chart.py @@ -100,7 +100,9 @@ def range(self, dim, data_range=True): pos_error = neg_error lower = np.nanmin(mean-neg_error) upper = np.nanmax(mean+pos_error) - return util.dimension_range(lower, upper, dim) + if data_range == 'exclusive': + return (lower, upper) + return util.dimension_range(lower, upper, dim.range, dim.soft_range) return super(ErrorBars, self).range(dim, data_range) diff --git a/holoviews/element/raster.py b/holoviews/element/raster.py index 560237654e..ae9ddbab2c 100644 --- a/holoviews/element/raster.py +++ b/holoviews/element/raster.py @@ -76,7 +76,7 @@ def range(self, dim, data_range=True): if data_range and idx == 2: dimension = self.get_dimension(dim) lower, upper = np.nanmin(self.data), np.nanmax(self.data) - return dimension_range(lower, upper, dimension) + return dimension_range(lower, upper, dimension.range, dimension.soft_range) return super(Raster, self).range(dim, data_range) diff --git a/holoviews/plotting/bokeh/chart.py b/holoviews/plotting/bokeh/chart.py index 05e6380e05..87f496aa48 100644 --- a/holoviews/plotting/bokeh/chart.py +++ b/holoviews/plotting/bokeh/chart.py @@ -174,7 +174,7 @@ def _get_lengths(self, element, ranges): base_dist = get_min_distance(element) if mag_dim: magnitudes = element.dimension_values(mag_dim) - _, max_magnitude = ranges[mag_dim.name] + _, max_magnitude = ranges[mag_dim.name]['combined'] if self.normalize_lengths and max_magnitude != 0: magnitudes = magnitudes / max_magnitude if self.rescale_lengths: @@ -502,11 +502,16 @@ class AreaPlot(SpreadPlot): def get_extents(self, element, ranges): vdims = element.vdims vdim = vdims[0].name + new_range = {} if len(vdims) > 1: - ranges[vdim] = max_range([ranges[vd.name] for vd in vdims]) + for r in ranges[vdim]: + new_range[r] = max_range([ranges[vd.name][r] for vd in vdims]) else: - vdim = vdims[0].name - ranges[vdim] = (np.nanmin([0, ranges[vdim][0]]), ranges[vdim][1]) + vranges = ranges[vdim] + for r in vranges: + vrange = vranges[r] + new_range[r] = (np.nanmin([0, vrange[0]]), vrange[1]) + ranges[vdim] = new_range return super(AreaPlot, self).get_extents(element, ranges) @@ -670,7 +675,7 @@ def get_extents(self, element, ranges): element = Bars(overlay.table(), kdims=element.kdims+overlay.kdims, vdims=element.vdims) for kd in overlay.kdims: - ranges[kd.name] = overlay.range(kd) + ranges[kd.name]['combined'] = overlay.range(kd) stacked = element.get_dimension(self.stack_index) extents = super(BarPlot, self).get_extents(element, ranges) @@ -684,7 +689,7 @@ def get_extents(self, element, ranges): neg_range = ds.select(**{ydim.name: (None, 0)}).aggregate(xdim, function=np.sum).range(ydim) y0, y1 = max_range([pos_range, neg_range]) else: - y0, y1 = ranges[ydim.name] + y0, y1 = ranges[ydim.name]['combined'] # Set y-baseline if y0 < 0: @@ -827,7 +832,7 @@ def get_data(self, element, ranges, style): container_type=OrderedDict, datatype=['dataframe', 'dictionary']) - y0, y1 = ranges.get(ydim.name, (None, None)) + y0, y1 = ranges.get(ydim.name, {'combined': (None, None)})['combined'] if self.logy: bottom = (ydim.range[0] or (10**(np.log10(y1)-2)) if y1 else 0.01) else: @@ -968,7 +973,7 @@ def get_extents(self, element, ranges): Extents are set to '' and None because x-axis is categorical and y-axis auto-ranges. """ - yrange = ranges.get(element.vdims[0].name, (np.NaN, np.NaN)) + yrange = ranges.get(element.vdims[0].name, {'combined': (np.NaN, np.NaN)})['combined'] return ('', yrange[0], '', yrange[1]) def _get_axis_labels(self, *args, **kwargs): diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py index 9a68ffc945..cd830eaea2 100644 --- a/holoviews/plotting/bokeh/element.py +++ b/holoviews/plotting/bokeh/element.py @@ -753,7 +753,6 @@ def initialize_plot(self, ranges=None, plot=None, plots=None, source=None): self.current_key = key style_element = element.last if self.batched else element ranges = util.match_spec(style_element, ranges) - # Initialize plot, source and glyph if plot is None: plot = self._init_plot(key, style_element, ranges=ranges, plots=plots) @@ -1093,7 +1092,10 @@ def _get_colormapper(self, dim, element, ranges, style, factors=None, colors=Non ncolors = None if factors is None else len(factors) if dim: - low, high = ranges.get(dim.name, element.range(dim.name)) + if dim.name in ranges: + low, high = ranges[dim.name]['combined'] + else: + low, high = element.range(dim.name) else: low, high = None, None diff --git a/holoviews/plotting/bokeh/graphs.py b/holoviews/plotting/bokeh/graphs.py index f340666f61..7f84bfb431 100644 --- a/holoviews/plotting/bokeh/graphs.py +++ b/holoviews/plotting/bokeh/graphs.py @@ -69,8 +69,8 @@ def _hover_opts(self, element): def get_extents(self, element, ranges): xdim, ydim = element.nodes.kdims[:2] - x0, x1 = ranges[xdim.name] - y0, y1 = ranges[ydim.name] + x0, x1 = ranges[xdim.name]['combined'] + y0, y1 = ranges[ydim.name]['combined'] return (x0, y0, x1, y1) def _get_axis_labels(self, *args, **kwargs): diff --git a/holoviews/plotting/mpl/chart.py b/holoviews/plotting/mpl/chart.py index 0741e2069e..1799972602 100644 --- a/holoviews/plotting/mpl/chart.py +++ b/holoviews/plotting/mpl/chart.py @@ -169,6 +169,7 @@ def update_handles(self, key, axis, element, ranges, style): return axis_kwargs + class AreaPlot(ChartPlot): show_legend = param.Boolean(default=False, doc=""" @@ -193,12 +194,20 @@ def init_artists(self, ax, plot_data, plot_kwargs): def get_extents(self, element, ranges): vdims = element.vdims vdim = vdims[0].name - ranges[vdim] = max_range([ranges[vd.name] for vd in vdims]) + new_range = {} + if len(vdims) > 1: + for r in ranges[vdim]: + new_range[r] = max_range([ranges[vd.name][r] for vd in vdims]) + else: + vranges = ranges[vdim] + for r in vranges: + vrange = vranges[r] + new_range[r] = (np.nanmin([0, vrange[0]]), vrange[1]) + ranges[vdim] = new_range return super(AreaPlot, self).get_extents(element, ranges) - class SpreadPlot(AreaPlot): """ SpreadPlot plots the Spread Element type. @@ -632,7 +641,7 @@ def get_data(self, element, ranges, style): mag_dim = element.get_dimension(self.size_index) if mag_dim: magnitudes = element.dimension_values(mag_dim) - _, max_magnitude = ranges[mag_dim.name] + _, max_magnitude = ranges[mag_dim.name]['combined'] if self.normalize_lengths and max_magnitude != 0: magnitudes = magnitudes / max_magnitude else: @@ -765,7 +774,7 @@ def get_extents(self, element, ranges): if self.stack_index in range(element.ndims): return 0, 0, ngroups, np.NaN else: - vrange = ranges[vdim] + vrange = ranges[vdim]['combined'] return 0, np.nanmin([vrange[0], 0]), ngroups, vrange[1] diff --git a/holoviews/plotting/mpl/element.py b/holoviews/plotting/mpl/element.py index 9af586da4d..d009bd2227 100644 --- a/holoviews/plotting/mpl/element.py +++ b/holoviews/plotting/mpl/element.py @@ -656,7 +656,10 @@ def _norm_kwargs(self, element, ranges, opts, vdim, prefix=''): if not isinstance(cs, np.ndarray): cs = np.array(cs) if len(cs) and cs.dtype.kind in 'if': - clim = ranges[vdim.name] if vdim.name in ranges else element.range(vdim) + if vdim.name in ranges: + clim = ranges[vdim.name]['combined'] + else: + clim = element.range(vdim) if self.logz: # Lower clim must be >0 when logz=True # Choose the maximum between the lowest non-zero value diff --git a/holoviews/plotting/plot.py b/holoviews/plotting/plot.py index 7810855272..241a2b78af 100644 --- a/holoviews/plotting/plot.py +++ b/holoviews/plotting/plot.py @@ -13,6 +13,7 @@ from ..core import OrderedDict from ..core import util, traversal from ..core.element import Element +from ..core.data import Dataset from ..core.overlay import Overlay, CompositeOverlay from ..core.layout import Empty, NdLayout, Layout from ..core.options import Store, Compositor, SkipRendering @@ -409,12 +410,25 @@ def _compute_group_range(group, elements, ranges): group_ranges = OrderedDict() for el in elements: if isinstance(el, (Empty, Table)): continue - for dim in el.dimensions('ranges', label=True): - dim_range = el.range(dim) - if dim not in group_ranges: - group_ranges[dim] = [] - group_ranges[dim].append(dim_range) - ranges[group] = OrderedDict((k, util.max_range(v)) for k, v in group_ranges.items()) + for dim in el.dimensions('ranges'): + data_range = el.range(dim, data_range='exclusive') + if dim.name not in group_ranges: + group_ranges[dim.name] = {'data': [], 'hard': [], 'soft': []} + group_ranges[dim.name]['data'].append(data_range) + group_ranges[dim.name]['hard'].append(dim.range) + group_ranges[dim.name]['soft'].append(dim.soft_range) + + dim_ranges = [] + for dim, values in group_ranges.items(): + hard_range = util.max_range(values['hard']) + soft_range = util.max_range(values['soft']) + data_range = util.max_range(values['data']) + combined = util.dimension_range(data_range[0], data_range[1], + hard_range, soft_range) + dranges = {'data': data_range, 'hard': hard_range, + 'soft': soft_range, 'combined': combined} + dim_ranges.append((dim, dranges)) + ranges[group] = OrderedDict(dim_ranges) @classmethod @@ -552,6 +566,9 @@ class GenericElementPlot(DimensionedPlot): apply_extents = param.Boolean(default=True, doc=""" Whether to apply extent overrides on the Elements""") + padding = param.Number(default=0, doc=""" + Amount of padding to apply to data ranges.""") + # A dictionary mapping of the plot methods used to draw the # glyphs corresponding to the ElementPlot, can support two # keyword arguments a 'single' implementation to draw an individual @@ -669,27 +686,53 @@ def get_extents(self, view, ranges): Gets the extents for the axes from the current View. The globally computed ranges can optionally override the extents. """ - ndims = len(view.dimensions()) + dims = view.dimensions() + ndims = len(dims) num = 6 if self.projection == '3d' else 4 if self.apply_ranges: + xdim = dims[0] if ranges: - dims = view.dimensions() - x0, x1 = ranges[dims[0].name] + x0, x1 = ranges[xdim.name]['data'] + xsrange = ranges[xdim.name]['soft'] + xhrange = ranges[xdim.name]['hard'] if ndims > 1: - y0, y1 = ranges[dims[1].name] + ydim = dims[1].name + y0, y1 = ranges[ydim]['data'] + ysrange = ranges[ydim]['soft'] + yhrange = ranges[ydim]['hard'] + else: + y0, y1 = ysrange = yhrange = (np.NaN, np.NaN) + + if self.projection == '3d' and ndims > 2: + zdim = dims[2].name + z0, z1 = ranges[zdim]['data'] + zsrange = ranges[zdim]['soft'] + zhrange = ranges[zdim]['hard'] else: - y0, y1 = (np.NaN, np.NaN) - if self.projection == '3d': - if len(dims) > 2: - z0, z1 = ranges[dims[2].name] - else: - z0, z1 = np.NaN, np.NaN + z0, z1 = zsrange = zhrange = (np.NaN, np.NaN) else: - x0, x1 = view.range(0) - y0, y1 = view.range(1) if ndims > 1 else (np.NaN, np.NaN) - if self.projection == '3d': + x0, x1 = view.range(0, data_range='exclusive') + xsrange = xdim.soft_range + xhrange = xdim.range + if ndims > 1: + ydim = dims[1] + y0, y1 = view.range(1, data_range='exclusive') + ysrange = ydim.soft_range + yhrange = ydim.range + else: + y0, y1 = ysrange = yhrange = (np.NaN, np.NaN) + if self.projection == '3d' and ndims > 2: + zdim = dims[2] z0, z1 = view.range(2) + zsrange = zdim.soft_range + zhrange = zdim.range + else: + z0, z1 = zsrange = zhrange = (np.NaN, np.NaN) + x0, x1 = util.dimension_range(x0, x1, xhrange, xsrange, self.padding) + if ndims > 1: + y0, y1 = util.dimension_range(y0, y1, yhrange, ysrange, self.padding) if self.projection == '3d': + z0, z1 = util.dimension_range(z0, z1, zhrange, zsrange, self.padding) range_extents = (x0, y0, z0, x1, y1, z1) else: range_extents = (x0, y0, x1, y1) diff --git a/holoviews/plotting/plotly/element.py b/holoviews/plotting/plotly/element.py index 39e544c8c8..38b4908a95 100644 --- a/holoviews/plotting/plotly/element.py +++ b/holoviews/plotting/plotly/element.py @@ -226,7 +226,10 @@ def get_color_opts(self, dim, element, ranges, style): opts['reversescale'] = True opts['colorscale'] = cmap if dim: - cmin, cmax = ranges.get(dim.name, element.range(dim.name)) + if dim.name in ranges: + cmin, cmax = ranges[dim.name]['combined'] + else: + cmin, cmax = element.range(dim.name) opts['cmin'] = cmin opts['cmax'] = cmax opts['cauto'] = False diff --git a/holoviews/plotting/util.py b/holoviews/plotting/util.py index a8cd828a34..9a60ea9b53 100644 --- a/holoviews/plotting/util.py +++ b/holoviews/plotting/util.py @@ -318,7 +318,7 @@ def get_sideplot_ranges(plot, element, main, ranges): ranges = match_spec(range_item.last, ranges) if dim.name in ranges: - main_range = ranges[dim.name] + main_range = ranges[dim.name]['combined'] else: framewise = plot.lookup_options(range_item.last, 'norm').options.get('framewise') if framewise and range_item.get(key, False):