diff --git a/doc/Tutorials/Bokeh_Backend.ipynb b/doc/Tutorials/Bokeh_Backend.ipynb index 4cb92c9e78..8c95e67cad 100644 --- a/doc/Tutorials/Bokeh_Backend.ipynb +++ b/doc/Tutorials/Bokeh_Backend.ipynb @@ -253,6 +253,61 @@ "hv.Curve((aapl_dates, AAPL['adj_close']), kdims=['Date'], vdims=['Stock Index'], label='Apple')" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Categorical axes\n", + "\n", + "A number of Elements will also support categorical (i.e. string types) as dimension values, these include ``HeatMap``, ``Points``, ``Scatter``, ``Curve``, ``ErrorBar`` and ``Text`` types.\n", + "\n", + "Here we create a set of points indexed by ascending alphabetical x- and y-coordinates and values multiplying the integer index of each coordinate. We then overlay a ``HeatMap`` of the points with the points themselves enabling the hover tool for both and scaling the point size by the 'z' coordines." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "%%opts Points [size_index='z' tools=['hover']] HeatMap [toolbar='above' tools=['hover']]\n", + "points = hv.Points([(chr(i+65), chr(j+65), i*j) for i in range(10) for j in range(10)], vdims=['z'])\n", + "hv.HeatMap(points) * points" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In the example above both axes are categorical because a HeatMap by definition represents 2D categorical coordinates (unlike Image and Raster types). Other Element types will automatically infer a categorical dimension if the coordinates along that dimension include string types.\n", + "\n", + "Here we will generate random samples indexed by categories from 'A' to 'E' using the Scatter Element and overlay them.\n", + "Secondly we compute the mean and standard deviation for each category and finally we overlay these two elements with a curve representing the mean value and a text element specifying the global mean. All these Elements respect the categorical index, providing us a view of the distribution of values in each category:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "%%opts Overlay [show_legend=False height=400 width=600] ErrorBars (line_width=5) Scatter(alpha=0.2 size=6)\n", + "\n", + "overlay = hv.NdOverlay({group: hv.Scatter(([group]*100, np.random.randn(100)*(i+1)+i))\n", + " for i, group in enumerate(['A', 'B', 'C', 'D', 'E'])})\n", + "\n", + "errorbars = hv.ErrorBars([(k, el.reduce(function=np.mean), el.reduce(function=np.std))\n", + " for k, el in overlay.items()])\n", + "\n", + "global_mean = hv.Text('A', 12, 'Global mean: %.3f' % overlay.dimension_values('y').mean())\n", + "\n", + "errorbars * overlay * hv.Curve(errorbars) * global_mean" + ] + }, { "cell_type": "markdown", "metadata": { diff --git a/holoviews/core/dimension.py b/holoviews/core/dimension.py index 86d1f75386..b816efa121 100644 --- a/holoviews/core/dimension.py +++ b/holoviews/core/dimension.py @@ -872,12 +872,12 @@ def range(self, dimension, data_range=True): if None not in dimension.range: return dimension.range elif data_range: - if dimension in self.kdims or dimension in self.vdims: + if dimension in self.kdims+self.vdims: dim_vals = self.dimension_values(dimension.name) drange = find_range(dim_vals) else: dname = dimension.name - match_fn = lambda x: dname in x.dimensions(['key', 'value'], True) + match_fn = lambda x: dname in x.kdims + x.vdims range_fn = lambda x: x.range(dname) ranges = self.traverse(range_fn, [match_fn]) drange = max_range(ranges) diff --git a/holoviews/core/util.py b/holoviews/core/util.py index daa0dbed63..b0d65a1e00 100644 --- a/holoviews/core/util.py +++ b/holoviews/core/util.py @@ -458,6 +458,9 @@ def max_range(ranges): if pd and all(isinstance(v, pd.tslib.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)]) + 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])) @@ -492,10 +495,14 @@ def max_extents(extents, zrange=False): upper = [v for v in arr[uidx] if v is not None] if lower and isinstance(lower[0], np.datetime64): extents[lidx] = np.min(lower) + elif any(isinstance(l, basestring) for l in lower): + extents[lidx] = np.sort(lower)[0] elif lower: extents[lidx] = np.nanmin(lower) if upper and isinstance(upper[0], np.datetime64): extents[uidx] = np.max(upper) + elif any(isinstance(u, basestring) for u in upper): + extents[uidx] = np.sort(upper)[-1] elif upper: extents[uidx] = np.nanmax(upper) return tuple(extents) diff --git a/holoviews/element/annotation.py b/holoviews/element/annotation.py index cf2d0d3e14..7c586a74e9 100644 --- a/holoviews/element/annotation.py +++ b/holoviews/element/annotation.py @@ -45,12 +45,12 @@ def __getitem__(self, key): return self.clone(self.data, extents=(xstart, ystart, xstop, ystop)) - def dimension_values(self, dimension): + def dimension_values(self, dimension, expanded=True, flat=True): index = self.get_dimension_index(dimension) if index == 0: - return [self.data if np.isscalar(self.data) else self.data[index]] + return np.array([self.data if np.isscalar(self.data) else self.data[index]]) elif index == 1: - return [] if np.isscalar(self.data) else [self.data[1]] + return [] if np.isscalar(self.data) else np.array([self.data[1]]) else: return super(Annotation, self).dimension_values(dimension) @@ -106,10 +106,10 @@ def __init__(self, spline_points, **params): super(Spline, self).__init__(spline_points, **params) - def dimension_values(self, dimension): + def dimension_values(self, dimension, expanded=True, flat=True): index = self.get_dimension_index(dimension) if index in [0, 1]: - return [point[index] for point in self.data[0]] + return np.array([point[index] for point in self.data[0]]) else: return super(Spline, self).dimension_values(dimension) @@ -158,6 +158,16 @@ def clone(self, *args, **overrides): settings = dict(self.get_param_values(), **overrides) return self.__class__(*args, **settings) + def dimension_values(self, dimension, expanded=True, flat=True): + index = self.get_dimension_index(dimension) + if index == 0: + return np.array([self.x]) + elif index == 1: + return np.array([self.y]) + else: + return super(Text, self).dimension_values(dimension) + + class Text(Annotation): """ @@ -165,9 +175,9 @@ class Text(Annotation): fontsize, alignment and rotation. """ - x = param.Number(default=0, doc="The x-position of the text.") + x = param.Parameter(default=0, doc="The x-position of the text.") - y = param.Number(default=0, doc="The y-position of text.") + y = param.Parameter(default=0, doc="The y-position of text.") text = param.String(default='', doc="The text to be displayed.") diff --git a/holoviews/element/util.py b/holoviews/element/util.py index 6168a87639..680751a8ca 100644 --- a/holoviews/element/util.py +++ b/holoviews/element/util.py @@ -93,15 +93,21 @@ def _get_coords(self, obj): grouped = obj.groupby(xdim, container_type=OrderedDict, group_type=Dataset).values() orderings = OrderedDict() + sort = True for group in grouped: - vals = group.dimension_values(ydim) + vals = group.dimension_values(ydim, False) if len(vals) == 1: orderings[vals[0]] = [vals[0]] else: for i in range(len(vals)-1): p1, p2 = vals[i:i+2] orderings[p1] = [p2] - if one_to_one(orderings, ycoords): + if sort: + if vals.dtype.kind in ('i', 'f'): + sort = (np.diff(vals)>=0).all() + else: + sort = np.array_equal(np.sort(vals), vals) + if sort or one_to_one(orderings, ycoords): ycoords = np.sort(ycoords) elif not is_cyclic(orderings): ycoords = list(itertools.chain(*sort_topologically(orderings))) diff --git a/holoviews/plotting/bokeh/annotation.py b/holoviews/plotting/bokeh/annotation.py index cc5ef2dc1e..4535dad04b 100644 --- a/holoviews/plotting/bokeh/annotation.py +++ b/holoviews/plotting/bokeh/annotation.py @@ -20,6 +20,7 @@ def get_data(self, element, ranges=None, empty=False): data = dict(x=[element.y], y=[element.x]) else: data = dict(x=[element.x], y=[element.y]) + self._categorize_data(data, ('x', 'y'), element.dimensions()) data['text'] = [element.text] return (data, mapping) diff --git a/holoviews/plotting/bokeh/chart.py b/holoviews/plotting/bokeh/chart.py index ce41d2979a..b76a5c342a 100644 --- a/holoviews/plotting/bokeh/chart.py +++ b/holoviews/plotting/bokeh/chart.py @@ -7,7 +7,7 @@ except: Bar, BokehBoxPlot = None, None from bokeh.models import (Circle, GlyphRenderer, ColumnDataSource, - Range1d, CustomJS) + Range1d, CustomJS, FactorRange) from bokeh.models.tools import BoxSelectTool from ...element import Raster, Points, Polygons, Spikes @@ -84,8 +84,10 @@ def get_data(self, element, ranges=None, empty=False): data[map_key] = np.sqrt(sizes) mapping['size'] = map_key - data[dims[xidx]] = [] if empty else element.dimension_values(xidx) - data[dims[yidx]] = [] if empty else element.dimension_values(yidx) + xdim, ydim = dims[xidx], dims[yidx] + data[xdim] = [] if empty else element.dimension_values(xidx) + data[ydim] = [] if empty else element.dimension_values(yidx) + self._categorize_data(data, (xdim, ydim), element.dimensions()) self._get_hover_data(data, element, empty) return data, mapping @@ -144,9 +146,10 @@ def get_data(self, element, ranges=None, empty=False): xidx, yidx = (1, 0) if self.invert_axes else (0, 1) x = element.get_dimension(xidx).name y = element.get_dimension(yidx).name - return ({x: [] if empty else element.dimension_values(xidx), - y: [] if empty else element.dimension_values(yidx)}, - dict(x=x, y=y)) + data = {x: [] if empty else element.dimension_values(xidx), + y: [] if empty else element.dimension_values(yidx)} + self._categorize_data(data, (x, y), element.dimensions()) + return (data, dict(x=x, y=y)) def _hover_tooltips(self, element): if self.batched: @@ -369,6 +372,7 @@ def get_data(self, element, ranges=None, empty=False): data = dict(xs=err_ys, ys=err_xs) else: data = dict(xs=err_xs, ys=err_ys) + self._categorize_data(data, ('xs', 'ys'), element.dimensions()) return (data, dict(self._mapping)) diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py index 9d07342cfc..68cd3628cd 100644 --- a/holoviews/plotting/bokeh/element.py +++ b/holoviews/plotting/bokeh/element.py @@ -5,7 +5,7 @@ import bokeh import bokeh.plotting from bokeh.core.properties import value -from bokeh.models import Range, HoverTool, Renderer, Range1d +from bokeh.models import Range, HoverTool, Renderer, Range1d, FactorRange from bokeh.models.tickers import Ticker, BasicTicker, FixedTicker from bokeh.models.widgets import Panel, Tabs @@ -170,6 +170,7 @@ class ElementPlot(BokehPlot, GenericElementPlot): # Any entries should be existing keys in the handles # instance attribute. _update_handles = ['source', 'glyph'] + _categorical = False def __init__(self, element, plot=None, **params): self.current_ranges = None @@ -293,6 +294,7 @@ def _axes_props(self, plots, subplots, element, ranges): range_el = el if self.batched and not isinstance(self, OverlayPlot) else element l, b, r, t = self.get_extents(range_el, ranges) + categorical = False if not 'x_range' in plot_ranges: if 'x_range' in ranges: plot_ranges['x_range'] = ranges['x_range'] @@ -301,16 +303,25 @@ def _axes_props(self, plots, subplots, element, ranges): if x_axis_type == 'datetime': low = convert_datetime(low) high = convert_datetime(high) + elif any(isinstance(x, util.basestring) for x in (low, high)): + plot_ranges['x_range'] = FactorRange() + categorical = True elif low == high and low is not None: offset = low*0.1 if low else 0.5 low -= offset high += offset - if all(x is not None and np.isfinite(x) for x in (low, high)): + if not categorical and all(x is not None and np.isfinite(x) for x in (low, high)): plot_ranges['x_range'] = [low, high] if self.invert_xaxis: - plot_ranges['x_ranges'] = plot_ranges['x_ranges'][::-1] - + x_range = plot_ranges['x_range'] + if isinstance(x_range, Range1d): + plot_ranges['x_range'] = x_range.__class__(start=x_range.end, + end=x_range.start) + elif not isinstance(x_range, (Range, FactorRange)): + plot_ranges['x_range'] = x_range[::-1] + + categorical = False if not 'y_range' in plot_ranges: if 'y_range' in ranges: plot_ranges['y_range'] = ranges['y_range'] @@ -319,19 +330,29 @@ def _axes_props(self, plots, subplots, element, ranges): if y_axis_type == 'datetime': low = convert_datetime(low) high = convert_datetime(high) + elif any(isinstance(y, util.basestring) for y in (low, high)): + plot_ranges['y_range'] = FactorRange() + categorical = True elif low == high and low is not None: offset = low*0.1 if low else 0.5 low -= offset high += offset - if all(y is not None and np.isfinite(y) for y in (low, high)): + if not categorical and all(y is not None and np.isfinite(y) for y in (low, high)): plot_ranges['y_range'] = [low, high] + if self.invert_yaxis: yrange = plot_ranges['y_range'] - if isinstance(yrange, Range): + if isinstance(yrange, Range1d): plot_ranges['y_range'] = yrange.__class__(start=yrange.end, end=yrange.start) - else: + elif not isinstance(yrange, (Range, FactorRange)): plot_ranges['y_range'] = yrange[::-1] + + categorical = any(self.traverse(lambda x: x._categorical)) + if categorical: + x_axis_type, y_axis_type = 'auto', 'auto' + plot_ranges['x_range'] = FactorRange() + plot_ranges['y_range'] = FactorRange() return (x_axis_type, y_axis_type), (xlabel, ylabel, zlabel), plot_ranges @@ -492,26 +513,78 @@ def _update_plot(self, key, plot, element=None): def _update_ranges(self, element, ranges): plot = self.handles['plot'] - l, b, r, t = self.get_extents(element, ranges) + x_range = self.handles['x_range'] + y_range = self.handles['y_range'] + + if any(isinstance(r, Range1d) for r in [x_range, y_range]): + l, b, r, t = self.get_extents(element, ranges) + if self.invert_axes: + l, b, r, t = b, l, t, r + + if any(isinstance(r, FactorRange) for r in [x_range, y_range]): + xfactors, yfactors = self._get_factors(element) + + if isinstance(x_range, Range1d): + if l == r and l is not None: + offset = abs(l*0.1 if l else 0.5) + l -= offset + r += offset + + if self.invert_xaxis: l, r = r, l + if l is not None and (isinstance(l, np.datetime64) or np.isfinite(l)): + plot.x_range.start = l + if r is not None and (isinstance(r, np.datetime64) or np.isfinite(r)): + plot.x_range.end = r + elif isinstance(x_range, FactorRange): + xfactors = list(xfactors) + if self.invert_xaxis: xfactors = xfactors[::-1] + x_range.factors = xfactors - if self.invert_axes: - l, b, r, t = b, l, t, r - if l == r: - offset = abs(l*0.1 if l else 0.5) - l -= offset - r += offset - if b == t: - offset = abs(b*0.1 if b else 0.5) - b -= offset - t += offset - - # Ensure that it never sets a NaN value - if isinstance(plot.x_range, Range1d): - if isinstance(l, np.datetime64) or np.isfinite(l): plot.x_range.start = l - if isinstance(l, np.datetime64) or np.isfinite(r): plot.x_range.end = r if isinstance(plot.y_range, Range1d): - if isinstance(l, np.datetime64) or np.isfinite(b): plot.y_range.start = b - if isinstance(l, np.datetime64) or np.isfinite(t): plot.y_range.end = t + if b == t and b is not None: + offset = abs(b*0.1 if b else 0.5) + b -= offset + t += offset + if self.invert_yaxis: b, t = t, b + if b is not None and (isinstance(l, np.datetime64) or np.isfinite(b)): + plot.y_range.start = b + if t is not None and (isinstance(l, np.datetime64) or np.isfinite(t)): + plot.y_range.end = t + elif isinstance(y_range, FactorRange): + yfactors = list(yfactors) + if self.invert_yaxis: yfactors = yfactors[::-1] + y_range.factors = yfactors + + + def _categorize_data(self, data, cols, dims): + """ + Transforms non-string or integer types in datasource if the + axis to be plotted on is categorical. Accepts the column data + sourcec data, the columns corresponding to the axes and the + dimensions for each axis, changing the data inplace. + """ + if self.invert_axes: + cols = cols[::-1] + dims = dims[:2][::-1] + ranges = [self.handles['%s_range' % ax] for ax in 'xy'] + for i, col in enumerate(cols): + column = data[col] + if (isinstance(ranges[i], FactorRange) and + (isinstance(column, list) or column.dtype.kind not in 'SU')): + data[col] = [dims[i].pprint_value(v) for v in column] + + + def _get_factors(self, element): + """ + Get factors for categorical axes. + """ + xdim, ydim = element.dimensions()[:2] + xvals, yvals = [element.dimension_values(i, False) + for i in range(2)] + coords = ([x if xvals.dtype.kind in 'SU' else xdim.pprint_value(x) for x in xvals], + [y if yvals.dtype.kind in 'SU' else ydim.pprint_value(y) for y in yvals]) + if self.invert_axes: coords = coords[::-1] + return coords def _process_legend(self): @@ -619,6 +692,7 @@ def initialize_plot(self, ranges=None, plot=None, plots=None, source=None): self._update_glyph(glyph, properties, mapping) if not self.overlaid: self._update_plot(key, plot, style_element) + self._update_ranges(style_element, ranges) if not self.batched: for cb in self.callbacks: @@ -714,6 +788,11 @@ def current_handles(self): if bokeh_version >= '0.12': handles.append(plot.title) + for ax in 'xy': + key = '%s_range' % ax + if isinstance(self.handles[key], FactorRange): + handles.append(self.handles[key]) + if self.current_frame: if not self.apply_ranges: rangex, rangey = False, False @@ -1098,6 +1177,21 @@ def _init_tools(self, element, callbacks=[]): return list(set(tools)) + def _get_factors(self, overlay): + xfactors, yfactors = [], [] + for k, sp in self.subplots.items(): + el = overlay.data.get(k) + if el is not None: + xfs, yfs = sp._get_factors(el) + xfactors.append(xfs) + yfactors.append(yfs) + if xfactors: + xfactors = np.concatenate(xfactors) + if yfactors: + yfactors = np.concatenate(yfactors) + return util.unique_array(xfactors), util.unique_array(yfactors) + + def initialize_plot(self, ranges=None, plot=None, plots=None): key = self.keys[-1] nonempty = [el for el in self.hmap.data.values() if len(el)] @@ -1108,9 +1202,11 @@ def initialize_plot(self, ranges=None, plot=None, plots=None): if plot is None and not self.tabs and not self.batched: plot = self._init_plot(key, element, ranges=ranges, plots=plots) self._init_axes(plot) + self.handles['plot'] = plot + if plot and not self.overlaid: self._update_plot(key, plot, element) - self.handles['plot'] = plot + self._update_ranges(element, ranges) panels = [] for key, subplot in self.subplots.items(): diff --git a/holoviews/plotting/bokeh/plot.py b/holoviews/plotting/bokeh/plot.py index b1b8806f44..283fa1fec5 100644 --- a/holoviews/plotting/bokeh/plot.py +++ b/holoviews/plotting/bokeh/plot.py @@ -457,7 +457,8 @@ def _create_subplots(self, layout, positions, layout_dimensions, ranges, num=0): layout_dimensions=layout_dimensions, ranges=ranges, subplot=True, uniform=self.uniform, layout_num=num, - **plotopts) + **dict({'shared_axes': self.shared_axes}, + **plotopts)) subplots[pos] = subplot if isinstance(plot_type, type) and issubclass(plot_type, GenericCompositePlot): adjoint_clone[pos] = subplots[pos].layout diff --git a/holoviews/plotting/bokeh/raster.py b/holoviews/plotting/bokeh/raster.py index 6005eb4b75..2db3b82a57 100644 --- a/holoviews/plotting/bokeh/raster.py +++ b/holoviews/plotting/bokeh/raster.py @@ -136,15 +136,11 @@ class HeatmapPlot(ColorbarPlot): _plot_methods = dict(single='rect') style_opts = ['cmap', 'color'] + line_properties + fill_properties - def _axes_props(self, plots, subplots, element, ranges): - dims = element.dimensions() - labels = self._get_axis_labels(dims) - agg = element.gridded - xvals, yvals = [agg.dimension_values(i, False) for i in range(2)] - if self.invert_yaxis: yvals = yvals[::-1] - plot_ranges = {'x_range': [str(x) for x in xvals], - 'y_range': [str(y) for y in yvals]} - return ('auto', 'auto'), labels, plot_ranges + _update_handles = ['color_mapper', 'source', 'glyph', 'colorbar'] + _categorical = True + + def _get_factors(self, element): + return super(HeatmapPlot, self)._get_factors(element.gridded) def get_data(self, element, ranges=None, empty=False): x, y, z = element.dimensions(label=True)[:3] @@ -154,9 +150,12 @@ def get_data(self, element, ranges=None, empty=False): if empty: data = {x: [], y: [], z: []} else: - zvals = aggregate.dimension_values(z) - xvals, yvals = [[str(v) for v in aggregate.dimension_values(i)] - for i in range(2)] + xdim, ydim = aggregate.dimensions()[:2] + xvals, yvals, zvals = (aggregate.dimension_values(i) for i in range(3)) + if xvals.dtype.kind not in 'SU': + xvals = [xdim.pprint_value(xv) for xv in xvals] + if yvals.dtype.kind not in 'SU': + yvals = [ydim.pprint_value(yv) for yv in yvals] data = {x: xvals, y: yvals, 'zvalues': zvals} if 'hover' in self.tools+self.default_tools: diff --git a/holoviews/plotting/bokeh/renderer.py b/holoviews/plotting/bokeh/renderer.py index 73af4ab088..f1222f9c05 100644 --- a/holoviews/plotting/bokeh/renderer.py +++ b/holoviews/plotting/bokeh/renderer.py @@ -27,7 +27,7 @@ class BokehRenderer(Renderer): mode_formats = {'fig': {'default': ['html', 'json', 'auto']}, 'holomap': {'default': ['widgets', 'scrubber', 'auto', None]}} - webgl = param.Boolean(default=True, doc="""Whether to render plots with WebGL + webgl = param.Boolean(default=False, doc="""Whether to render plots with WebGL if bokeh version >=0.10""") widgets = {'scrubber': BokehScrubberWidget, diff --git a/holoviews/plotting/bokeh/util.py b/holoviews/plotting/bokeh/util.py index 69b66668e7..37dc230a35 100644 --- a/holoviews/plotting/bokeh/util.py +++ b/holoviews/plotting/bokeh/util.py @@ -43,16 +43,17 @@ # and can therefore be safely ignored. Axes currently fail saying # LinearAxis.computed_bounds cannot be updated IGNORED_MODELS = ['LinearAxis', 'LogAxis', 'DatetimeAxis', 'DatetimeTickFormatter', - 'CategoricalAxis', 'BasicTicker', 'BasicTickFormatter', - 'FixedTicker', 'FuncTickFormatter', 'LogTickFormatter', + 'BasicTicker', 'BasicTickFormatter', 'FixedTicker', + 'FuncTickFormatter', 'LogTickFormatter', 'CategoricalTickFormatter'] # List of attributes that can safely be dropped from the references -IGNORED_ATTRIBUTES = ['data', 'palette', 'image', 'x', 'y'] +IGNORED_ATTRIBUTES = ['data', 'palette', 'image', 'x', 'y', 'factors'] # Model priority order to ensure some types are updated before others MODEL_PRIORITY = ['Range1d', 'Title', 'Image', 'LinearColorMapper', - 'Plot', 'Range1d', 'LinearAxis', 'ColumnDataSource'] + 'Plot', 'Range1d', 'FactorRange', 'CategoricalAxis', + 'LinearAxis', 'ColumnDataSource'] def rgb2hex(rgb): diff --git a/holoviews/plotting/plot.py b/holoviews/plotting/plot.py index ab6ebb5e89..421084e60c 100644 --- a/holoviews/plotting/plot.py +++ b/holoviews/plotting/plot.py @@ -705,8 +705,12 @@ def get_extents(self, view, ranges): extents = util.max_extents(extent_list, self.projection == '3d') else: extents = (np.NaN,) * num - return tuple(l1 if l2 is None or not np.isfinite(l2) else - l2 for l1, l2 in zip(range_extents, extents)) + + if getattr(self, 'shared_axes', False) and self.subplot: + return util.max_extents([range_extents, extents], self.projection == '3d') + else: + return tuple(l1 if l2 is None or not np.isfinite(l2) else + l2 for l1, l2 in zip(range_extents, extents)) def _get_axis_labels(self, dimensions, xlabel=None, ylabel=None, zlabel=None): diff --git a/tests/testplotinstantiation.py b/tests/testplotinstantiation.py index 70d100a266..ca92f62b8e 100644 --- a/tests/testplotinstantiation.py +++ b/tests/testplotinstantiation.py @@ -15,7 +15,8 @@ NdOverlay, GridSpace, HoloMap, Layout) from holoviews.element import (Curve, Scatter, Image, VLine, Points, HeatMap, QuadMesh, Spikes, ErrorBars, - Scatter3D, Path, Polygons, Bars, BoxWhisker) + Scatter3D, Path, Polygons, Bars, Text, + BoxWhisker) from holoviews.element.comparison import ComparisonTestCase from holoviews.streams import PositionXY, PositionX from holoviews.plotting import comms @@ -33,7 +34,7 @@ import holoviews.plotting.bokeh bokeh_renderer = Store.renderers['bokeh'] from holoviews.plotting.bokeh.callbacks import Callback - from bokeh.models import Div, ColumnDataSource + from bokeh.models import Div, ColumnDataSource, FactorRange, Range1d from bokeh.models.mappers import LinearColorMapper, LogColorMapper from bokeh.models.tools import HoverTool except: @@ -401,6 +402,144 @@ def test_points_non_numeric_size_warning(self): 'cannot use to scale Points size.\n' % plot.name) self.assertEqual(log_msg, warning) + def test_curve_categorical_xaxis(self): + curve = Curve((['A', 'B', 'C'], [1,2,3])) + plot = bokeh_renderer.get_plot(curve) + x_range = plot.handles['x_range'] + self.assertIsInstance(x_range, FactorRange) + self.assertEqual(x_range.factors, ['A', 'B', 'C']) + + def test_curve_categorical_xaxis_invert_axes(self): + curve = Curve((['A', 'B', 'C'], (1,2,3)))(plot=dict(invert_axes=True)) + plot = bokeh_renderer.get_plot(curve) + y_range = plot.handles['y_range'] + self.assertIsInstance(y_range, FactorRange) + self.assertEqual(y_range.factors, ['A', 'B', 'C']) + + def test_points_categorical_xaxis(self): + points = Points((['A', 'B', 'C'], (1,2,3))) + plot = bokeh_renderer.get_plot(points) + x_range = plot.handles['x_range'] + self.assertIsInstance(x_range, FactorRange) + self.assertEqual(x_range.factors, ['A', 'B', 'C']) + + def test_points_categorical_xaxis_mixed_type(self): + points = Points(range(10)) + points2 = Points((['A', 'B', 'C', 1, 2.0], (1, 2, 3, 4, 5))) + plot = bokeh_renderer.get_plot(points*points2) + x_range = plot.handles['x_range'] + self.assertIsInstance(x_range, FactorRange) + self.assertEqual(x_range.factors, list(map(str, range(10))) + ['A', 'B', 'C', '2.0']) + def test_points_categorical_xaxis_invert_axes(self): + points = Points((['A', 'B', 'C'], (1,2,3)))(plot=dict(invert_axes=True)) + plot = bokeh_renderer.get_plot(points) + y_range = plot.handles['y_range'] + self.assertIsInstance(y_range, FactorRange) + self.assertEqual(y_range.factors, ['A', 'B', 'C']) + + def test_points_overlay_categorical_xaxis(self): + points = Points((['A', 'B', 'C'], (1,2,3))) + points2 = Points((['B', 'C', 'D'], (1,2,3))) + plot = bokeh_renderer.get_plot(points*points2) + x_range = plot.handles['x_range'] + self.assertIsInstance(x_range, FactorRange) + self.assertEqual(x_range.factors, ['A', 'B', 'C', 'D']) + + def test_points_overlay_categorical_xaxis_invert_axis(self): + points = Points((['A', 'B', 'C'], (1,2,3)))(plot=dict(invert_xaxis=True)) + points2 = Points((['B', 'C', 'D'], (1,2,3))) + plot = bokeh_renderer.get_plot(points*points2) + x_range = plot.handles['x_range'] + self.assertIsInstance(x_range, FactorRange) + self.assertEqual(x_range.factors, ['A', 'B', 'C', 'D'][::-1]) + + def test_points_overlay_categorical_xaxis_invert_axes(self): + points = Points((['A', 'B', 'C'], (1,2,3)))(plot=dict(invert_axes=True)) + points2 = Points((['B', 'C', 'D'], (1,2,3))) + plot = bokeh_renderer.get_plot(points*points2) + y_range = plot.handles['y_range'] + self.assertIsInstance(y_range, FactorRange) + self.assertEqual(y_range.factors, ['A', 'B', 'C', 'D']) + + def test_heatmap_categorical_axes_string_int(self): + hmap = HeatMap([('A',1, 1), ('B', 2, 2)]) + plot = bokeh_renderer.get_plot(hmap) + x_range = plot.handles['x_range'] + y_range = plot.handles['y_range'] + self.assertIsInstance(x_range, FactorRange) + self.assertEqual(x_range.factors, ['A', 'B']) + self.assertIsInstance(y_range, FactorRange) + self.assertEqual(y_range.factors, ['1', '2']) + + def test_heatmap_categorical_axes_string_int_invert_xyaxis(self): + opts = dict(invert_xaxis=True, invert_yaxis=True) + hmap = HeatMap([('A',1, 1), ('B', 2, 2)])(plot=opts) + plot = bokeh_renderer.get_plot(hmap) + x_range = plot.handles['x_range'] + y_range = plot.handles['y_range'] + self.assertIsInstance(x_range, FactorRange) + self.assertEqual(x_range.factors, ['A', 'B'][::-1]) + self.assertIsInstance(y_range, FactorRange) + self.assertEqual(y_range.factors, ['1', '2'][::-1]) + + def test_heatmap_categorical_axes_string_int_inverted(self): + hmap = HeatMap([('A',1, 1), ('B', 2, 2)])(plot=dict(invert_axes=True)) + plot = bokeh_renderer.get_plot(hmap) + x_range = plot.handles['x_range'] + y_range = plot.handles['y_range'] + self.assertIsInstance(x_range, FactorRange) + self.assertEqual(x_range.factors, ['1', '2']) + self.assertIsInstance(y_range, FactorRange) + self.assertEqual(y_range.factors, ['A', 'B']) + + def test_heatmap_points_categorical_axes_string_int(self): + hmap = HeatMap([('A',1, 1), ('B', 2, 2)]) + points = Points([('A', 2), ('B', 1), ('C', 3)]) + plot = bokeh_renderer.get_plot(hmap*points) + x_range = plot.handles['x_range'] + y_range = plot.handles['y_range'] + self.assertIsInstance(x_range, FactorRange) + self.assertEqual(x_range.factors, ['A', 'B', 'C']) + self.assertIsInstance(y_range, FactorRange) + self.assertEqual(y_range.factors, ['1', '2', '3']) + + def test_heatmap_points_categorical_axes_string_int_inverted(self): + hmap = HeatMap([('A',1, 1), ('B', 2, 2)])(plot=dict(invert_axes=True)) + points = Points([('A', 2), ('B', 1), ('C', 3)]) + plot = bokeh_renderer.get_plot(hmap*points) + x_range = plot.handles['x_range'] + y_range = plot.handles['y_range'] + self.assertIsInstance(x_range, FactorRange) + self.assertEqual(x_range.factors, ['1', '2', '3']) + self.assertIsInstance(y_range, FactorRange) + self.assertEqual(y_range.factors, ['A', 'B', 'C']) + + def test_points_errorbars_text_ndoverlay_categorical_xaxis(self): + overlay = NdOverlay({i: Points(([chr(65+i)]*10,np.random.randn(10))) + for i in range(5)}) + error = ErrorBars([(el['x'][0], np.mean(el['y']), np.std(el['y'])) + for el in overlay]) + text = Text('C', 0, 'Test') + plot = bokeh_renderer.get_plot(overlay*error*text) + x_range = plot.handles['x_range'] + y_range = plot.handles['y_range'] + self.assertIsInstance(x_range, FactorRange) + self.assertEqual(x_range.factors, ['A', 'B', 'C', 'D', 'E']) + self.assertIsInstance(y_range, Range1d) + + def test_points_errorbars_text_ndoverlay_categorical_xaxis_invert_axes(self): + overlay = NdOverlay({i: Points(([chr(65+i)]*10,np.random.randn(10))) + for i in range(5)}) + error = ErrorBars([(el['x'][0], np.mean(el['y']), np.std(el['y'])) + for el in overlay])(plot=dict(invert_axes=True)) + text = Text('C', 0, 'Test') + plot = bokeh_renderer.get_plot(overlay*error*text) + x_range = plot.handles['x_range'] + y_range = plot.handles['y_range'] + self.assertIsInstance(x_range, Range1d) + self.assertIsInstance(y_range, FactorRange) + self.assertEqual(y_range.factors, ['A', 'B', 'C', 'D', 'E']) + def test_box_whisker_datetime(self): times = np.arange(dt.datetime(2017,1,1), dt.datetime(2017,2,1), dt.timedelta(days=1))