diff --git a/.travis.yml b/.travis.yml index dc4c966dad..fd68cdf5eb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -142,7 +142,7 @@ jobs: - &doc_build <<: *default stage: docs_dev - env: DESC="docs" CHANS_DEV="-c pyviz/label/dev" HV_DOC_GALLERY='false' HV_REQUIREMENTS="doc" + env: DESC="docs" CHANS_DEV="-c pyviz/label/dev" HV_DOC_GALLERY='false' HV_DOC_REF_GALLERY='false' HV_REQUIREMENTS="doc" PANEL_EMBED='true' PANEL_EMBED_JSON='true' PANEL_EMBED_JSON_PREFIX='json' script: - bokeh sampledata - conda install -c conda-forge awscli @@ -171,7 +171,7 @@ jobs: - <<: *gallery_build stage: gallery_daily - env: DESC="gallery" CHANS_DEV="-c pyviz/label/dev" HV_DOC_GALLERY='true' HV_REQUIREMENTS="doc" BUCKET="build." + env: DESC="gallery" CHANS_DEV="-c pyviz/label/dev" HV_DOC_GALLERY='false' HV_DOC_REF_GALLERY='false' HV_REQUIREMENTS="doc" BUCKET="build." - <<: *doc_build stage: docs diff --git a/doc/nbpublisher b/doc/nbpublisher index 90ed382834..0ffe6a0fde 160000 --- a/doc/nbpublisher +++ b/doc/nbpublisher @@ -1 +1 @@ -Subproject commit 90ed3828347afd8bb93cd3183733fedb26a214a4 +Subproject commit 0ffe6a0fde289cffe51efa3776565bfd75b5633d diff --git a/examples/user_guide/15-Large_Data.ipynb b/examples/user_guide/15-Large_Data.ipynb index 755c895f3b..2569ec7612 100644 --- a/examples/user_guide/15-Large_Data.ipynb +++ b/examples/user_guide/15-Large_Data.ipynb @@ -159,7 +159,42 @@ "source": [ "In all three of the above plots, `rasterize()` is being called to aggregate the data (a large set of x,y locations) into a rectangular grid, with each grid cell counting up the number of points that fall into it. In the plot on the left, only `rasterize()` is done, and the resulting numeric array of counts is passed to Bokeh for colormapping. Bokeh can then use dynamic (client-side, browser-based) operations in JavaScript, allowing users to have dynamic control over even static HTML plots. For instance, in this case, users can use the Box Select tool and select a range of the histogram shown, dynamically remapping the colors used in the plot to cover the selected range.\n", "\n", - "The other two plots should be identical. In both cases, the numerical array output of `rasterize()` is mapped into RGB colors by Datashader itself, in Python (\"server-side\"), which allows special Datashader computations like the histogram-equalization in the above plots and the \"spreading\" discussed below. The `shade()` and `datashade()` operations accept a `cmap` argument that lets you control the colormap used, which can be selected to match the HoloViews/Bokeh `cmap` option but is strictly independent of it. See ``hv.help(rasterize)``, ``hv.help(shade)``, and ``hv.help(datashade)`` for options that can be selected, and the [Datashader web site](http://datashader.org) for all the details. You can also try the lower-level ``hv.aggregate()`` (for points and lines) and ``hv.regrid()` (for image/raster data) operations, which may provide more control." + "The other two plots should be identical. In both cases, the numerical array output of `rasterize()` is mapped into RGB colors by Datashader itself, in Python (\"server-side\"), which allows special Datashader computations like the histogram-equalization in the above plots and the \"spreading\" discussed below. The `shade()` and `datashade()` operations accept a `cmap` argument that lets you control the colormap used, which can be selected to match the HoloViews/Bokeh `cmap` option but is strictly independent of it. See ``hv.help(rasterize)``, ``hv.help(shade)``, and ``hv.help(datashade)`` for options that can be selected, and the [Datashader web site](http://datashader.org) for all the details. The lower-level `aggregate()` and `regrid()` give more control over how the data is aggregated.\n", + "\n", + "Since datashader only sends the data currently in view to the plotting backend, the default behavior is to rescale colormap to the range of the visible data as the zoom level changes. This behavior may not be desirable when working with images; to instead use a fixed colormap range, the `clim` parameter can be passed to the `bokeh` backend via the `opts()` method. Note that this approach works with `rasterize()` where the colormapping is done by the `bokeh` backend. With `datashade()`, the colormapping is done with the `shade()` function which takes a `clims` parameter directly instead of passing additional parameters to the backend via `opts()`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "n = 10_000\n", + "\n", + "# Strong signal on top\n", + "rs = np.random.RandomState(101010)\n", + "x = rs.pareto(n, n)\n", + "y = x + rs.standard_normal(n)\n", + "img1, *_ = np.histogram2d(x, y, bins=60)\n", + "\n", + "# Weak signal in the middle\n", + "x2 = rs.standard_normal(n)\n", + "y2 = 5 * x + 10 * rs.standard_normal(n)\n", + "img2, *_ = np.histogram2d(x2, y2, bins=60)\n", + "\n", + "img = img1 + img2\n", + "hv_img = hv.Image(img).opts(active_tools=['wheel_zoom'])\n", + "auto_scale_grid = rasterize(hv_img).opts(title='Automatic color range rescaling')\n", + "fixed_scale_grid = rasterize(hv_img).opts(title='Fixed color range', clim=(img.min(), img.max()))\n", + "auto_scale_grid + fixed_scale_grid; # Output supressed and gif shown below instead" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "![](http://assets.holoviews.org/gifs/guides/user_guide/Large_Data/rasterize_color_range.gif)" ] }, { diff --git a/examples/user_guide/17-Dashboards.ipynb b/examples/user_guide/17-Dashboards.ipynb index f2fdcd7229..5ecadaa492 100644 --- a/examples/user_guide/17-Dashboards.ipynb +++ b/examples/user_guide/17-Dashboards.ipynb @@ -155,7 +155,7 @@ "\n", "smoothed = rolling(stock_dmap, rolling_window=rolling_window.param.value)\n", "\n", - "pn.Row(pn.WidgetBox('## Stock Explorer', symbol, variable, window), smoothed.opts(width=500))" + "pn.Row(pn.WidgetBox('## Stock Explorer', symbol, variable, rolling_window), smoothed.opts(width=500))" ] }, { diff --git a/examples/user_guide/Plots_and_Renderers.ipynb b/examples/user_guide/Plots_and_Renderers.ipynb index 71d7ed608b..280d593def 100644 --- a/examples/user_guide/Plots_and_Renderers.ipynb +++ b/examples/user_guide/Plots_and_Renderers.ipynb @@ -374,7 +374,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Rendering plots containing ``HoloMap`` and ``DynamicMap`` objects will automatically generate a widget class instance, which you can get a handle on with the ``get_widget`` method:" + "Rendering plots containing ``HoloMap`` and ``DynamicMap`` objects will automatically generate a Panel HoloViews pane which can be rendered in the notebook, saved or rendered as a server app:" ] }, { @@ -401,7 +401,7 @@ "metadata": {}, "outputs": [], "source": [ - "display_html(renderer.static_html(holomap), raw=True)" + "html = renderer.static_html(holomap)" ] }, { diff --git a/holoviews/core/data/dask.py b/holoviews/core/data/dask.py index 41b93307fd..a47e68a176 100644 --- a/holoviews/core/data/dask.py +++ b/holoviews/core/data/dask.py @@ -44,7 +44,7 @@ class DaskInterface(PandasInterface): @classmethod def loaded(cls): - return 'dask' in sys.modules and 'pandas' in sys.modules + return 'dask.dataframe' in sys.modules and 'pandas' in sys.modules @classmethod def applies(cls, obj): diff --git a/holoviews/core/data/grid.py b/holoviews/core/data/grid.py index 97ea668a1a..983433dbb3 100644 --- a/holoviews/core/data/grid.py +++ b/holoviews/core/data/grid.py @@ -419,6 +419,8 @@ def groupby(cls, dataset, dim_names, container_type, group_type, **kwargs): @classmethod def key_select_mask(cls, dataset, values, ind): + if util.pd and values.dtype.kind == 'M': + ind = util.parse_datetime_selection(ind) if isinstance(ind, tuple): ind = slice(*ind) if isinstance(ind, get_array_types()): diff --git a/holoviews/core/data/interface.py b/holoviews/core/data/interface.py index ff5cd25cd5..8f8945a256 100644 --- a/holoviews/core/data/interface.py +++ b/holoviews/core/data/interface.py @@ -291,30 +291,35 @@ def select_mask(cls, dataset, selection): have been selected. """ mask = np.ones(len(dataset), dtype=np.bool) - for dim, k in selection.items(): - if isinstance(k, tuple): - k = slice(*k) + for dim, sel in selection.items(): + if isinstance(sel, tuple): + sel = slice(*sel) arr = cls.values(dataset, dim) - if isinstance(k, slice): + if util.isdatetime(arr) and util.pd: + try: + sel = util.parse_datetime_selection(sel) + except: + pass + if isinstance(sel, slice): with warnings.catch_warnings(): warnings.filterwarnings('ignore', r'invalid value encountered') - if k.start is not None: - mask &= k.start <= arr - if k.stop is not None: - mask &= arr < k.stop - elif isinstance(k, (set, list)): + if sel.start is not None: + mask &= sel.start <= arr + if sel.stop is not None: + mask &= arr < sel.stop + elif isinstance(sel, (set, list)): iter_slcs = [] - for ik in k: + for ik in sel: with warnings.catch_warnings(): warnings.filterwarnings('ignore', r'invalid value encountered') iter_slcs.append(arr == ik) mask &= np.logical_or.reduce(iter_slcs) - elif callable(k): - mask &= k(arr) + elif callable(sel): + mask &= sel(arr) else: - index_mask = arr == k + index_mask = arr == sel if dataset.ndims == 1 and np.sum(index_mask) == 0: - data_index = np.argmin(np.abs(arr - k)) + data_index = np.argmin(np.abs(arr - sel)) mask = np.zeros(len(dataset), dtype=np.bool) mask[data_index] = True else: diff --git a/holoviews/core/util.py b/holoviews/core/util.py index 966ff8ea4c..69b5976bba 100644 --- a/holoviews/core/util.py +++ b/holoviews/core/util.py @@ -1830,7 +1830,7 @@ def dt64_to_dt(dt64): Safely converts NumPy datetime64 to a datetime object. """ ts = (dt64 - np.datetime64('1970-01-01T00:00:00')) / np.timedelta64(1, 's') - return dt.datetime.utcfromtimestamp(ts) + return dt.datetime(1970,1,1,0,0,0) + dt.timedelta(seconds=ts) def is_nan(x): @@ -1910,6 +1910,31 @@ def date_range(start, end, length, time_unit='us'): return start+step/2.+np.arange(length)*step +def parse_datetime(date): + """ + Parses dates specified as string or integer or pandas Timestamp + """ + if pd is None: + raise ImportError('Parsing dates from strings requires pandas') + return pd.to_datetime(date).to_datetime64() + + +def parse_datetime_selection(sel): + """ + Parses string selection specs as datetimes. + """ + if isinstance(sel, basestring) or isdatetime(sel): + sel = parse_datetime(sel) + if isinstance(sel, slice): + if isinstance(sel.start, basestring) or isdatetime(sel.start): + sel = slice(parse_datetime(sel.start), sel.stop) + if isinstance(sel.stop, basestring) or isdatetime(sel.stop): + sel = slice(sel.start, parse_datetime(sel.stop)) + if isinstance(sel, (set, list)): + sel = [parse_datetime(v) if isinstance(v, basestring) else v for v in sel] + return sel + + def dt_to_int(value, time_unit='us'): """ Converts a datetime type to an integer with the supplied time unit. diff --git a/holoviews/element/chart.py b/holoviews/element/chart.py index 0f55363034..e5127091fb 100644 --- a/holoviews/element/chart.py +++ b/holoviews/element/chart.py @@ -45,21 +45,7 @@ class Chart(Dataset, Element2D): __abstract = True def __getitem__(self, index): - sliced = super(Chart, self).__getitem__(index) - if not isinstance(sliced, Chart): - return sliced - - if not isinstance(index, tuple): index = (index,) - ndims = len(self.extents)//2 - lower_bounds, upper_bounds = [None]*ndims, [None]*ndims - for i, slc in enumerate(index[:ndims]): - if isinstance(slc, slice): - lbound = self.extents[i] - ubound = self.extents[ndims:][i] - lower_bounds[i] = lbound if slc.start is None else slc.start - upper_bounds[i] = ubound if slc.stop is None else slc.stop - sliced.extents = tuple(lower_bounds+upper_bounds) - return sliced + return super(Chart, self).__getitem__(index) class Scatter(Chart): @@ -69,7 +55,7 @@ class Scatter(Chart): location along the x-axis while the first value dimension represents the location of the point along the y-axis. """ - + group = param.String(default='Scatter', constant=True) diff --git a/holoviews/plotting/bokeh/bokehwidgets.css b/holoviews/plotting/bokeh/bokehwidgets.css deleted file mode 100644 index 2976c6df9f..0000000000 --- a/holoviews/plotting/bokeh/bokehwidgets.css +++ /dev/null @@ -1,12 +0,0 @@ -div.bk-hbox { - display: flex; - justify-content: center; -} - -div.bk-hbox div.bk-plot { - padding: 8px; -} - -div.bk-hbox div.bk-data-table { - padding: 20px; -} diff --git a/holoviews/plotting/bokeh/bokehwidgets.js b/holoviews/plotting/bokeh/bokehwidgets.js deleted file mode 100644 index 09375a2355..0000000000 --- a/holoviews/plotting/bokeh/bokehwidgets.js +++ /dev/null @@ -1,68 +0,0 @@ -// Define Bokeh specific subclasses -function BokehSelectionWidget() { - SelectionWidget.apply(this, arguments); -} - -function BokehScrubberWidget() { - ScrubberWidget.apply(this, arguments); -} - -// Let them inherit from the baseclasses -BokehSelectionWidget.prototype = Object.create(SelectionWidget.prototype); -BokehScrubberWidget.prototype = Object.create(ScrubberWidget.prototype); - -// Define methods to override on widgets -var BokehMethods = { - update_cache : function(){ - for (var index in this.frames) { - this.frames[index] = JSON.parse(this.frames[index]); - } - }, - update : function(current){ - if (current === undefined) { - return; - } - var data = this.frames[current]; - if (data !== undefined) { - if (data.root in HoloViews.plot_index) { - var doc = HoloViews.plot_index[data.root].model.document; - } else { - var doc = Bokeh.index[data.root].model.document; - } - doc.apply_json_patch(data.content); - } - }, - init_comms: function() { - if (Bokeh.protocol !== undefined) { - this.receiver = new Bokeh.protocol.Receiver() - } else { - this.receiver = null; - } - return HoloViewsWidget.prototype.init_comms.call(this); - }, - process_msg : function(msg) { - if (this.plot_id in HoloViews.plot_index) { - var doc = HoloViews.plot_index[this.plot_id].model.document; - } else { - var doc = Bokeh.index[this.plot_id].model.document; - } - if (this.receiver === null) { return } - var receiver = this.receiver; - if (msg.buffers.length > 0) { - receiver.consume(msg.buffers[0].buffer) - } else { - receiver.consume(msg.content.data) - } - const comm_msg = receiver.message; - if ((comm_msg != null) && (doc != null)) { - doc.apply_json_patch(comm_msg.content, comm_msg.buffers) - } - } -} - -// Extend Bokeh widgets with backend specific methods -extend(BokehSelectionWidget.prototype, BokehMethods); -extend(BokehScrubberWidget.prototype, BokehMethods); - -window.HoloViews.BokehSelectionWidget = BokehSelectionWidget -window.HoloViews.BokehScrubberWidget = BokehScrubberWidget diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py index e95e8a6766..65c93f098a 100644 --- a/holoviews/plotting/bokeh/element.py +++ b/holoviews/plotting/bokeh/element.py @@ -13,8 +13,7 @@ from bokeh.models import Renderer, Title, Legend, ColorBar, tools from bokeh.models.axes import CategoricalAxis, DatetimeAxis from bokeh.models.formatters import ( - FuncTickFormatter, TickFormatter, PrintfTickFormatter, - MercatorTickFormatter) + FuncTickFormatter, TickFormatter, MercatorTickFormatter) from bokeh.models.mappers import ( LinearColorMapper, LogColorMapper, CategoricalColorMapper) from bokeh.models.ranges import Range1d, DataRange1d, FactorRange @@ -40,7 +39,7 @@ TOOL_TYPES, date_to_integer, decode_bytes, get_tab_title, glyph_order, py2js_tickformatter, recursive_model_update, theme_attr_json, cds_column_replace, hold_policy, match_dim_specs, - compute_layout_properties) + compute_layout_properties, wrap_formatter) @@ -631,18 +630,7 @@ def _axis_properties(self, axis, key, plot, dimension=None, formatter = self.xformatter if axis == 'x' else self.yformatter if formatter: - if isinstance(formatter, TickFormatter): - pass - elif isinstance(formatter, FunctionType): - msg = ('%sformatter could not be ' - 'converted to tick formatter. ' % axis) - jsfunc = py2js_tickformatter(formatter, msg) - if jsfunc: - formatter = FuncTickFormatter(code=jsfunc) - else: - formatter = None - else: - formatter = PrintfTickFormatter(format=formatter) + formatter = wrap_formatter(formatter, axis) if formatter is not None: axis_props['formatter'] = formatter elif FuncTickFormatter is not None and ax_mapping and isinstance(dimension, Dimension): @@ -1556,6 +1544,10 @@ class ColorbarPlot(ElementPlot): User-specified colorbar axis range limits for the plot, as a tuple (low,high). If specified, takes precedence over data and dimension ranges.""") + cformatter = param.ClassSelector( + default=None, class_=(util.basestring, TickFormatter, FunctionType), doc=""" + Formatter for ticks along the colorbar axis.""") + colorbar = param.Boolean(default=False, doc=""" Whether to display a colorbar.""") @@ -1608,6 +1600,10 @@ def _draw_colorbar(self, plot, color_mapper, prefix=''): if self.clabel: self.colorbar_opts.update({'title': self.clabel}) + + if self.cformatter is not None: + self.colorbar_opts.update({'formatter': wrap_formatter(self.cformatter, 'c')}) + opts = dict(cbar_opts['opts'], color_mapper=color_mapper, ticker=ticker, **self._colorbar_defaults) color_bar = ColorBar(**dict(opts, **self.colorbar_opts)) @@ -1871,10 +1867,6 @@ class OverlayPlot(GenericOverlayPlot, LegendPlot): 'margin', 'aspect', 'data_aspect', 'frame_width', 'frame_height', 'responsive'] - def __init__(self, overlay, **params): - super(OverlayPlot, self).__init__(overlay, **params) - self.set_root(params.pop('root', None)) - def _process_legend(self): plot = self.handles['plot'] subplots = self.traverse(lambda x: x, [lambda x: x is not self]) diff --git a/holoviews/plotting/bokeh/plot.py b/holoviews/plotting/bokeh/plot.py index c9898fee90..8ea02dd1d2 100644 --- a/holoviews/plotting/bokeh/plot.py +++ b/holoviews/plotting/bokeh/plot.py @@ -1,20 +1,18 @@ from __future__ import absolute_import, division, unicode_literals -import json from itertools import groupby from collections import defaultdict import numpy as np import param -from bokeh.io import curdoc from bokeh.layouts import gridplot from bokeh.models import (ColumnDataSource, Column, Row, Div) from bokeh.models.widgets import Panel, Tabs from ...core import ( OrderedDict, Store, AdjointLayout, NdLayout, Layout, Empty, - GridSpace, HoloMap, Element, DynamicMap + GridSpace, HoloMap, Element ) from ...core.options import SkipRendering from ...core.util import ( @@ -24,7 +22,8 @@ from ..links import Link from ..plot import ( DimensionedPlot, GenericCompositePlot, GenericLayoutPlot, - GenericElementPlot, GenericOverlayPlot, CallbackPlot + GenericElementPlot, GenericOverlayPlot, GenericAdjointLayoutPlot, + CallbackPlot ) from ..util import attach_streams, displayable, collate from .callbacks import LinkCallback @@ -78,50 +77,10 @@ class BokehPlot(DimensionedPlot, CallbackPlot): backend = 'bokeh' - @property - def document(self): - return self._document - @property def id(self): return self.root.ref['id'] if self.root else None - @property - def root(self): - if self._root: - return self._root - elif 'plot' in self.handles and self.top_level: - return self.state - else: - return None - - @document.setter - def document(self, doc): - if (doc and hasattr(doc, 'on_session_destroyed') and - self.root is self.handles.get('plot') and not isinstance(self, AdjointLayoutPlot)): - doc.on_session_destroyed(self._session_destroy) - if self._document: - if isinstance(self._document._session_destroyed_callbacks, set): - self._document._session_destroyed_callbacks.discard(self._session_destroy) - else: - self._document._session_destroyed_callbacks.pop(self._session_destroy, None) - - self._document = doc - if self.subplots: - for plot in self.subplots.values(): - if plot is not None: - plot.document = doc - - - def _session_destroy(self, session_context): - self.cleanup() - - def __init__(self, *args, **params): - root = params.pop('root', None) - super(BokehPlot, self).__init__(*args, **params) - self._document = None - self._root = root - def get_data(self, element, ranges, style): """ @@ -132,46 +91,6 @@ def get_data(self, element, ranges, style): """ raise NotImplementedError - def refresh(self, **kwargs): - if self.renderer.mode == 'server' and curdoc() is not self.document: - # If we do not have the Document lock, schedule refresh as callback - self.document.add_next_tick_callback(self.refresh) - else: - super(BokehPlot, self).refresh(**kwargs) - - def push(self): - """ - Pushes updated plot data via the Comm. - """ - if self.renderer.mode == 'server': - return - if self.comm is None: - raise Exception('Renderer does not have a comm.') - - if self._root and 'embedded' in self._root.tags: - # Allows external libraries to prevent comm updates - return - - msg = self.renderer.diff(self, binary=True) - if msg is None: - return - self.comm.send(msg.header_json) - self.comm.send(msg.metadata_json) - self.comm.send(msg.content_json) - for header, payload in msg.buffers: - self.comm.send(json.dumps(header)) - self.comm.send(buffers=[payload]) - - - def set_root(self, root): - """ - Sets the root model on all subplots. - """ - if root is None: - return - for plot in self.traverse(lambda x: x): - plot._root = root - def _init_datasource(self, data): """ @@ -291,9 +210,6 @@ def cleanup(self): if get_method_owner(subscriber) not in plots ] - if self.comm and self.root is self.handles.get('plot'): - self.comm.close() - def _fontsize(self, key, label='fontsize', common=True): """ @@ -538,10 +454,7 @@ def __init__(self, layout, ranges=None, layout_num=1, keys=None, **params): ranges=ranges, keys=keys, **params) self.cols, self.rows = layout.shape self.subplots, self.layout = self._create_subplots(layout, ranges) - self.set_root(params.pop('root', None)) if self.top_level: - self.comm = self.init_comm() - self.traverse(lambda x: setattr(x, 'comm', self.comm)) self.traverse(lambda x: attach_streams(self, x.hmap, 2), [GenericElementPlot]) if 'axis_offset' in params: @@ -749,10 +662,7 @@ class LayoutPlot(CompositePlot, GenericLayoutPlot): def __init__(self, layout, keys=None, **params): super(LayoutPlot, self).__init__(layout, keys=keys, **params) self.layout, self.subplots, self.paths = self._init_layout(layout) - self.set_root(params.pop('root', None)) if self.top_level: - self.comm = self.init_comm() - self.traverse(lambda x: setattr(x, 'comm', self.comm)) self.traverse(lambda x: attach_streams(self, x.hmap, 2), [GenericElementPlot]) @@ -1031,11 +941,7 @@ def update_frame(self, key, ranges=None): -class AdjointLayoutPlot(BokehPlot): - - layout_dict = {'Single': {'positions': ['main']}, - 'Dual': {'positions': ['main', 'right']}, - 'Triple': {'positions': ['main', 'right', 'top']}} +class AdjointLayoutPlot(BokehPlot, GenericAdjointLayoutPlot): registry = {} diff --git a/holoviews/plotting/bokeh/renderer.py b/holoviews/plotting/bokeh/renderer.py index 69ac991ae2..562fda32a2 100644 --- a/holoviews/plotting/bokeh/renderer.py +++ b/holoviews/plotting/bokeh/renderer.py @@ -7,13 +7,12 @@ import param import bokeh -from pyviz_comms import bokeh_msg_handler -from param.parameterized import bothmethod from bokeh.document import Document from bokeh.io import curdoc from bokeh.models import Model -from bokeh.protocol import Protocol from bokeh.themes.theme import Theme +from panel.io.notebook import render_mimebundle +from param.parameterized import bothmethod from panel.pane import HoloViews, Viewable @@ -32,60 +31,32 @@ class BokehRenderer(Renderer): - theme = param.ClassSelector(default=default_theme, class_=(Theme, str), - allow_None=True, doc=""" - The applicable Bokeh Theme object (if any).""") - backend = param.String(default='bokeh', doc="The backend name.") + fig = param.ObjectSelector(default='auto', objects=['html', 'json', 'auto', 'png'], doc=""" Output render format for static figures. If None, no figure rendering will occur. """) holomap = param.ObjectSelector(default='auto', - objects=['widgets', 'scrubber', 'server', + objects=['widgets', 'scrubber', None, 'auto'], doc=""" Output render multi-frame (typically animated) format. If None, no multi-frame rendering will occur.""") - mode = param.ObjectSelector(default='default', - objects=['default', 'server'], doc=""" - Whether to render the object in regular or server mode. In server - mode a bokeh Document will be returned which can be served as a - bokeh server app. By default renders all output is rendered to HTML.""") - - # Defines the valid output formats for each mode. - mode_formats = {'fig': {'default': ['html', 'json', 'auto', 'png'], - 'server': ['html', 'json', 'auto']}, - 'holomap': {'default': ['widgets', 'scrubber', 'auto', None], - 'server': ['server', 'auto', None]}} + theme = param.ClassSelector(default=default_theme, class_=(Theme, str), + allow_None=True, doc=""" + The applicable Bokeh Theme object (if any).""") webgl = param.Boolean(default=False, doc=""" Whether to render plots with WebGL if available""") - _loaded = False - - # Define the handler for updating bokeh plots - comm_msg_handler = bokeh_msg_handler + # Defines the valid output formats for each mode. + mode_formats = {'fig': ['html', 'auto', 'png'], + 'holomap': ['widgets', 'scrubber', 'auto', None]} - def __call__(self, obj, fmt=None, doc=None): - """ - Render the supplied HoloViews component using the appropriate - backend. The output is not a file format but a suitable, - in-memory byte stream together with any suitable metadata. - """ - plot, fmt = self._validate(obj, fmt, doc=doc) - info = {'file-ext': fmt, 'mime_type': MIME_TYPES[fmt]} - - if self.mode == 'server': - return self.server_doc(plot, doc), info - elif isinstance(plot, Viewable): - return plot, info - elif fmt == 'png': - png = self._figure_data(plot, fmt=fmt, doc=doc) - return png, info - elif fmt == 'json': - return self.diff(plot), info + _loaded = False + _render_with_panel = True @bothmethod def _save_prefix(self_or_cls, ext): @@ -100,52 +71,13 @@ def get_plot(self_or_cls, obj, doc=None, renderer=None, **kwargs): Allows supplying a document attach the plot to, useful when combining the bokeh model with another plot. """ - if doc is None: - doc = Document() if self_or_cls.notebook_context else curdoc() - - if self_or_cls.notebook_context: - curdoc().theme = self_or_cls.theme - doc.theme = self_or_cls.theme - plot = super(BokehRenderer, self_or_cls).get_plot(obj, renderer, **kwargs) - plot.document = doc + plot = super(BokehRenderer, self_or_cls).get_plot(obj, doc, renderer, **kwargs) + if plot.document is None: + plot.document = Document() if self_or_cls.notebook_context else curdoc() + plot.document.theme = self_or_cls.theme return plot - @bothmethod - def app(self_or_cls, plot, show=False, new_window=False, websocket_origin=None, port=0): - """ - Creates a bokeh app from a HoloViews object or plot. By - default simply attaches the plot to bokeh's curdoc and returns - the Document, if show option is supplied creates an - Application instance and displays it either in a browser - window or inline if notebook extension has been loaded. Using - the new_window option the app may be displayed in a new - browser tab once the notebook extension has been loaded. A - websocket origin is required when launching from an existing - tornado server (such as the notebook) and it is not on the - default port ('localhost:8888'). - """ - pane = HoloViews(plot) - if new_window: - return pane._get_server(port, websocket_origin, show=show) - else: - kwargs = {'notebook_url': websocket_origin} if websocket_origin else {} - return pane.app(port, **kwargs) - - @bothmethod - def server_doc(self_or_cls, obj, doc=None): - """ - Get a bokeh Document with the plot attached. May supply - an existing doc, otherwise bokeh.io.curdoc() is used to - attach the plot to the global document instance. - """ - return HoloViews(obj).server_doc(doc) - - - def components(self, obj, fmt=None, comm=True, **kwargs): - return super(BokehRenderer, self).components(obj, fmt, comm, **kwargs) - - def _figure_data(self, plot, fmt, doc=None, as_script=False, **kwargs): """ Given a plot instance, an output format and an optional bokeh @@ -181,7 +113,7 @@ def _figure_data(self, plot, fmt, doc=None, as_script=False, **kwargs): src = HTML_TAGS['base64'].format(mime_type=mime_type, b64=b64) div = tag.format(src=src, mime_type=mime_type, css='') else: - raise ValueError('Unsupported format: {fmt}'.format(fmt=fmt)) + div = render_mimebundle(plot.state, doc, plot.comm)[0]['text/html'] plot.document = doc if as_script: @@ -190,19 +122,6 @@ def _figure_data(self, plot, fmt, doc=None, as_script=False, **kwargs): return data - def diff(self, plot, binary=True): - """ - Returns a json diff required to update an existing plot with - the latest plot data. - """ - events = list(plot.document._held_events) - if not events: - return None - msg = Protocol("1.0").create("PATCH-DOC", events, use_buffers=binary) - plot.document._held_events = [] - return msg - - @classmethod def plot_options(cls, obj, percent_size): """ diff --git a/holoviews/plotting/bokeh/util.py b/holoviews/plotting/bokeh/util.py index f802082b9b..128789da58 100644 --- a/holoviews/plotting/bokeh/util.py +++ b/holoviews/plotting/bokeh/util.py @@ -5,6 +5,7 @@ import sys import calendar import datetime as dt +from types import FunctionType from collections import defaultdict from contextlib import contextmanager @@ -19,6 +20,7 @@ from bokeh.layouts import WidgetBox, Row, Column from bokeh.models import tools from bokeh.models import Model, ToolbarBox, FactorRange, Range1d, Plot, Spacer, CustomJS, GridBox +from bokeh.models.formatters import FuncTickFormatter, TickFormatter, PrintfTickFormatter from bokeh.models.widgets import DataTable, Tabs, Div from bokeh.plotting import Figure from bokeh.themes.theme import Theme @@ -898,3 +900,23 @@ def match_dim_specs(specs1, specs2): if s1 != s2: return False return True + + +def wrap_formatter(formatter, axis): + """ + Wraps formatting function or string in + appropriate bokeh formatter type. + """ + if isinstance(formatter, TickFormatter): + pass + elif isinstance(formatter, FunctionType): + msg = ('%sformatter could not be ' + 'converted to tick formatter. ' % axis) + jsfunc = py2js_tickformatter(formatter, msg) + if jsfunc: + formatter = FuncTickFormatter(code=jsfunc) + else: + formatter = None + else: + formatter = PrintfTickFormatter(format=formatter) + return formatter diff --git a/holoviews/plotting/mpl/chart3d.py b/holoviews/plotting/mpl/chart3d.py index 9f71e6c1f0..7213230158 100644 --- a/holoviews/plotting/mpl/chart3d.py +++ b/holoviews/plotting/mpl/chart3d.py @@ -131,6 +131,8 @@ def get_data(self, element, ranges, style): self._compute_styles(element, ranges, style) with abbreviated_exception(): style = self._apply_transforms(element, ranges, style) + if style.get('edgecolors') == 'none': + style.pop('edgecolors') return (xs, ys, zs), style, {} def update_handles(self, key, axis, element, ranges, style): diff --git a/holoviews/plotting/mpl/element.py b/holoviews/plotting/mpl/element.py index f42eec7e74..f24ce9c09e 100644 --- a/holoviews/plotting/mpl/element.py +++ b/holoviews/plotting/mpl/element.py @@ -663,6 +663,10 @@ class ColorbarPlot(ElementPlot): User-specified colorbar axis range limits for the plot, as a tuple (low,high). If specified, takes precedence over data and dimension ranges.""") + cformatter = param.ClassSelector( + default=None, class_=(util.basestring, ticker.Formatter, FunctionType), doc=""" + Formatter for ticks along the colorbar axis.""") + colorbar = param.Boolean(default=False, doc=""" Whether to draw a colorbar.""") @@ -770,6 +774,7 @@ def _draw_colorbar(self, element=None, dimension=None, redraw=True): cax = fig.add_axes([l+w+padding+(scaled_w+padding+w*0.15)*offset, b, scaled_w, h]) cbar = fig.colorbar(artist, cax=cax, ax=axis, extend=self._cbar_extend) + self._set_axis_formatter(cbar.ax.yaxis, dimension, self.cformatter) self._adjust_cbar(cbar, label, dimension) self.handles['cax'] = cax self.handles['cbar'] = cbar diff --git a/holoviews/plotting/mpl/mplwidgets.js b/holoviews/plotting/mpl/mplwidgets.js deleted file mode 100644 index 21a3486daf..0000000000 --- a/holoviews/plotting/mpl/mplwidgets.js +++ /dev/null @@ -1,40 +0,0 @@ -// Define MPL specific subclasses -function MPLSelectionWidget() { - SelectionWidget.apply(this, arguments); -} - -function MPLScrubberWidget() { - ScrubberWidget.apply(this, arguments); -} - -// Let them inherit from the baseclasses -MPLSelectionWidget.prototype = Object.create(SelectionWidget.prototype); -MPLScrubberWidget.prototype = Object.create(ScrubberWidget.prototype); - -// Define methods to override on widgets -var MPLMethods = { - init_slider : function(init_val){ - if(this.load_json) { - this.from_json() - } else { - this.update_cache(); - } - if (this.dynamic | !this.cached | (this.current_vals === undefined)) { - this.update(0) - } else { - this.set_frame(this.current_vals[0], 0) - } - }, - process_msg : function(msg) { - var data = msg.content.data; - this.frames[this.current] = data; - this.update_cache(true); - this.update(this.current); - } -} -// Extend MPL widgets with backend specific methods -extend(MPLSelectionWidget.prototype, MPLMethods); -extend(MPLScrubberWidget.prototype, MPLMethods); - -window.HoloViews.MPLSelectionWidget = MPLSelectionWidget -window.HoloViews.MPLScrubberWidget = MPLScrubberWidget diff --git a/holoviews/plotting/mpl/plot.py b/holoviews/plotting/mpl/plot.py index 442075e06b..024d94fe0b 100644 --- a/holoviews/plotting/mpl/plot.py +++ b/holoviews/plotting/mpl/plot.py @@ -17,7 +17,7 @@ from ...core.options import Store, SkipRendering from ...core.util import int_to_roman, int_to_alpha, basestring, wrap_tuple_streams from ..plot import (DimensionedPlot, GenericLayoutPlot, GenericCompositePlot, - GenericElementPlot) + GenericElementPlot, GenericAdjointLayoutPlot) from ..util import attach_streams, collate, displayable from .util import compute_ratios, fix_aspect, mpl_version @@ -350,8 +350,6 @@ def __init__(self, layout, axis=None, create_axes=True, ranges=None, self.subplots, self.subaxes, self.layout = self._create_subplots(layout, axis, ranges, create_axes) if self.top_level: - self.comm = self.init_comm() - self.traverse(lambda x: setattr(x, 'comm', self.comm)) self.traverse(lambda x: attach_streams(self, x.hmap, 2), [GenericElementPlot]) @@ -613,14 +611,9 @@ def _adjust_subplots(self, axis, subaxes): -class AdjointLayoutPlot(MPLPlot): +class AdjointLayoutPlot(MPLPlot, GenericAdjointLayoutPlot): """ - LayoutPlot allows placing up to three Views in a number of - predefined and fixed layouts, which are defined by the layout_dict - class attribute. This allows placing subviews next to a main plot - in either a 'top' or 'right' position. - - Initially, a LayoutPlot computes an appropriate layout based for + Initially, a AdjointLayoutPlot computes an appropriate layout based for the number of Views in the AdjointLayout object it has been given, but when embedded in a NdLayout, it can recompute the layout to match the number of rows and columns as part of a larger grid. @@ -772,8 +765,6 @@ def __init__(self, layout, keys=None, **params): with mpl.rc_context(rc=self.fig_rcparams): self.subplots, self.subaxes, self.layout = self._compute_gridspec(layout) if self.top_level: - self.comm = self.init_comm() - self.traverse(lambda x: setattr(x, 'comm', self.comm)) self.traverse(lambda x: attach_streams(self, x.hmap, 2), [GenericElementPlot]) diff --git a/holoviews/plotting/mpl/raster.py b/holoviews/plotting/mpl/raster.py index cbbc8b9e0a..bf30cb4bc4 100644 --- a/holoviews/plotting/mpl/raster.py +++ b/holoviews/plotting/mpl/raster.py @@ -225,8 +225,6 @@ def __init__(self, layout, keys=None, dimensions=None, create_axes=False, ranges if top_level: dimensions, keys = traversal.unique_dimkeys(layout) MPLPlot.__init__(self, dimensions=dimensions, keys=keys, **params) - if top_level: - self.comm = self.init_comm() self.layout = layout self.cyclic_index = 0 diff --git a/holoviews/plotting/mpl/renderer.py b/holoviews/plotting/mpl/renderer.py index e7431bfba0..b74228176a 100644 --- a/holoviews/plotting/mpl/renderer.py +++ b/holoviews/plotting/mpl/renderer.py @@ -10,8 +10,6 @@ import param -from panel.pane import Viewable - import matplotlib as mpl from matplotlib import pyplot as plt @@ -84,34 +82,12 @@ class MPLRenderer(Renderer): mode = param.ObjectSelector(default='default', objects=['default']) - mode_formats = {'fig': {'default': ['png', 'svg', 'pdf', 'html', None, 'auto']}, - 'holomap': {'default': ['widgets', 'scrubber', 'webm','mp4', 'gif', - 'html', None, 'auto']}} + mode_formats = {'fig': ['png', 'svg', 'pdf', 'html', None, 'auto'], + 'holomap': ['widgets', 'scrubber', 'webm','mp4', 'gif', + 'html', None, 'auto']} counter = 0 - # Define the handler for updating matplotlib plots - comm_msg_handler = mpl_msg_handler - - def __call__(self, obj, fmt='auto'): - """ - Render the supplied HoloViews component or MPLPlot instance - using matplotlib. - """ - plot, fmt = self._validate(obj, fmt) - info = {'file-ext': fmt, 'mime_type': MIME_TYPES[fmt]} - - if plot is None: - return - elif isinstance(plot, Viewable): - return plot, info - else: - with mpl.rc_context(rc=plot.fig_rcparams): - data = self._figure_data(plot, fmt, **({'dpi':self.dpi} if self.dpi else {})) - - data = self._apply_post_render_hooks(data, obj, fmt) - return data, info - def show(self, obj): """ Renders the supplied object and displays it using the active @@ -168,17 +144,6 @@ def get_size(self_or_cls, plot): return (int(w*dpi), int(h*dpi)) - def diff(self, plot): - """ - Returns the latest plot data to update an existing plot. - """ - if self.fig == 'auto': - figure_format = self.params('fig').objects[0] - else: - figure_format = self.fig - return self.html(plot, figure_format) - - def _figure_data(self, plot, fmt, bbox_inches='tight', as_script=False, **kwargs): """ Render matplotlib figure object and return the corresponding diff --git a/holoviews/plotting/plot.py b/holoviews/plotting/plot.py index 27f94854dd..e2b1ceb56b 100644 --- a/holoviews/plotting/plot.py +++ b/holoviews/plotting/plot.py @@ -3,15 +3,21 @@ plotting package or backend. Every plotting classes must be a subclass of this Plot baseclass. """ +from __future__ import absolute_import +import threading import warnings + from itertools import groupby, product from collections import Counter, defaultdict import numpy as np import param -from ..core import OrderedDict, Stream +from panel.io.notebook import push +from panel.io.state import state + +from ..core import OrderedDict from ..core import util, traversal from ..core.element import Element, Element3D from ..core.overlay import Overlay, CompositeOverlay @@ -21,6 +27,7 @@ from ..core.spaces import HoloMap, DynamicMap from ..core.util import stream_parameters, isfinite from ..element import Table, Graph, Contours +from ..streams import Stream from ..util.transform import dim from .util import (get_dynamic_mode, initialize_unbounded, dim_axis_label, attach_streams, traverse_setter, get_nested_streams, @@ -35,6 +42,8 @@ class Plot(param.Parameterized): general enough to use any plotting package or backend. """ + backend = None + # A list of style options that may be supplied to the plotting # call style_opts = [] @@ -42,6 +51,98 @@ class Plot(param.Parameterized): # Use this list to disable any invalid style options _disabled_opts = [] + def __init__(self, renderer=None, root=None, **params): + params = {k: v for k, v in params.items() + if k in self.params()} + super(Plot, self).__init__(**params) + self.renderer = renderer if renderer else Store.renderers[self.backend].instance() + self._force = False + self._comm = None + self._document = None + self._root = None + self._pane = None + self.set_root(root) + + + @property + def state(self): + """ + The plotting state that gets updated via the update method and + used by the renderer to generate output. + """ + raise NotImplementedError + + + def set_root(self, root): + """ + Sets the root model on all subplots. + """ + if root is None: + return + for plot in self.traverse(lambda x: x): + plot._root = root + + + @property + def root(self): + if self._root: + return self._root + elif 'plot' in self.handles and self.top_level: + return self.state + else: + return None + + + @property + def document(self): + return self._document + + @document.setter + def document(self, doc): + if (doc and hasattr(doc, 'on_session_destroyed') and + self.root is self.handles.get('plot') and + not isinstance(self, GenericAdjointLayoutPlot)): + doc.on_session_destroyed(self._session_destroy) + if self._document: + if isinstance(self._document._session_destroyed_callbacks, set): + self._document._session_destroyed_callbacks.discard(self._session_destroy) + else: + self._document._session_destroyed_callbacks.pop(self._session_destroy, None) + + self._document = doc + if self.subplots: + for plot in self.subplots.values(): + if plot is not None: + plot.document = doc + + + @property + def pane(self): + return self._pane + + @pane.setter + def pane(self, pane): + self._pane = pane + if self.subplots: + for plot in self.subplots.values(): + if plot is not None: + plot.pane = pane + + + @property + def comm(self): + return self._comm + + + @comm.setter + def comm(self, comm): + self._comm = comm + if self.subplots: + for plot in self.subplots.values(): + if plot is not None: + plot.comm = comm + + def initialize_plot(self, ranges=None): """ Initialize the matplotlib figure. @@ -57,14 +158,6 @@ def update(self, key): """ return self.state - @property - def state(self): - """ - The plotting state that gets updated via the update method and - used by the renderer to generate output. - """ - raise NotImplementedError - def cleanup(self): """ @@ -79,8 +172,64 @@ def cleanup(self): stream._subscribers = [ (p, subscriber) for p, subscriber in stream._subscribers if util.get_method_owner(subscriber) not in plots] - if self.comm: - self.comm.close() + + + def _session_destroy(self, session_context): + self.cleanup() + + + def refresh(self, **kwargs): + """ + Refreshes the plot by rerendering it and then pushing + the updated data if the plot has an associated Comm. + """ + if self.renderer.mode == 'server': + from bokeh.io import curdoc + thread = threading.current_thread() + thread_id = thread.ident if thread else None + if (curdoc() is not self.document or (state._thread_id is not None and + thread_id != state._thread_id)): + # If we do not have the Document lock, schedule refresh as callback + self.document.add_next_tick_callback(self.refresh) + return + + traverse_setter(self, '_force', True) + key = self.current_key if self.current_key else self.keys[0] + dim_streams = [stream for stream in self.streams + if any(c in self.dimensions for c in stream.contents)] + stream_params = stream_parameters(dim_streams) + key = tuple(None if d in stream_params else k + for d, k in zip(self.dimensions, key)) + stream_key = util.wrap_tuple_streams(key, self.dimensions, self.streams) + + self._trigger_refresh(stream_key) + if self.top_level: + self.push() + + + def _trigger_refresh(self, key): + "Triggers update to a plot on a refresh event" + # Update if not top-level, batched or an ElementPlot + if not self.top_level or isinstance(self, GenericElementPlot): + self.update(key) + + + def push(self): + """ + Pushes plot updates to the frontend. + """ + root = self._root + if (root and self.pane is not None and + root.ref['id'] in self.pane._plots): + child_pane = self.pane._plots[root.ref['id']][1] + else: + child_pane = None + + if self.renderer.backend != 'bokeh' and child_pane is not None: + child_pane.object = self.state + elif ((self.renderer.mode != 'server' or (root and 'embedded' in root.tags)) + and self.document and self.comm): + push(self.document, self.comm) @property @@ -94,6 +243,7 @@ def __len__(self): """ raise NotImplementedError + @classmethod def lookup_options(cls, obj, group): plot_class = None @@ -223,8 +373,7 @@ class DimensionedPlot(Plot): def __init__(self, keys=None, dimensions=None, layout_dimensions=None, uniform=True, subplot=False, adjoined=None, layout_num=0, - style=None, subplots=None, dynamic=False, renderer=None, - comm=None, **params): + style=None, subplots=None, dynamic=False, **params): self.subplots = subplots self.adjoined = adjoined self.dimensions = dimensions @@ -241,12 +390,7 @@ def __init__(self, keys=None, dimensions=None, layout_dimensions=None, self.current_frame = None self.current_key = None self.ranges = {} - self.renderer = renderer if renderer else Store.renderers[self.backend].instance() - self.comm = comm - self._force = False self._updated = False # Whether the plot should be marked as updated - params = {k: v for k, v in params.items() - if k in self.params()} super(DimensionedPlot, self).__init__(**params) @@ -615,59 +759,57 @@ def update(self, key): return item - def refresh(self, **kwargs): + def __len__(self): """ - Refreshes the plot by rerendering it and then pushing - the updated data if the plot has an associated Comm. + Returns the total number of available frames. """ - traverse_setter(self, '_force', True) - key = self.current_key if self.current_key else self.keys[0] - dim_streams = [stream for stream in self.streams - if any(c in self.dimensions for c in stream.contents)] - stream_params = stream_parameters(dim_streams) - key = tuple(None if d in stream_params else k - for d, k in zip(self.dimensions, key)) - stream_key = util.wrap_tuple_streams(key, self.dimensions, self.streams) - - self._trigger_refresh(stream_key) - if self.comm is not None and self.top_level: - self.push() + return len(self.keys) - def _trigger_refresh(self, key): - "Triggers update to a plot on a refresh event" - # Update if not top-level, batched or an ElementPlot - if not self.top_level or isinstance(self, GenericElementPlot): - self.update(key) - - - def push(self): - """ - Pushes updated plot data via the Comm. - """ - if self.comm is None: - raise Exception('Renderer does not have a comm.') - diff = self.renderer.diff(self) - self.comm.send(diff) +class CallbackPlot(object): - def init_comm(self): + def _construct_callbacks(self): """ - Initializes comm and attaches streams. + Initializes any callbacks for streams which have defined + the plotted object as a source. """ - if self.comm: - return self.comm - comm = None - if self.dynamic or self.renderer.widget_mode == 'live': - comm = self.renderer.comm_manager.get_server_comm() - return comm + cb_classes = set() + registry = list(Stream.registry.items()) + callbacks = Stream._callbacks[self.backend] + for source in self.link_sources: + streams = [ + s for src, streams in registry for s in streams + if src is source or (src._plot_id is not None and + src._plot_id == source._plot_id)] + cb_classes |= {(callbacks[type(stream)], stream) for stream in streams + if type(stream) in callbacks and stream.linked + and stream.source is not None} + cbs = [] + sorted_cbs = sorted(cb_classes, key=lambda x: id(x[0])) + for cb, group in groupby(sorted_cbs, lambda x: x[0]): + cb_streams = [s for _, s in group] + cbs.append(cb(self, cb_streams, source)) + return cbs + @property + def link_sources(self): + "Returns potential Link or Stream sources." + if isinstance(self, GenericOverlayPlot): + zorders = [] + elif self.batched: + zorders = list(range(self.zorder, self.zorder+len(self.hmap.last))) + else: + zorders = [self.zorder] - def __len__(self): - """ - Returns the total number of available frames. - """ - return len(self.keys) + if isinstance(self, GenericOverlayPlot) and not self.batched: + sources = [] + elif not self.static or isinstance(self.hmap, DynamicMap): + sources = [o for i, inputs in self.stream_sources.items() + for o in inputs if i in zorders] + else: + sources = [self.hmap.last] + return sources class CallbackPlot(object): @@ -891,9 +1033,6 @@ def __init__(self, element, keys=None, ranges=None, dimensions=None, dynamic=dynamic, **dict(params, **plot_opts)) self.streams = get_nested_streams(self.hmap) if streams is None else streams - if self.top_level: - self.comm = self.init_comm() - self.traverse(lambda x: setattr(x, 'comm', self.comm)) # Attach streams if not overlaid and not a batched ElementPlot if not (self.overlaid or (self.batched and not isinstance(self, GenericOverlayPlot))): @@ -1211,8 +1350,6 @@ def __init__(self, overlay, ranges=None, batched=True, keys=None, group_counter= self.top_level = keys is None self.dynamic_subplots = [] if self.top_level: - self.comm = self.init_comm() - self.traverse(lambda x: setattr(x, 'comm', self.comm)) self.traverse(lambda x: attach_streams(self, x.hmap, 2), [GenericElementPlot]) @@ -1584,3 +1721,16 @@ def __init__(self, layout, **params): self.rows, self.cols = layout.shape[::-1] if self.transpose else layout.shape self.coords = list(product(range(self.rows), range(self.cols))) + + +class GenericAdjointLayoutPlot(Plot): + """ + AdjointLayoutPlot allows placing up to three Views in a number of + predefined and fixed layouts, which are defined by the layout_dict + class attribute. This allows placing subviews next to a main plot + in either a 'top' or 'right' position. + """ + + layout_dict = {'Single': {'positions': ['main']}, + 'Dual': {'positions': ['main', 'right']}, + 'Triple': {'positions': ['main', 'right', 'top']}} diff --git a/holoviews/plotting/plotly/__init__.py b/holoviews/plotting/plotly/__init__.py index f45bbf5c86..7ff4d32bb8 100644 --- a/holoviews/plotting/plotly/__init__.py +++ b/holoviews/plotting/plotly/__init__.py @@ -17,6 +17,7 @@ from .callbacks import * # noqa (API import) from .shapes import * # noqa (API import) from .images import * # noqa (API import) + from ...core.util import LooseVersion, VersionError import plotly diff --git a/holoviews/plotting/plotly/callbacks.py b/holoviews/plotting/plotly/callbacks.py index a724ce0168..0d136214c6 100644 --- a/holoviews/plotting/plotly/callbacks.py +++ b/holoviews/plotting/plotly/callbacks.py @@ -1,12 +1,15 @@ from weakref import WeakValueDictionary -from holoviews.streams import ( + +from param.parameterized import add_metaclass + +from ...streams import ( Stream, Selection1D, RangeXY, RangeX, RangeY, BoundsXY, BoundsX, BoundsY ) from .util import _trace_to_subplot -class MetaPlotlyCallback(type): +class PlotlyCallbackMetaClass(type): """ Metaclass for PlotlyCallback classes. @@ -15,13 +18,13 @@ class MetaPlotlyCallback(type): """ def __init__(cls, name, bases, attrs): - super(MetaPlotlyCallback, cls).__init__(name, bases, attrs) + super(PlotlyCallbackMetaClass, cls).__init__(name, bases, attrs) # Create weak-value dictionary to hold instances of the class cls.instances = WeakValueDictionary() def __call__(cls, *args, **kwargs): - inst = super(MetaPlotlyCallback, cls).__call__(*args, **kwargs) + inst = super(PlotlyCallbackMetaClass, cls).__call__(*args, **kwargs) # Store weak reference to the callback instance in the _instances # WeakValueDictionary. This will allow instances to be garbage collected and @@ -32,7 +35,8 @@ def __call__(cls, *args, **kwargs): return inst -class PlotlyCallback(object, metaclass=MetaPlotlyCallback): +@add_metaclass(PlotlyCallbackMetaClass) +class PlotlyCallback(object): def __init__(self, plot, streams, source, **params): self.plot = plot diff --git a/holoviews/plotting/plotly/element.py b/holoviews/plotting/plotly/element.py index 4fb95387ff..fb7cd6eb91 100644 --- a/holoviews/plotting/plotly/element.py +++ b/holoviews/plotting/plotly/element.py @@ -183,8 +183,6 @@ def generate_plot(self, key, ranges, element=None): self.drawn = True fig = dict(data=components['traces'], layout=layout) - # Add plot's id to figure for bookkeeping - fig['_id'] = self.id self.handles['fig'] = fig return fig @@ -623,9 +621,6 @@ def generate_plot(self, key, ranges, element=None): figure['layout'].update(layout) self.drawn = True - # Add plot's id to figure for bookkeeping - figure['_id'] = self.id - self.handles['fig'] = figure return figure diff --git a/holoviews/plotting/plotly/plot.py b/holoviews/plotting/plotly/plot.py index e580a99e42..0e0715c574 100644 --- a/holoviews/plotting/plotly/plot.py +++ b/holoviews/plotting/plotly/plot.py @@ -10,8 +10,10 @@ from ...element import Histogram from ...core.options import Store from ...core.util import wrap_tuple -from ..plot import DimensionedPlot, GenericLayoutPlot, GenericCompositePlot, \ - GenericElementPlot, CallbackPlot +from ..plot import ( + DimensionedPlot, GenericLayoutPlot, GenericCompositePlot, + GenericElementPlot, GenericAdjointLayoutPlot, CallbackPlot +) from .util import figure_grid, configure_matching_axes_from_dims @@ -68,7 +70,6 @@ def update_frame(self, key, ranges=None): PlotlyRenderer.trigger_plot_pane(self.id, self.state) return plot - class LayoutPlot(PlotlyPlot, GenericLayoutPlot): hspacing = param.Number(default=120, bounds=(0, None)) @@ -85,8 +86,6 @@ def __init__(self, layout, **params): self.layout, self.subplots, self.paths = self._init_layout(layout) if self.top_level: - self.comm = self.init_comm() - self.traverse(lambda x: setattr(x, 'comm', self.comm)) self.traverse(lambda x: attach_streams(self, x.hmap, 2), [GenericElementPlot]) @@ -250,18 +249,12 @@ def generate_plot(self, key, ranges=None): self.drawn = True - # Add plot's id to figure for bookkeeping - fig['_id'] = self.id self.handles['fig'] = fig return self.handles['fig'] -class AdjointLayoutPlot(PlotlyPlot): - - layout_dict = {'Single': {'positions': ['main']}, - 'Dual': {'positions': ['main', 'right']}, - 'Triple': {'positions': ['main', 'right', 'top']}} +class AdjointLayoutPlot(PlotlyPlot, GenericAdjointLayoutPlot): registry = {} @@ -319,8 +312,6 @@ def __init__(self, layout, ranges=None, layout_num=1, **params): self.subplots, self.layout = self._create_subplots(layout, ranges) if self.top_level: - self.comm = self.init_comm() - self.traverse(lambda x: setattr(x, 'comm', self.comm)) self.traverse(lambda x: attach_streams(self, x.hmap, 2), [GenericElementPlot]) @@ -395,8 +386,6 @@ def generate_plot(self, key, ranges=None): self.drawn = True - # Add plot's id to figure for bookkeeping - fig['_id'] = self.id self.handles['fig'] = fig return self.handles['fig'] diff --git a/holoviews/plotting/plotly/renderer.py b/holoviews/plotting/plotly/renderer.py index a60bcfe5c5..256d0bbac0 100644 --- a/holoviews/plotting/plotly/renderer.py +++ b/holoviews/plotting/plotly/renderer.py @@ -1,25 +1,20 @@ from __future__ import absolute_import, division, unicode_literals import base64 -import json -from weakref import WeakValueDictionary import param -from param.parameterized import bothmethod - -from holoviews.plotting.plotly.util import clean_internal_figure_properties +import panel as pn with param.logging_level('CRITICAL'): - from plotly import utils import plotly.graph_objs as go -import panel as pn from panel.pane import Viewable from ..renderer import Renderer, MIME_TYPES, HTML_TAGS from ...core.options import Store from ...core import HoloMap from .callbacks import callbacks +from .util import clean_internal_figure_properties @@ -27,28 +22,18 @@ def _PlotlyHoloviews(fig_dict): """ Custom Plotly pane constructor for use by the HoloViews Pane. """ - # Save plot id - plot_id = fig_dict['_id'] # Remove internal HoloViews properties clean_internal_figure_properties(fig_dict) - - # Create plotly pane - plotly_pane = pn.pane.Plotly(fig_dict) - - # Configure pane callbacks - plotly_pane.viewport_update_policy = 'mouseup' - - # Add pane to renderer so that we can find it again to update it - PlotlyRenderer._plot_panes[plot_id] = plotly_pane - + + plotly_pane = pn.pane.Plotly(fig_dict, viewport_update_policy='mouseup') + # Register callbacks on pane for callback_cls in callbacks.values(): plotly_pane.param.watch( lambda event, cls=callback_cls: cls.update_streams_from_property_update(event.new, event.obj.object), callback_cls.callback_property, ) - return plotly_pane @@ -56,42 +41,18 @@ class PlotlyRenderer(Renderer): backend = param.String(default='plotly', doc="The backend name.") - fig = param.ObjectSelector(default='auto', objects=['html', 'json', 'png', 'svg', 'auto'], doc=""" + fig = param.ObjectSelector(default='auto', objects=['html', 'png', 'svg', 'auto'], doc=""" Output render format for static figures. If None, no figure rendering will occur. """) - mode_formats = {'fig': {'default': ['html', 'png', 'svg', 'json']}, - 'holomap': {'default': ['widgets', 'scrubber', 'auto']}} + mode_formats = {'fig': ['html', 'png', 'svg'], + 'holomap': ['widgets', 'scrubber', 'auto']} widgets = ['scrubber', 'widgets'] _loaded = False - - _plot_panes = WeakValueDictionary() - - def __call__(self, obj, fmt='html', divuuid=None): - plot, fmt = self._validate(obj, fmt) - mime_types = {'file-ext':fmt, 'mime_type': MIME_TYPES[fmt]} - - if isinstance(plot, Viewable): - # fmt == 'html' - return plot, mime_types - elif fmt in ('png', 'svg'): - return self._figure_data(plot, fmt, divuuid=divuuid), mime_types - elif fmt == 'json': - return self.diff(plot), mime_types - - - def diff(self, plot, serialize=True): - """ - Returns a json diff required to update an existing plot with - the latest plot data. - """ - diff = plot.state - if serialize: - return json.dumps(diff, cls=utils.PlotlyJSONEncoder) - else: - return diff + + _render_with_panel = True @bothmethod def get_plot_state(self_or_cls, obj, doc=None, renderer=None, **kwargs): @@ -112,6 +73,7 @@ def get_plot_state(self_or_cls, obj, doc=None, renderer=None, **kwargs): fig_dict.get('layout', {}).pop('template', None) return fig_dict + def _figure_data(self, plot, fmt, as_script=False, **kwargs): # Wrapping plot.state in go.Figure here performs validation # and applies any default theme. @@ -156,17 +118,8 @@ def load_nb(cls, inline=True): cls._loaded = True - @classmethod - def trigger_plot_pane(cls, plot_id, fig_dict): - if plot_id in cls._plot_panes: - clean_internal_figure_properties(fig_dict) - pane = cls._plot_panes[plot_id] - pane.object = fig_dict - - def _activate_plotly_backend(renderer): if renderer == "plotly": - pn.pane.HoloViews._panes["plotly"] = _PlotlyHoloviews - + pn.pane.HoloViews._panes["plotly"] = _PlotlyHoloviewsPane Store._backend_switch_hooks.append(_activate_plotly_backend) diff --git a/holoviews/plotting/renderer.py b/holoviews/plotting/renderer.py index 1b3c80855b..ef0395c7a2 100644 --- a/holoviews/plotting/renderer.py +++ b/holoviews/plotting/renderer.py @@ -2,7 +2,7 @@ Public API for all plotting renderers supported by HoloViews, regardless of plotting package or backend. """ -from __future__ import unicode_literals +from __future__ import unicode_literals, absolute_import import base64 from io import BytesIO @@ -15,8 +15,9 @@ import param from panel import config -from panel.io.notebook import load_notebook -from panel.pane import HoloViews +from panel.io.notebook import load_notebook, render_model, render_mimebundle +from panel.io.state import state +from panel.pane import HoloViews as HoloViewsPane from panel.widgets.player import PlayerBase from panel.viewable import Viewable @@ -69,10 +70,6 @@ static_template = """ - - {css} - {js} - {html} @@ -96,14 +93,14 @@ class Renderer(Exporter): The full, lowercase name of the rendering backend or third part plotting package used e.g 'matplotlib' or 'cairo'.""") - dpi=param.Integer(None, doc=""" + dpi = param.Integer(None, doc=""" The render resolution in dpi (dots per inch)""") fig = param.ObjectSelector(default='auto', objects=['auto'], doc=""" Output render format for static figures. If None, no figure rendering will occur. """) - fps=param.Number(20, doc=""" + fps = param.Number(20, doc=""" Rendered fps (frames per second) for animated formats.""") holomap = param.ObjectSelector(default='auto', @@ -111,19 +108,27 @@ class Renderer(Exporter): Output render multi-frame (typically animated) format. If None, no multi-frame rendering will occur.""") - mode = param.ObjectSelector(default='default', objects=['default'], doc=""" - The available rendering modes. As a minimum, the 'default' - mode must be supported.""") + mode = param.ObjectSelector(default='default', + objects=['default', 'server'], doc=""" + Whether to render the object in regular or server mode. In server + mode a bokeh Document will be returned which can be served as a + bokeh server app. By default renders all output is rendered to HTML.""") - size=param.Integer(100, doc=""" + size = param.Integer(100, doc=""" The rendered size as a percentage size""") + widget_location = param.ObjectSelector(default=None, allow_None=True, objects=[ + 'left', 'bottom', 'right', 'top', 'top_left', 'top_right', + 'bottom_left', 'bottom_right', 'left_top', 'left_bottom', + 'right_top', 'right_bottom'], doc=""" + The position of the widgets relative to the plot.""") + widget_mode = param.ObjectSelector(default='embed', objects=['embed', 'live'], doc=""" The widget mode determining whether frames are embedded or generated 'live' when interacting with the widget.""") - css = param.Dict(default={}, - doc="Dictionary of CSS attributes and values to apply to HTML output") + css = param.Dict(default={}, doc=""" + Dictionary of CSS attributes and values to apply to HTML output.""") info_fn = param.Callable(None, allow_None=True, constant=True, doc=""" Renderers do not support the saving of object info metadata""") @@ -140,16 +145,13 @@ class Renderer(Exporter): data before output is saved to file or displayed.""") # Defines the valid output formats for each mode. - mode_formats = {'fig': {'default': [None, 'auto']}, - 'holomap': {'default': [None, 'auto']}} + mode_formats = {'fig': [None, 'auto'], + 'holomap': [None, 'auto']} # The comm_manager handles the creation and registering of client, # and server side comms comm_manager = CommManager - # JS code which handles comm messages and updates the plot - comm_msg_handler = None - # Define appropriate widget classes widgets = ['scrubber', 'widgets'] @@ -159,13 +161,32 @@ class Renderer(Exporter): # Plot registry _plots = {} + # Whether to render plots with Panel + _render_with_panel = False + def __init__(self, **params): self.last_plot = None super(Renderer, self).__init__(**params) + def __call__(self, obj, fmt='auto', **kwargs): + plot, fmt = self._validate(obj, fmt) + info = {'file-ext': fmt, 'mime_type': MIME_TYPES[fmt]} + + if plot is None: + return None, info + elif self.mode == 'server': + return self.server_doc(plot, doc=kwargs.get('doc')), info + elif isinstance(plot, Viewable): + return self.static_html(plot), info + else: + data = self._figure_data(plot, fmt, **kwargs) + data = self._apply_post_render_hooks(data, obj, fmt) + return data, info + + @bothmethod - def get_plot(self_or_cls, obj, renderer=None, **kwargs): + def get_plot(self_or_cls, obj, doc=None, renderer=None, comm=None, **kwargs): """ Given a HoloViews Viewable return a corresponding plot instance. """ @@ -190,6 +211,7 @@ def get_plot(self_or_cls, obj, renderer=None, **kwargs): renderer = self_or_cls if not isinstance(self_or_cls, Renderer): renderer = self_or_cls.instance() + if not isinstance(obj, Plot): obj = Layout.from_values(obj) if isinstance(obj, AdjointLayout) else obj plot_opts = dict(self_or_cls.plot_options(obj, self_or_cls.size), @@ -202,6 +224,19 @@ def get_plot(self_or_cls, obj, renderer=None, **kwargs): plot.update(init_key) else: plot = obj + + if isinstance(self_or_cls, Renderer): + self_or_cls.last_plot = plot + + if comm: + plot.comm = comm + + if comm or self_or_cls.mode == 'server': + from bokeh.document import Document + from bokeh.io import curdoc + if doc is None: + doc = Document() if self_or_cls.notebook_context else curdoc() + plot.document = doc return plot @bothmethod @@ -212,6 +247,15 @@ def get_plot_state(self_or_cls, obj, renderer=None, **kwargs): plot = self_or_cls.get_plot(obj, renderer, **kwargs) return plot.state + @bothmethod + def get_plot_state(self_or_cls, obj, renderer=None, **kwargs): + """ + Given a HoloViews Viewable return a corresponding plot state. + """ + plot = self_or_cls.get_plot(obj, renderer, **kwargs) + return plot.state + + def _validate(self, obj, fmt, **kwargs): """ Helper method to be used in the __call__ method to get a @@ -220,22 +264,24 @@ def _validate(self, obj, fmt, **kwargs): if isinstance(obj, Viewable): return obj, 'html' - fig_formats = self.mode_formats['fig'][self.mode] - holomap_formats = self.mode_formats['holomap'][self.mode] + fig_formats = self.mode_formats['fig'] + holomap_formats = self.mode_formats['holomap'] + + holomaps = obj.traverse(lambda x: x, [HoloMap]) + dynamic = any(isinstance(m, DynamicMap) for m in holomaps) if fmt in ['auto', None]: if any(len(o) > 1 or (isinstance(o, DynamicMap) and unbound_dimensions(o.streams, o.kdims)) - for o in obj.traverse(lambda x: x, HoloMap)): - fmt = holomap_formats[0] if self.holomap == 'auto' else self.holomap + for o in holomaps): + fmt = holomap_formats[0] if self.holomap in ['auto', None] else self.holomap else: fmt = fig_formats[0] if self.fig == 'auto' else self.fig if fmt in self.widgets: - plot = self.get_widget(obj, fmt, display_options={'fps': self.fps}) + plot = self.get_widget(obj, fmt) fmt = 'html' - elif fmt == 'html': - plot, fmt = HoloViews( - obj, fancy_layout=True, backend=Store.current_backend), 'html' + elif dynamic or (self._render_with_panel and fmt == 'html'): + plot, fmt = HoloViewsPane(obj, center=True, backend=self.backend, renderer=self), fmt else: plot = self.get_plot(obj, renderer=self, **kwargs) @@ -247,24 +293,6 @@ def _validate(self, obj, fmt, **kwargs): return plot, fmt - def __call__(self, obj, fmt=None): - """ - Render the supplied HoloViews component or plot instance using - the appropriate backend. The output is not a file format but a - suitable, in-memory byte stream together with any suitable - metadata. - """ - plot, fmt = self._validate(obj, fmt) - if plot is None: return - # [Backend specific code goes here to generate data] - data = None - - # Example of how post_render_hooks are applied - data = self._apply_post_render_hooks(data, obj, fmt) - # Example of the return format where the first value is the rendered data. - return data, {'file-ext':fmt, 'mime_type':MIME_TYPES[fmt]} - - def _apply_post_render_hooks(self, data, obj, fmt): """ Apply the post-render hooks to the data. @@ -279,7 +307,7 @@ def _apply_post_render_hooks(self, data, obj, fmt): return data - def html(self, obj, fmt=None, css=None, **kwargs): + def html(self, obj, fmt=None, css=None, resources='CDN', **kwargs): """ Renders plot or data structure and wraps the output in HTML. The comm argument defines whether the HTML output includes @@ -289,7 +317,18 @@ def html(self, obj, fmt=None, css=None, **kwargs): figdata, _ = self(plot, fmt, **kwargs) if css is None: css = self.css - if fmt in ['html', 'json']: + if isinstance(plot, Viewable): + from bokeh.document import Document + from bokeh.embed import file_html + from bokeh.resources import CDN, INLINE + doc = Document() + plot._render_model(doc) + if resources == 'cdn': + resources = CDN + elif resources == 'inline': + resources = INLINE + return file_html(doc, resources) + elif fmt in ['html', 'json']: return figdata else: if fmt == 'svg': @@ -314,10 +353,7 @@ def components(self, obj, fmt=None, comm=True, **kwargs): """ Returns data and metadata dictionaries containing HTML and JS components to include render in app, notebook, or standalone - document. Depending on the backend the fmt defines the format - embedded in the HTML, e.g. png or svg. If comm is enabled the - JS code will set up a Websocket comm channel using the - currently defined CommManager. + document. """ if isinstance(obj, Plot): plot = obj @@ -326,11 +362,16 @@ def components(self, obj, fmt=None, comm=True, **kwargs): data, metadata = {}, {} if isinstance(plot, Viewable): - with config.set(embed=not bool(plot.object.traverse(DynamicMap))): - return plot.layout._repr_mimebundle_() + from bokeh.document import Document + dynamic = bool(plot.object.traverse(lambda x: x, [DynamicMap])) + embed = (not (dynamic or self.widget_mode == 'live') or config.embed) + comm = self.comm_manager.get_server_comm() if comm else None + doc = Document() + with config.set(embed=embed): + model = plot.layout._render_model(doc, comm) + return render_model(model, comm) if embed else render_mimebundle(model, doc, comm) else: html = self._figure_data(plot, fmt, as_script=True, **kwargs) - data['text/html'] = html return (data, {MIME_TYPES['jlab-hv-exec']: metadata}) @@ -342,22 +383,28 @@ def static_html(self, obj, fmt=None, template=None): supplied format. Allows supplying a template formatting string with fields to interpolate 'js', 'css' and the main 'html'. """ - js_html, css_html = self.html_assets() - if template is None: template = static_template - html = self.html(obj, fmt) - return template.format(js=js_html, css=css_html, html=html) + html_bytes = StringIO() + self.save(obj, html_bytes, fmt) + html_bytes.seek(0) + return html_bytes.read() @bothmethod def get_widget(self_or_cls, plot, widget_type, **kwargs): - if widget_type != 'scrubber': + if widget_type == 'scrubber': + widget_location = self_or_cls.widget_location or 'bottom' + else: widget_type = 'individual' - layout = HoloViews(plot, widget_type=widget_type, fancy_layout=True) + widget_location = self_or_cls.widget_location or 'right' + + layout = HoloViewsPane(plot, widget_type=widget_type, center=True, + widget_location=widget_location, renderer=self_or_cls) interval = int((1./self_or_cls.fps) * 1000) for player in layout.layout.select(PlayerBase): player.interval = interval return layout + @bothmethod def export_widgets(self_or_cls, obj, filename, fmt=None, template=None, json=False, json_path='', **kwargs): @@ -375,6 +422,56 @@ def export_widgets(self_or_cls, obj, filename, fmt=None, template=None, self_or_cls.get_widget(obj, fmt).save(filename) + @bothmethod + def _widget_kwargs(self_or_cls): + if self_or_cls.holomap in ('auto', 'widgets'): + widget_type = 'individual' + loc = self_or_cls.widget_location or 'right' + else: + widget_type = 'scrubber' + loc = self_or_cls.widget_location or 'bottom' + return {'widget_location': loc, 'widget_type': widget_type, 'center': True} + + + @bothmethod + def app(self_or_cls, plot, show=False, new_window=False, websocket_origin=None, port=0): + """ + Creates a bokeh app from a HoloViews object or plot. By + default simply attaches the plot to bokeh's curdoc and returns + the Document, if show option is supplied creates an + Application instance and displays it either in a browser + window or inline if notebook extension has been loaded. Using + the new_window option the app may be displayed in a new + browser tab once the notebook extension has been loaded. A + websocket origin is required when launching from an existing + tornado server (such as the notebook) and it is not on the + default port ('localhost:8888'). + """ + if isinstance(plot, HoloViewsPane): + pane = plot + else: + pane = HoloViewsPane(plot, backend=self_or_cls.backend, renderer=self_or_cls, + **self_or_cls._widget_kwargs()) + if new_window: + return pane._get_server(port, websocket_origin, show=show) + else: + kwargs = {'notebook_url': websocket_origin} if websocket_origin else {} + return pane.app(port=port, **kwargs) + + + @bothmethod + def server_doc(self_or_cls, obj, doc=None): + """ + Get a bokeh Document with the plot attached. May supply + an existing doc, otherwise bokeh.io.curdoc() is used to + attach the plot to the global document instance. + """ + if not isinstance(obj, HoloViewsPane): + obj = HoloViewsPane(obj, renderer=self_or_cls, backend=self_or_cls.backend, + **self_or_cls._widget_kwargs()) + return obj.layout.server_doc(doc) + + @classmethod def plotting_class(cls, obj): """ @@ -401,7 +498,7 @@ def html_assets(cls, core=True, extras=True, backends=None, script=False): Deprecated: No longer needed """ param.main.warning("Renderer.html_assets is deprecated as all " - "JS and CSS dependencies are not handled by " + "JS and CSS dependencies are now handled by " "Panel.") @classmethod @@ -419,7 +516,7 @@ def plot_options(cls, obj, percent_size): @bothmethod def save(self_or_cls, obj, basename, fmt='auto', key={}, info={}, - options=None, resources='cdn', **kwargs): + options=None, resources='inline', **kwargs): """ Save a HoloViews object to file, either using an explicitly supplied format or to the appropriate default. @@ -432,9 +529,9 @@ def save(self_or_cls, obj, basename, fmt='auto', key={}, info={}, if isinstance(plot, Viewable): from bokeh.resources import CDN, INLINE - if resources == 'cdn': + if resources.lower() == 'cdn': resources = CDN - elif resources == 'inline': + elif resources.lower() == 'inline': resources = INLINE plot.layout.save(basename, embed=True, resources=resources) return @@ -497,8 +594,15 @@ def load_nb(cls, inline=True): """ load_notebook(inline) with param.logging_level('ERROR'): + try: + ip = get_ipython() # noqa + except: + ip = None + if not ip or not hasattr(ip, 'kernel'): + return cls.notebook_context = True cls.comm_manager = JupyterCommManager + state._comm_manager = JupyterCommManager @classmethod diff --git a/holoviews/tests/element/testelementselect.py b/holoviews/tests/element/testelementselect.py index 4530587119..5331b393e1 100644 --- a/holoviews/tests/element/testelementselect.py +++ b/holoviews/tests/element/testelementselect.py @@ -1,8 +1,14 @@ from itertools import product +import datetime as dt import numpy as np +try: + import pandas as pd +except ImportError: + pd = None + from holoviews.core import HoloMap -from holoviews.element import Image, Contours +from holoviews.element import Image, Contours, Curve from holoviews.element.comparison import ComparisonTestCase class DimensionedSelectionTest(ComparisonTestCase): @@ -11,6 +17,11 @@ def setUp(self): self.img_fn = lambda: Image(np.random.rand(10, 10)) self.contour_fn = lambda: Contours([np.random.rand(10, 2) for i in range(2)]) + self.datetime_fn = lambda: Curve(( + [dt.datetime(2000,1,1), dt.datetime(2000,1,2), + dt.datetime(2000,1,3)], + np.random.rand(3) + ), 'time', 'x') params = [list(range(3)) for i in range(2)] self.sanitized_map = HoloMap({i: Image(i*np.random.rand(10,10)) for i in range(1,10)}, kdims=['A B']) @@ -85,3 +96,17 @@ def test_duplicate_dim_select(self): def test_overlap_select(self): selection = self.overlap_layout.select(Default=(6, None)) self.assertEqual(selection, self.overlap1.clone(shared_data=False) + self.overlap2[6:]) + + def test_datetime_select(self): + s, e = '1999-12-31', '2000-1-2' + curve = self.datetime_fn() + overlay = curve * self.datetime_fn() + for el in [curve, overlay]: + self.assertEqual(el.select(time=(s, e)), el[s:e]) + self.assertEqual(el.select(time= + (dt.datetime(1999, 12, 31), dt.datetime(2000, 1, 2))), el[s:e] + ) + if pd: + self.assertEqual(el.select( + time=(pd.Timestamp(s), pd.Timestamp(e)) + ), el[pd.Timestamp(s):pd.Timestamp(e)]) diff --git a/holoviews/tests/plotting/bokeh/testlayoutplot.py b/holoviews/tests/plotting/bokeh/testlayoutplot.py index d7d1116349..9679a182aa 100644 --- a/holoviews/tests/plotting/bokeh/testlayoutplot.py +++ b/holoviews/tests/plotting/bokeh/testlayoutplot.py @@ -2,7 +2,7 @@ from holoviews.core import (HoloMap, GridSpace, Layout, Empty, Dataset, NdOverlay, DynamicMap, Dimension) -from holoviews.element import Curve, Image, Points, HLine, VLine, Path, Histogram +from holoviews.element import Curve, Image, Points, Histogram from holoviews.streams import Stream from .testplot import TestBokehPlot, bokeh_renderer @@ -271,17 +271,7 @@ def test_layout_axis_not_linked_mismatching_unit(self): plot = bokeh_renderer.get_plot(layout) p1, p2 = (sp.subplots['main'] for sp in plot.subplots.values()) self.assertIsNot(p1.handles['y_range'], p2.handles['y_range']) - - def test_dimensioned_streams_with_dynamic_map_overlay_clone(self): - time = Stream.define('Time', time=-3.0)() - def crosshair(time): - return VLine(time) * HLine(time) - crosshair = DynamicMap(crosshair, kdims='time', streams=[time]) - path = Path([]) - t = crosshair * path - html, _ = bokeh_renderer(t) - self.assertIn('Bokeh Application', html) - + def test_dimensioned_streams_with_dynamic_callback_returns_layout(self): stream = Stream.define('aname', aname='a')() def cb(aname): @@ -296,4 +286,4 @@ def cb(aname): stream.event(aname=T) self.assertIn('aname: ' + T, p.handles['title'].text, p.handles['title'].text) p.cleanup() - self.assertEqual(stream._subscribers, []) \ No newline at end of file + self.assertEqual(stream._subscribers, []) diff --git a/holoviews/tests/plotting/bokeh/testrenderer.py b/holoviews/tests/plotting/bokeh/testrenderer.py index d6ddd0cc98..c756e388cb 100644 --- a/holoviews/tests/plotting/bokeh/testrenderer.py +++ b/holoviews/tests/plotting/bokeh/testrenderer.py @@ -1,36 +1,49 @@ from __future__ import unicode_literals +from collections import OrderedDict from io import BytesIO from unittest import SkipTest import numpy as np +import param -from holoviews import HoloMap, Image, GridSpace, Table, Curve, Store +from holoviews import DynamicMap, HoloMap, Image, GridSpace, Table, Curve, Store +from holoviews.streams import Stream from holoviews.plotting import Renderer from holoviews.element.comparison import ComparisonTestCase +from pyviz_comms import CommManager try: + import panel as pn + from bokeh.io import curdoc from holoviews.plotting.bokeh import BokehRenderer from bokeh.themes.theme import Theme + + from panel.widgets import DiscreteSlider, Player, FloatSlider except: - pass + pn = None class BokehRendererTest(ComparisonTestCase): def setUp(self): - if 'bokeh' not in Store.renderers: - raise SkipTest("Bokeh required to test widgets") + if 'bokeh' not in Store.renderers and pn is not None: + raise SkipTest("Bokeh and Panel required to test 'bokeh' renderer") self.image1 = Image(np.array([[0,1],[2,3]]), label='Image1') self.image2 = Image(np.array([[1,0],[4,-2]]), label='Image2') self.map1 = HoloMap({1:self.image1, 2:self.image2}, label='TestMap') self.renderer = BokehRenderer.instance() self.nbcontext = Renderer.notebook_context - Renderer.notebook_context = False + self.comm_manager = Renderer.comm_manager + with param.logging_level('ERROR'): + Renderer.notebook_context = False + Renderer.comm_manager = CommManager def tearDown(self): - Renderer.notebook_context = self.nbcontext + with param.logging_level('ERROR'): + Renderer.notebook_context = self.nbcontext + Renderer.comm_manager = self.comm_manager def test_save_html(self): bytesio = BytesIO() @@ -74,16 +87,6 @@ def test_get_size_tables_in_layout(self): w, h = self.renderer.get_size(plot) self.assertEqual((w, h), (800, 300)) - def test_render_to_png(self): - curve = Curve([]) - renderer = BokehRenderer.instance(fig='png') - try: - png, info = renderer(curve) - except RuntimeError: - raise SkipTest("Test requires selenium") - self.assertIsInstance(png, bytes) - self.assertEqual(info['file-ext'], 'png') - def test_theme_rendering(self): theme = Theme( json={ @@ -94,7 +97,117 @@ def test_theme_rendering(self): }) self.renderer.theme = theme plot = self.renderer.get_plot(Curve([])) - diff = self.renderer.diff(plot) - events = [e for e in diff.content['events'] if e.get('attr', None) == 'outline_line_color'] - self.assertTrue(bool(events)) - self.assertEqual(events[-1]['new']['value'], '#444444') + self.renderer.components(plot, 'html') + self.assertEqual(plot.state.outline_line_color, '#444444') + + def test_render_to_png(self): + curve = Curve([]) + renderer = BokehRenderer.instance(fig='png') + try: + png, info = renderer(curve) + except RuntimeError: + raise SkipTest("Test requires selenium") + self.assertIsInstance(png, bytes) + self.assertEqual(info['file-ext'], 'png') + + def test_render_static(self): + curve = Curve([]) + obj, _ = self.renderer._validate(curve, None) + self.assertIsInstance(obj, pn.pane.HoloViews) + self.assertEqual(obj.center, True) + self.assertIs(obj.renderer, self.renderer) + self.assertEqual(obj.backend, 'bokeh') + + def test_render_holomap_individual(self): + hmap = HoloMap({i: Curve([1, 2, i]) for i in range(5)}) + obj, _ = self.renderer._validate(hmap, None) + self.assertIsInstance(obj, pn.pane.HoloViews) + self.assertEqual(obj.center, True) + self.assertEqual(obj.widget_location, 'right') + self.assertEqual(obj.widget_type, 'individual') + widgets = obj.layout.select(DiscreteSlider) + self.assertEqual(len(widgets), 1) + slider = widgets[0] + self.assertEqual(slider.options, OrderedDict([(str(i), i) for i in range(5)])) + + def test_render_holomap_embedded(self): + hmap = HoloMap({i: Curve([1, 2, i]) for i in range(5)}) + data, _ = self.renderer.components(hmap) + self.assertIn('State', data['text/html']) + + def test_render_holomap_not_embedded(self): + hmap = HoloMap({i: Curve([1, 2, i]) for i in range(5)}) + data, _ = self.renderer.instance(widget_mode='live').components(hmap) + self.assertNotIn('State', data['text/html']) + + def test_render_holomap_scrubber(self): + hmap = HoloMap({i: Curve([1, 2, i]) for i in range(5)}) + obj, _ = self.renderer._validate(hmap, 'scrubber') + self.assertIsInstance(obj, pn.pane.HoloViews) + self.assertEqual(obj.center, True) + self.assertEqual(obj.widget_location, 'bottom') + self.assertEqual(obj.widget_type, 'scrubber') + widgets = obj.layout.select(Player) + self.assertEqual(len(widgets), 1) + player = widgets[0] + self.assertEqual(player.start, 0) + self.assertEqual(player.end, 4) + + def test_render_holomap_scrubber_fps(self): + hmap = HoloMap({i: Curve([1, 2, i]) for i in range(5)}) + obj, _ = self.renderer.instance(fps=2)._validate(hmap, 'scrubber') + self.assertIsInstance(obj, pn.pane.HoloViews) + widgets = obj.layout.select(Player) + self.assertEqual(len(widgets), 1) + player = widgets[0] + self.assertEqual(player.interval, 500) + + def test_render_holomap_individual_widget_position(self): + hmap = HoloMap({i: Curve([1, 2, i]) for i in range(5)}) + obj, _ = self.renderer.instance(widget_location='top')._validate(hmap, None) + self.assertIsInstance(obj, pn.pane.HoloViews) + self.assertEqual(obj.center, True) + self.assertEqual(obj.widget_location, 'top') + self.assertEqual(obj.widget_type, 'individual') + + def test_render_dynamicmap_with_dims(self): + dmap = DynamicMap(lambda y: Curve([1, 2, y]), kdims=['y']).redim.range(y=(0.1, 5)) + obj, _ = self.renderer._validate(dmap, None) + self.renderer.components(obj) + [(plot, pane)] = obj._plots.values() + cds = plot.handles['cds'] + + self.assertEqual(cds.data['y'][2], 0.1) + slider = obj.layout.select(FloatSlider)[0] + slider.value = 3.1 + self.assertEqual(cds.data['y'][2], 3.1) + + def test_render_dynamicmap_with_stream(self): + stream = Stream.define(str('Custom'), y=2)() + dmap = DynamicMap(lambda y: Curve([1, 2, y]), kdims=['y'], streams=[stream]) + obj, _ = self.renderer._validate(dmap, None) + self.renderer.components(obj) + [(plot, pane)] = obj._plots.values() + cds = plot.handles['cds'] + + self.assertEqual(cds.data['y'][2], 2) + stream.event(y=3) + self.assertEqual(cds.data['y'][2], 3) + + def test_render_dynamicmap_with_stream_dims(self): + stream = Stream.define(str('Custom'), y=2)() + dmap = DynamicMap(lambda x, y: Curve([x, 1, y]), kdims=['x', 'y'], + streams=[stream]).redim.values(x=[1, 2, 3]) + obj, _ = self.renderer._validate(dmap, None) + self.renderer.components(obj) + [(plot, pane)] = obj._plots.values() + cds = plot.handles['cds'] + + self.assertEqual(cds.data['y'][2], 2) + stream.event(y=3) + self.assertEqual(cds.data['y'][2], 3) + + self.assertEqual(cds.data['y'][0], 1) + slider = obj.layout.select(DiscreteSlider)[0] + slider.value = 3 + self.assertEqual(cds.data['y'][0], 3) diff --git a/holoviews/tests/plotting/bokeh/testserver.py b/holoviews/tests/plotting/bokeh/testserver.py index 103d356c22..740d735c07 100644 --- a/holoviews/tests/plotting/bokeh/testserver.py +++ b/holoviews/tests/plotting/bokeh/testserver.py @@ -1,11 +1,15 @@ +import time +import threading + from unittest import SkipTest +from threading import Event from holoviews.core.spaces import DynamicMap from holoviews.core.options import Store from holoviews.element import Curve, Polygons, Path, HLine from holoviews.element.comparison import ComparisonTestCase from holoviews.plotting import Renderer -from holoviews.streams import RangeXY, PlotReset +from holoviews.streams import Stream, RangeXY, PlotReset try: from bokeh.application.handlers import FunctionHandler @@ -13,12 +17,16 @@ from bokeh.client import pull_session from bokeh.document import Document from bokeh.io import curdoc + from bokeh.models import ColumnDataSource from bokeh.server.server import Server from holoviews.plotting.bokeh.callbacks import ( Callback, RangeXYCallback, ResetCallback ) from holoviews.plotting.bokeh.renderer import BokehRenderer + from panel.widgets import DiscreteSlider, FloatSlider + from panel.io.server import StoppableThread + from panel.io.state import state bokeh_renderer = BokehRenderer.instance(mode='server') except: bokeh_renderer = None @@ -39,6 +47,8 @@ def tearDown(self): bokeh_renderer.last_plot = None Callback._callbacks = {} Renderer.notebook_context = self.nbcontext + state.curdoc = None + curdoc().clear() def test_render_server_doc_element(self): obj = Curve([]) @@ -89,43 +99,77 @@ def setUp(self): if not bokeh_renderer: raise SkipTest("Bokeh required to test plot instantiation") Store.current_backend = 'bokeh' + self._loaded = Event() + self._port = None + self._thread = None + self._server = None def tearDown(self): Store.current_backend = self.previous_backend Callback._callbacks = {} - - def test_launch_simple_server(self): - obj = Curve([]) + if self._thread is not None: + try: + self._thread.stop() + except: + pass + state._thread_id = None + if self._server is not None: + try: + self._server.stop() + except: + pass + time.sleep(1) + + def _launcher(self, obj, threaded=False, io_loop=None): + if io_loop: + io_loop.make_current() launched = [] def modify_doc(doc): bokeh_renderer(obj, doc=doc) launched.append(True) - server.stop() handler = FunctionHandler(modify_doc) app = Application(handler) - server = Server({'/': app}, port=0) + server = Server({'/': app}, port=0, io_loop=io_loop) server.start() - url = "http://localhost:" + str(server.port) + "/" - pull_session(session_id='Test', url=url, io_loop=server.io_loop) - self.assertTrue(len(launched)==1) + self._port = server.port + self._server = server + if threaded: + server.io_loop.add_callback(self._loaded.set) + thread = threading.current_thread() + state._thread_id = thread.ident if thread else None + io_loop.start() + else: + url = "http://localhost:" + str(server.port) + "/" + session = pull_session(session_id='Test', url=url, io_loop=server.io_loop) + self.assertTrue(len(launched)==1) + return session, server + return None, server + + def _threaded_launcher(self, obj): + from tornado.ioloop import IOLoop + io_loop = IOLoop() + thread = StoppableThread(target=self._launcher, io_loop=io_loop, + args=(obj, True, io_loop)) + thread.setDaemon(True) + thread.start() + self._loaded.wait() + self._thread = thread + return self.session + + @property + def session(self): + url = "http://localhost:" + str(self._port) + "/" + return pull_session(session_id='Test', url=url) + + def test_launch_simple_server(self): + obj = Curve([]) + self._launcher(obj) def test_launch_server_with_stream(self): obj = Curve([]) stream = RangeXY(source=obj) - launched = [] - def modify_doc(doc): - bokeh_renderer(obj, doc=doc) - launched.append(True) - server.stop() - handler = FunctionHandler(modify_doc) - app = Application(handler) - server = Server({'/': app}, port=0) - server.start() - url = "http://localhost:" + str(server.port) + "/" - pull_session(session_id='Test', url=url, io_loop=server.io_loop) - - self.assertTrue(len(launched)==1) + _, server = self._launcher(obj) cb = bokeh_renderer.last_plot.callbacks[0] self.assertIsInstance(cb, RangeXYCallback) self.assertEqual(cb.streams, [stream]) @@ -135,7 +179,7 @@ def modify_doc(doc): y_range = bokeh_renderer.last_plot.handles['y_range'] self.assertIn(cb.on_change, y_range._callbacks['start']) self.assertIn(cb.on_change, y_range._callbacks['end']) - + server.stop() def test_launch_server_with_complex_plot(self): dmap = DynamicMap(lambda x_range, y_range: Curve([]), streams=[RangeXY()]) @@ -143,15 +187,64 @@ def test_launch_server_with_complex_plot(self): static = Polygons([]) * Path([]) * Curve([]) layout = overlay + static - launched = [] - def modify_doc(doc): - bokeh_renderer(layout, doc=doc) - launched.append(True) - server.stop() - handler = FunctionHandler(modify_doc) - app = Application(handler) - server = Server({'/': app}, port=0) - server.start() - url = "http://localhost:" + str(server.port) + "/" - pull_session(session_id='Test', url=url, io_loop=server.io_loop) - self.assertTrue(len(launched)==1) + _, server = self._launcher(layout) + server.stop() + + def test_server_dynamicmap_with_dims(self): + dmap = DynamicMap(lambda y: Curve([1, 2, y]), kdims=['y']).redim.range(y=(0.1, 5)) + obj, _ = bokeh_renderer._validate(dmap, None) + session = self._threaded_launcher(obj) + [(plot, _)] = obj._plots.values() + [(doc, _)] = obj.layout._documents.items() + + cds = session.document.roots[0].select_one({'type': ColumnDataSource}) + self.assertEqual(cds.data['y'][2], 0.1) + slider = obj.layout.select(FloatSlider)[0] + def run(): + slider.value = 3.1 + doc.add_next_tick_callback(run) + time.sleep(1) + cds = self.session.document.roots[0].select_one({'type': ColumnDataSource}) + self.assertEqual(cds.data['y'][2], 3.1) + + def test_server_dynamicmap_with_stream(self): + stream = Stream.define('Custom', y=2)() + dmap = DynamicMap(lambda y: Curve([1, 2, y]), kdims=['y'], streams=[stream]) + obj, _ = bokeh_renderer._validate(dmap, None) + session = self._threaded_launcher(obj) + [(doc, _)] = obj.layout._documents.items() + + cds = session.document.roots[0].select_one({'type': ColumnDataSource}) + self.assertEqual(cds.data['y'][2], 2) + def run(): + stream.event(y=3) + doc.add_next_tick_callback(run) + time.sleep(1) + cds = self.session.document.roots[0].select_one({'type': ColumnDataSource}) + self.assertEqual(cds.data['y'][2], 3) + + def test_server_dynamicmap_with_stream_dims(self): + stream = Stream.define('Custom', y=2)() + dmap = DynamicMap(lambda x, y: Curve([x, 1, y]), kdims=['x', 'y'], + streams=[stream]).redim.values(x=[1, 2, 3]) + obj, _ = bokeh_renderer._validate(dmap, None) + session = self._threaded_launcher(obj) + [(doc, _)] = obj.layout._documents.items() + + orig_cds = session.document.roots[0].select_one({'type': ColumnDataSource}) + self.assertEqual(orig_cds.data['y'][2], 2) + def run(): + stream.event(y=3) + doc.add_next_tick_callback(run) + time.sleep(1) + cds = self.session.document.roots[0].select_one({'type': ColumnDataSource}) + self.assertEqual(cds.data['y'][2], 3) + + self.assertEqual(orig_cds.data['y'][0], 1) + slider = obj.layout.select(DiscreteSlider)[0] + def run(): + slider.value = 3 + doc.add_next_tick_callback(run) + time.sleep(1) + cds = self.session.document.roots[0].select_one({'type': ColumnDataSource}) + self.assertEqual(cds.data['y'][0], 3) diff --git a/holoviews/tests/plotting/matplotlib/testrenderer.py b/holoviews/tests/plotting/matplotlib/testrenderer.py index c6360e862a..58443479d6 100644 --- a/holoviews/tests/plotting/matplotlib/testrenderer.py +++ b/holoviews/tests/plotting/matplotlib/testrenderer.py @@ -8,17 +8,26 @@ import sys import subprocess +from collections import OrderedDict from unittest import SkipTest import numpy as np +import param -from holoviews import HoloMap, Image, ItemTable, Store, GridSpace, Table +from holoviews import (DynamicMap, HoloMap, Image, ItemTable, Store, + GridSpace, Table, Curve) from holoviews.element.comparison import ComparisonTestCase +from holoviews.streams import Stream +from pyviz_comms import CommManager try: - from holoviews.plotting.mpl import MPLRenderer + import panel as pn + + from holoviews.plotting.mpl import MPLRenderer, CurvePlot + from holoviews.plotting.renderer import Renderer + from panel.widgets import DiscreteSlider, Player, FloatSlider except: - pass + pn = None class MPLRendererTest(ComparisonTestCase): @@ -28,8 +37,8 @@ class MPLRendererTest(ComparisonTestCase): """ def setUp(self): - if 'matplotlib' not in Store.renderers: - raise SkipTest("Matplotlib required to test widgets") + if 'matplotlib' not in Store.renderers and pn is not None: + raise SkipTest("Matplotlib and Panel required to test rendering.") self.basename = 'no-file' self.image1 = Image(np.array([[0,1],[2,3]]), label='Image1') @@ -40,6 +49,16 @@ def setUp(self): label='Poincaré', group='α Festkörperphysik') self.renderer = MPLRenderer.instance() + self.nbcontext = Renderer.notebook_context + self.comm_manager = Renderer.comm_manager + with param.logging_level('ERROR'): + Renderer.notebook_context = False + Renderer.comm_manager = CommManager + + def tearDown(self): + with param.logging_level('ERROR'): + Renderer.notebook_context = self.nbcontext + Renderer.comm_manager = self.comm_manager def test_get_size_single_plot(self): plot = self.renderer.get_plot(self.image1) @@ -83,3 +102,110 @@ def test_render_mp4(self): raise SkipTest('ffmpeg not available, skipping mp4 export test') data, metadata = self.renderer.components(self.map1, 'mp4') self.assertIn("=2.1', 'bokeh>=1.1.0,<2.0.0', 'panel'] + 'pandas', 'matplotlib>=2.1', 'bokeh>=1.1.0,<2.0.0', 'panel>=0.7.0a8'] # Requirements to run all examples extras_require['examples'] = extras_require['recommended'] + [ @@ -32,7 +32,7 @@ 'cyordereddict', 'pscript==0.7.1'] # Test requirements -extras_require['tests'] = ['nose', 'flake8==3.6.0', 'coveralls', 'path.py', 'matplotlib>=2.1,<3.1'] +extras_require['tests'] = ['nose', 'mock', 'flake8==3.6.0', 'coveralls', 'path.py', 'matplotlib>=2.1,<3.1'] extras_require['unit_tests'] = extras_require['examples']+extras_require['tests'] @@ -142,9 +142,7 @@ def get_setup_version(reponame): "holoviews.tests.util"], package_data={'holoviews': ['.version'], 'holoviews.ipython': ['*.html'], - 'holoviews.plotting.mpl': ['*.mplstyle', '*.jinja', '*.js'], - 'holoviews.plotting.bokeh': ['*.js', '*.css'], - 'holoviews.plotting.plotly': ['*.js'], + 'holoviews.plotting.mpl': ['*.mplstyle'], 'holoviews.tests.ipython.notebooks': ['*.ipynb']}, classifiers=[ "License :: OSI Approved :: BSD License",