From 3750b38f263bc6a5f407791787081d3e3bd212df Mon Sep 17 00:00:00 2001 From: BenPortner <52913510+BenPortner@users.noreply.github.com> Date: Fri, 9 Aug 2019 13:10:07 +0200 Subject: [PATCH 1/9] fixed "OSError: [Errno 22] Invalid argument" for dates < 1970 (#3879) --- holoviews/core/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/holoviews/core/util.py b/holoviews/core/util.py index 75acb952f7..0cece0767b 100644 --- a/holoviews/core/util.py +++ b/holoviews/core/util.py @@ -1846,7 +1846,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): From ddadf09750883be7f3354552dde32d751ea18ffa Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 14 Aug 2019 02:30:05 +0200 Subject: [PATCH 2/9] Fix for rendering Scatter3D with matplotlib 3.1 (#3898) --- holoviews/plotting/mpl/chart3d.py | 2 ++ 1 file changed, 2 insertions(+) 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): From e2902ec1bdaeae683a9ec20ef10aeb8585d09a0e Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 14 Aug 2019 19:45:34 +0200 Subject: [PATCH 3/9] Ensure dask.dataframe is imported to use DaskInterface (#3900) --- holoviews/core/data/dask.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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): From 272391ffe4775d2afa2addeaa2a94e03f76d5c92 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 16 Aug 2019 00:46:52 +0200 Subject: [PATCH 4/9] Replace HoloViews widgets with Panel (#3836) --- .travis.yml | 4 +- doc/nbpublisher | 2 +- examples/user_guide/17-Dashboards.ipynb | 2 +- examples/user_guide/Plots_and_Renderers.ipynb | 4 +- holoviews/core/util.py | 30 +- holoviews/ipython/__init__.py | 29 +- holoviews/ipython/load_notebook.html | 2 - holoviews/plotting/bokeh/bokehwidgets.css | 12 - holoviews/plotting/bokeh/bokehwidgets.js | 68 --- holoviews/plotting/bokeh/plot.py | 80 +-- holoviews/plotting/bokeh/renderer.py | 251 +------- holoviews/plotting/bokeh/widgets.py | 324 ---------- holoviews/plotting/mpl/mplwidgets.js | 40 -- holoviews/plotting/mpl/plot.py | 11 +- holoviews/plotting/mpl/renderer.py | 49 +- holoviews/plotting/mpl/widgets.py | 49 -- holoviews/plotting/plot.py | 190 ++++-- holoviews/plotting/plotly/plot.py | 11 +- holoviews/plotting/plotly/plotlywidgets.js | 46 -- holoviews/plotting/plotly/renderer.py | 117 +--- holoviews/plotting/plotly/widgets.py | 45 -- holoviews/plotting/renderer.py | 397 ++++++------ holoviews/plotting/util.py | 16 +- holoviews/plotting/widgets/__init__.py | 489 --------------- holoviews/plotting/widgets/htmlscrubber.jinja | 24 - holoviews/plotting/widgets/htmlslider.jinja | 36 -- holoviews/plotting/widgets/jsscrubber.jinja | 11 - holoviews/plotting/widgets/jsslider.css | 93 --- holoviews/plotting/widgets/jsslider.jinja | 33 - holoviews/plotting/widgets/widgets.js | 567 ------------------ .../tests/plotting/bokeh/testlayoutplot.py | 16 +- .../tests/plotting/bokeh/testrenderer.py | 157 ++++- holoviews/tests/plotting/bokeh/testserver.py | 163 +++-- holoviews/tests/plotting/bokeh/testwidgets.py | 439 -------------- .../tests/plotting/matplotlib/testrenderer.py | 136 ++++- .../tests/plotting/matplotlib/testwidgets.py | 53 -- .../tests/plotting/plotly/testrenderer.py | 150 +++++ .../tests/plotting/plotly/testwidgets.py | 27 - holoviews/util/__init__.py | 8 +- holoviews/util/settings.py | 71 ++- setup.py | 6 +- 41 files changed, 978 insertions(+), 3280 deletions(-) delete mode 100644 holoviews/plotting/bokeh/bokehwidgets.css delete mode 100644 holoviews/plotting/bokeh/bokehwidgets.js delete mode 100644 holoviews/plotting/bokeh/widgets.py delete mode 100644 holoviews/plotting/mpl/mplwidgets.js delete mode 100644 holoviews/plotting/mpl/widgets.py delete mode 100644 holoviews/plotting/plotly/plotlywidgets.js delete mode 100644 holoviews/plotting/plotly/widgets.py delete mode 100644 holoviews/plotting/widgets/__init__.py delete mode 100644 holoviews/plotting/widgets/htmlscrubber.jinja delete mode 100644 holoviews/plotting/widgets/htmlslider.jinja delete mode 100644 holoviews/plotting/widgets/jsscrubber.jinja delete mode 100644 holoviews/plotting/widgets/jsslider.css delete mode 100644 holoviews/plotting/widgets/jsslider.jinja delete mode 100644 holoviews/plotting/widgets/widgets.js delete mode 100644 holoviews/tests/plotting/bokeh/testwidgets.py delete mode 100644 holoviews/tests/plotting/matplotlib/testwidgets.py create mode 100644 holoviews/tests/plotting/plotly/testrenderer.py delete mode 100644 holoviews/tests/plotting/plotly/testwidgets.py 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/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/util.py b/holoviews/core/util.py index 0cece0767b..0027e04785 100644 --- a/holoviews/core/util.py +++ b/holoviews/core/util.py @@ -1,23 +1,23 @@ -import os, sys, warnings, operator +import sys, warnings, operator +import json import time import types import numbers import inspect import itertools -import string, fnmatch +import string import unicodedata import datetime as dt -from collections import defaultdict + +from distutils.version import LooseVersion as _LooseVersion from functools import partial +from collections import defaultdict from contextlib import contextmanager -from distutils.version import LooseVersion as _LooseVersion - from threading import Thread, Event + import numpy as np import param -import json - try: from cyordereddict import OrderedDict except: @@ -1407,22 +1407,6 @@ def get_spec(obj): obj.group, obj.label) -def find_file(folder, filename): - """ - Find a file given folder and filename. If the filename can be - resolved directly returns otherwise walks the supplied folder. - """ - matches = [] - if os.path.isabs(filename) and os.path.isfile(filename): - return filename - for root, _, filenames in os.walk(folder): - for fn in fnmatch.filter(filenames, filename): - matches.append(os.path.join(root, fn)) - if not matches: - raise IOError('File %s could not be found' % filename) - return matches[-1] - - def is_dataframe(data): """ Checks whether the supplied data is of DataFrame type. diff --git a/holoviews/ipython/__init__.py b/holoviews/ipython/__init__.py index d14a71a1c4..c2e2a877ff 100644 --- a/holoviews/ipython/__init__.py +++ b/holoviews/ipython/__init__.py @@ -8,14 +8,13 @@ from IPython.core.completer import IPCompleter from IPython.display import HTML, publish_display_data from param import ipython as param_ext -from pyviz_comms import nb_mime_js from ..core.dimension import LabelledData from ..core.tree import AttrTree from ..core.options import Store from ..element.comparison import ComparisonTestCase from ..util import extension -from ..plotting.renderer import Renderer, MIME_TYPES +from ..plotting.renderer import Renderer from .magics import load_magics from .display_hooks import display # noqa (API import) from .display_hooks import pprint_display, png_display, svg_display @@ -172,15 +171,16 @@ def __call__(self, *args, **params): css += '' % p.width if p.css: css += '' % p.css + if css: display(HTML(css)) resources = list(resources) if len(resources) == 0: return - Renderer.load_nb() for r in [r for r in resources if r != 'holoviews']: Store.renderers[r].load_nb(inline=p.inline) + Renderer.load_nb() if hasattr(ip, 'kernel') and not loaded: Renderer.comm_manager.get_client_comm(notebook_extension._process_comm_msg, @@ -191,8 +191,7 @@ def __call__(self, *args, **params): bokeh_logo= p.logo and ('bokeh' in resources), mpl_logo= p.logo and (('matplotlib' in resources) or resources==['holoviews']), - plotly_logo= p.logo and ('plotly' in resources), - JS=('holoviews' in resources)) + plotly_logo= p.logo and ('plotly' in resources)) @classmethod def completions_sorting_key(cls, word): @@ -246,35 +245,17 @@ def load_hvjs(cls, logo=False, bokeh_logo=False, mpl_logo=False, plotly_logo=Fal Displays javascript and CSS to initialize HoloViews widgets. """ import jinja2 - # Evaluate load_notebook.html template with widgetjs code - if JS: - widgetjs, widgetcss = Renderer.html_assets(extras=False, backends=[], script=True) - else: - widgetjs, widgetcss = '', '' - - # Add classic notebook MIME renderer - widgetjs += nb_mime_js templateLoader = jinja2.FileSystemLoader(os.path.dirname(os.path.abspath(__file__))) jinjaEnv = jinja2.Environment(loader=templateLoader) template = jinjaEnv.get_template('load_notebook.html') - html = template.render({'widgetcss': widgetcss, - 'logo': logo, + html = template.render({'logo': logo, 'bokeh_logo': bokeh_logo, 'mpl_logo': mpl_logo, 'plotly_logo': plotly_logo, 'message': message}) publish_display_data(data={'text/html': html}) - # Vanilla JS mime type is only consumed by classic notebook - # Custom mime type is only consumed by JupyterLab - if JS: - mimebundle = { - MIME_TYPES['js'] : widgetjs, - MIME_TYPES['jlab-hv-load'] : widgetjs - } - publish_display_data(data=mimebundle) - @param.parameterized.bothmethod def tab_completion_docstring(self_or_cls): diff --git a/holoviews/ipython/load_notebook.html b/holoviews/ipython/load_notebook.html index cc5371d18c..72deaecc80 100644 --- a/holoviews/ipython/load_notebook.html +++ b/holoviews/ipython/load_notebook.html @@ -1,5 +1,3 @@ -{{ widgetcss }} - {% if logo %}
- {plot_script} - -""" default_theme = Theme(json={ 'attrs': { @@ -45,77 +29,36 @@ 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""") - widgets = {'scrubber': BokehScrubberWidget, - 'widgets': BokehSelectionWidget, - 'server': BokehServerWidgets} - - backend_dependencies = {'js': CDN.js_files if CDN.js_files else tuple(INLINE.js_raw), - 'css': CDN.css_files if CDN.css_files else tuple(INLINE.css_raw)} + # Defines the valid output formats for each mode. + mode_formats = {'fig': ['html', 'auto', 'png'], + 'holomap': ['widgets', 'scrubber', 'auto', None]} _loaded = False - - # Define the handler for updating bokeh plots - comm_msg_handler = bokeh_msg_handler - - 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, tuple(self.widgets.values())): - return plot(), info - elif fmt == 'png': - png = self._figure_data(plot, fmt=fmt, doc=doc) - return png, info - elif fmt == 'html': - html = self._figure_data(plot, doc=doc) - html = "
%s
" % html - return self._apply_post_render_hooks(html, obj, fmt), info - elif fmt == 'json': - return self.diff(plot), info + _render_with_panel = True @bothmethod def _save_prefix(self_or_cls, ext): "Hook to prefix content for instance JS when saving HTML" - if ext == 'html': - return '\n'.join(self_or_cls.html_assets()).encode('utf8') return @@ -126,131 +69,14 @@ 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 get_widget(self_or_cls, plot, widget_type, doc=None, **kwargs): - if not isinstance(plot, Plot): - plot = self_or_cls.get_plot(plot, doc) - if self_or_cls.mode == 'server': - return BokehServerWidgets(plot, renderer=self_or_cls.instance(), **kwargs) - else: - return super(BokehRenderer, self_or_cls).get_widget(plot, widget_type, **kwargs) - - - @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 not isinstance(self_or_cls, BokehRenderer) or self_or_cls.mode != 'server': - renderer = self_or_cls.instance(mode='server') - else: - renderer = self_or_cls - - def modify_doc(doc): - renderer(plot, doc=doc) - handler = FunctionHandler(modify_doc) - app = Application(handler) - - if not show: - # If not showing and in notebook context return app - return app - elif self_or_cls.notebook_context and not new_window: - # If in notebook, show=True and no new window requested - # display app inline - if isinstance(websocket_origin, list): - if len(websocket_origin) > 1: - raise ValueError('In the notebook only a single websocket origin ' - 'may be defined, which must match the URL of the ' - 'notebook server.') - websocket_origin = websocket_origin[0] - opts = dict(notebook_url=websocket_origin) if websocket_origin else {} - return bkshow(app, **opts) - - # If app shown outside notebook or new_window requested - # start server and open in new browser tab - from tornado.ioloop import IOLoop - loop = IOLoop.current() - if websocket_origin and not isinstance(websocket_origin, list): - websocket_origin = [websocket_origin] - opts = dict(allow_websocket_origin=websocket_origin) if websocket_origin else {} - opts['io_loop'] = loop - server = Server({'/': app}, port=port, **opts) - def show_callback(): - server.show('/') - server.io_loop.add_callback(show_callback) - server.start() - - def sig_exit(*args, **kwargs): - loop.add_callback_from_signal(do_stop) - - def do_stop(*args, **kwargs): - loop.stop() - - signal.signal(signal.SIGINT, sig_exit) - try: - loop.start() - except RuntimeError: - pass - return server - - - @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, (Plot, BokehServerWidgets)): - if not isinstance(self_or_cls, BokehRenderer) or self_or_cls.mode != 'server': - renderer = self_or_cls.instance(mode='server') - else: - renderer = self_or_cls - plot, _ = renderer._validate(obj, 'auto') - else: - plot = obj - - root = plot.state - if isinstance(plot, BokehServerWidgets): - plot = plot.plot - - if doc is None: - doc = plot.document - else: - plot.document = doc - - plot.traverse(lambda x: attach_periodic(x), [GenericElementPlot]) - doc.add_root(root) - return doc - - - def components(self, obj, fmt=None, comm=True, **kwargs): - # Bokeh has to handle comms directly in <0.12.15 - comm = False if bokeh_version < '0.12.15' else comm - return super(BokehRenderer, self).components(obj,fmt, comm, **kwargs) - - - def _figure_data(self, plot, fmt='html', doc=None, as_script=False, **kwargs): + def _figure_data(self, plot, fmt, doc=None, as_script=False, **kwargs): """ Given a plot instance, an output format and an optional bokeh document, return the corresponding data. If as_script is True, @@ -284,36 +110,14 @@ def _figure_data(self, plot, fmt='html', doc=None, as_script=False, **kwargs): (mime_type, tag) = MIME_TYPES[fmt], HTML_TAGS[fmt] src = HTML_TAGS['base64'].format(mime_type=mime_type, b64=b64) div = tag.format(src=src, mime_type=mime_type, css='') - js = '' else: - try: - with silence_warnings(EMPTY_LAYOUT, MISSING_RENDERERS): - js, div, _ = notebook_content(model) - html = NOTEBOOK_DIV.format(plot_script=js, plot_div=div) - data = encode_utf8(html) - doc.hold() - except: - logger.disabled = False - raise - logger.disabled = False + div = render_mimebundle(plot.state, doc, plot.comm)[0]['text/html'] plot.document = doc if as_script: - return div, js - 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 + return div + else: + return data @classmethod @@ -364,11 +168,4 @@ def get_size(self_or_cls, plot): @classmethod def load_nb(cls, inline=True): - """ - Loads the bokeh notebook resources. - """ - LOAD_MIME_TYPE = bokeh.io.notebook.LOAD_MIME_TYPE - bokeh.io.notebook.LOAD_MIME_TYPE = MIME_TYPES['jlab-hv-load'] - load_notebook(hide_banner=True, resources=INLINE if inline else CDN) - bokeh.io.notebook.LOAD_MIME_TYPE = LOAD_MIME_TYPE - bokeh.io.notebook.curstate().output_notebook() + cls._loaded = True diff --git a/holoviews/plotting/bokeh/widgets.py b/holoviews/plotting/bokeh/widgets.py deleted file mode 100644 index 283da4adc5..0000000000 --- a/holoviews/plotting/bokeh/widgets.py +++ /dev/null @@ -1,324 +0,0 @@ -from __future__ import absolute_import, division, unicode_literals - -import math -import json -from functools import partial - -import param -import numpy as np -from bokeh.models.widgets import Select, Slider, AutocompleteInput, TextInput, Div -from bokeh.layouts import widgetbox, row, column - -from ...core import Store, NdMapping, OrderedDict -from ...core.util import (drop_streams, unique_array, isnumeric, - wrap_tuple_streams, unicode) -from ..renderer import MIME_TYPES -from ..widgets import NdWidget, SelectionWidget, ScrubberWidget -from .util import serialize_json - - - -class BokehServerWidgets(param.Parameterized): - """ - BokehServerWidgets create bokeh widgets corresponding to all the - key dimensions found on a BokehPlot instance. It currently supports - two types of widgets: sliders (which may be discrete or continuous), - and dropdown widgets letting you select non-numeric values. - """ - - display_options = param.Dict(default={}, doc=""" - Additional options controlling display options of the widgets.""") - - editable = param.Boolean(default=False, doc=""" - Whether the slider text fields should be editable. Disabled - by default for a more compact widget layout.""") - - position = param.ObjectSelector(default='right', - objects=['right', 'left', 'above', 'below']) - - sizing_mode = param.ObjectSelector(default='fixed', - objects=['fixed', 'stretch_both', 'scale_width', - 'scale_height', 'scale_both']) - - width = param.Integer(default=250, doc=""" - Width of the widget box in pixels""") - - basejs = param.String(default=None, precedence=-1, doc=""" - Defines the local CSS file to be loaded for this widget.""") - - extensionjs = param.String(default=None, precedence=-1, doc=""" - Optional javascript extension file for a particular backend.""") - - css = param.String(default=None, precedence=-1, doc=""" - Defines the local CSS file to be loaded for this widget.""") - - def __init__(self, plot, renderer=None, **params): - super(BokehServerWidgets, self).__init__(**params) - self.plot = plot - streams = [] - for stream in plot.streams: - if any(k in plot.dimensions for k in stream.contents): - streams.append(stream) - self.dimensions, self.keys = drop_streams(streams, - plot.dimensions, - plot.keys) - if renderer is None: - backend = Store.current_backend - self.renderer = Store.renderers[backend] - else: - self.renderer = renderer - # Create mock NdMapping to hold the common dimensions and keys - self.mock_obj = NdMapping([(k, None) for k in self.keys], - kdims=list(self.dimensions)) - self.widgets, self.lookups = self.get_widgets() - self.subplots = {} - if self.plot.renderer.mode == 'default': - self.attach_callbacks() - self.state = self.init_layout() - self._queue = [] - self._active = False - - if hasattr(self.plot.document, 'on_session_destroyed'): - self.plot.document.on_session_destroyed(self.plot._session_destroy) - - - @classmethod - def create_widget(self, dim, holomap=None, editable=False): - """" - Given a Dimension creates bokeh widgets to select along that - dimension. For numeric data a slider widget is created which - may be either discrete, if a holomap is supplied or the - Dimension.values are set, or a continuous widget for - DynamicMaps. If the slider is discrete the returned mapping - defines a mapping between values and labels making it possible - sync the two slider and label widgets. For non-numeric data - a simple dropdown selection widget is generated. - """ - label, mapping = None, None - if holomap is None: - if dim.values: - if dim.default is None: - default = dim.values[0] - elif dim.default not in dim.values: - raise ValueError("%s dimension default %r is not in dimension values: %s" - % (dim, dim.default, dim.values)) - else: - default = dim.default - value = dim.values.index(default) - - if all(isnumeric(v) for v in dim.values): - values = sorted(dim.values) - labels = [unicode(dim.pprint_value(v)) for v in values] - if editable: - label = AutocompleteInput(value=labels[value], completions=labels, - title=dim.pprint_label) - else: - label = Div(text='%s' % dim.pprint_value_string(labels[value])) - widget = Slider(value=value, start=0, end=len(dim.values)-1, title=None, step=1) - mapping = list(enumerate(zip(values, labels))) - else: - values = [(v, dim.pprint_value(v)) for v in dim.values] - widget = Select(title=dim.pprint_label, value=values[value][0], - options=values) - else: - start = dim.soft_range[0] if dim.soft_range[0] else dim.range[0] - end = dim.soft_range[1] if dim.soft_range[1] else dim.range[1] - dim_range = end - start - int_type = isinstance(dim.type, type) and issubclass(dim.type, int) - if dim.step is not None: - step = dim.step - elif isinstance(dim_range, int) or int_type: - step = 1 - else: - step = 10**((round(math.log10(dim_range))-3)) - - if dim.default is None: - default = start - elif (dim.default < start or dim.default > end): - raise ValueError("%s dimension default %r is not in the provided range: %s" - % (dim, dim.default, (start, end))) - else: - default = dim.default - - if editable: - label = TextInput(value=str(default), title=dim.pprint_label) - else: - label = Div(text='%s' % dim.pprint_value_string(default)) - widget = Slider(value=default, start=start, - end=end, step=step, title=None) - else: - values = (dim.values if dim.values else - list(unique_array(holomap.dimension_values(dim.name)))) - if dim.default is None: - default = values[0] - elif dim.default not in values: - raise ValueError("%s dimension default %r is not in dimension values: %s" - % (dim, dim.default, values)) - else: - default = dim.default - if isinstance(values[0], np.datetime64) or isnumeric(values[0]): - values = sorted(values) - labels = [dim.pprint_value(v) for v in values] - value = values.index(default) - if editable: - label = AutocompleteInput(value=labels[value], completions=labels, - title=dim.pprint_label) - else: - label = Div(text='%s' % (dim.pprint_value_string(labels[value]))) - widget = Slider(value=value, start=0, end=len(values)-1, title=None, step=1) - else: - labels = [dim.pprint_value(v) for v in values] - widget = Select(title=dim.pprint_label, value=default, - options=list(zip(values, labels))) - mapping = list(enumerate(zip(values, labels))) - return widget, label, mapping - - - def get_widgets(self): - """ - Creates a set of widgets representing the dimensions on the - plot object used to instantiate the widgets class. - """ - widgets = OrderedDict() - mappings = {} - for dim in self.mock_obj.kdims: - holomap = None if self.plot.dynamic else self.mock_obj - widget, label, mapping = self.create_widget(dim, holomap, self.editable) - if label is not None and not isinstance(label, Div): - label.on_change('value', partial(self.on_change, dim, 'label')) - widget.on_change('value', partial(self.on_change, dim, 'widget')) - widgets[dim.pprint_label] = (label, widget) - if mapping: - mappings[dim.pprint_label] = OrderedDict(mapping) - return widgets, mappings - - - def init_layout(self): - widgets = [widget for d in self.widgets.values() - for widget in d if widget] - wbox = widgetbox(widgets, width=self.width) - if self.position in ['right', 'below']: - plots = [self.plot.state, wbox] - else: - plots = [wbox, self.plot.state] - layout_fn = row if self.position in ['left', 'right'] else column - layout = layout_fn(plots, sizing_mode=self.sizing_mode) - return layout - - - def attach_callbacks(self): - """ - Attach callbacks to interact with Comms. - """ - pass - - - def on_change(self, dim, widget_type, attr, old, new): - self._queue.append((dim, widget_type, attr, old, new)) - if not self._active: - self.plot.document.add_timeout_callback(self.update, 50) - self._active = True - - - def update(self): - """ - Handle update events on bokeh server. - """ - if not self._queue: - return - - dim, widget_type, attr, old, new = self._queue[-1] - self._queue = [] - dim_label = dim.pprint_label - - label, widget = self.widgets[dim_label] - if widget_type == 'label': - if isinstance(label, AutocompleteInput): - value = [new] - widget.value = value - else: - widget.value = float(new) - elif label: - lookups = self.lookups.get(dim_label) - if not self.editable: - if lookups: - new = lookups[widget.value][1] - label.text = '%s' % dim.pprint_value_string(new) - elif isinstance(label, AutocompleteInput): - text = lookups[new][1] - label.value = text - else: - label.value = dim.pprint_value(new) - - key = [] - for dim, (label, widget) in self.widgets.items(): - lookups = self.lookups.get(dim) - if label and lookups: - val = lookups[widget.value][0] - else: - val = widget.value - key.append(val) - key = wrap_tuple_streams(tuple(key), self.plot.dimensions, - self.plot.streams) - self.plot.update(key) - self._active = False - - - -class BokehWidget(NdWidget): - - css = param.String(default='bokehwidgets.css', doc=""" - Defines the local CSS file to be loaded for this widget.""") - - extensionjs = param.String(default='bokehwidgets.js', doc=""" - Optional javascript extension file for a particular backend.""") - - def _get_data(self): - msg, metadata = self.renderer.components(self.plot, comm=False) - data = super(BokehWidget, self)._get_data() - return dict(data, init_html=msg['text/html'], - init_js=msg[MIME_TYPES['js']], - plot_id=self.plot.state._id) - - def encode_frames(self, frames): - if self.export_json: - self.save_json(frames) - frames = {} - else: - frames = json.dumps(frames).replace('' - 'window.PLOTLYENV=window.PLOTLYENV || {};' - '') - - script = '\n'.join([ - 'var plotly = window._Plotly || window.Plotly;' - 'plotly.plot("{id}", {data}, {layout}, {config}).then(function() {{', - ' var elem = document.getElementById("{id}.loading"); elem.parentNode.removeChild(elem);', - '}})']).format(id=divuuid, - data=jdata, - layout=jlayout, - config=jconfig) - - html = ('
' - 'Drawing...
' - '
' - '
'.format(id=divuuid, height=height, width=width)) - if as_script: - return html, header + script - - content = ( - '{html}' - '' - ).format(html=html, script=script) - return '\n'.join([header, content]) - + raise ValueError("Unsupported format: {fmt}".format(fmt=fmt)) @classmethod def plot_options(cls, obj, percent_size): @@ -150,8 +66,5 @@ def load_nb(cls, inline=True): """ Loads the plotly notebook resources. """ - from IPython.display import publish_display_data + import panel.models.plotly # noqa cls._loaded = True - init_notebook_mode(connected=not inline) - publish_display_data(data={MIME_TYPES['jlab-hv-load']: - get_plotlyjs()}) diff --git a/holoviews/plotting/plotly/widgets.py b/holoviews/plotting/plotly/widgets.py deleted file mode 100644 index 50057721f6..0000000000 --- a/holoviews/plotting/plotly/widgets.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import absolute_import, division, unicode_literals - -import json - -import param - -from ..widgets import NdWidget, SelectionWidget, ScrubberWidget - -class PlotlyWidget(NdWidget): - - extensionjs = param.String(default='plotlywidgets.js', doc=""" - Optional javascript extension file for a particular backend.""") - - def _get_data(self): - msg, metadata = self.renderer.components(self.plot, divuuid=self.id, comm=False) - data = super(PlotlyWidget, self)._get_data() - return dict(data, init_html=msg['text/html'], - init_js=msg['application/javascript']) - - def encode_frames(self, frames): - frames = json.dumps(frames).replace(' - - {css} - {js} - {html} @@ -90,14 +92,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', @@ -105,19 +107,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""") @@ -134,29 +144,15 @@ 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': ScrubberWidget, 'widgets': SelectionWidget} - - core_dependencies = {'jQueryUI': {'js': ['https://code.jquery.com/ui/1.10.4/jquery-ui.min.js'], - 'css': ['https://code.jquery.com/ui/1.10.4/themes/smoothness/jquery-ui.css']}} - - extra_dependencies = {'jQuery': {'js': ['https://code.jquery.com/jquery-2.1.4.min.js']}, - 'underscore': {'js': ['https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.3/underscore-min.js']}, - 'require': {'js': ['https://cdnjs.cloudflare.com/ajax/libs/require.js/2.1.20/require.min.js']}, - 'bootstrap': {'css': ['https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css']}} - - # Any additional JS and CSS dependencies required by a specific backend - backend_dependencies = {} + widgets = ['scrubber', 'widgets'] # Whether in a notebook context, set when running Renderer.load_nb notebook_context = False @@ -164,13 +160,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, **kwargs): """ Given a HoloViews Viewable return a corresponding plot instance. """ @@ -207,6 +222,16 @@ 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 plot.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 @@ -215,25 +240,29 @@ def _validate(self, obj, fmt, **kwargs): Helper method to be used in the __call__ method to get a suitable plot or widget object and the appropriate format. """ - if isinstance(obj, tuple(self.widgets.values())): + if isinstance(obj, Viewable): return obj, 'html' - plot = self.get_plot(obj, renderer=self, **kwargs) - 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 (((len(plot) == 1 and not plot.dynamic) - or (len(plot) > 1 and self.holomap is None) or - (plot.dynamic and len(plot.keys[0]) == 0)) or - not unbound_dimensions(plot.streams, plot.dimensions, no_duplicates=False)): - fmt = fig_formats[0] if self.fig=='auto' else self.fig + if any(len(o) > 1 or (isinstance(o, DynamicMap) and unbound_dimensions(o.streams, o.kdims)) + for o in holomaps): + fmt = holomap_formats[0] if self.holomap in ['auto', None] else self.holomap else: - fmt = holomap_formats[0] if self.holomap=='auto' else self.holomap + fmt = fig_formats[0] if self.fig == 'auto' else self.fig if fmt in self.widgets: - plot = self.get_widget(plot, fmt, display_options={'fps': self.fps}) + plot = self.get_widget(obj, fmt) fmt = '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) all_formats = set(fig_formats + holomap_formats) if fmt not in all_formats: @@ -243,24 +272,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. @@ -275,7 +286,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 @@ -285,7 +296,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': @@ -310,39 +332,27 @@ 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, NdWidget)): + if isinstance(obj, Plot): plot = obj else: plot, fmt = self._validate(obj, fmt) data, metadata = {}, {} - if isinstance(plot, NdWidget): - js, html = plot(as_script=True) - plot_id = plot.plot_id + if isinstance(plot, Viewable): + 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, js = self._figure_data(plot, fmt, as_script=True, **kwargs) - plot_id = plot.id - if comm and plot.comm is not None and self.comm_msg_handler: - msg_handler = self.comm_msg_handler.format(plot_id=plot_id) - html = plot.comm.html_template.format(init_frame=html, - plot_id=plot_id) - comm_js = plot.comm.js_template.format(msg_handler=msg_handler, - comm_id=plot.comm.id, - plot_id=plot_id) - js = '\n'.join([js, comm_js]) - html = "
%s
" % (plot_id, html) - + html = self._figure_data(plot, fmt, as_script=True, **kwargs) data['text/html'] = html - if js: - data[MIME_TYPES['js']] = js - data[MIME_TYPES['jlab-hv-exec']] = '' - metadata['id'] = plot_id - self._plots[plot_id] = plot + return (data, {MIME_TYPES['jlab-hv-exec']: metadata}) @@ -352,38 +362,26 @@ 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 not isinstance(plot, Plot): - plot = self_or_cls.get_plot(plot) - dynamic = plot.dynamic - # Whether dimensions define discrete space - discrete = all(d.values for d in plot.dimensions) - if widget_type == 'auto': - isuniform = plot.uniform - if not isuniform: - widget_type = 'scrubber' - else: - widget_type = 'widgets' - elif dynamic and not discrete: - widget_type = 'widgets' - - if widget_type in [None, 'auto']: - holomap_formats = self_or_cls.mode_formats['holomap'][self_or_cls.mode] - widget_type = holomap_formats[0] if self_or_cls.holomap=='auto' else self_or_cls.holomap + if widget_type == 'scrubber': + widget_location = self_or_cls.widget_location or 'bottom' + else: + widget_type = 'individual' + widget_location = self_or_cls.widget_location or 'right' - widget_cls = self_or_cls.widgets[widget_type] - renderer = self_or_cls - if not isinstance(self_or_cls, Renderer): - renderer = self_or_cls.instance() - embed = self_or_cls.widget_mode == 'embed' - return widget_cls(plot, renderer=renderer, embed=embed, **kwargs) + 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 @@ -397,33 +395,60 @@ def export_widgets(self_or_cls, obj, filename, fmt=None, template=None, data to a json file in the supplied json_path (defaults to current path). """ - if fmt not in list(self_or_cls.widgets.keys())+['auto', None]: + if fmt not in self_or_cls.widgets+['auto', None]: raise ValueError("Renderer.export_widget may only export " "registered widget types.") + self_or_cls.get_widget(obj, fmt).save(filename) - if not isinstance(obj, NdWidget): - if not isinstance(filename, (BytesIO, StringIO)): - filedir = os.path.dirname(filename) - current_path = os.getcwd() - html_path = os.path.abspath(filedir) - rel_path = os.path.relpath(html_path, current_path) - save_path = os.path.join(rel_path, json_path) - else: - save_path = json_path - kwargs['json_save_path'] = save_path - kwargs['json_load_path'] = json_path - widget = self_or_cls.get_widget(obj, fmt, **kwargs) + + @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 = obj + widget_type = 'scrubber' + loc = self_or_cls.widget_location or 'bottom' + return {'widget_location': loc, 'widget_type': widget_type, 'center': True} + - html = self_or_cls.static_html(widget, fmt, template) - encoded = self_or_cls.encode((html, {'mime_type': 'text/html'})) - if isinstance(filename, (BytesIO, StringIO)): - filename.write(encoded) - filename.seek(0) + @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: - with open(filename, 'wb') as f: - f.write(encoded) + 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 @@ -449,72 +474,11 @@ class needed to render it with the current renderer. @classmethod def html_assets(cls, core=True, extras=True, backends=None, script=False): """ - Returns JS and CSS and for embedding of widgets. - """ - if backends is None: - backends = [cls.backend] if cls.backend else [] - - # Get all the widgets and find the set of required js widget files - widgets = [wdgt for r in [Renderer]+Renderer.__subclasses__() - for wdgt in r.widgets.values()] - css = list({wdgt.css for wdgt in widgets}) - basejs = list({wdgt.basejs for wdgt in widgets}) - extensionjs = list({wdgt.extensionjs for wdgt in widgets}) - - # Join all the js widget code into one string - path = os.path.dirname(os.path.abspath(__file__)) - - def open_and_read(path, f): - with open(find_file(path, f), 'r') as f: - txt = f.read() - return txt - - widgetjs = '\n'.join(open_and_read(path, f) - for f in basejs + extensionjs if f is not None) - widgetcss = '\n'.join(open_and_read(path, f) - for f in css if f is not None) - - dependencies = {} - if core: - dependencies.update(cls.core_dependencies) - if extras: - dependencies.update(cls.extra_dependencies) - for backend in backends: - dependencies[backend] = Store.renderers[backend].backend_dependencies - - js_html, css_html = '', '' - for _, dep in sorted(dependencies.items(), key=lambda x: x[0]): - js_data = dep.get('js', []) - if isinstance(js_data, tuple): - for js in js_data: - if script: - js_html += js - else: - js_html += '\n' % js - elif not script: - for js in js_data: - js_html += '\n' % js - css_data = dep.get('css', []) - if isinstance(js_data, tuple): - for css in css_data: - css_html += '\n' % css - else: - for css in css_data: - css_html += '\n' % css - if script: - js_html += widgetjs - else: - js_html += '\n' % widgetjs - css_html += '\n' % widgetcss - - comm_js = cls.comm_manager.js_manager - if script: - js_html += comm_js - else: - js_html += '\n' % comm_js - - return unicode(js_html), unicode(css_html) - + Deprecated: No longer needed + """ + param.main.warning("Renderer.html_assets is deprecated as all " + "JS and CSS dependencies are now handled by " + "Panel.") @classmethod def plot_options(cls, obj, percent_size): @@ -530,7 +494,8 @@ def plot_options(cls, obj, percent_size): @bothmethod - def save(self_or_cls, obj, basename, fmt='auto', key={}, info={}, options=None, **kwargs): + def save(self_or_cls, obj, basename, fmt='auto', key={}, info={}, + options=None, resources='inline', **kwargs): """ Save a HoloViews object to file, either using an explicitly supplied format or to the appropriate default. @@ -538,17 +503,16 @@ def save(self_or_cls, obj, basename, fmt='auto', key={}, info={}, options=None, if info or key: raise Exception('Renderer does not support saving metadata to file.') - if isinstance(obj, (Plot, NdWidget)): - plot = obj - else: - with StoreOptions.options(obj, options, **kwargs): - plot = self_or_cls.get_plot(obj) - - if (fmt in list(self_or_cls.widgets.keys())+['auto']) and len(plot) > 1: - with StoreOptions.options(obj, options, **kwargs): - if isinstance(basename, basestring): - basename = basename+'.html' - self_or_cls.export_widgets(plot, basename, fmt) + with StoreOptions.options(obj, options, **kwargs): + plot, fmt = self_or_cls._validate(obj, fmt) + + if isinstance(plot, Viewable): + from bokeh.resources import CDN, INLINE + if resources.lower() == 'cdn': + resources = CDN + elif resources.lower() == 'inline': + resources = INLINE + plot.layout.save(basename, embed=True, resources=resources) return rendered = self_or_cls(plot, fmt) @@ -607,6 +571,7 @@ def load_nb(cls, inline=True): Loads any resources required for display of plots in the Jupyter notebook """ + load_notebook(inline) with param.logging_level('ERROR'): cls.notebook_context = True cls.comm_manager = JupyterCommManager diff --git a/holoviews/plotting/util.py b/holoviews/plotting/util.py index 7e6b07c164..8a5f6c1764 100644 --- a/holoviews/plotting/util.py +++ b/holoviews/plotting/util.py @@ -11,7 +11,7 @@ import param from ..core import (HoloMap, DynamicMap, CompositeOverlay, Layout, - Overlay, GridSpace, NdLayout, Store, NdOverlay) + Overlay, GridSpace, NdLayout, NdOverlay) from ..core.options import Cycle from ..core.ndmapping import item_check from ..core.spaces import get_nested_streams @@ -482,20 +482,6 @@ def initialize_unbounded(obj, dimensions, key): pass -def save_frames(obj, filename, fmt=None, backend=None, options=None): - """ - Utility to export object to files frame by frame, numbered individually. - Will use default backend and figure format by default. - """ - backend = Store.current_backend if backend is None else backend - renderer = Store.renderers[backend] - fmt = renderer.params('fig').objects[0] if fmt is None else fmt - plot = renderer.get_plot(obj) - for i in range(len(plot)): - plot.update(i) - renderer.save(plot, '%s_%s' % (filename, i), fmt=fmt, options=options) - - def dynamic_update(plot, subplot, key, overlay, items): """ Given a plot, subplot and dynamically generated (Nd)Overlay diff --git a/holoviews/plotting/widgets/__init__.py b/holoviews/plotting/widgets/__init__.py deleted file mode 100644 index f292911457..0000000000 --- a/holoviews/plotting/widgets/__init__.py +++ /dev/null @@ -1,489 +0,0 @@ -from __future__ import unicode_literals - -import os, uuid, json, math - -import param -import numpy as np - -from ...core import OrderedDict, NdMapping -from ...core.options import Store -from ...core.ndmapping import item_check -from ...core.util import ( - dimension_sanitizer, bytes_to_unicode, unique_iterator, unicode, - isnumeric, cross_index, wrap_tuple_streams, drop_streams, datetime_types -) -from ...core.traversal import hierarchical - -def escape_vals(vals, escape_numerics=True): - """ - Escapes a list of values to a string, converting to - unicode for safety. - """ - # Ints formatted as floats to disambiguate with counter mode - ints, floats = "%.1f", "%.10f" - - escaped = [] - for v in vals: - if isinstance(v, np.timedelta64): - v = "'"+str(v)+"'" - elif isinstance(v, np.datetime64): - v = "'"+str(v.astype('datetime64[ns]'))+"'" - elif not isnumeric(v): - v = "'"+unicode(bytes_to_unicode(v))+"'" - else: - if v % 1 == 0: - v = ints % v - else: - v = (floats % v)[:-1] - if escape_numerics: - v = "'"+v+"'" - escaped.append(v) - return escaped - -def escape_tuple(vals): - return "(" + ", ".join(vals) + (",)" if len(vals) == 1 else ")") - -def escape_list(vals): - return "[" + ", ".join(vals) + "]" - -def escape_dict(vals): - vals = [': '.join([k, escape_list(v)]) for k, v in - zip(escape_vals(vals.keys()), vals.values())] - return "{" + ", ".join(vals) + "}" - - -subdirs = [p[0] for p in os.walk(os.path.join(os.path.split(__file__)[0], '..'))] - -class NdWidget(param.Parameterized): - """ - NdWidget is an abstract base class implementing a method to find - the dimensions and keys of any ViewableElement, GridSpace or - UniformNdMapping type. In the process it creates a mock_obj to - hold the dimensions and keys. - """ - - display_options = param.Dict(default={}, doc=""" - The display options used to generate individual frames""") - - embed = param.Boolean(default=True, doc=""" - Whether to embed all plots in the Javascript, generating - a static widget not dependent on the IPython server.""") - - ####################### - # JSON export options # - ####################### - - export_json = param.Boolean(default=False, doc=""" - Whether to export plots as JSON files, which can be - dynamically loaded through a callback from the slider.""") - - json_save_path = param.String(default='./json_figures', doc=""" - If export_json is enabled the widget will save the JSON - data to this path. If None data will be accessible via the - json_data attribute.""") - - json_load_path = param.String(default=None, doc=""" - If export_json is enabled the widget JS code will load the data - from this path, if None defaults to json_save_path. For loading - the data from within the notebook the path must be relative, - when exporting the notebook the path can be set to another - location like a webserver where the JSON files can be uploaded to.""") - - ############################## - # Javascript include options # - ############################## - - css = param.String(default=None, doc=""" - Defines the local CSS file to be loaded for this widget.""") - - basejs = param.String(default='widgets.js', doc=""" - JS file containing javascript baseclasses for the widget.""") - - extensionjs = param.String(default=None, doc=""" - Optional javascript extension file for a particular backend.""") - - widgets = {} - counter = 0 - - def __init__(self, plot, renderer=None, **params): - super(NdWidget, self).__init__(**params) - self.id = plot.comm.id if plot.comm else uuid.uuid4().hex - self.plot = plot - self.plot_id = plot.id - streams = [] - for stream in plot.streams: - if any(k in plot.dimensions for k in stream.contents): - streams.append(stream) - - keys = plot.keys[:1] if self.plot.dynamic else plot.keys - self.dimensions, self.keys = drop_streams(streams, - plot.dimensions, - keys) - defaults = [kd.default for kd in self.dimensions] - self.init_key = tuple(v if d is None else d for v, d in - zip(self.keys[0], defaults)) - - self.json_data = {} - if self.plot.dynamic: self.embed = False - if renderer is None: - backend = Store.current_backend - self.renderer = Store.renderers[backend] - else: - self.renderer = renderer - - # Create mock NdMapping to hold the common dimensions and keys - sorted_dims = [] - for dim in self.dimensions: - if dim.values and all(isnumeric(v) for v in dim.values): - dim = dim.clone(values=sorted(dim.values)) - sorted_dims.append(dim) - - if self.plot.dynamic: - self.length = np.product([len(d.values) for d in sorted_dims if d.values]) - else: - self.length = len(self.plot) - - with item_check(False): - self.mock_obj = NdMapping([(k, None) for k in self.keys], - kdims=sorted_dims, sort=False) - - NdWidget.widgets[self.id] = self - - # Set up jinja2 templating - import jinja2 - templateLoader = jinja2.FileSystemLoader(subdirs) - self.jinjaEnv = jinja2.Environment(loader=templateLoader) - if not self.embed: - comm_manager = self.renderer.comm_manager - self.comm = comm_manager.get_client_comm(id=self.id+'_client', - on_msg=self._process_update) - - - def cleanup(self): - self.plot.cleanup() - del NdWidget.widgets[self.id] - - - def _process_update(self, msg): - if 'content' not in msg: - raise ValueError('Received widget comm message has no content.') - self.update(msg['content']) - - - def __call__(self, as_script=False): - data = self._get_data() - html = self.render_html(data) - js = self.render_js(data) - if as_script: - return js, html - js = '' % js - html = '\n'.join([html, js]) - return html - - - def _get_data(self): - delay = int(1000./self.display_options.get('fps', 5)) - CDN = {} - for name, resources in self.plot.renderer.core_dependencies.items(): - if 'js' in resources: - CDN[name] = resources['js'][0] - for name, resources in self.plot.renderer.extra_dependencies.items(): - if 'js' in resources: - CDN[name] = resources['js'][0] - name = type(self).__name__ - cached = str(self.embed).lower() - load_json = str(self.export_json).lower() - mode = str(self.renderer.mode) - json_path = (self.json_save_path if self.json_load_path is None - else self.json_load_path) - if json_path and json_path[-1] != '/': - json_path = json_path + '/' - dynamic = json.dumps(self.plot.dynamic) if self.plot.dynamic else 'false' - return dict(CDN=CDN, frames=self.get_frames(), delay=delay, - cached=cached, load_json=load_json, mode=mode, id=self.id, - Nframes=self.length, widget_name=name, json_path=json_path, - dynamic=dynamic, plot_id=self.plot_id) - - - def render_html(self, data): - template = self.jinjaEnv.get_template(self.html_template) - return template.render(**data) - - - def render_js(self, data): - template = self.jinjaEnv.get_template(self.js_template) - return template.render(**data) - - - def get_frames(self): - if self.embed: - frames = OrderedDict([(idx, self._plot_figure(idx)) - for idx in range(len(self.plot))]) - else: - frames = {} - return self.encode_frames(frames) - - - def encode_frames(self, frames): - if isinstance(frames, dict): - frames = dict(frames) - return json.dumps(frames) - - - def save_json(self, frames): - """ - Saves frames data into a json file at the - specified json_path, named with the widget uuid. - """ - if self.json_save_path is None: return - path = os.path.join(self.json_save_path, '%s.json' % self.id) - if not os.path.isdir(self.json_save_path): - os.mkdir(self.json_save_path) - with open(path, 'w') as f: - json.dump(frames, f) - self.json_data = frames - - - def _plot_figure(self, idx): - with self.renderer.state(): - self.plot.update(idx) - css = self.display_options.get('css', {}) - figure_format = self.display_options.get('figure_format', - self.renderer.fig) - return self.renderer.html(self.plot, figure_format, css=css) - - - def update(self, key): - pass - - - - -class ScrubberWidget(NdWidget): - """ - ScrubberWidget generates a basic animation widget with a slider - and various play/rewind/stepping options. It has been adapted - from Jake Vanderplas' JSAnimation library, which was released - under BSD license. - - Optionally the individual plots can be exported to json, which can - be dynamically loaded by serving the data the data for each frame - on a simple server. - """ - - html_template = param.String('htmlscrubber.jinja', doc=""" - The jinja2 template used to generate the html output.""") - - js_template = param.String('jsscrubber.jinja', doc=""" - The jinja2 template used to generate the html output.""") - - def update(self, key): - if not self.plot.dimensions: - self.plot.refresh() - else: - if self.plot.dynamic: - key = cross_index([d.values for d in self.mock_obj.kdims], key) - self.plot.update(key) - self.plot.push() - - -class SelectionWidget(NdWidget): - """ - Javascript based widget to select and view ViewableElement objects - contained in an NdMapping. For each dimension in the NdMapping a - slider or dropdown selection widget is created and can be used to - select the html output associated with the selected - ViewableElement type. The widget maybe set to embed all frames in - the supplied object into the rendered html or to dynamically - update the widget with a live IPython kernel. - - The widget supports all current HoloViews figure backends - including png and svg output.. - - Just like the ScrubberWidget the data can be optionally saved - to json and dynamically loaded from a server. - """ - - css = param.String(default='jsslider.css', doc=""" - Defines the local CSS file to be loaded for this widget.""") - - html_template = param.String('htmlslider.jinja', doc=""" - The jinja2 template used to generate the html output.""") - - js_template = param.String('jsslider.jinja', doc=""" - The jinja2 template used to generate the html output.""") - - ############################## - # Javascript include options # - ############################## - - throttle = {True: 0, False: 100} - - def get_widgets(self): - # Generate widget data - widgets, dimensions, init_dim_vals = [], [], [] - if self.plot.dynamic: - hierarchy = None - else: - hierarchy = hierarchical(list(self.mock_obj.data.keys())) - for idx, dim in enumerate(self.mock_obj.kdims): - # Hide widget if it has 1-to-1 mapping to next widget - if self.plot.dynamic: - widget_data = self._get_dynamic_widget(idx, dim) - else: - widget_data = self._get_static_widget(idx, dim, self.mock_obj, hierarchy, - init_dim_vals) - init_dim_vals.append(widget_data['init_val']) - visibility = '' if widget_data.get('visible', True) else 'display: none' - dim_str = dim.pprint_label - escaped_dim = dimension_sanitizer(dim_str) - widget_data = dict(widget_data, dim=escaped_dim, dim_label=dim_str, - dim_idx=idx, visibility=visibility) - widgets.append(widget_data) - dimensions.append(escaped_dim) - return widgets, dimensions, init_dim_vals - - - @classmethod - def _get_static_widget(cls, idx, dim, mock_obj, hierarchy, init_dim_vals): - next_dim = '' - next_vals = {} - visible = True - if next_vals: - values = next_vals[init_dim_vals[idx-1]] - else: - values = (list(dim.values) if dim.values else - list(unique_iterator(mock_obj.dimension_values(dim.name)))) - visible = visible and len(values) > 1 - - if idx < mock_obj.ndims-1: - next_vals = hierarchy[idx] - next_dim = bytes_to_unicode(mock_obj.kdims[idx+1]) - else: - next_vals = {} - - if isinstance(values[0], datetime_types): - values = sorted(values) - dim_vals = [str(v.astype('datetime64[ns]')) - if isinstance(v, np.datetime64) else str(v) - for v in values] - widget_type = 'slider' - elif isnumeric(values[0]): - values = sorted(values) - dim_vals = [round(v, 10) for v in values] - if next_vals: - next_vals = {round(k, 10): [round(v, 10) if isnumeric(v) else v - for v in vals] - for k, vals in next_vals.items()} - widget_type = 'slider' - else: - dim_vals = values - next_vals = dict(next_vals) - widget_type = 'dropdown' - - value = values[0] if dim.default is None else dim.default - value_labels = escape_list(escape_vals([dim.pprint_value(v) - for v in dim_vals])) - - if dim.default is None: - default = 0 - init_val = dim_vals[0]; - elif dim.default not in dim_vals: - raise ValueError("%s dimension default %r is not in dimension values: %s" - % (dim, dim.default, dim.values)) - else: - default = repr(dim_vals.index(dim.default)) - init_val = dim.default - - dim_vals = escape_list(escape_vals(dim_vals)) - next_vals = escape_dict({k: escape_vals(v) for k, v in next_vals.items()}) - return {'type': widget_type, 'vals': dim_vals, 'labels': value_labels, - 'step': 1, 'default': default, 'next_vals': next_vals, - 'next_dim': next_dim or None, 'init_val': init_val, - 'visible': visible, 'value': value} - - - @classmethod - def _get_dynamic_widget(cls, idx, dim): - step = 1 - if dim.values: - if all(isnumeric(v) or isinstance(v, datetime_types) for v in dim.values): - # Widgets currently detect dynamic mode by type - # this value representation is now redundant - # and should be removed in a refactor - values = dim.values - dim_vals = {i: i for i, v in enumerate(values)} - widget_type = 'slider' - else: - values = list(dim.values) - dim_vals = list(range(len(values))) - widget_type = 'dropdown' - - value_labels = escape_list(escape_vals([dim.pprint_value(v) - for v in values])) - - if dim.default is None: - default = dim_vals[0] - elif widget_type == 'slider': - default = values.index(dim.default) - else: - default = repr(values.index(dim.default)) - init_val = default - else: - widget_type = 'slider' - value_labels = [] - dim_vals = [dim.soft_range[0] if dim.soft_range[0] else dim.range[0], - dim.soft_range[1] if dim.soft_range[1] else dim.range[1]] - dim_range = dim_vals[1] - dim_vals[0] - int_type = isinstance(dim.type, type) and issubclass(dim.type, int) - if dim.step is not None: - step = dim.step - elif isinstance(dim_range, int) or int_type: - step = 1 - else: - step = 10**(round(math.log10(dim_range))-3) - - if dim.default is None: - default = dim_vals[0] - else: - default = dim.default - init_val = default - dim_vals = escape_list(escape_vals(sorted(dim_vals))) - return {'type': widget_type, 'vals': dim_vals, 'labels': value_labels, - 'step': step, 'default': default, 'next_vals': {}, - 'next_dim': None, 'init_val': init_val} - - - def get_key_data(self): - # Generate key data - key_data = OrderedDict() - for i, k in enumerate(self.mock_obj.data.keys()): - key = escape_tuple(escape_vals(k)) - key_data[key] = i - return json.dumps(key_data) - - - def _get_data(self): - data = super(SelectionWidget, self)._get_data() - widgets, dimensions, init_dim_vals = self.get_widgets() - init_dim_vals = escape_list(escape_vals(init_dim_vals, not self.plot.dynamic)) - key_data = {} if self.plot.dynamic else self.get_key_data() - notfound_msg = "

-
- - - - - - - - - -
- Once - Loop - Reflect -
-

diff --git a/holoviews/plotting/widgets/htmlslider.jinja b/holoviews/plotting/widgets/htmlslider.jinja deleted file mode 100644 index 41e0b75b19..0000000000 --- a/holoviews/plotting/widgets/htmlslider.jinja +++ /dev/null @@ -1,36 +0,0 @@ -
-
-
- {% block init_frame %} - {{ init_html }} - {% endblock %} -
-
-
-
- {% for widget_data in widgets %} - {% if widget_data['type'] == 'slider' %} -
- -
-
- -
-
-
-
- {% elif widget_data['type']=='dropdown' %} -
- - -
- {% endif %} - {% endfor %} -
-
-
diff --git a/holoviews/plotting/widgets/jsscrubber.jinja b/holoviews/plotting/widgets/jsscrubber.jinja deleted file mode 100644 index b749f280a7..0000000000 --- a/holoviews/plotting/widgets/jsscrubber.jinja +++ /dev/null @@ -1,11 +0,0 @@ -/* Instantiate the {{ widget_name }} class. */ -/* The IDs given should match those used in the template above. */ -function create_widget() { - var frame_data = {{ frames | safe }}; - var anim = new HoloViews.{{ widget_name }}(frame_data, {{ Nframes }}, "{{ id }}", {{ delay }}, {{ load_json }}, "{{ mode }}", {{ cached }}, "{{ json_path }}", {{ dynamic }}, "{{ plot_id }}"); - HoloViews.index['{{ plot_id }}'] = anim; -} - -create_widget(); - -{{ init_js }} diff --git a/holoviews/plotting/widgets/jsslider.css b/holoviews/plotting/widgets/jsslider.css deleted file mode 100644 index 19fe1e810f..0000000000 --- a/holoviews/plotting/widgets/jsslider.css +++ /dev/null @@ -1,93 +0,0 @@ -div.hololayout { - display: flex; - align-items: center; - margin: 0; -} - -div.holoframe { - width: 75%; -} - -div.holowell { - display: flex; - align-items: center; -} - -form.holoform { - background-color: #fafafa; - border-radius: 5px; - overflow: hidden; - padding-left: 0.8em; - padding-right: 0.8em; - padding-top: 0.4em; - padding-bottom: 0.4em; - box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05); - margin-bottom: 20px; - border: 1px solid #e3e3e3; -} - -div.holowidgets { - padding-right: 0; - width: 25%; -} - -div.holoslider { - min-height: 0 !important; - height: 0.8em; - width: 100%; -} - -div.holoformgroup { - padding-top: 0.5em; - margin-bottom: 0.5em; -} - -div.hologroup { - padding-left: 0; - padding-right: 0.8em; - width: 100%; -} - -.holoselect { - width: 92%; - margin-left: 0; - margin-right: 0; -} - -.holotext { - padding-left: 0.5em; - padding-right: 0; - width: 100%; -} - -.holowidgets .ui-resizable-se { - visibility: hidden -} - -.holoframe > .ui-resizable-se { - visibility: hidden -} - -.holowidgets .ui-resizable-s { - visibility: hidden -} - - -/* CSS rules for noUISlider based slider used by JupyterLab extension */ - -.noUi-handle { - width: 20px !important; - height: 20px !important; - left: -5px !important; - top: -5px !important; -} - -.noUi-handle:before, .noUi-handle:after { - visibility: hidden; - height: 0px; -} - -.noUi-target { - margin-left: 0.5em; - margin-right: 0.5em; -} diff --git a/holoviews/plotting/widgets/jsslider.jinja b/holoviews/plotting/widgets/jsslider.jinja deleted file mode 100644 index 219f84776a..0000000000 --- a/holoviews/plotting/widgets/jsslider.jinja +++ /dev/null @@ -1,33 +0,0 @@ -/* Instantiate the {{ widget_name }} class. */ -/* The IDs given should match those used in the template above. */ -var widget_ids = new Array({{ Nwidget }}); - -{% for dim in dimensions %} -widget_ids[{{ loop.index0 }}] = "_anim_widget{{ id }}_{{ dim }}"; -{% endfor %} - -function create_widget() { - var frame_data = {{ frames | safe }}; - var dim_vals = {{ init_dim_vals }}; - var keyMap = {{ key_data }}; - var notFound = "{{ notFound }}"; - - var anim = new HoloViews.{{ widget_name }}(frame_data, "{{ id }}", widget_ids, - keyMap, dim_vals, notFound, {{ load_json }}, "{{ mode }}", - {{ cached }}, "{{ json_path}}", {{ dynamic }}, "{{ plot_id }}"); - - HoloViews.index['{{ plot_id }}'] = anim; -} - -{% for widget_data in widgets %} - -{% if widget_data['type'] == 'slider' %} -HoloViews.init_slider('{{ id }}', '{{ plot_id }}', '{{ widget_data['dim'] }}', {{ widget_data['vals'] }}, {{ widget_data['next_vals'] }}, {{ widget_data['labels'] }}, {{ dynamic }}, {{ widget_data['step'] }}, {{ widget_data['default'] }}, '{{ widget_data['next_dim'] }}', {{ widget_data['dim_idx'] }}, {{ delay }}, '{{ CDN['jQueryUI'] }}', '{{ CDN['underscore'] }}') -{% elif widget_data['type']=='dropdown' %} -HoloViews.init_dropdown('{{ id }}', '{{ plot_id }}', '{{ widget_data['dim'] }}', {{ widget_data['vals'] }}, {{ widget_data['default'] }}, {{ widget_data['next_vals'] }}, {{ widget_data['labels'] }}, '{{ widget_data['next_dim'] }}', {{ widget_data['dim_idx'] }}, {{ dynamic }}) -{% endif %} - -{% endfor %} - -create_widget(); -{{ init_js }} diff --git a/holoviews/plotting/widgets/widgets.js b/holoviews/plotting/widgets/widgets.js deleted file mode 100644 index e3288d6de9..0000000000 --- a/holoviews/plotting/widgets/widgets.js +++ /dev/null @@ -1,567 +0,0 @@ -function HoloViewsWidget() { -} - -HoloViewsWidget.prototype.init_slider = function(init_val){ - if(this.load_json) { - this.from_json() - } else { - this.update_cache(); - } -} - -HoloViewsWidget.prototype.populate_cache = function(idx){ - this.cache[idx].innerHTML = this.frames[idx]; - if (this.embed) { - delete this.frames[idx]; - } -} - -HoloViewsWidget.prototype.process_error = function(msg){ -} - -HoloViewsWidget.prototype.from_json = function() { - var data_url = this.json_path + this.id + '.json'; - $.getJSON(data_url, $.proxy(function(json_data) { - this.frames = json_data; - this.update_cache(); - this.update(0); - }, this)); -} - -HoloViewsWidget.prototype.dynamic_update = function(current){ - if (current === undefined) { - return - } - this.current = current; - if (this.comm) { - var msg = {comm_id: this.id+'_client', content: current} - this.comm.send(msg); - } -} - -HoloViewsWidget.prototype.update_cache = function(force){ - var frame_len = Object.keys(this.frames).length; - for (var i=0; i 0) { - that.time = Date.now(); - that.dynamic_update(that.queue[that.queue.length-1]); - that.queue = []; - } else { - that.wait = false; - } - if ((msg.msg_type == "Ready") && msg.content) { - console.log("Python callback returned following output:", msg.content); - } else if (msg.msg_type == "Error") { - console.log("Python failed with the following traceback:", msg.traceback) - } - } - var comm = HoloViews.comm_manager.get_client_comm(this.plot_id, this.id+'_client', ack_callback); - return comm - } -} - -HoloViewsWidget.prototype.msg_handler = function(msg) { - var metadata = msg.metadata; - if ((metadata.msg_type == "Ready")) { - if (metadata.content) { - console.log("Python callback returned following output:", metadata.content); - } - return; - } else if (metadata.msg_type == "Error") { - console.log("Python failed with the following traceback:", metadata.traceback) - return - } - this.process_msg(msg) -} - -HoloViewsWidget.prototype.process_msg = function(msg) { -} - -function SelectionWidget(frames, id, slider_ids, keyMap, dim_vals, notFound, load_json, mode, cached, json_path, dynamic, plot_id){ - this.frames = frames; - this.id = id; - this.plot_id = plot_id; - this.slider_ids = slider_ids; - this.keyMap = keyMap - this.current_frame = 0; - this.current_vals = dim_vals; - this.load_json = load_json; - this.mode = mode; - this.notFound = notFound; - this.cached = cached; - this.dynamic = dynamic; - this.cache = {}; - this.json_path = json_path; - this.init_slider(this.current_vals[0]); - this.queue = []; - this.wait = false; - if (!this.cached || this.dynamic) { - this.comm = this.init_comms(); - } -} - -SelectionWidget.prototype = new HoloViewsWidget; - - -SelectionWidget.prototype.get_key = function(current_vals) { - var key = "("; - for (var i=0; i Date.now()))) { - this.queue.push(key); - return - } - this.queue = []; - this.time = Date.now(); - this.current_frame = key; - this.wait = true; - this.dynamic_update(key) - } else if (key !== undefined) { - this.update(key) - } -} - - -/* Define the ScrubberWidget class */ -function ScrubberWidget(frames, num_frames, id, interval, load_json, mode, cached, json_path, dynamic, plot_id){ - this.slider_id = "_anim_slider" + id; - this.loop_select_id = "_anim_loop_select" + id; - this.id = id; - this.plot_id = plot_id; - this.interval = interval; - this.current_frame = 0; - this.direction = 0; - this.dynamic = dynamic; - this.timer = null; - this.load_json = load_json; - this.mode = mode; - this.cached = cached; - this.frames = frames; - this.cache = {}; - this.length = num_frames; - this.json_path = json_path; - document.getElementById(this.slider_id).max = this.length - 1; - this.init_slider(0); - this.wait = false; - this.queue = []; - if (!this.cached || this.dynamic) { - this.comm = this.init_comms() - } -} - -ScrubberWidget.prototype = new HoloViewsWidget; - -ScrubberWidget.prototype.set_frame = function(frame){ - this.current_frame = frame; - var widget = document.getElementById(this.slider_id); - if (widget === null) { - this.pause_animation(); - return - } - widget.value = this.current_frame; - if (this.dynamic || !this.cached) { - if ((this.time !== undefined) && ((this.wait) && ((this.time + 10000) > Date.now()))) { - this.queue.push(frame); - return - } - this.queue = []; - this.time = Date.now(); - this.wait = true; - this.dynamic_update(frame) - } else { - this.update(frame) - } -} - -ScrubberWidget.prototype.get_loop_state = function(){ - var button_group = document[this.loop_select_id].state; - for (var i = 0; i < button_group.length; i++) { - var button = button_group[i]; - if (button.checked) { - return button.value; - } - } - return undefined; -} - - -ScrubberWidget.prototype.next_frame = function() { - this.set_frame(Math.min(this.length - 1, this.current_frame + 1)); -} - -ScrubberWidget.prototype.previous_frame = function() { - this.set_frame(Math.max(0, this.current_frame - 1)); -} - -ScrubberWidget.prototype.first_frame = function() { - this.set_frame(0); -} - -ScrubberWidget.prototype.last_frame = function() { - this.set_frame(this.length - 1); -} - -ScrubberWidget.prototype.slower = function() { - this.interval /= 0.7; - if(this.direction > 0){this.play_animation();} - else if(this.direction < 0){this.reverse_animation();} -} - -ScrubberWidget.prototype.faster = function() { - this.interval *= 0.7; - if(this.direction > 0){this.play_animation();} - else if(this.direction < 0){this.reverse_animation();} -} - -ScrubberWidget.prototype.anim_step_forward = function() { - if(this.current_frame < this.length - 1){ - this.next_frame(); - }else{ - var loop_state = this.get_loop_state(); - if(loop_state == "loop"){ - this.first_frame(); - }else if(loop_state == "reflect"){ - this.last_frame(); - this.reverse_animation(); - }else{ - this.pause_animation(); - this.last_frame(); - } - } -} - -ScrubberWidget.prototype.anim_step_reverse = function() { - if(this.current_frame > 0){ - this.previous_frame(); - } else { - var loop_state = this.get_loop_state(); - if(loop_state == "loop"){ - this.last_frame(); - }else if(loop_state == "reflect"){ - this.first_frame(); - this.play_animation(); - }else{ - this.pause_animation(); - this.first_frame(); - } - } -} - -ScrubberWidget.prototype.pause_animation = function() { - this.direction = 0; - if (this.timer){ - clearInterval(this.timer); - this.timer = null; - } -} - -ScrubberWidget.prototype.play_animation = function() { - this.pause_animation(); - this.direction = 1; - var t = this; - if (!this.timer) this.timer = setInterval(function(){t.anim_step_forward();}, this.interval); -} - -ScrubberWidget.prototype.reverse_animation = function() { - this.pause_animation(); - this.direction = -1; - var t = this; - if (!this.timer) this.timer = setInterval(function(){t.anim_step_reverse();}, this.interval); -} - -function extend(destination, source) { - for (var k in source) { - if (source.hasOwnProperty(k)) { - destination[k] = source[k]; - } - } - return destination; -} - -function update_widget(widget, values) { - if (widget.hasClass("ui-slider")) { - widget.slider('option', { - min: 0, - max: values.length-1, - dim_vals: values, - value: 0, - dim_labels: values - }) - widget.slider('option', 'slide').call(widget, event, {value: 0}) - } else { - widget.empty(); - for (var i=0; i", { - value: i, - text: values[i] - })) - }; - widget.data('values', values); - widget.data('value', 0); - widget.trigger("change"); - }; -} - -function init_slider(id, plot_id, dim, values, next_vals, labels, dynamic, step, value, next_dim, - dim_idx, delay, jQueryUI_CDN, UNDERSCORE_CDN) { - // Slider JS Block START - function loadcssfile(filename){ - var fileref=document.createElement("link") - fileref.setAttribute("rel", "stylesheet") - fileref.setAttribute("type", "text/css") - fileref.setAttribute("href", filename) - document.getElementsByTagName("head")[0].appendChild(fileref) - } - loadcssfile("https://code.jquery.com/ui/1.10.4/themes/smoothness/jquery-ui.css"); - /* Check if jQuery and jQueryUI have been loaded - otherwise load with require.js */ - var jQuery = window.jQuery, - // check for old versions of jQuery - oldjQuery = jQuery && !!jQuery.fn.jquery.match(/^1\.[0-4](\.|$)/), - jquery_path = '', - paths = {}, - noConflict; - var jQueryUI = jQuery.ui; - // check for jQuery - if (!jQuery || oldjQuery) { - // load if it's not available or doesn't meet min standards - paths.jQuery = jQuery; - noConflict = !!oldjQuery; - } else { - // register the current jQuery - define('jquery', [], function() { return jQuery; }); - } - if (!jQueryUI) { - paths.jQueryUI = jQueryUI_CDN.slice(null, -3); - } else { - define('jQueryUI', [], function() { return jQuery.ui; }); - } - paths.underscore = UNDERSCORE_CDN.slice(null, -3); - var jquery_require = { - paths: paths, - shim: { - "jQueryUI": { - exports:"$", - deps: ['jquery'] - }, - "underscore": { - exports: '_' - } - } - } - require.config(jquery_require); - require(["jQueryUI", "underscore"], function(jUI, _){ - if (noConflict) $.noConflict(true); - var vals = values; - if (dynamic && vals.constructor === Array) { - var default_value = parseFloat(value); - var min = parseFloat(vals[0]); - var max = parseFloat(vals[vals.length-1]); - var wstep = step; - var wlabels = [default_value]; - var init_label = default_value; - } else { - var min = 0; - if (dynamic) { - var max = Object.keys(vals).length - 1; - var init_label = labels[value]; - var default_value = values[value]; - } else { - var max = vals.length - 1; - var init_label = labels[value]; - var default_value = value; - } - var wstep = 1; - var wlabels = labels; - } - function adjustFontSize(text) { - var width_ratio = (text.parent().width()/8)/text.val().length; - var size = Math.min(0.9, Math.max(0.6, width_ratio))+'em'; - text.css('font-size', size); - } - var slider = $('#_anim_widget'+id+'_'+dim); - slider.slider({ - animate: "fast", - min: min, - max: max, - step: wstep, - value: default_value, - dim_vals: vals, - dim_labels: wlabels, - next_vals: next_vals, - slide: function(event, ui) { - var vals = slider.slider("option", "dim_vals"); - var next_vals = slider.slider("option", "next_vals"); - var dlabels = slider.slider("option", "dim_labels"); - if (dynamic) { - var dim_val = ui.value; - if (vals.constructor === Array) { - var label = ui.value; - } else { - var label = dlabels[ui.value]; - } - } else { - var dim_val = vals[ui.value]; - var label = dlabels[ui.value]; - } - var text = $('#textInput'+id+'_'+dim); - text.val(label); - adjustFontSize(text); - HoloViews.index[plot_id].set_frame(dim_val, dim_idx); - if (Object.keys(next_vals).length > 0) { - var new_vals = next_vals[dim_val]; - var next_widget = $('#_anim_widget'+id+'_'+next_dim); - update_widget(next_widget, new_vals); - } - } - }); - slider.keypress(function(event) { - if (event.which == 80 || event.which == 112) { - var start = slider.slider("option", "value"); - var stop = slider.slider("option", "max"); - for (var i=start; i<=stop; i++) { - var delay = i*delay; - $.proxy(function doSetTimeout(i) { setTimeout($.proxy(function() { - var val = {value:i}; - slider.slider('value',i); - slider.slider("option", "slide")(null, val); - }, slider), delay);}, slider)(i); - } - } - if (event.which == 82 || event.which == 114) { - var start = slider.slider("option", "value"); - var stop = slider.slider("option", "min"); - var count = 0; - for (var i=start; i>=stop; i--) { - var delay = count*delay; - count = count + 1; - $.proxy(function doSetTimeout(i) { setTimeout($.proxy(function() { - var val = {value:i}; - slider.slider('value',i); - slider.slider("option", "slide")(null, val); - }, slider), delay);}, slider)(i); - } - } - }); - var textInput = $('#textInput'+id+'_'+dim) - textInput.val(init_label); - adjustFontSize(textInput); - }); -} - -function init_dropdown(id, plot_id, dim, vals, value, next_vals, labels, next_dim, dim_idx, dynamic) { - var widget = $("#_anim_widget"+id+'_'+dim); - widget.data('values', vals) - for (var i=0; i", { - value: val, - text: labels[i] - })); - }; - widget.data("next_vals", next_vals); - widget.val(value); - widget.on('change', function(event, ui) { - if (dynamic) { - var dim_val = parseInt(this.value); - } else { - var dim_val = $.data(this, 'values')[this.value]; - } - var next_vals = $.data(this, "next_vals"); - if (Object.keys(next_vals).length > 0) { - var new_vals = next_vals[dim_val]; - var next_widget = $('#_anim_widget'+id+'_'+next_dim); - update_widget(next_widget, new_vals); - } - var widgets = HoloViews.index[plot_id] - if (widgets) { - widgets.set_frame(dim_val, dim_idx); - } - }); -} - - -if (window.HoloViews === undefined) { - window.HoloViews = {} - window.PyViz = window.HoloViews -} else if (window.PyViz === undefined) { - window.PyViz = window.HoloViews -} - - -var _namespace = { - init_slider: init_slider, - init_dropdown: init_dropdown, - comms: {}, - comm_status: {}, - index: {}, - plot_index: {}, - kernels: {}, - receivers: {} -} - -for (var k in _namespace) { - if (!(k in window.HoloViews)) { - window.HoloViews[k] = _namespace[k]; - } -} 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 47ccf58709..c756e388cb 100644 --- a/holoviews/tests/plotting/bokeh/testrenderer.py +++ b/holoviews/tests/plotting/bokeh/testrenderer.py @@ -1,45 +1,54 @@ 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() self.renderer.save(self.image1, bytesio) - def test_export_widgets(self): - bytesio = BytesIO() - self.renderer.export_widgets(self.map1, bytesio, fmt='widgets') - def test_render_get_plot_server_doc(self): renderer = self.renderer.instance(mode='server') plot = renderer.get_plot(self.image1) @@ -78,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={ @@ -98,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/bokeh/testwidgets.py b/holoviews/tests/plotting/bokeh/testwidgets.py deleted file mode 100644 index a9843d2053..0000000000 --- a/holoviews/tests/plotting/bokeh/testwidgets.py +++ /dev/null @@ -1,439 +0,0 @@ -from unittest import SkipTest - -import numpy as np - -from holoviews import renderer -from holoviews.core import Dimension, NdMapping, DynamicMap, HoloMap -from holoviews.element import Curve -from holoviews.element.comparison import ComparisonTestCase - -try: - from holoviews.plotting.bokeh.widgets import BokehServerWidgets - from bokeh.models.widgets import Select, Slider, AutocompleteInput, TextInput, Div - bokeh_renderer = renderer('bokeh') -except: - BokehServerWidgets = None - - -class TestBokehServerWidgets(ComparisonTestCase): - - def setUp(self): - if not BokehServerWidgets: - raise SkipTest("Bokeh required to test BokehServerWidgets") - - def test_bokeh_widgets_server_mode(self): - dmap = DynamicMap(lambda X: Curve([]), kdims=['X']).redim.range(X=(0, 5)) - widgets = bokeh_renderer.instance(mode='server').get_widget(dmap, None) - div, widget = widgets.widgets['X'] - self.assertIsInstance(widget, Slider) - self.assertEqual(widget.value, 0) - self.assertEqual(widget.start, 0) - self.assertEqual(widget.end, 5) - self.assertEqual(widget.step, 1) - self.assertEqual(widgets.state.sizing_mode, 'fixed') - - def test_bokeh_server_dynamic_range_int(self): - dim = Dimension('x', range=(3, 11)) - widget, label, mapping = BokehServerWidgets.create_widget(dim, editable=True) - self.assertIsInstance(widget, Slider) - self.assertEqual(widget.value, 3) - self.assertEqual(widget.start, 3) - self.assertEqual(widget.end, 11) - self.assertEqual(widget.step, 1) - self.assertIsInstance(label, TextInput) - self.assertEqual(label.title, dim.pprint_label) - self.assertEqual(label.value, '3') - self.assertIs(mapping, None) - - def test_bokeh_server_dynamic_range_float(self): - dim = Dimension('x', range=(3.1, 11.2)) - widget, label, mapping = BokehServerWidgets.create_widget(dim, editable=True) - self.assertIsInstance(widget, Slider) - self.assertEqual(widget.value, 3.1) - self.assertEqual(widget.start, 3.1) - self.assertEqual(widget.end, 11.2) - self.assertEqual(widget.step, 0.01) - self.assertIsInstance(label, TextInput) - self.assertEqual(label.title, dim.pprint_label) - self.assertEqual(label.value, '3.1') - self.assertIs(mapping, None) - - def test_bokeh_server_dynamic_range_float_step(self): - dim = Dimension('x', range=(3.1, 11.2), step=0.1) - widget, label, mapping = BokehServerWidgets.create_widget(dim, editable=True) - self.assertIsInstance(widget, Slider) - self.assertEqual(widget.value, 3.1) - self.assertEqual(widget.start, 3.1) - self.assertEqual(widget.end, 11.2) - self.assertEqual(widget.step, 0.1) - self.assertIsInstance(label, TextInput) - self.assertEqual(label.title, dim.pprint_label) - self.assertEqual(label.value, '3.1') - self.assertIs(mapping, None) - - def test_bokeh_server_dynamic_range_not_editable(self): - dim = Dimension('x', range=(3.1, 11.2)) - widget, label, mapping = BokehServerWidgets.create_widget(dim, editable=False) - self.assertIsInstance(widget, Slider) - self.assertEqual(widget.value, 3.1) - self.assertEqual(widget.start, 3.1) - self.assertEqual(widget.end, 11.2) - self.assertEqual(widget.step, 0.01) - self.assertIsInstance(label, Div) - self.assertEqual(label.text, '%s' % dim.pprint_value_string(3.1)) - self.assertIs(mapping, None) - - def test_bokeh_server_dynamic_values_int(self): - values = list(range(3, 11)) - dim = Dimension('x', values=values) - widget, label, mapping = BokehServerWidgets.create_widget(dim, editable=True) - self.assertIsInstance(widget, Slider) - self.assertEqual(widget.value, 0) - self.assertEqual(widget.start, 0) - self.assertEqual(widget.end, 7) - self.assertEqual(widget.step, 1) - self.assertIsInstance(label, AutocompleteInput) - self.assertEqual(label.title, dim.pprint_label) - self.assertEqual(label.value, '3') - self.assertEqual(mapping, [(i, (v, dim.pprint_value(v))) for i, v in enumerate(values)]) - - def test_bokeh_server_dynamic_values_float_not_editable(self): - values = list(np.linspace(3.1, 11.2, 7)) - dim = Dimension('x', values=values) - widget, label, mapping = BokehServerWidgets.create_widget(dim, editable=False) - self.assertIsInstance(widget, Slider) - self.assertEqual(widget.value, 0) - self.assertEqual(widget.start, 0) - self.assertEqual(widget.end, 6) - self.assertEqual(widget.step, 1) - self.assertIsInstance(label, Div) - self.assertEqual(label.text, '%s' % dim.pprint_value_string(3.1)) - self.assertEqual(mapping, [(i, (v, dim.pprint_value(v))) for i, v in enumerate(values)]) - - def test_bokeh_server_dynamic_values_float_editable(self): - values = list(np.linspace(3.1, 11.2, 7)) - dim = Dimension('x', values=values) - widget, label, mapping = BokehServerWidgets.create_widget(dim, editable=True) - self.assertIsInstance(widget, Slider) - self.assertEqual(widget.value, 0) - self.assertEqual(widget.start, 0) - self.assertEqual(widget.end, 6) - self.assertEqual(widget.step, 1) - self.assertIsInstance(label, AutocompleteInput) - self.assertEqual(label.title, dim.pprint_label) - self.assertEqual(label.value, '3.1') - self.assertEqual(mapping, [(i, (v, dim.pprint_value(v))) for i, v in enumerate(values)]) - - def test_bokeh_server_dynamic_values_str_1(self): - values = [chr(65+i) for i in range(10)] - dim = Dimension('x', values=values) - widget, label, mapping = BokehServerWidgets.create_widget(dim, editable=True) - self.assertIsInstance(widget, Select) - self.assertEqual(widget.value, 'A') - self.assertEqual(widget.options, list(zip(values, values))) - self.assertEqual(widget.title, dim.pprint_label) - self.assertIs(mapping, None) - self.assertIs(label, None) - - def test_bokeh_server_dynamic_values_str_2(self): - keys = [chr(65+i) for i in range(10)] - ndmap = NdMapping({i: None for i in keys}, kdims=['x']) - dim = Dimension('x') - widget, label, mapping = BokehServerWidgets.create_widget(dim, ndmap, editable=True) - self.assertIsInstance(widget, Select) - self.assertEqual(widget.value, 'A') - self.assertEqual(widget.options, list(zip(keys, keys))) - self.assertEqual(widget.title, dim.pprint_label) - self.assertEqual(mapping, list(enumerate(zip(keys, keys)))) - - def test_bokeh_server_static_numeric_values(self): - dim = Dimension('x') - ndmap = NdMapping({i: None for i in range(3, 12)}, kdims=['x']) - widget, label, mapping = BokehServerWidgets.create_widget(dim, ndmap, editable=True) - self.assertIsInstance(widget, Slider) - self.assertEqual(widget.value, 0) - self.assertEqual(widget.start, 0) - self.assertEqual(widget.end, 8) - self.assertEqual(widget.step, 1) - self.assertIsInstance(label, AutocompleteInput) - self.assertEqual(label.title, dim.pprint_label) - self.assertEqual(label.value, '3') - self.assertEqual(mapping, [(i, (k, dim.pprint_value(k))) for i, k in enumerate(ndmap.keys())]) - - - -class TestSelectionWidget(ComparisonTestCase): - - def setUp(self): - if not BokehServerWidgets: - raise SkipTest("Bokeh required to test BokehServerWidgets") - - def test_holomap_slider(self): - hmap = HoloMap({i: Curve([1, 2, 3]) for i in range(10)}, 'X') - widgets = bokeh_renderer.get_widget(hmap, 'widgets') - widgets, dimensions, init_dim_vals = widgets.get_widgets() - self.assertEqual(len(widgets), 1) - slider = widgets[0] - self.assertEqual(slider['type'], 'slider') - self.assertEqual(slider['dim'], 'X') - self.assertEqual(slider['dim_idx'], 0) - self.assertEqual(slider['vals'], repr([repr(float(v)) for v in range(10)])) - self.assertEqual(slider['labels'], repr([str(v) for v in range(10)])) - self.assertEqual(slider['step'], 1) - self.assertEqual(slider['default'], 0) - self.assertIs(slider['next_dim'], None) - self.assertEqual(dimensions, ['X']) - self.assertEqual(init_dim_vals, [0.0]) - - def test_holomap_cftime_slider(self): - try: - import cftime - except: - raise SkipTest('Test requires cftime library') - dates = [cftime.DatetimeGregorian(2000, 2, 28), - cftime.DatetimeGregorian(2000, 3, 1), - cftime.DatetimeGregorian(2000, 3, 2)] - hmap = HoloMap({d: Curve([1, 2, i]) for i, d in enumerate(dates)}, 'Date') - widgets = bokeh_renderer.get_widget(hmap, 'widgets') - widgets, dimensions, init_dim_vals = widgets.get_widgets() - self.assertEqual(len(widgets), 1) - slider = widgets[0] - self.assertEqual(slider['type'], 'slider') - self.assertEqual(slider['dim'], 'Date') - self.assertEqual(slider['dim_idx'], 0) - self.assertEqual(slider['vals'], repr([str(d) for d in dates])) - self.assertEqual(slider['labels'], repr([str(d) for d in dates])) - self.assertEqual(slider['step'], 1) - self.assertEqual(slider['default'], 0) - self.assertIs(slider['next_dim'], None) - self.assertEqual(dimensions, ['Date']) - self.assertEqual(init_dim_vals, ['2000-02-28 00:00:00']) - - def test_holomap_slider_unsorted(self): - data = {(i, j): Curve([1, 2, 3]) for i in range(3) for j in range(3)} - del data[2, 2] - hmap = HoloMap(data, ['X', 'Y']) - widgets = bokeh_renderer.get_widget(hmap, 'widgets') - widgets, dimensions, init_dim_vals = widgets.get_widgets() - self.assertEqual(len(widgets), 2) - slider = widgets[1] - self.assertEqual(slider['vals'], repr([repr(float(v)) for v in range(3)])) - self.assertEqual(slider['labels'], repr([str(v) for v in range(3)])) - - def test_holomap_slider_unsorted_initialization(self): - data = [(3, Curve([3, 2, 1])), (1, Curve([1, 2, 3]))] - hmap = HoloMap(data, ['X'], sort=False) - widgets = bokeh_renderer.get_widget(hmap, 'widgets') - widgets, dimensions, init_dim_vals = widgets.get_widgets() - self.assertEqual(len(widgets), 1) - slider = widgets[0] - self.assertEqual(slider['vals'], "['1.0', '3.0']") - self.assertEqual(slider['labels'], "['1', '3']") - - def test_holomap_slider_unsorted_datetime_values_initialization(self): - hmap = HoloMap([(np.datetime64(10005, 'D'), Curve([1, 2, 3])), - (np.datetime64(10000, 'D'), Curve([1, 2, 4]))], sort=False) - widgets = bokeh_renderer.get_widget(hmap, 'widgets') - widgets() - self.assertEqual(widgets.plot.current_key, (np.datetime64(10000, 'D'),)) - self.assertEqual(widgets.plot.current_frame, hmap[np.datetime64(10000, 'D')]) - - def test_holomap_dropdown(self): - hmap = HoloMap({chr(65+i): Curve([1, 2, 3]) for i in range(10)}, 'X') - widgets = bokeh_renderer.get_widget(hmap, 'widgets') - widgets, dimensions, init_dim_vals = widgets.get_widgets() - self.assertEqual(len(widgets), 1) - dropdown = widgets[0] - self.assertEqual(dropdown['type'], 'dropdown') - self.assertEqual(dropdown['dim'], 'X') - self.assertEqual(dropdown['dim_idx'], 0) - self.assertEqual(dropdown['vals'], repr([chr(65+v) for v in range(10)])) - self.assertEqual(dropdown['labels'], repr([chr(65+v) for v in range(10)])) - self.assertEqual(dropdown['step'], 1) - self.assertEqual(dropdown['default'], 0) - self.assertIs(dropdown['next_dim'], None) - self.assertEqual(dimensions, ['X']) - self.assertEqual(init_dim_vals, ['A']) - - def test_holomap_slider_and_dropdown(self): - hmap = HoloMap({(i, chr(65+i)): Curve([1, 2, 3]) for i in range(10)}, ['X', 'Y']) - widgets = bokeh_renderer.get_widget(hmap, 'widgets') - widgets, dimensions, init_dim_vals = widgets.get_widgets() - self.assertEqual(len(widgets), 2) - - slider = widgets[0] - self.assertEqual(slider['type'], 'slider') - self.assertEqual(slider['dim'], 'X') - self.assertEqual(slider['dim_idx'], 0) - self.assertEqual(slider['vals'], repr([repr(float(v)) for v in range(10)])) - self.assertEqual(slider['labels'], repr([str(v) for v in range(10)])) - self.assertEqual(slider['step'], 1) - self.assertEqual(slider['default'], 0) - self.assertEqual(slider['next_dim'], Dimension('Y')) - self.assertEqual(eval(slider['next_vals']), - {str(float(i)): [chr(65+i)] for i in range(10)}) - - dropdown = widgets[1] - self.assertEqual(dropdown['type'], 'dropdown') - self.assertEqual(dropdown['dim'], 'Y') - self.assertEqual(dropdown['dim_idx'], 1) - self.assertEqual(dropdown['vals'], repr([chr(65+v) for v in range(10)])) - self.assertEqual(dropdown['labels'], repr([chr(65+v) for v in range(10)])) - self.assertEqual(dropdown['step'], 1) - self.assertEqual(dropdown['default'], 0) - self.assertIs(dropdown['next_dim'], None) - - self.assertEqual(dimensions, ['X', 'Y']) - self.assertEqual(init_dim_vals, [0.0, 'A']) - - def test_dynamicmap_int_range_slider(self): - hmap = DynamicMap(lambda x: Curve([1, 2, 3]), kdims=[Dimension('X', range=(0, 5))]) - widgets = bokeh_renderer.get_widget(hmap, 'widgets') - widgets, dimensions, init_dim_vals = widgets.get_widgets() - self.assertEqual(len(widgets), 1) - slider = widgets[0] - self.assertEqual(slider['type'], 'slider') - self.assertEqual(slider['dim'], 'X') - self.assertEqual(slider['dim_idx'], 0) - self.assertEqual(slider['vals'], "['0.0', '5.0']") - self.assertEqual(slider['labels'], []) - self.assertEqual(slider['step'], 1) - self.assertEqual(slider['default'], 0) - self.assertIs(slider['next_dim'], None) - self.assertEqual(dimensions, ['X']) - self.assertEqual(init_dim_vals, [0.0]) - - def test_dynamicmap_float_range_slider(self): - hmap = DynamicMap(lambda x: Curve([1, 2, 3]), kdims=[Dimension('X', range=(0., 5.))]) - widgets = bokeh_renderer.get_widget(hmap, 'widgets') - widgets, dimensions, init_dim_vals = widgets.get_widgets() - self.assertEqual(len(widgets), 1) - slider = widgets[0] - self.assertEqual(slider['type'], 'slider') - self.assertEqual(slider['dim'], 'X') - self.assertEqual(slider['dim_idx'], 0) - self.assertEqual(slider['vals'], "['0.0', '5.0']") - self.assertEqual(slider['labels'], []) - self.assertEqual(slider['step'], 0.01) - self.assertEqual(slider['default'], 0.0) - self.assertIs(slider['next_dim'], None) - self.assertEqual(dimensions, ['X']) - self.assertEqual(init_dim_vals, [0.0]) - - def test_dynamicmap_float_range_slider_with_step(self): - dimension = Dimension('X', range=(0., 5.), step=0.05) - hmap = DynamicMap(lambda x: Curve([1, 2, 3]), kdims=[dimension]) - widgets = bokeh_renderer.get_widget(hmap, 'widgets') - widgets, dimensions, init_dim_vals = widgets.get_widgets() - self.assertEqual(widgets[0]['step'], 0.05) - - def test_dynamicmap_int_range_slider_with_step(self): - dimension = Dimension('X', range=(0, 10), step=2) - hmap = DynamicMap(lambda x: Curve([1, 2, 3]), kdims=[dimension]) - widgets = bokeh_renderer.get_widget(hmap, 'widgets') - widgets, dimensions, init_dim_vals = widgets.get_widgets() - self.assertEqual(widgets[0]['step'], 2) - - def test_dynamicmap_values_slider(self): - dimension = Dimension('X', values=list(range(10))) - hmap = DynamicMap(lambda x: Curve([1, 2, 3]), kdims=[dimension]) - widgets = bokeh_renderer.get_widget(hmap, 'widgets') - widgets, dimensions, init_dim_vals = widgets.get_widgets() - self.assertEqual(len(widgets), 1) - slider = widgets[0] - self.assertEqual(slider['type'], 'slider') - self.assertEqual(slider['dim'], 'X') - self.assertEqual(slider['dim_idx'], 0) - self.assertEqual(slider['vals'], {i: i for i in range(10)}) - self.assertEqual(slider['labels'], repr([str(i) for i in range(10)])) - self.assertEqual(slider['step'], 1) - self.assertEqual(slider['default'], 0) - self.assertIs(slider['next_dim'], None) - self.assertEqual(dimensions, ['X']) - self.assertEqual(init_dim_vals, [0.0]) - - def test_dynamicmap_values_dropdown(self): - values = [chr(65+i) for i in range(10)] - dimension = Dimension('X', values=values) - hmap = DynamicMap(lambda x: Curve([1, 2, 3]), kdims=[dimension]) - widgets = bokeh_renderer.get_widget(hmap, 'widgets') - widgets, dimensions, init_dim_vals = widgets.get_widgets() - self.assertEqual(len(widgets), 1) - dropdown = widgets[0] - self.assertEqual(dropdown['type'], 'dropdown') - self.assertEqual(dropdown['dim'], 'X') - self.assertEqual(dropdown['dim_idx'], 0) - self.assertEqual(dropdown['vals'], list(range(10))) - self.assertEqual(dropdown['labels'], repr(values)) - self.assertEqual(dropdown['step'], 1) - self.assertEqual(dropdown['default'], 0) - self.assertIs(dropdown['next_dim'], None) - self.assertEqual(dimensions, ['X']) - self.assertEqual(init_dim_vals, [0.0]) - - def test_dynamicmap_values_default(self): - values = [chr(65+i) for i in range(10)] - dimension = Dimension('X', values=values, default='C') - hmap = DynamicMap(lambda x: Curve([1, 2, 3]), kdims=[dimension]) - widgets = bokeh_renderer.get_widget(hmap, 'widgets') - widgets, dimensions, init_dim_vals = widgets.get_widgets() - self.assertEqual(widgets[0]['default'], '2') - self.assertEqual(init_dim_vals, ['2']) - - def test_dynamicmap_range_default(self): - dimension = Dimension('X', range=(0., 5.), default=0.05) - hmap = DynamicMap(lambda x: Curve([1, 2, 3]), kdims=[dimension]) - widgets = bokeh_renderer.get_widget(hmap, 'widgets') - widgets, dimensions, init_dim_vals = widgets.get_widgets() - self.assertEqual(widgets[0]['default'], 0.05) - self.assertEqual(init_dim_vals, [0.05]) - - def test_holomap_slider_default(self): - dim = Dimension('X', default=3) - hmap = HoloMap({i: Curve([1, 2, 3]) for i in range(1, 9)}, dim) - widgets = bokeh_renderer.get_widget(hmap, 'widgets') - widgets, dimensions, init_dim_vals = widgets.get_widgets() - self.assertEqual(widgets[0]['default'], '2') - self.assertEqual(init_dim_vals, [3.0]) - - def test_holomap_slider_bad_default(self): - dim = Dimension('X', default=42) - hmap = HoloMap({i: Curve([1, 2, 3]) for i in range(1, 9)}, dim) - with self.assertRaises(ValueError): - bokeh_renderer.get_widget(hmap, 'widgets').get_widgets() - - def test_holomap_dropdown_default(self): - dim = Dimension('X', default='C') - hmap = HoloMap({chr(65+i): Curve([1, 2, 3]) for i in range(10)}, dim) - widgets = bokeh_renderer.get_widget(hmap, 'widgets') - widgets, dimensions, init_dim_vals = widgets.get_widgets() - self.assertEqual(widgets[0]['default'], '2') - self.assertEqual(init_dim_vals, ['C']) - - def test_holomap_dropdown_bad_default(self): - dim = Dimension('X', default='Z') - hmap = HoloMap({chr(65+i): Curve([1, 2, 3]) for i in range(10)}, dim) - with self.assertRaises(ValueError): - bokeh_renderer.get_widget(hmap, 'widgets').get_widgets() - - def test_dynamicmap_default_value_slider_plot_initialization(self): - dims = [Dimension('N', default=5, range=(0, 10))] - dmap = DynamicMap(lambda N: Curve([1, N, 5]), kdims=dims) - widgets = bokeh_renderer.get_widget(dmap, 'widgets') - widgets.get_widgets() - self.assertEqual(widgets.plot.current_key, (5,)) - - def test_dynamicmap_unsorted_numeric_values_slider_plot_initialization(self): - dims = [Dimension('N', values=[10, 5, 0])] - dmap = DynamicMap(lambda N: Curve([1, N, 5]), kdims=dims) - widgets = bokeh_renderer.get_widget(dmap, 'widgets') - widgets.get_widgets() - self.assertEqual(widgets.plot.current_key, (0,)) - - def test_dynamicmap_unsorted_numeric_values_slider_plot_update(self): - dims = [Dimension('N', values=[10, 5, 0])] - dmap = DynamicMap(lambda N: Curve([1, N, 5]), kdims=dims) - widgets = bokeh_renderer.get_widget(dmap, 'widgets') - widgets.get_widgets() - widgets.update((2,)) - self.assertEqual(widgets.plot.current_key, (10,)) 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(" Date: Fri, 16 Aug 2019 04:18:46 -0500 Subject: [PATCH 5/9] Add cformatter (#3906) --- holoviews/plotting/mpl/element.py | 5 +++++ 1 file changed, 5 insertions(+) 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 From 26fea8b3042de37b6233d3794c6e90354c1811ff Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Sat, 17 Aug 2019 00:01:51 +0200 Subject: [PATCH 6/9] Plotly: Add Panel-based support for linked streams and dynamic updates (#3905) --- holoviews/plotting/bokeh/element.py | 4 - holoviews/plotting/bokeh/plot.py | 71 +--- holoviews/plotting/mpl/plot.py | 4 - holoviews/plotting/mpl/raster.py | 2 - holoviews/plotting/plot.py | 142 ++++++-- holoviews/plotting/plotly/__init__.py | 1 + holoviews/plotting/plotly/callbacks.py | 215 +++++++++++ holoviews/plotting/plotly/element.py | 19 +- holoviews/plotting/plotly/plot.py | 11 +- holoviews/plotting/plotly/renderer.py | 29 +- holoviews/plotting/renderer.py | 25 +- .../tests/plotting/plotly/testcallbacks.py | 340 ++++++++++++++++++ .../tests/plotting/plotly/testdynamic.py | 170 +++++++++ .../tests/plotting/plotly/testelementplot.py | 2 +- holoviews/util/__init__.py | 2 +- setup.py | 4 +- 16 files changed, 915 insertions(+), 126 deletions(-) create mode 100644 holoviews/plotting/plotly/callbacks.py create mode 100644 holoviews/tests/plotting/plotly/testcallbacks.py create mode 100644 holoviews/tests/plotting/plotly/testdynamic.py diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py index e95e8a6766..f8831c9dd4 100644 --- a/holoviews/plotting/bokeh/element.py +++ b/holoviews/plotting/bokeh/element.py @@ -1871,10 +1871,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 8e198ee587..28412eb85a 100644 --- a/holoviews/plotting/bokeh/plot.py +++ b/holoviews/plotting/bokeh/plot.py @@ -12,17 +12,17 @@ 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 ( basestring, cftime_to_timestamp, cftime_types, get_method_owner, unique_iterator, wrap_tuple, wrap_tuple_streams, _STANDARD_CALENDARS) -from ...streams import Stream from ..links import Link from ..plot import ( DimensionedPlot, GenericCompositePlot, GenericLayoutPlot, - GenericElementPlot, GenericOverlayPlot, GenericAdjointLayoutPlot + GenericElementPlot, GenericOverlayPlot, GenericAdjointLayoutPlot, + CallbackPlot ) from ..util import attach_streams, displayable, collate from .callbacks import LinkCallback @@ -31,7 +31,7 @@ empty_plot, decode_bytes, theme_attr_json, cds_column_replace) -class BokehPlot(DimensionedPlot): +class BokehPlot(DimensionedPlot, CallbackPlot): """ Plotting baseclass for the Bokeh backends, implementing the basic plotting interface for Bokeh based plots. @@ -91,60 +91,6 @@ def get_data(self, element, ranges, style): raise NotImplementedError - @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] - - 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 - - - def _construct_callbacks(self): - """ - Initializes any callbacks for streams which have defined - the plotted object as a source. - """ - cb_classes = set() - registry = list(Stream.registry.items()) - callbacks = Stream._callbacks['bokeh'] - 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 - - - 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): """ Initializes a data source to be passed into the bokeh glyph. @@ -263,9 +209,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): """ @@ -510,10 +453,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: @@ -721,10 +661,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]) diff --git a/holoviews/plotting/mpl/plot.py b/holoviews/plotting/mpl/plot.py index f57d71667d..024d94fe0b 100644 --- a/holoviews/plotting/mpl/plot.py +++ b/holoviews/plotting/mpl/plot.py @@ -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]) @@ -767,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/plot.py b/holoviews/plotting/plot.py index c248129771..d01c024e87 100644 --- a/holoviews/plotting/plot.py +++ b/holoviews/plotting/plot.py @@ -27,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, @@ -41,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 = [] @@ -48,6 +51,19 @@ 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): """ @@ -56,6 +72,17 @@ def state(self): """ 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: @@ -65,6 +92,7 @@ def root(self): else: return None + @property def document(self): return self._document @@ -88,6 +116,33 @@ def document(self, doc): 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. @@ -117,8 +172,6 @@ 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): @@ -150,7 +203,7 @@ def refresh(self, **kwargs): 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: + if self.top_level: self.push() @@ -166,30 +219,19 @@ 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] + 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): + elif ((self.renderer.mode != 'server' or (root and 'embedded' in root.tags)) + and self.document and self.comm): push(self.document, self.comm) - def init_comm(self): - """ - Initializes comm and attaches streams. - """ - 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 - - @property def id(self): return self.comm.id if self.comm else id(self.state) @@ -331,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, root=None, pane=None, **params): + style=None, subplots=None, dynamic=False, **params): self.subplots = subplots self.adjoined = adjoined self.dimensions = dimensions @@ -349,15 +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._document = None - self._root = root - self._pane = pane 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) @@ -734,6 +767,52 @@ def __len__(self): +class CallbackPlot(object): + + def _construct_callbacks(self): + """ + Initializes any callbacks for streams which have defined + the plotted object as a source. + """ + 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] + + 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 GenericElementPlot(DimensionedPlot): """ Plotting baseclass to render contents of an Element. Implements @@ -909,9 +988,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))): @@ -1229,8 +1305,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]) diff --git a/holoviews/plotting/plotly/__init__.py b/holoviews/plotting/plotly/__init__.py index 9003317d72..fbbc2d3cc8 100644 --- a/holoviews/plotting/plotly/__init__.py +++ b/holoviews/plotting/plotly/__init__.py @@ -14,6 +14,7 @@ from .plot import * # noqa (API import) from .stats import * # noqa (API import) from .tabular import * # noqa (API import) +from .callbacks 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 new file mode 100644 index 0000000000..0d136214c6 --- /dev/null +++ b/holoviews/plotting/plotly/callbacks.py @@ -0,0 +1,215 @@ +from weakref import WeakValueDictionary + +from param.parameterized import add_metaclass + +from ...streams import ( + Stream, Selection1D, RangeXY, RangeX, RangeY, BoundsXY, BoundsX, BoundsY +) + +from .util import _trace_to_subplot + + +class PlotlyCallbackMetaClass(type): + """ + Metaclass for PlotlyCallback classes. + + We want each callback class to keep track of all of the instances of the class. + Using a meta class here lets us keep the logic for instance tracking in one place. + """ + + def __init__(cls, 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(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 + # the references will be automatically removed from the colleciton when this + # happens. + cls.instances[inst.plot.trace_uid] = inst + + return inst + + +@add_metaclass(PlotlyCallbackMetaClass) +class PlotlyCallback(object): + + def __init__(self, plot, streams, source, **params): + self.plot = plot + self.streams = streams + self.source = source + + @classmethod + def update_streams_from_property_update(cls, property_value, fig_dict): + raise NotImplementedError() + + +class Selection1DCallback(PlotlyCallback): + callback_property = "selected_data" + + @classmethod + def update_streams_from_property_update(cls, selected_data, fig_dict): + + traces = fig_dict.get('data', []) + + # build event data and compute which trace UIDs are eligible + # Look up callback with UID + # graph reference and update the streams + point_inds = {} + if selected_data: + for point in selected_data['points']: + point_inds.setdefault(point['curveNumber'], []) + point_inds[point['curveNumber']].append(point['pointNumber']) + + for trace_ind, trace in enumerate(traces): + trace_uid = trace.get('uid', None) + if trace_uid in cls.instances: + cb = cls.instances[trace_uid] + new_index = point_inds.get(trace_ind, []) + for stream in cb.streams: + stream.event(index=new_index) + + +class BoundsCallback(PlotlyCallback): + callback_property = "selected_data" + boundsx = False + boundsy = False + + @classmethod + def update_streams_from_property_update(cls, selected_data, fig_dict): + + traces = fig_dict.get('data', []) + + if not selected_data or 'range' not in selected_data: + # No valid box selection + box = None + else: + # Get x and y axis references + box = selected_data["range"] + axis_refs = list(box) + xref = [ref for ref in axis_refs if ref.startswith('x')][0] + yref = [ref for ref in axis_refs if ref.startswith('y')][0] + + # Process traces + for trace_ind, trace in enumerate(traces): + trace_type = trace.get('type', 'scatter') + trace_uid = trace.get('uid', None) + + if (trace_uid not in cls.instances or + _trace_to_subplot.get(trace_type, None) != ['xaxis', 'yaxis']): + continue + + cb = cls.instances[trace_uid] + + if (box and trace.get('xaxis', 'x') == xref and + trace.get('yaxis', 'y') == yref): + + new_bounds = (box[xref][0], box[yref][0], box[xref][1], box[yref][1]) + + if cls.boundsx and cls.boundsy: + event_kwargs = dict(bounds=new_bounds) + elif cls.boundsx: + event_kwargs = dict(boundsx=(new_bounds[0], new_bounds[2])) + elif cls.boundsy: + event_kwargs = dict(boundsy=(new_bounds[1], new_bounds[3])) + else: + event_kwargs = dict() + + for stream in cb.streams: + stream.event(**event_kwargs) + else: + if cls.boundsx and cls.boundsy: + event_kwargs = dict(bounds=None) + elif cls.boundsx: + event_kwargs = dict(boundsx=None) + elif cls.boundsy: + event_kwargs = dict(boundsy=None) + else: + event_kwargs = dict() + + for stream in cb.streams: + stream.event(**event_kwargs) + + +class BoundsXYCallback(BoundsCallback): + boundsx = True + boundsy = True + + +class BoundsXCallback(BoundsCallback): + boundsx = True + + +class BoundsYCallback(BoundsCallback): + boundsy = True + + +class RangeCallback(PlotlyCallback): + callback_property = "viewport" + x_range = False + y_range = False + + @classmethod + def update_streams_from_property_update(cls, viewport, fig_dict): + + traces = fig_dict.get('data', []) + + # Process traces + for trace_ind, trace in enumerate(traces): + trace_type = trace.get('type', 'scatter') + trace_uid = trace.get('uid', None) + + if (trace_uid not in cls.instances or + _trace_to_subplot.get(trace_type, None) != ['xaxis', 'yaxis']): + continue + + xaxis = trace.get('xaxis', 'x').replace('x', 'xaxis') + yaxis = trace.get('yaxis', 'y').replace('y', 'yaxis') + xprop = '{xaxis}.range'.format(xaxis=xaxis) + yprop = '{yaxis}.range'.format(yaxis=yaxis) + + if not viewport or xprop not in viewport or yprop not in viewport: + x_range = None + y_range = None + else: + x_range = tuple(viewport[xprop]) + y_range = tuple(viewport[yprop]) + + stream_kwargs = {} + if cls.x_range: + stream_kwargs['x_range'] = x_range + + if cls.y_range: + stream_kwargs['y_range'] = y_range + + cb = cls.instances[trace_uid] + for stream in cb.streams: + stream.event(**stream_kwargs) + + +class RangeXYCallback(RangeCallback): + x_range = True + y_range = True + + +class RangeXCallback(RangeCallback): + x_range = True + + +class RangeYCallback(RangeCallback): + y_range = True + + +callbacks = Stream._callbacks['plotly'] +callbacks[Selection1D] = Selection1DCallback +callbacks[BoundsXY] = BoundsXYCallback +callbacks[BoundsX] = BoundsXCallback +callbacks[BoundsY] = BoundsYCallback +callbacks[RangeXY] = RangeXYCallback +callbacks[RangeX] = RangeXCallback +callbacks[RangeY] = RangeYCallback + diff --git a/holoviews/plotting/plotly/element.py b/holoviews/plotting/plotly/element.py index 09aa0b5716..ed47e16c3a 100644 --- a/holoviews/plotting/plotly/element.py +++ b/holoviews/plotting/plotly/element.py @@ -1,5 +1,6 @@ from __future__ import absolute_import, division, unicode_literals +import uuid import numpy as np import param @@ -100,6 +101,13 @@ class ElementPlot(PlotlyPlot, GenericElementPlot): # Declare which styles cannot be mapped to a non-scalar dimension _nonvectorized_styles = [] + def __init__(self, element, plot=None, **params): + super(ElementPlot, self).__init__(element, **params) + self.trace_uid = str(uuid.uuid4()) + self.static = len(self.hmap) == 1 and len(self.keys) == len(self.hmap) + self.callbacks = self._construct_callbacks() + + def initialize_plot(self, ranges=None): """ Initializes a new plot object with the last available frame. @@ -138,6 +146,13 @@ def generate_plot(self, key, ranges, element=None): # Initialize traces traces = self.init_graph(d, opts, index=i) graphs.extend(traces) + + if i == 0: + # Associate element with trace.uid property of the first + # plotly trace that is used to render the element. This is + # used to associate the element with the trace during callbacks + traces[0]['uid'] = self.trace_uid + self.handles['graphs'] = graphs # Initialize layout @@ -147,6 +162,7 @@ def generate_plot(self, key, ranges, element=None): # Create figure and return it self.drawn = True fig = dict(data=graphs, layout=layout) + self.handles['fig'] = fig return fig @@ -277,7 +293,7 @@ def init_layout(self, key, element, ranges): else: l, b, z0, r, t, z1 = extent - options = {} + options = {'uirevision': True} dims = self._get_axis_dims(el) if len(dims) > 2: @@ -492,6 +508,7 @@ def generate_plot(self, key, ranges, element=None): layout = self.init_layout(key, element, ranges) figure['layout'].update(layout) self.drawn = True + self.handles['fig'] = figure return figure diff --git a/holoviews/plotting/plotly/plot.py b/holoviews/plotting/plotly/plot.py index e2200a3e6e..12e6abc782 100644 --- a/holoviews/plotting/plotly/plot.py +++ b/holoviews/plotting/plotly/plot.py @@ -10,11 +10,11 @@ from ...core.util import wrap_tuple from ..plot import ( DimensionedPlot, GenericLayoutPlot, GenericCompositePlot, - GenericElementPlot, GenericAdjointLayoutPlot) + GenericElementPlot, GenericAdjointLayoutPlot, CallbackPlot) from .util import figure_grid -class PlotlyPlot(DimensionedPlot): +class PlotlyPlot(DimensionedPlot, CallbackPlot): backend = 'plotly' @@ -48,7 +48,6 @@ def update_frame(self, key, ranges=None): return self.generate_plot(key, ranges) - class LayoutPlot(PlotlyPlot, GenericLayoutPlot): hspacing = param.Number(default=0.15, bounds=(0, 1)) @@ -60,8 +59,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]) @@ -238,6 +235,7 @@ def generate_plot(self, key, ranges=None): title=self._format_title(key)) self.drawn = True + self.handles['fig'] = fig return self.handles['fig'] @@ -301,8 +299,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]) @@ -371,6 +367,7 @@ def generate_plot(self, key, ranges=None): title=self._format_title(key)) self.drawn = True + self.handles['fig'] = fig return self.handles['fig'] diff --git a/holoviews/plotting/plotly/renderer.py b/holoviews/plotting/plotly/renderer.py index 1ba2d3e970..1b201bd1cb 100644 --- a/holoviews/plotting/plotly/renderer.py +++ b/holoviews/plotting/plotly/renderer.py @@ -3,12 +3,30 @@ import base64 import param +import panel as pn + with param.logging_level('CRITICAL'): import plotly.graph_objs as go from ..renderer import Renderer, MIME_TYPES, HTML_TAGS from ...core.options import Store from ...core import HoloMap +from .callbacks import callbacks + + +def _PlotlyHoloviewsPane(fig_dict): + """ + Custom Plotly pane constructor for use by the HoloViews 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 class PlotlyRenderer(Renderer): @@ -25,6 +43,7 @@ class PlotlyRenderer(Renderer): widgets = ['scrubber', 'widgets'] _loaded = False + _render_with_panel = True def _figure_data(self, plot, fmt, as_script=False, **kwargs): @@ -38,7 +57,7 @@ def _figure_data(self, plot, fmt, as_script=False, **kwargs): if fmt == 'svg': data = data.decode('utf-8') - + if as_script: b64 = base64.b64encode(data).decode("utf-8") (mime_type, tag) = MIME_TYPES[fmt], HTML_TAGS[fmt] @@ -50,6 +69,7 @@ def _figure_data(self, plot, fmt, as_script=False, **kwargs): else: raise ValueError("Unsupported format: {fmt}".format(fmt=fmt)) + @classmethod def plot_options(cls, obj, percent_size): factor = percent_size / 100.0 @@ -68,3 +88,10 @@ def load_nb(cls, inline=True): """ import panel.models.plotly # noqa cls._loaded = True + + +def _activate_plotly_backend(renderer): + if renderer == "plotly": + 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 3cdd2c4994..d9fd033fe4 100644 --- a/holoviews/plotting/renderer.py +++ b/holoviews/plotting/renderer.py @@ -16,6 +16,7 @@ from panel import config 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 @@ -185,7 +186,7 @@ def __call__(self, obj, fmt='auto', **kwargs): @bothmethod - def get_plot(self_or_cls, obj, doc=None, 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. """ @@ -210,6 +211,7 @@ def get_plot(self_or_cls, obj, doc=None, 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), @@ -226,7 +228,10 @@ def get_plot(self_or_cls, obj, doc=None, renderer=None, **kwargs): if isinstance(self_or_cls, Renderer): self_or_cls.last_plot = plot - if plot.comm or self_or_cls.mode == 'server': + 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: @@ -235,6 +240,15 @@ def get_plot(self_or_cls, obj, doc=None, renderer=None, **kwargs): return plot + @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 @@ -573,8 +587,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/plotting/plotly/testcallbacks.py b/holoviews/tests/plotting/plotly/testcallbacks.py new file mode 100644 index 0000000000..c3956f6636 --- /dev/null +++ b/holoviews/tests/plotting/plotly/testcallbacks.py @@ -0,0 +1,340 @@ +from unittest import TestCase + +try: + from unittest.mock import Mock +except: + from mock import Mock + +import uuid +import plotly.graph_objs as go + +from holoviews.plotting.plotly.callbacks import ( + RangeXYCallback, RangeXCallback, RangeYCallback, + BoundsXYCallback, BoundsXCallback, BoundsYCallback, + Selection1DCallback +) + + +def mock_plot(trace_uid=None): + # Build a mock to stand in for a PlotlyPlot subclass + if trace_uid is None: + trace_uid = str(uuid.uuid4()) + + plot = Mock() + plot.trace_uid = trace_uid + return plot + + +def build_callback_set(callback_cls, trace_uids, num_streams=2): + """ + Build a collection of plots, callbacks, and streams for a given callback class and + a list of trace_uids + """ + plots = [] + streamss = [] + callbacks = [] + for trace_uid in trace_uids: + plot = mock_plot(trace_uid) + streams = [Mock() for _ in range(num_streams)] + callback = callback_cls(plot, streams, None) + + plots.append(plot) + streamss.append(streams) + callbacks.append(callback) + + return plots, streamss, callbacks + + +class TestCallbacks(TestCase): + + def setUp(self): + self.fig_dict = go.Figure({ + 'data': [ + {'type': 'scatter', + 'y': [1, 2, 3], + 'uid': 'first'}, + {'type': 'bar', + 'y': [1, 2, 3], + 'uid': 'second', + 'xaxis': 'x', + 'yaxis': 'y'}, + {'type': 'scatter', + 'y': [1, 2, 3], + 'uid': 'third', + 'xaxis': 'x2', + 'yaxis': 'y2'}, + {'type': 'bar', + 'y': [1, 2, 3], + 'uid': 'forth', + 'xaxis': 'x3', + 'yaxis': 'y3'}, + ], + 'layout': { + 'title': {'text': 'Figure Title'}} + }).to_dict() + + def testCallbackClassInstanceTracking(self): + # Each callback class should track all active instances of its own class in a + # weak value dictionary. Here we make sure that instances stay separated per + # class + plot1 = mock_plot() + plot2 = mock_plot() + plot3 = mock_plot() + + # Check RangeXYCallback + rangexy_cb = RangeXYCallback(plot1, [], None) + self.assertIn(plot1.trace_uid, RangeXYCallback.instances) + self.assertIs(rangexy_cb, RangeXYCallback.instances[plot1.trace_uid]) + + # Check BoundsXYCallback + boundsxy_cb = BoundsXYCallback(plot2, [], None) + self.assertIn(plot2.trace_uid, BoundsXYCallback.instances) + self.assertIs(boundsxy_cb, BoundsXYCallback.instances[plot2.trace_uid]) + + # Check Selection1DCallback + selection1d_cb = Selection1DCallback(plot3, [], None) + self.assertIn(plot3.trace_uid, Selection1DCallback.instances) + self.assertIs(selection1d_cb, Selection1DCallback.instances[plot3.trace_uid]) + + # Check that objects don't show up as instances in the wrong class + self.assertNotIn(plot1.trace_uid, BoundsXYCallback.instances) + self.assertNotIn(plot1.trace_uid, Selection1DCallback.instances) + self.assertNotIn(plot2.trace_uid, RangeXYCallback.instances) + self.assertNotIn(plot2.trace_uid, Selection1DCallback.instances) + self.assertNotIn(plot3.trace_uid, RangeXYCallback.instances) + self.assertNotIn(plot3.trace_uid, BoundsXYCallback.instances) + + def testRangeCallbacks(self): + + # Build callbacks + range_classes = [RangeXYCallback, RangeXCallback, RangeYCallback] + + xyplots, xystreamss, xycallbacks = build_callback_set( + RangeXYCallback, ['first', 'second', 'third', 'forth', 'other'], 2 + ) + + xplots, xstreamss, xcallbacks = build_callback_set( + RangeXCallback, ['first', 'second', 'third', 'forth', 'other'], 2 + ) + + yplots, ystreamss, ycallbacks = build_callback_set( + RangeYCallback, ['first', 'second', 'third', 'forth', 'other'], 2 + ) + + # Sanity check the length of the streams lists + for xystreams in xystreamss: + self.assertEqual(len(xystreams), 2) + + # Change viewport on first set of axes + viewport1 = {'xaxis.range': [1, 4], 'yaxis.range': [-1, 5]} + for cb_cls in range_classes: + cb_cls.update_streams_from_property_update(viewport1, self.fig_dict) + + # Check that all streams attached to 'first' and 'second' plots were triggered + for xystream, xstream, ystream in zip( + xystreamss[0] + xystreamss[1], + xstreamss[0] + xstreamss[1], + ystreamss[0] + ystreamss[1], + ): + xystream.event.assert_called_once_with(x_range=(1, 4), y_range=(-1, 5)) + xstream.event.assert_called_once_with(x_range=(1, 4)) + ystream.event.assert_called_once_with(y_range=(-1, 5)) + + # And that no other streams were triggered + for xystream, xstream, ystream in zip( + xystreamss[2] + xystreamss[3], + xstreamss[2] + xstreamss[3], + ystreamss[2] + ystreamss[3], + ): + xystream.event.assert_called_with(x_range=None, y_range=None) + xstream.event.assert_called_with(x_range=None) + ystream.event.assert_called_with(y_range=None) + + # Change viewport on second set of axes + viewport2 = {'xaxis2.range': [2, 5], 'yaxis2.range': [0, 6]} + for cb_cls in range_classes: + cb_cls.update_streams_from_property_update(viewport2, self.fig_dict) + + # Check that all streams attached to 'third' were triggered + for xystream, xstream, ystream in zip( + xystreamss[2], xstreamss[2], ystreamss[2] + ): + xystream.event.assert_called_with(x_range=(2, 5), y_range=(0, 6)) + xstream.event.assert_called_with(x_range=(2, 5)) + ystream.event.assert_called_with(y_range=(0, 6)) + + # Change viewport on third set of axes + viewport3 = {'xaxis3.range': [3, 6], 'yaxis3.range': [1, 7]} + for cb_cls in range_classes: + cb_cls.update_streams_from_property_update(viewport3, self.fig_dict) + + # Check that all streams attached to 'forth' were triggered + for xystream, xstream, ystream in zip( + xystreamss[3], xstreamss[3], ystreamss[3] + ): + xystream.event.assert_called_with(x_range=(3, 6), y_range=(1, 7)) + xstream.event.assert_called_with(x_range=(3, 6)) + ystream.event.assert_called_with(y_range=(1, 7)) + + # Check that streams attached to a trace not in this plot are not triggered + for xystream, xstream, ystream in zip( + xystreamss[4], xstreamss[4], ystreamss[4], + ): + xystream.event.assert_not_called() + xstream.event.assert_not_called() + ystream.event.assert_not_called() + + def testBoundsCallbacks(self): + + # Build callbacks + bounds_classes = [BoundsXYCallback, BoundsXCallback, BoundsYCallback] + + xyplots, xystreamss, xycallbacks = build_callback_set( + BoundsXYCallback, ['first', 'second', 'third', 'forth', 'other'], 2 + ) + + xplots, xstreamss, xcallbacks = build_callback_set( + BoundsXCallback, ['first', 'second', 'third', 'forth', 'other'], 2 + ) + + yplots, ystreamss, ycallbacks = build_callback_set( + BoundsYCallback, ['first', 'second', 'third', 'forth', 'other'], 2 + ) + + # box selection on first set of axes + selected_data1 = {'range': {'x': [1, 4], 'y': [-1, 5]}} + for cb_cls in bounds_classes: + cb_cls.update_streams_from_property_update(selected_data1, self.fig_dict) + + # Check that all streams attached to 'first' and 'second' plots were triggered + for xystream, xstream, ystream in zip( + xystreamss[0] + xystreamss[1], + xstreamss[0] + xstreamss[1], + ystreamss[0] + ystreamss[1], + ): + xystream.event.assert_called_once_with(bounds=(1, -1, 4, 5)) + xstream.event.assert_called_once_with(boundsx=(1, 4)) + ystream.event.assert_called_once_with(boundsy=(-1, 5)) + + # Check that streams attached to plots in other subplots are called with None + # to clear their bounds + for xystream, xstream, ystream in zip( + xystreamss[2] + xystreamss[3], + xstreamss[2] + xstreamss[3], + ystreamss[2] + ystreamss[3], + ): + xystream.event.assert_called_once_with(bounds=None) + xstream.event.assert_called_once_with(boundsx=None) + ystream.event.assert_called_once_with(boundsy=None) + + # box select on second set of axes + selected_data2 = {'range': {'x2': [2, 5], 'y2': [0, 6]}} + for cb_cls in bounds_classes: + cb_cls.update_streams_from_property_update(selected_data2, self.fig_dict) + + # Check that all streams attached to 'second' were triggered + for xystream, xstream, ystream in zip( + xystreamss[2], xstreamss[2], ystreamss[2], + ): + xystream.event.assert_called_with(bounds=(2, 0, 5, 6)) + xstream.event.assert_called_with(boundsx=(2, 5)) + ystream.event.assert_called_with(boundsy=(0, 6)) + + # box select on third set of axes + selected_data3 = {'range': {'x3': [3, 6], 'y3': [1, 7]}} + for cb_cls in bounds_classes: + cb_cls.update_streams_from_property_update(selected_data3, self.fig_dict) + + # Check that all streams attached to 'third' were triggered + for xystream, xstream, ystream in zip( + xystreamss[3], xstreamss[3], ystreamss[3], + ): + xystream.event.assert_called_with(bounds=(3, 1, 6, 7)) + xstream.event.assert_called_with(boundsx=(3, 6)) + ystream.event.assert_called_with(boundsy=(1, 7)) + + # lasso select on first set of axes should clear all bounds + selected_data_lasso = {'lassoPoints': {'x': [1, 4, 2], 'y': [-1, 5, 2]}} + for cb_cls in bounds_classes: + cb_cls.update_streams_from_property_update( + selected_data_lasso, self.fig_dict) + + # Check that all streams attached to this figure are called with None + # to clear their bounds + for xystream, xstream, ystream in zip( + xystreamss[0] + xystreamss[1] + xystreamss[2] + xystreamss[3], + xstreamss[0] + xstreamss[1] + xstreamss[2] + xstreamss[3], + ystreamss[0] + ystreamss[1] + ystreamss[2] + ystreamss[3], + ): + xystream.event.assert_called_with(bounds=None) + xstream.event.assert_called_with(boundsx=None) + ystream.event.assert_called_with(boundsy=None) + + # Check that streams attached to plots not in this figure are not called + for xystream, xstream, ystream in zip( + xystreamss[4], xstreamss[4], ystreamss[4] + ): + xystream.event.assert_not_called() + xstream.event.assert_not_called() + ystream.event.assert_not_called() + + def testSelection1DCallback(self): + plots, streamss, callbacks = build_callback_set( + Selection1DCallback, ['first', 'second', 'third', 'forth', 'other'], 2 + ) + + # Select points from the 'first' plot (first set of axes) + selected_data1 = {'points': [ + {"pointNumber": 0, "curveNumber": 0}, + {"pointNumber": 2, "curveNumber": 0}, + ]} + Selection1DCallback.update_streams_from_property_update( + selected_data1, self.fig_dict) + + # Check that all streams attached to the 'first' plots were triggered + for stream in streamss[0]: + stream.event.assert_called_once_with(index=[0, 2]) + + # Check that all streams attached to other plots in this figure were triggered + # with empty selection + for stream in streamss[1] + streamss[2] + streamss[3]: + stream.event.assert_called_once_with(index=[]) + + # Select points from the 'first' and 'second' plot (first set of axes) + selected_data1 = {'points': [ + {"pointNumber": 0, "curveNumber": 0}, + {"pointNumber": 1, "curveNumber": 0}, + {"pointNumber": 1, "curveNumber": 1}, + {"pointNumber": 2, "curveNumber": 1}, + ]} + Selection1DCallback.update_streams_from_property_update( + selected_data1, self.fig_dict) + + # Check that all streams attached to the 'first' plot were triggered + for stream in streamss[0]: + stream.event.assert_called_with(index=[0, 1]) + + # Check that all streams attached to the 'second' plot were triggered + for stream in streamss[1]: + stream.event.assert_called_with(index=[1, 2]) + + # Check that all streams attached to other plots in this figure were triggered + # with empty selection + for stream in streamss[2] + streamss[3]: + stream.event.assert_called_with(index=[]) + + # Select points from the 'forth' plot (third set of axes) + selected_data1 = {'points': [ + {"pointNumber": 0, "curveNumber": 3}, + {"pointNumber": 2, "curveNumber": 3}, + ]} + Selection1DCallback.update_streams_from_property_update( + selected_data1, self.fig_dict) + + # Check that all streams attached to the 'forth' plot were triggered + for stream in streamss[3]: + stream.event.assert_called_with(index=[0, 2]) + + # Check that streams attached to plots not in this figure are not called + for stream in streamss[4]: + stream.event.assert_not_called() diff --git a/holoviews/tests/plotting/plotly/testdynamic.py b/holoviews/tests/plotting/plotly/testdynamic.py new file mode 100644 index 0000000000..e240949abd --- /dev/null +++ b/holoviews/tests/plotting/plotly/testdynamic.py @@ -0,0 +1,170 @@ +from unittest import TestCase + +try: + from unittest.mock import Mock +except: + from mock import Mock + +import holoviews as hv +import panel as pn +import numpy as np + +from holoviews.streams import ( + Stream, Selection1D, RangeXY, BoundsXY, +) + +import holoviews.plotting.plotly # noqa (Activate backend) +hv.Store.set_current_backend("plotly") + +from bokeh.document import Document +from pyviz_comms import Comm + + +class TestDynamicMap(TestCase): + + def test_update_dynamic_map_with_stream(self): + ys = np.arange(10) + + # Build stream + Scale = Stream.define('Scale', scale=1.0) + scale_stream = Scale() + + # Build DynamicMap + def build_scatter(scale): + return hv.Scatter(ys * scale) + + dmap = hv.DynamicMap(build_scatter, streams=[scale_stream]) + + # Create HoloViews Pane using panel so that we can access the plotly pane + # used to display the plotly figure + dmap_pane = pn.pane.HoloViews(dmap, backend='plotly') + + # Call get_root to force instantiation of internal plots/models + doc = Document() + comm = Comm() + dmap_pane.get_root(doc, comm) + + # Get reference to the plotly pane + _, plotly_pane = next(iter(dmap_pane._plots.values())) + + # Check initial data + data = plotly_pane.object['data'] + self.assertEqual(len(data), 1) + self.assertEqual(data[0]['type'], 'scatter') + np.testing.assert_equal(data[0]['y'], ys) + + # Watch object for changes + fn = Mock() + plotly_pane.param.watch(fn, 'object') + + # Update stream + scale_stream.event(scale=2.0) + + # Check that figure object was updated + data = plotly_pane.object['data'] + np.testing.assert_equal(data[0]['y'], ys * 2.0) + + # Check that object callback was triggered + fn.assert_called_once() + args, kwargs = fn.call_args_list[0] + event = args[0] + self.assertIs(event.obj, plotly_pane) + self.assertIs(event.new, plotly_pane.object) + + +class TestInteractiveStream(TestCase): + # Note: Testing the core logic of each interactive stream should take place in + # testcallbacks.py. Here we are testing that that callbacks are properly + # routed to streams + + def test_interactive_streams(self): + ys = np.arange(10) + scatter1 = hv.Scatter(ys) + scatter2 = hv.Scatter(ys) + scatter3 = hv.Scatter(ys) + + # Single stream on the first scatter + rangexy1 = RangeXY(source=scatter1) + + # Multiple streams of the same type on second scatter + boundsxy2a = BoundsXY(source=scatter2) + boundsxy2b = BoundsXY(source=scatter2) + + # Multiple streams of different types on third scatter + rangexy3 = RangeXY(source=scatter3) + boundsxy3 = BoundsXY(source=scatter3) + selection1d3 = Selection1D(source=scatter3) + + # Build layout and layout Pane + layout = scatter1 + scatter2 + scatter3 + layout_pane = pn.pane.HoloViews(layout, backend='plotly') + + # Get plotly pane reference + doc = Document() + comm = Comm() + layout_pane.get_root(doc, comm) + _, plotly_pane = next(iter(layout_pane._plots.values())) + + # Simulate zoom and check that RangeXY streams updated accordingly + plotly_pane.viewport = { + 'xaxis.range': [1, 3], + 'yaxis.range': [2, 4], + 'xaxis2.range': [3, 5], + 'yaxis2.range': [4, 6], + 'xaxis3.range': [5, 7], + 'yaxis3.range': [6, 8], + } + + self.assertEqual(rangexy1.x_range, (1, 3)) + self.assertEqual(rangexy1.y_range, (2, 4)) + self.assertEqual(rangexy3.x_range, (5, 7)) + self.assertEqual(rangexy3.y_range, (6, 8)) + + plotly_pane.viewport = None + self.assertIsNone(rangexy1.x_range) + self.assertIsNone(rangexy1.y_range) + self.assertIsNone(rangexy3.x_range) + self.assertIsNone(rangexy3.y_range) + + # Simulate box selection and check that BoundsXY and Selection1D streams + # update accordingly + + # Box select on second subplot + plotly_pane.selected_data = { + 'points': [], + 'range': { + 'x2': [10, 20], + 'y2': [11, 22] + } + } + + self.assertEqual(boundsxy2a.bounds, (10, 11, 20, 22)) + self.assertEqual(boundsxy2b.bounds, (10, 11, 20, 22)) + + # Box selecrt on third subplot + plotly_pane.selected_data = { + 'points': [ + {'curveNumber': 2, 'pointNumber': 0}, + {'curveNumber': 2, 'pointNumber': 3}, + {'curveNumber': 2, 'pointNumber': 7}, + ], + 'range': { + 'x3': [0, 5], + 'y3': [1, 6] + } + } + + self.assertEqual(boundsxy3.bounds, (0, 1, 5, 6)) + self.assertEqual(selection1d3.index, [0, 3, 7]) + + # bounds streams on scatter 2 are None + self.assertIsNone(boundsxy2a.bounds) + self.assertIsNone(boundsxy2b.bounds) + + # Clear selection + plotly_pane.selected_data = None + self.assertIsNone(boundsxy3.bounds) + self.assertIsNone(boundsxy2a.bounds) + self.assertIsNone(boundsxy2b.bounds) + self.assertEqual(selection1d3.index, []) + diff --git a/holoviews/tests/plotting/plotly/testelementplot.py b/holoviews/tests/plotting/plotly/testelementplot.py index 2148b1e1ec..5adf50afdf 100644 --- a/holoviews/tests/plotting/plotly/testelementplot.py +++ b/holoviews/tests/plotting/plotly/testelementplot.py @@ -18,7 +18,7 @@ def history_callback(x, history=deque(maxlen=10)): stream = PointerX(x=0) dmap = DynamicMap(history_callback, kdims=[], streams=[stream]) plot = plotly_renderer.get_plot(dmap) - plotly_renderer(plot) + plotly_renderer(dmap) for i in range(20): stream.event(x=i) state = plot.state diff --git a/holoviews/util/__init__.py b/holoviews/util/__init__.py index 3207461084..98e9a53c4b 100644 --- a/holoviews/util/__init__.py +++ b/holoviews/util/__init__.py @@ -797,7 +797,7 @@ def render(obj, backend=None, **kwargs): plot = renderer_obj.get_plot(obj) if backend == 'matplotlib' and len(plot) > 1: return plot.anim(fps=renderer_obj.fps) - return renderer_obj.get_plot(obj).state + return renderer_obj.get_plot_state(obj) class Dynamic(param.ParameterizedFunction): diff --git a/setup.py b/setup.py index 29bbb0a54d..12bdad4ffc 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ # IPython Notebook + pandas + matplotlib + bokeh extras_require['recommended'] = extras_require['notebook'] + [ - 'pandas', 'matplotlib>=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'] From 1e22bfa8b6993d3374604b9d5cd0d7343f93baaa Mon Sep 17 00:00:00 2001 From: Joel Ostblom Date: Sat, 17 Aug 2019 04:31:23 -0700 Subject: [PATCH 7/9] Regrid doc elaboration for image data (#3911) --- examples/user_guide/15-Large_Data.ipynb | 37 ++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) 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)" ] }, { From d8e2f1b73ce08935849fa48cbfe20d8edebc5e59 Mon Sep 17 00:00:00 2001 From: Andrew <15331990+ahuang11@users.noreply.github.com> Date: Sat, 17 Aug 2019 06:32:22 -0500 Subject: [PATCH 8/9] Add cformatter for bokeh (#3913) --- holoviews/plotting/bokeh/element.py | 26 +++++++++++--------------- holoviews/plotting/bokeh/util.py | 22 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py index f8831c9dd4..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)) 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 From 343f28d403f1c484cb9d41f3d54e0ee5a0649dce Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Sat, 17 Aug 2019 13:35:23 +0200 Subject: [PATCH 9/9] Implement datetime selections as strings and datetimes (#3197) --- holoviews/core/data/grid.py | 2 ++ holoviews/core/data/interface.py | 33 +++++++++++--------- holoviews/core/util.py | 25 +++++++++++++++ holoviews/element/chart.py | 18 ++--------- holoviews/tests/element/testelementselect.py | 27 +++++++++++++++- 5 files changed, 74 insertions(+), 31 deletions(-) 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 0027e04785..69b5976bba 100644 --- a/holoviews/core/util.py +++ b/holoviews/core/util.py @@ -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/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)])