diff --git a/holoviews/plotting/bokeh/__init__.py b/holoviews/plotting/bokeh/__init__.py index 485294b374..a3feba9bcc 100644 --- a/holoviews/plotting/bokeh/__init__.py +++ b/holoviews/plotting/bokeh/__init__.py @@ -130,3 +130,8 @@ options.Raster = Options('style', cmap='hot') options.QuadMesh = Options('style', cmap='hot') options.HeatMap = Options('style', cmap='RdYlBu_r', line_alpha=0) + +# Annotations +options.HLine = Options('style', line_color='black', line_width=3, line_alpha=1) +options.VLine = Options('style', line_color='black', line_width=3, line_alpha=1) + diff --git a/holoviews/plotting/bokeh/annotation.py b/holoviews/plotting/bokeh/annotation.py index 59eb8dddeb..4bdf02c98e 100644 --- a/holoviews/plotting/bokeh/annotation.py +++ b/holoviews/plotting/bokeh/annotation.py @@ -1,4 +1,5 @@ import numpy as np +from bokeh.models import BoxAnnotation from ...element import HLine, VLine from .element import ElementPlot, text_properties, line_properties @@ -9,8 +10,10 @@ class TextPlot(ElementPlot): style_opts = text_properties _plot_method = 'text' - def get_data(self, element, ranges=None): + def get_data(self, element, ranges=None, empty=False): mapping = dict(x='x', y='y', text='text') + if empty: + return dict(x=[], y=[], text=[]), mapping return (dict(x=[element.x], y=[element.y], text=[element.text]), mapping) @@ -21,22 +24,29 @@ def get_extents(self, element, ranges=None): class LineAnnotationPlot(ElementPlot): style_opts = line_properties - _plot_method = 'segment' - def get_data(self, element, ranges=None): + def get_data(self, element, ranges=None, empty=False): plot = self.handles['plot'] + data, mapping = {}, {} if isinstance(element, HLine): - x0 = plot.x_range.start - y0 = element.data - x1 = plot.x_range.end - y1 = element.data + mapping['bottom'] = element.data + mapping['top'] = element.data elif isinstance(element, VLine): - x0 = element.data - y0 = plot.y_range.start - x1 = element.data - y1 = plot.y_range.end - return (dict(x0=[x0], y0=[y0], x1=[x1], y1=[y1]), - dict(x0='x0', y0='y0', x1='x1', y1='y1')) + mapping['left'] = element.data + mapping['right'] = element.data + return (data, mapping) + + + def _init_glyph(self, plot, mapping, properties): + """ + Returns a Bokeh glyph object. + """ + properties.pop('source') + properties.pop('legend') + box = BoxAnnotation(plot=plot, level='overlay', + **dict(mapping, **properties)) + plot.renderers.append(box) + return box def get_extents(self, element, ranges=None): @@ -53,10 +63,15 @@ class SplinePlot(ElementPlot): style_opts = line_properties _plot_method = 'bezier' - def get_data(self, element, ranges=None): - verts = np.array(element.data[0]) - xs, ys = verts[:, 0], verts[:, 1] - return (dict(x0=[xs[0]], y0=[ys[0]], x1=[xs[-1]], y1=[ys[-1]], - cx0=[xs[1]], cy0=[ys[1]], cx1=[xs[2]], cy1=[ys[2]]), - dict(x0='x0', y0='y0', x1='x1', y1='y1', - cx0='cx0', cx1='cx1', cy0='cy0', cy1='cy1')) + def get_data(self, element, ranges=None, empty=False): + data_attrs = ['x0', 'y0', 'x1', 'y1', + 'cx0', 'cx1', 'cy0', 'cy1'] + if empty: + data = {attr: [] for attr in data_attrs} + else: + verts = np.array(element.data[0]) + xs, ys = verts[:, 0], verts[:, 1] + data = dict(x0=[xs[0]], y0=[ys[0]], x1=[xs[-1]], y1=[ys[-1]], + cx0=[xs[1]], cy0=[ys[1]], cx1=[xs[2]], cy1=[ys[2]]) + + return (data, dict(zip(data_attrs, data_attrs))) diff --git a/holoviews/plotting/bokeh/callbacks.py b/holoviews/plotting/bokeh/callbacks.py index 742173b847..ad612d5158 100644 --- a/holoviews/plotting/bokeh/callbacks.py +++ b/holoviews/plotting/bokeh/callbacks.py @@ -156,7 +156,22 @@ def serialize(self, objects): -class DownsampleImage(Callback): +class DownsampleCallback(Callback): + """ + DownsampleCallbacks can downsample the data before it is + plotted and can therefore provide major speed optimizations. + """ + + apply_on_update = param.Boolean(default=True, doc=""" + Callback should always be applied after each update to + downsample the data before it is displayed.""") + + reinitialize = param.Boolean(default=True, doc=""" + DownsampleColumns should be reinitialized per plot object""") + + + +class DownsampleImage(DownsampleCallback): """ Downsamples any Image plot to the specified max_width and max_height by slicing the @@ -165,10 +180,6 @@ class DownsampleImage(Callback): constraints. """ - apply_on_update = param.Boolean(default=True, doc=""" - Callback should always be applied after each update to - downsample the data before it is displayed.""") - max_width = param.Integer(default=250, doc=""" Maximum plot width in pixels after slicing and downsampling.""") @@ -210,16 +221,15 @@ def __call__(self, data): -class DownsampleColumns(Callback): +class DownsampleColumns(DownsampleCallback): """ Downsamples any column based Element by randomizing the rows and updating the ColumnDataSource with up to max_samples. """ - apply_on_update = param.Boolean(default=True, doc=""" - Callback should always be applied after each update to - downsample the data before it is displayed.""") + compute_ranges = param.Boolean(default=False, doc=""" + Whether the ranges are recomputed for the sliced region""") max_samples = param.Integer(default=800, doc=""" Maximum number of samples to display at the same time.""") @@ -227,9 +237,6 @@ class DownsampleColumns(Callback): random_seed = param.Integer(default=42, doc=""" Seed used to initialize randomization.""") - reinitialize = param.Boolean(default=True, doc=""" - DownsampleColumns should be reinitialized per plot object""") - plot_attributes = param.Dict(default={'x_range': ['start', 'end'], 'y_range': ['start', 'end']}) @@ -248,13 +255,17 @@ def __call__(self, data): element = plot.current_frame if element.interface is not ArrayColumns: element = plot.current_frame.clone(datatype=['array']) - ranges = plot.current_ranges # Slice element to current ranges xdim, ydim = element.dimensions(label=True)[0:2] sliced = element.select(**{xdim: (xstart, xend), ydim: (ystart, yend)}) + if self.compute_ranges: + ranges = {d: element.range(d) for d in element.dimensions()} + else: + ranges = plot.current_ranges + # Avoid randomizing if possible (expensive) if len(sliced) > self.max_samples: # Randomize element samples and slice to region @@ -381,6 +392,11 @@ def _chain_callbacks(self, plot, cb_obj, callbacks): else: cb_obj.callback = callback + @property + def downsample(self): + return any(isinstance(v, DownsampleCallback) + for _ , v in self.get_param_values()) + def __call__(self, plot): """ diff --git a/holoviews/plotting/bokeh/chart.py b/holoviews/plotting/bokeh/chart.py index 79f8ad16bf..13f8ec7d5e 100644 --- a/holoviews/plotting/bokeh/chart.py +++ b/holoviews/plotting/bokeh/chart.py @@ -41,7 +41,7 @@ class PointPlot(ElementPlot): _plot_method = 'scatter' - def get_data(self, element, ranges=None): + def get_data(self, element, ranges=None, empty=False): style = self.style[self.cyclic_index] dims = element.dimensions(label=True) @@ -52,22 +52,29 @@ def get_data(self, element, ranges=None): if self.color_index < len(dims) and cmap: map_key = 'color_' + dims[self.color_index] mapping['color'] = map_key - cmap = get_cmap(cmap) - colors = element.dimension_values(self.color_index) - crange = ranges.get(dims[self.color_index], None) - data[map_key] = map_colors(colors, crange, cmap) + if empty: + data[map_key] = [] + else: + cmap = get_cmap(cmap) + colors = element.dimension_values(self.color_index) + crange = ranges.get(dims[self.color_index], None) + data[map_key] = map_colors(colors, crange, cmap) if self.size_index < len(dims): map_key = 'size_' + dims[self.size_index] mapping['size'] = map_key - ms = style.get('size', 1) - sizes = element.dimension_values(self.size_index) - data[map_key] = compute_sizes(sizes, self.size_fn, - self.scaling_factor, ms) - data[dims[0]] = element.dimension_values(0) - data[dims[1]] = element.dimension_values(1) + if empty: + data[map_key] = [] + else: + ms = style.get('size', 1) + sizes = element.dimension_values(self.size_index) + data[map_key] = compute_sizes(sizes, self.size_fn, + self.scaling_factor, ms) + + data[dims[0]] = [] if empty else element.dimension_values(0) + data[dims[1]] = [] if empty else element.dimension_values(1) if 'hover' in self.tools: for d in dims[2:]: - data[d] = element.dimension_values(d) + data[d] = [] if empty else element.dimension_values(d) return data, mapping @@ -84,12 +91,12 @@ def _init_glyph(self, plot, mapping, properties): color = mapping.pop('color', color) properties.pop('legend', None) unselected = Circle(**dict(properties, fill_color=unselect_color, **mapping)) - selected = Circle(**dict(properties, fill_color=color, **mapping)) - plot.add_glyph(source, selected, selection_glyph=selected, + glyph = Circle(**dict(properties, fill_color=color, **mapping)) + plot.add_glyph(source, selected, selection_glyph=glyph, nonselection_glyph=unselected) else: - getattr(plot, self._plot_method)(**dict(properties, **mapping)) - + glyph = getattr(plot, self._plot_method)(**dict(properties, **mapping)) + return glyph class CurvePlot(ElementPlot): @@ -97,11 +104,11 @@ class CurvePlot(ElementPlot): style_opts = ['color'] + line_properties _plot_method = 'line' - def get_data(self, element, ranges=None): + def get_data(self, element, ranges=None, empty=False): x = element.get_dimension(0).name y = element.get_dimension(1).name - return ({x: element.dimension_values(0), - y: element.dimension_values(1)}, + return ({x: [] if empty else element.dimension_values(0), + y: [] if empty else element.dimension_values(1)}, dict(x=x, y=y)) @@ -112,7 +119,9 @@ class SpreadPlot(PolygonPlot): def __init__(self, *args, **kwargs): super(SpreadPlot, self).__init__(*args, **kwargs) - def get_data(self, element, ranges=None): + def get_data(self, element, ranges=None, empty=None): + if empty: + return dict(xs=[], ys=[]), self._mapping xvals = element.dimension_values(0) mean = element.dimension_values(1) @@ -132,13 +141,16 @@ class HistogramPlot(ElementPlot): style_opts = ['color'] + line_properties + fill_properties _plot_method = 'quad' - def get_data(self, element, ranges=None): + def get_data(self, element, ranges=None, empty=None): mapping = dict(top='top', bottom=0, left='left', right='right') - data = dict(top=element.values, left=element.edges[:-1], - right=element.edges[1:]) + if empty: + data = dict(top=[], left=[], right=[]) + else: + data = dict(top=element.values, left=element.edges[:-1], + right=element.edges[1:]) if 'hover' in self.default_tools + self.tools: - data.update({d: element.dimension_values(d) + data.update({d: [] if empty else element.dimension_values(d) for d in element.dimensions(label=True)}) return (data, mapping) @@ -154,14 +166,17 @@ class SideHistogramPlot(HistogramPlot): show_title = param.Boolean(default=False, doc=""" Whether to display the plot title.""") - def get_data(self, element, ranges=None): + def get_data(self, element, ranges=None, empty=None): if self.invert_axes: mapping = dict(top='left', bottom='right', left=0, right='top') else: mapping = dict(top='top', bottom=0, left='left', right='right') - data = dict(top=element.values, left=element.edges[:-1], - right=element.edges[1:]) + if empty: + data = dict(top=[], left=[], right=[]) + else: + data = dict(top=element.values, left=element.edges[:-1], + right=element.edges[1:]) dim = element.get_dimension(0).name main = self.adjoined.main @@ -174,12 +189,11 @@ def get_data(self, element, ranges=None): if 'cmap' in style or 'palette' in style: cmap = get_cmap(style.get('cmap', style.get('palette', None))) - colors = map_colors(vals, main_range, cmap) - data['color'] = colors + data['color'] = [] if empty else map_colors(vals, main_range, cmap) mapping['fill_color'] = 'color' if 'hover' in self.default_tools + self.tools: - data.update({d: element.dimension_values(d) + data.update({d: [] if empty else element.dimension_values(d) for d in element.dimensions(label=True)}) return (data, mapping) @@ -191,7 +205,10 @@ class ErrorPlot(PathPlot): style_opts = ['color'] + line_properties - def get_data(self, element, ranges=None): + def get_data(self, element, ranges=None, empty=False): + if empty: + return dict(xs=[], ys=[]), self._mapping + data = element.array(dimensions=element.dimensions()[0:4]) err_xs = [] err_ys = [] @@ -231,12 +248,14 @@ def get_extents(self, element, ranges): return l, b, r, t - def get_data(self, element, ranges=None): + def get_data(self, element, ranges=None, empty=False): style = self.style[self.cyclic_index] dims = element.dimensions(label=True) pos = self.position - if len(dims) > 1: + if empty: + xs, ys, keys = [], [], [] + elif len(dims) > 1: xs, ys = zip(*(((x, x), (pos, pos+y)) for x, y in element.array())) mapping = dict(xs=dims[0], ys=dims[1]) @@ -248,7 +267,7 @@ def get_data(self, element, ranges=None): mapping = dict(xs=dims[0], ys='heights') keys = (dims[0], 'heights') - if self.invert_axes: keys = keys[::-1] + if not empty and self.invert_axes: keys = keys[::-1] data = dict(zip(keys, (xs, ys))) cmap = style.get('palette', style.get('cmap', None)) @@ -256,10 +275,14 @@ def get_data(self, element, ranges=None): cdim = dims[self.color_index] map_key = 'color_' + cdim mapping['color'] = map_key - cmap = get_cmap(cmap) - colors = element.dimension_values(cdim) - crange = ranges.get(cdim, None) - data[map_key] = map_colors(colors, crange, cmap) + if empty: + colors = [] + else: + cmap = get_cmap(cmap) + cvals = element.dimension_values(cdim) + crange = ranges.get(cdim, None) + colors = map_colors(cvals, crange, cmap) + data[map_key] = colors return data, mapping diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py index 42e3cebb0f..4c82bb2141 100644 --- a/holoviews/plotting/bokeh/element.py +++ b/holoviews/plotting/bokeh/element.py @@ -3,7 +3,7 @@ import numpy as np import bokeh import bokeh.plotting -from bokeh.models import Range, HoverTool +from bokeh.models import Range, HoverTool, Renderer from bokeh.models.tickers import Ticker, BasicTicker, FixedTicker from bokeh.models.widgets import Panel, Tabs from distutils.version import LooseVersion @@ -372,7 +372,7 @@ def _init_glyph(self, plot, mapping, properties): Returns a Bokeh glyph object. """ properties = mpl_to_bokeh(properties) - getattr(plot, self._plot_method)(**dict(properties, **mapping)) + return getattr(plot, self._plot_method)(**dict(properties, **mapping)) def _glyph_properties(self, plot, element, source, ranges): @@ -414,16 +414,19 @@ def initialize_plot(self, ranges=None, plot=None, plots=None, source=None): self._init_axes(plot) self.handles['plot'] = plot - data, mapping = self.get_data(element, ranges) + # Get data and initialize data source + empty = self.callbacks and self.callbacks.downsample + data, mapping = self.get_data(element, ranges, empty) if source is None: source = self._init_datasource(data) self.handles['source'] = source properties = self._glyph_properties(plot, element, source, ranges) - self._init_glyph(plot, mapping, properties) - glyph = plot.renderers[-1].glyph - self.handles['glyph_renderer'] = plot.renderers[-1] + glyph = self._init_glyph(plot, mapping, properties) self.handles['glyph'] = glyph + renderer = plot.renderers[-1] + if isinstance(renderer, Renderer): + self.handles['glyph_renderer'] = plot.renderers[-1] # Update plot, source and glyph self._update_glyph(glyph, properties, mapping) @@ -467,7 +470,8 @@ def update_frame(self, key, ranges=None, plot=None, element=None): plot = self.handles['plot'] source = self.handles['source'] - data, mapping = self.get_data(element, ranges) + empty = self.callbacks and self.callbacks.downsample + data, mapping = self.get_data(element, ranges, empty) self._update_datasource(source, data) self.style = self.lookup_options(element, 'style') @@ -636,10 +640,12 @@ def _init_tools(self, element): Processes the list of tools to be supplied to the plot. """ tools = [] - for i, subplot in enumerate(self.subplots.values()): - el = element.get(i) - if el is not None: - tools.extend(subplot._init_tools(el)) + for key, subplot in self.subplots.items(): + try: + el = element[key] + except: + el = None + tools.extend(subplot._init_tools(el)) return list(set(tools)) diff --git a/holoviews/plotting/bokeh/path.py b/holoviews/plotting/bokeh/path.py index 4f63cf6abd..cdf192a6c0 100644 --- a/holoviews/plotting/bokeh/path.py +++ b/holoviews/plotting/bokeh/path.py @@ -10,9 +10,9 @@ class PathPlot(ElementPlot): _plot_method = 'multi_line' _mapping = dict(xs='xs', ys='ys') - def get_data(self, element, ranges=None): - xs = [path[:, 0] for path in element.data] - ys = [path[:, 1] for path in element.data] + def get_data(self, element, ranges=None, empty=False): + xs = [] if empty else [path[:, 0] for path in element.data] + ys = [] if empty else [path[:, 1] for path in element.data] return dict(xs=xs, ys=ys), self._mapping @@ -21,9 +21,9 @@ class PolygonPlot(PathPlot): style_opts = ['color', 'cmap', 'palette'] + line_properties + fill_properties _plot_method = 'patches' - def get_data(self, element, ranges=None): - xs = [path[:, 0] for path in element.data] - ys = [path[:, 1] for path in element.data] + def get_data(self, element, ranges=None, empty=False): + xs = [] if empty else [path[:, 0] for path in element.data] + ys = [] if empty else [path[:, 1] for path in element.data] data = dict(xs=xs, ys=ys) style = self.style[self.cyclic_index] @@ -33,6 +33,6 @@ def get_data(self, element, ranges=None): cmap = get_cmap(cmap) colors = map_colors(np.array([element.level]), ranges[element.vdims[0].name], cmap) mapping['color'] = 'color' - data['color'] = list(colors)*len(element.data) + data['color'] = [] if empty else list(colors)*len(element.data) return data, mapping diff --git a/holoviews/plotting/bokeh/plot.py b/holoviews/plotting/bokeh/plot.py index b625662bf4..f8101837f8 100644 --- a/holoviews/plotting/bokeh/plot.py +++ b/holoviews/plotting/bokeh/plot.py @@ -37,7 +37,7 @@ class BokehPlot(DimensionedPlot): renderer = BokehRenderer - def get_data(self, element, ranges=None): + def get_data(self, element, ranges=None, empty=False): """ Returns the data from an element in the appropriate format for initializing or updating a ColumnDataSource and a dictionary diff --git a/holoviews/plotting/bokeh/raster.py b/holoviews/plotting/bokeh/raster.py index 6ff3fd884c..a5dc7408ec 100644 --- a/holoviews/plotting/bokeh/raster.py +++ b/holoviews/plotting/bokeh/raster.py @@ -20,7 +20,7 @@ def __init__(self, *args, **kwargs): self.invert_yaxis = not self.invert_yaxis - def get_data(self, element, ranges=None): + def get_data(self, element, ranges=None, empty=False): img = element.data if isinstance(element, Image): l, b, r, t = element.bounds.lbrt() @@ -29,9 +29,14 @@ def get_data(self, element, ranges=None): dh = t-b if type(element) is Raster: b = t + mapping = dict(image='image', x='x', y='y', dw='dw', dh='dh') - return (dict(image=[np.flipud(img)], x=[l], - y=[b], dw=[r-l], dh=[dh]), mapping) + if empty: + data = dict(image=[], x=[], y=[], dw=[], dh=[]) + else: + data = dict(image=[np.flipud(img)], x=[l], + y=[b], dw=[r-l], dh=[dh]) + return (data, mapping) def _glyph_properties(self, plot, element, source, ranges): @@ -65,11 +70,13 @@ class RGBPlot(RasterPlot): style_opts = [] _plot_method = 'image_rgba' - def get_data(self, element, ranges=None): - data, mapping = super(RGBPlot, self).get_data(element, ranges) + def get_data(self, element, ranges=None, empty=False): + data, mapping = super(RGBPlot, self).get_data(element, ranges, empty) img = data['image'][0] - if img.ndim == 3: + if empty: + data['image'] = [] + elif img.ndim == 3: if img.shape[2] == 3: # alpha channel not included alpha = np.ones(img.shape[:2]) if img.dtype.name == 'uint8': @@ -102,14 +109,18 @@ def _axes_props(self, plots, subplots, element, ranges): return ('auto', 'auto'), labels, plot_ranges - def get_data(self, element, ranges=None): - style = self.style[self.cyclic_index] - cmap = style.get('palette', style.get('cmap', None)) - cmap = get_cmap(cmap) + def get_data(self, element, ranges=None, empty=False): x, y, z = element.dimensions(label=True) - zvals = np.rot90(element.raster, 3).flatten() - colors = map_colors(zvals, ranges[z], cmap) - xvals, yvals = [[str(v) for v in element.dimension_values(i)] - for i in range(2)] - return ({x: xvals, y: yvals, z: zvals, 'color': colors}, - {'x': x, 'y': y, 'fill_color': 'color', 'height': 1, 'width': 1}) + if empty: + data = {x: [], y: [], z: [], 'color': []} + else: + style = self.style[self.cyclic_index] + cmap = style.get('palette', style.get('cmap', None)) + cmap = get_cmap(cmap) + zvals = np.rot90(element.raster, 3).flatten() + colors = map_colors(zvals, ranges[z], cmap) + xvals, yvals = [[str(v) for v in element.dimension_values(i)] + for i in range(2)] + data = {x: xvals, y: yvals, z: zvals, 'color': colors} + + return (data, {'x': x, 'y': y, 'fill_color': 'color', 'height': 1, 'width': 1}) diff --git a/holoviews/plotting/bokeh/tabular.py b/holoviews/plotting/bokeh/tabular.py index 394e58c00c..835408ba59 100644 --- a/holoviews/plotting/bokeh/tabular.py +++ b/holoviews/plotting/bokeh/tabular.py @@ -16,9 +16,9 @@ class TablePlot(BokehPlot, GenericElementPlot): style_opts = ['row_headers', 'selectable', 'editable', 'sortable', 'fit_columns', 'width', 'height'] - def get_data(self, element, ranges=None): + def get_data(self, element, ranges=None, empty=False): dims = element.dimensions() - return ({d.name: element.dimension_values(d) for d in dims}, + return ({d.name: [] if empty else element.dimension_values(d) for d in dims}, {d.name: d.name for d in dims})