From ab26f518dac3951562598488a3f6d5535eb5df7d Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Mon, 31 Oct 2016 01:30:04 +0000 Subject: [PATCH 01/30] Added BokehRenderer server mode --- holoviews/plotting/bokeh/plot.py | 12 +++++++++++ holoviews/plotting/bokeh/renderer.py | 30 ++++++++++++++++++++++++---- holoviews/plotting/renderer.py | 1 + 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/holoviews/plotting/bokeh/plot.py b/holoviews/plotting/bokeh/plot.py index d40b30af28..a7b06765c8 100644 --- a/holoviews/plotting/bokeh/plot.py +++ b/holoviews/plotting/bokeh/plot.py @@ -81,6 +81,18 @@ def get_data(self, element, ranges=None, empty=False): raise NotImplementedError + def push(self): + """ + Pushes updated plot data via the Comm. + """ + if self.renderer.mode == 'server': + return + if self.comm is None: + raise Exception('Renderer does not have a comm.') + diff = self.renderer.diff(self) + self.comm.send(diff) + + def set_root(self, root): """ Sets the current document on all subplots. diff --git a/holoviews/plotting/bokeh/renderer.py b/holoviews/plotting/bokeh/renderer.py index a27e8dedfa..85bc301fc6 100644 --- a/holoviews/plotting/bokeh/renderer.py +++ b/holoviews/plotting/bokeh/renderer.py @@ -7,13 +7,14 @@ from bokeh.charts import Chart from bokeh.document import Document from bokeh.embed import notebook_div -from bokeh.io import load_notebook +from bokeh.io import load_notebook, curdoc from bokeh.models import (Row, Column, Plot, Model, ToolbarBox, WidgetBox, Div, DataTable, Tabs) from bokeh.plotting import Figure from bokeh.resources import CDN, INLINE from ...core import Store, HoloMap +from ..comms import JupyterComm, Comm from ..renderer import Renderer, MIME_TYPES from .widgets import BokehScrubberWidget, BokehSelectionWidget from .util import compute_static_patch, serialize_json @@ -28,9 +29,15 @@ class BokehRenderer(Renderer): Output render format for static figures. If None, no figure rendering will occur. """) + mode = param.ObjectSelector(default='default', + objects=['default', 'server'], doc=""" + Whether to render the DynamicMap in regular or server mode. """) + # Defines the valid output formats for each mode. - mode_formats = {'fig': {'default': ['html', 'json', 'auto']}, - 'holomap': {'default': ['widgets', 'scrubber', 'auto', None]}} + mode_formats = {'fig': {'default': ['html', 'json', 'auto'], + 'server': ['html', 'json', 'auto']}, + 'holomap': {'default': ['widgets', 'scrubber', 'auto', None], + 'server': ['widgets', 'auto', None]}} webgl = param.Boolean(default=False, doc="""Whether to render plots with WebGL if bokeh version >=0.10""") @@ -41,6 +48,9 @@ class BokehRenderer(Renderer): 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)} + comms = {'default': (JupyterComm, None), + 'server': (Comm, None)} + _loaded = False def __call__(self, obj, fmt=None): @@ -52,7 +62,9 @@ def __call__(self, obj, fmt=None): plot, fmt = self._validate(obj, fmt) info = {'file-ext': fmt, 'mime_type': MIME_TYPES[fmt]} - if isinstance(plot, tuple(self.widgets.values())): + if self.mode == 'server': + return self.server_doc(plot), info + elif isinstance(plot, tuple(self.widgets.values())): return plot(), info elif fmt == 'html': html = self.figure_data(plot) @@ -62,6 +74,16 @@ def __call__(self, obj, fmt=None): return self.diff(plot), info + def server_doc(self, plot): + """ + Get server document. + """ + doc = curdoc() + plot.document = doc + doc.add_root(plot.state) + return doc + + def figure_data(self, plot, fmt='html', **kwargs): model = plot.state doc = Document() diff --git a/holoviews/plotting/renderer.py b/holoviews/plotting/renderer.py index 26318b40e4..b63dbfbe31 100644 --- a/holoviews/plotting/renderer.py +++ b/holoviews/plotting/renderer.py @@ -52,6 +52,7 @@ 'pdf': 'application/pdf', 'html': 'text/html', 'json': 'text/json' + 'server': None } static_template = """ From c1e2550b38eff28352cf178b4a61898892533613 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Mon, 31 Oct 2016 01:30:32 +0000 Subject: [PATCH 02/30] Added initial bokeh server stream callback handling --- holoviews/plotting/bokeh/callbacks.py | 114 ++++++++++++++++++++------ holoviews/plotting/renderer.py | 2 +- 2 files changed, 89 insertions(+), 27 deletions(-) diff --git a/holoviews/plotting/bokeh/callbacks.py b/holoviews/plotting/bokeh/callbacks.py index 8ec1fc395e..b5c45c3c8f 100644 --- a/holoviews/plotting/bokeh/callbacks.py +++ b/holoviews/plotting/bokeh/callbacks.py @@ -229,10 +229,13 @@ class Callback(object): def __init__(self, plot, streams, source, **params): self.plot = plot self.streams = streams - self.comm = self._comm_type(plot, on_msg=self.on_msg) + if plot.renderer.mode != 'server': + self.comm = self._comm_type(plot, on_msg=self.on_msg) self.source = source self.handle_ids = defaultdict(dict) self.callbacks = [] + self.plot_handles = {} + self._event_queue = [] def initialize(self): @@ -240,28 +243,45 @@ def initialize(self): if self.plot.subplots: plots += list(self.plot.subplots.values()) - handles = self._get_plot_handles(plots) + self.plot_handles = self._get_plot_handles(plots) requested = {} for h in self.models+self.extra_models: - if h in handles: - requested[h] = handles[h] + if h in self.plot_handles: + requested[h] = self.plot_handles[h] elif h in self.extra_models: print("Warning %s could not find the %s model. " "The corresponding stream may not work.") self.handle_ids.update(self._get_stream_handle_ids(requested)) + found = [] for plot in plots: for handle_name in self.models: - if handle_name not in handles: + if handle_name not in self.plot_handles: warn_args = (handle_name, type(self.plot).__name__, type(self).__name__) - self.warning('%s handle not found on %s, cannot' - 'attach %s callback' % warn_args) + print('%s handle not found on %s, cannot ' + 'attach %s callback' % warn_args) continue - handle = handles[handle_name] - js_callback = self.get_customjs(requested) - self.set_customjs(js_callback, handle) - self.callbacks.append(js_callback) + handle = self.plot_handles[handle_name] + + # Hash the plot handle with Callback type allowing multiple + # callbacks on one handle to be merged + cb_hash = (id(handle), id(type(self))) + if cb_hash in self._callbacks: + # Merge callbacks if another callback has already been attached + cb = self._callbacks[cb_hash] + cb.streams += self.streams + for k, v in self.handle_ids.items(): + cb.handle_ids[k].update(v) + continue + + if self.plot.renderer.mode == 'server': + self.set_onchange(plot.handles[handle_name]) + else: + js_callback = self.get_customjs(requested) + self.set_customjs(js_callback, handle) + self.callbacks.append(js_callback) + self._callbacks[cb_hash] = self def _filter_msg(self, msg, ids): @@ -278,7 +298,7 @@ def _filter_msg(self, msg, ids): else: filtered_msg[k] = v return filtered_msg - + def on_msg(self, msg): for stream in self.streams: @@ -330,7 +350,61 @@ def _get_stream_handle_ids(self, handles): return stream_handle_ids - def get_customjs(self, references): + def on_change(self, attr, old, new): + """ + Process change events adding timeout to process multiple concerted + value change at once rather than firing off multiple plot updates. + """ + self._event_queue.append((attr, old, new)) + if self.trigger not in self.plot.document._session_callbacks: + self.plot.document.add_timeout_callback(self.trigger, 50) + + + def trigger(self): + """ + Trigger callback change event and triggering corresponding streams. + """ + if not self._event_queue: + return + + values = {} + for attr, path in self.attributes.items(): + attr_path = path.split('.') + if attr_path[0] == 'cb_obj': + attr_path = self.models[0] + obj = self.plot_handles.get(attr_path[0]) + if not obj: + raise Exception('Bokeh plot attribute %s could not be found' % path) + for p in attr_path[1:]: + if p == 'attributes': + continue + if isinstance(obj, dict): + obj = obj.get(p) + else: + obj = getattr(obj, p, None) + values[attr] = obj + values = self._process_msg(values) + if any(v is None for v in values.values()): + return + for stream in self.streams: + stream.update(trigger=False, **values) + Stream.trigger(self.streams) + self._event_queue = [] + + + def set_onchange(self, handle): + """ + Set up on_change events for bokeh server interactions. + """ + if self.events and bokeh_version >= '0.12.5': + for event in self.events: + handle.on_event(event, self.on_change) + elif self.change: + for change in self.change: + handle.on_change(change, self.on_change) + + + def set_customjs(self, handle, references): """ Creates a CustomJS callback that will send the requested attributes back to python. @@ -357,23 +431,11 @@ def set_customjs(self, js_callback, handle): the requested callback handle. """ - # Hash the plot handle with Callback type allowing multiple - # callbacks on one handle to be merged - cb_hash = (id(handle), id(type(self))) - if cb_hash in self._callbacks: - # Merge callbacks if another callback has already been attached - cb = self._callbacks[cb_hash] - if isinstance(cb, type(self)): - cb.streams += self.streams - for k, v in self.handle_ids.items(): - cb.handle_ids[k].update(v) - return - self._callbacks[cb_hash] = self if self.events and bokeh_version >= '0.12.5': for event in self.events: handle.js_on_event(event, js_callback) - elif self.change and bokeh_version >= '0.12.5': + elif self.change: for change in self.change: handle.js_on_change(change, js_callback) elif hasattr(handle, 'callback'): diff --git a/holoviews/plotting/renderer.py b/holoviews/plotting/renderer.py index b63dbfbe31..13cec36302 100644 --- a/holoviews/plotting/renderer.py +++ b/holoviews/plotting/renderer.py @@ -51,7 +51,7 @@ 'mp4': 'video/mp4', 'pdf': 'application/pdf', 'html': 'text/html', - 'json': 'text/json' + 'json': 'text/json', 'server': None } From afec80feabe9edda57ecb2d881ed1bfac5d68c85 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Mon, 31 Oct 2016 13:47:16 +0000 Subject: [PATCH 03/30] Small fixes for bokeh server implementation --- holoviews/plotting/bokeh/renderer.py | 16 +++++++++++++--- holoviews/plotting/renderer.py | 4 +++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/holoviews/plotting/bokeh/renderer.py b/holoviews/plotting/bokeh/renderer.py index 85bc301fc6..fdc598b869 100644 --- a/holoviews/plotting/bokeh/renderer.py +++ b/holoviews/plotting/bokeh/renderer.py @@ -29,6 +29,12 @@ class BokehRenderer(Renderer): Output render format for static figures. If None, no figure rendering will occur. """) + holomap = param.ObjectSelector(default='auto', + objects=['widgets', 'scrubber', 'server', + 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 DynamicMap in regular or server mode. """) @@ -37,13 +43,14 @@ class BokehRenderer(Renderer): mode_formats = {'fig': {'default': ['html', 'json', 'auto'], 'server': ['html', 'json', 'auto']}, 'holomap': {'default': ['widgets', 'scrubber', 'auto', None], - 'server': ['widgets', 'auto', None]}} + 'server': ['server', 'auto', None]}} webgl = param.Boolean(default=False, doc="""Whether to render plots with WebGL if bokeh version >=0.10""") widgets = {'scrubber': BokehScrubberWidget, - 'widgets': BokehSelectionWidget} + '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)} @@ -79,7 +86,10 @@ def server_doc(self, plot): Get server document. """ doc = curdoc() - plot.document = doc + if isinstance(plot, BokehServerWidgets): + plot.plot.document = doc + else: + plot.document = doc doc.add_root(plot.state) return doc diff --git a/holoviews/plotting/renderer.py b/holoviews/plotting/renderer.py index 13cec36302..00d2daead0 100644 --- a/holoviews/plotting/renderer.py +++ b/holoviews/plotting/renderer.py @@ -299,7 +299,9 @@ 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 - if widget_type == 'auto': + if widget_type == 'server': + pass + elif widget_type == 'auto': isuniform = plot.uniform if not isuniform: widget_type = 'scrubber' From 312975e1c1bb8270f4e742f7ccdbf91deeb9820b Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Mon, 31 Oct 2016 13:48:05 +0000 Subject: [PATCH 04/30] Added initial BokehServerWidgets implementation --- holoviews/plotting/bokeh/widgets.py | 149 ++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/holoviews/plotting/bokeh/widgets.py b/holoviews/plotting/bokeh/widgets.py index 345710be1e..29b79874f9 100644 --- a/holoviews/plotting/bokeh/widgets.py +++ b/holoviews/plotting/bokeh/widgets.py @@ -1,12 +1,161 @@ +from __future__ import unicode_literals + import json +from functools import partial import param from bokeh.io import _CommsHandle from bokeh.util.notebook import get_comms +from bokeh.models.widgets import Select, Slider, AutocompleteInput, TextInput +from bokeh.layouts import layout, gridplot, widgetbox, row, column +from ...core import Store, NdMapping, OrderedDict +from ...core.util import drop_streams, unique_array, isnumeric, wrap_tuple_streams from ..widgets import NdWidget, SelectionWidget, ScrubberWidget from .util import serialize_json + + +class BokehServerWidgets(param.Parameterized): + """ + """ + + 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']) + + 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=self.dimensions) + self.widgets, self.lookups = self.get_widgets() + self.reverse_lookups = {d: {v: k for k, v in item.items()} + for d, item in self.lookups.items()} + self.subplots = {} + if self.plot.renderer.mode == 'default': + self.attach_callbacks() + self.state = self.init_layout() + + + def get_widgets(self): + # Generate widget data + widgets = OrderedDict() + lookups = {} + for idx, dim in enumerate(self.mock_obj.kdims): + label, lookup = None, None + if self.plot.dynamic: + if dim.values: + if all(isnumeric(v) for v in dim.values): + values = dim.values + labels = [unicode(dim.pprint_value(v)) for v in dim.values] + label = AutocompleteInput(value=labels[0], completions=labels, + title=dim.pprint_label) + widget = Slider(value=0, end=len(dim.values)-1, title=None, step=1) + lookup = zip(values, labels) + else: + values = [(v, dim.pprint_value(v)) for v in dim.values] + widget = Select(title=dim.pprint_label, value=dim_vals[0][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] + int_type = isinstance(dim.type, type) and issubclass(dim.type, int) + if isinstance(dim_range, int) or int_type: + step = 1 + else: + step = 10**(round(math.log10(dim_range))-3) + label = TextInput(value=str(start), title=dim.pprint_label) + widget = Slider(value=start, start=start, + end=end, step=step, title=None) + else: + values = (dim.values if dim.values else + list(unique_array(self.mock_obj.dimension_values(dim.name)))) + labels = [str(dim.pprint_value(v)) for v in values] + if isinstance(values[0], np.datetime64) or isnumeric(values[0]): + label = AutocompleteInput(value=labels[0], completions=labels, + title=dim.pprint_label) + widget = Slider(value=0, end=len(dim.values)-1, title=None) + else: + widget = Select(title=dim.pprint_label, value=values[0], + options=list(zip(values, labels))) + lookup = zip(values, labels) + if label: + label.on_change('value', partial(self.update, dim.pprint_label, 'label')) + widget.on_change('value', partial(self.update, dim.pprint_label, 'widget')) + widgets[dim.pprint_label] = (label, widget) + if lookup: + lookups[dim.pprint_label] = OrderedDict(lookup) + return widgets, lookups + + + def init_layout(self): + widgets = [widget for d in self.widgets.values() + for widget in d if widget] + wbox = widgetbox(widgets, width=200) + 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 update(self, dim, widget_type, attr, old, new): + """ + Handle update events on bokeh server. + """ + label, widget = self.widgets[dim] + if widget_type == 'label': + if isinstance(label, AutocompleteInput): + value = self.reverse_lookups[dim][new] + widget.value = value + else: + widget.value = new + else: + if label: + text = self.lookups[dim][new] + label.value = text + key = [] + for dim, (label, widget) in self.widgets.items(): + if label: + if isinstance(label, AutocompleteInput): + val = self.lookups[dim].keys()[widget.value] + else: + val = new + else: + val = widget.value + key.append(val) + key = wrap_tuple_streams(tuple(key), self.plot.dimensions, + self.plot.streams) + self.plot.update(key) + + + class BokehWidget(NdWidget): css = param.String(default='bokehwidgets.css', doc=""" From 6f420b3a77d534dbd2f5db75b95547f51d66b84e Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Mon, 31 Oct 2016 18:33:08 +0000 Subject: [PATCH 05/30] Fixes for BokehServerWidgets --- holoviews/plotting/bokeh/widgets.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/holoviews/plotting/bokeh/widgets.py b/holoviews/plotting/bokeh/widgets.py index 29b79874f9..e26ed60b60 100644 --- a/holoviews/plotting/bokeh/widgets.py +++ b/holoviews/plotting/bokeh/widgets.py @@ -71,7 +71,7 @@ def get_widgets(self): lookup = zip(values, labels) else: values = [(v, dim.pprint_value(v)) for v in dim.values] - widget = Select(title=dim.pprint_label, value=dim_vals[0][0], + widget = Select(title=dim.pprint_label, value=values[0][0], options=values) else: start = dim.soft_range[0] if dim.soft_range[0] else dim.range[0] @@ -135,18 +135,15 @@ def update(self, dim, widget_type, attr, old, new): value = self.reverse_lookups[dim][new] widget.value = value else: - widget.value = new + widget.value = float(new) else: if label: text = self.lookups[dim][new] label.value = text key = [] for dim, (label, widget) in self.widgets.items(): - if label: - if isinstance(label, AutocompleteInput): - val = self.lookups[dim].keys()[widget.value] - else: - val = new + if label and isinstance(label, AutocompleteInput): + val = list(self.lookups[dim].keys())[widget.value] else: val = widget.value key.append(val) From 3b5d10c7c480bb632e2f4adb62b4f8eecc13b4b8 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 4 Nov 2016 13:35:47 +0000 Subject: [PATCH 06/30] Ensure all subplots have the same plotting classes --- holoviews/plotting/bokeh/plot.py | 2 ++ holoviews/plotting/mpl/plot.py | 4 ++-- holoviews/plotting/plot.py | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/holoviews/plotting/bokeh/plot.py b/holoviews/plotting/bokeh/plot.py index a7b06765c8..dd8e5a5865 100644 --- a/holoviews/plotting/bokeh/plot.py +++ b/holoviews/plotting/bokeh/plot.py @@ -354,6 +354,7 @@ def _create_subplots(self, layout, ranges): else: subplot = plotting_class(view, dimensions=self.dimensions, show_title=False, subplot=True, + renderer=self.renderer, ranges=frame_ranges, uniform=self.uniform, keys=self.keys, **dict(opts, **kwargs)) collapsed_layout[coord] = (subplot.layout @@ -581,6 +582,7 @@ def _create_subplots(self, layout, positions, layout_dimensions, ranges, num=0): layout_dimensions=layout_dimensions, ranges=ranges, subplot=True, uniform=self.uniform, layout_num=num, + renderer=self.renderer, **dict({'shared_axes': self.shared_axes}, **plotopts)) subplots[pos] = subplot diff --git a/holoviews/plotting/mpl/plot.py b/holoviews/plotting/mpl/plot.py index f17e996e77..215d5c363c 100644 --- a/holoviews/plotting/mpl/plot.py +++ b/holoviews/plotting/mpl/plot.py @@ -406,7 +406,7 @@ def _create_subplots(self, layout, axis, ranges, create_axes): dimensions=self.dimensions, show_title=False, subplot=not create_axes, ranges=frame_ranges, uniform=self.uniform, keys=self.keys, - show_legend=False) + show_legend=False, renderer=self.renderer) plotting_class = Store.registry['matplotlib'][vtype] subplot = plotting_class(view, **dict(opts, **dict(params, **kwargs))) collapsed_layout[coord] = subplot.layout if isinstance(subplot, CompositePlot) else subplot.hmap @@ -1022,7 +1022,7 @@ def _create_subplots(self, layout, positions, layout_dimensions, ranges, axes={} layout_dimensions=layout_dimensions, ranges=ranges, subplot=True, uniform=self.uniform, layout_num=num, - **plotopts) + renderer=self.renderer, **plotopts) if isinstance(view, (Element, HoloMap, Collator, CompositeOverlay)): adjoint_clone[pos] = subplots[pos].hmap else: diff --git a/holoviews/plotting/plot.py b/holoviews/plotting/plot.py index 731d905e79..3d38ca3626 100644 --- a/holoviews/plotting/plot.py +++ b/holoviews/plotting/plot.py @@ -895,6 +895,7 @@ def _create_subplots(self, ranges): layout_dimensions=self.layout_dimensions, ranges=ranges, show_title=self.show_title, style=style, uniform=self.uniform, + renderer=self.renderer, zorder=zorder, **passed_handles) if not isinstance(key, tuple): key = (key,) From c7cedc28421ede74484ed42f0a8f0318c5dae743 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 4 Nov 2016 22:05:19 +0000 Subject: [PATCH 07/30] Defined bokeh widget parameters --- holoviews/plotting/bokeh/widgets.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/holoviews/plotting/bokeh/widgets.py b/holoviews/plotting/bokeh/widgets.py index e26ed60b60..0b6c69a2f8 100644 --- a/holoviews/plotting/bokeh/widgets.py +++ b/holoviews/plotting/bokeh/widgets.py @@ -20,6 +20,15 @@ class BokehServerWidgets(param.Parameterized): """ """ + basejs = param.String(default=None, doc=""" + Defines the local CSS file to be loaded for this widget.""") + + extensionjs = param.String(default=None, doc=""" + Optional javascript extension file for a particular backend.""") + + css = param.String(default=None, doc=""" + Defines the local CSS file to be loaded for this widget.""") + position = param.ObjectSelector(default='right', objects=['right', 'left', 'above', 'below']) From 3b43aabfc2139bdb4eb93b1f0622daef183ac686 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 3 Feb 2017 14:08:22 +0000 Subject: [PATCH 08/30] Added bokeh app examples --- examples/apps/crossfilter.py | 78 +++++++++++++++++++++++++++++++ examples/apps/player.py | 49 +++++++++++++++++++ examples/apps/selection_stream.py | 17 +++++++ 3 files changed, 144 insertions(+) create mode 100644 examples/apps/crossfilter.py create mode 100644 examples/apps/player.py create mode 100644 examples/apps/selection_stream.py diff --git a/examples/apps/crossfilter.py b/examples/apps/crossfilter.py new file mode 100644 index 0000000000..f9fe0ab3b0 --- /dev/null +++ b/examples/apps/crossfilter.py @@ -0,0 +1,78 @@ +import numpy as np +import pandas as pd +import holoviews as hv +import holoviews.plotting.bokeh + +from bokeh.layouts import row, widgetbox +from bokeh.models import Select +from bokeh.plotting import curdoc, figure +from bokeh.sampledata.autompg import autompg + +df = autompg.copy() + +SIZES = list(range(6, 22, 3)) +ORIGINS = ['North America', 'Europe', 'Asia'] + +# data cleanup +df.cyl = [str(x) for x in df.cyl] +df.origin = [ORIGINS[x-1] for x in df.origin] + +df['year'] = [str(x) for x in df.yr] +del df['yr'] + +df['mfr'] = [x.split()[0] for x in df.name] +df.loc[df.mfr=='chevy', 'mfr'] = 'chevrolet' +df.loc[df.mfr=='chevroelt', 'mfr'] = 'chevrolet' +df.loc[df.mfr=='maxda', 'mfr'] = 'mazda' +df.loc[df.mfr=='mercedes-benz', 'mfr'] = 'mercedes' +df.loc[df.mfr=='toyouta', 'mfr'] = 'toyota' +df.loc[df.mfr=='vokswagen', 'mfr'] = 'volkswagen' +df.loc[df.mfr=='vw', 'mfr'] = 'volkswagen' +del df['name'] + +columns = sorted(df.columns) +discrete = [x for x in columns if df[x].dtype == object] +continuous = [x for x in columns if x not in discrete] +quantileable = [x for x in continuous if len(df[x].unique()) > 20] + +hv.Store.current_backend = 'bokeh' +renderer = hv.Store.renderers['bokeh'] +options = hv.Store.options(backend='bokeh') +options.Points = hv.Options('plot', width=800, height=600, size_index=None,) +options.Points = hv.Options('style', cmap='rainbow', line_color='black') + +def create_figure(): + label = "%s vs %s" % (x.value.title(), y.value.title()) + kdims = [x.value, y.value] + + opts, style = {}, {} + opts['color_index'] = color.value if color.value != 'None' else None + if size.value != 'None': + opts['size_index'] = size.value + opts['scaling_factor'] = (1./df[size.value].max())*200 + points = hv.Points(df, kdims=kdims, label=label)(plot=opts, style=style) + plot = renderer.get_plot(points) + plot.initialize_plot() + return plot.state + +def update(attr, old, new): + layout.children[1] = create_figure() + + +x = Select(title='X-Axis', value='mpg', options=quantileable) +x.on_change('value', update) + +y = Select(title='Y-Axis', value='hp', options=quantileable) +y.on_change('value', update) + +size = Select(title='Size', value='None', options=['None'] + quantileable) +size.on_change('value', update) + +color = Select(title='Color', value='None', options=['None'] + quantileable) +color.on_change('value', update) + +controls = widgetbox([x, y, color, size], width=200) +layout = row(controls, create_figure()) + +curdoc().add_root(layout) +curdoc().title = "Crossfilter" diff --git a/examples/apps/player.py b/examples/apps/player.py new file mode 100644 index 0000000000..800e22a190 --- /dev/null +++ b/examples/apps/player.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +import numpy as np +from bokeh.io import curdoc +from bokeh.layouts import layout +from bokeh.models import ( + ColumnDataSource, HoverTool, SingleIntervalTicker, Slider, Button, Label, + CategoricalColorMapper, +) +import holoviews as hv +import holoviews.plotting.bokeh + +renderer = hv.Store.renderers['bokeh'] + +start = 0 +end = 10 + +hmap = hv.HoloMap({i: hv.Image(np.random.rand(10,10)) for i in range(start, end+1)}) +plot = renderer.get_plot(hmap) +plot.update(0) + +def animate_update(): + year = slider.value + 1 + if year > end: + year = start + slider.value = year + +def slider_update(attrname, old, new): + plot.update(slider.value) + +slider = Slider(start=start, end=end, value=0, step=1, title="Year") +slider.on_change('value', slider_update) + +def animate(): + if button.label == '► Play': + button.label = '❚❚ Pause' + curdoc().add_periodic_callback(animate_update, 200) + else: + button.label = '► Play' + curdoc().remove_periodic_callback(animate_update) + +button = Button(label='► Play', width=60) +button.on_click(animate) + +layout = layout([ + [plot.state], + [slider, button], +], sizing_mode='fixed') + +curdoc().add_root(layout) diff --git a/examples/apps/selection_stream.py b/examples/apps/selection_stream.py new file mode 100644 index 0000000000..5deb34fd7c --- /dev/null +++ b/examples/apps/selection_stream.py @@ -0,0 +1,17 @@ +import numpy as np +import holoviews as hv +import holoviews.plotting.bokeh +from holoviews.streams import Selection1D + +hv.Store.current_backend = 'bokeh' +renderer = hv.Store.renderers['bokeh'].instance(mode='server') +hv.Store.options(backend='bokeh').Points = hv.Options('plot', tools=['box_select']) + +data = np.random.multivariate_normal((0, 0), [[1, 0.1], [0.1, 1]], (1000,)) +points = hv.Points(data) +sel = Selection1D(source=points) +mean_sel = hv.DynamicMap(lambda index: hv.HLine(points['y'][index].mean() + if index else -10), + kdims=[], streams=[sel]) +doc,_ = renderer((points * mean_sel)) +doc.title = 'HoloViews Selection Stream' From 7697a2a432b9efe8d891a7fe33c9f0af07a248d6 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Sun, 26 Mar 2017 12:51:42 +0100 Subject: [PATCH 09/30] Small fix for bokeh widget import --- holoviews/plotting/bokeh/renderer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/holoviews/plotting/bokeh/renderer.py b/holoviews/plotting/bokeh/renderer.py index fdc598b869..6821028c1e 100644 --- a/holoviews/plotting/bokeh/renderer.py +++ b/holoviews/plotting/bokeh/renderer.py @@ -16,7 +16,7 @@ from ...core import Store, HoloMap from ..comms import JupyterComm, Comm from ..renderer import Renderer, MIME_TYPES -from .widgets import BokehScrubberWidget, BokehSelectionWidget +from .widgets import BokehScrubberWidget, BokehSelectionWidget, BokehServerWidgets from .util import compute_static_patch, serialize_json From 27ee5660aef93409bf050f635e9d6386936b3a70 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Sun, 26 Mar 2017 13:19:09 +0100 Subject: [PATCH 10/30] Improved handling of boomeranging events in bokeh backend --- holoviews/plotting/bokeh/callbacks.py | 16 +++++--------- holoviews/plotting/bokeh/element.py | 32 ++++++++++++++++++--------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/holoviews/plotting/bokeh/callbacks.py b/holoviews/plotting/bokeh/callbacks.py index b5c45c3c8f..eba39f55f2 100644 --- a/holoviews/plotting/bokeh/callbacks.py +++ b/holoviews/plotting/bokeh/callbacks.py @@ -373,22 +373,18 @@ def trigger(self): if attr_path[0] == 'cb_obj': attr_path = self.models[0] obj = self.plot_handles.get(attr_path[0]) + attr_val = obj if not obj: raise Exception('Bokeh plot attribute %s could not be found' % path) for p in attr_path[1:]: if p == 'attributes': continue - if isinstance(obj, dict): - obj = obj.get(p) + if isinstance(attr_val, dict): + attr_val = attr_val.get(p) else: - obj = getattr(obj, p, None) - values[attr] = obj - values = self._process_msg(values) - if any(v is None for v in values.values()): - return - for stream in self.streams: - stream.update(trigger=False, **values) - Stream.trigger(self.streams) + attr_val = getattr(attr_val, p, None) + values[attr] = {'id': obj.ref['id'], 'value': attr_val} + self.on_msg(values) self._event_queue = [] diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py index 43e07b6a55..033a3c0a47 100644 --- a/holoviews/plotting/bokeh/element.py +++ b/holoviews/plotting/bokeh/element.py @@ -526,8 +526,10 @@ def _update_ranges(self, element, ranges): xfactors, yfactors = None, None if any(isinstance(ax_range, FactorRange) for ax_range in [x_range, y_range]): xfactors, yfactors = self._get_factors(element) - self._update_range(x_range, l, r, xfactors, self.invert_xaxis, self._shared['x']) - self._update_range(y_range, b, t, yfactors, self.invert_yaxis, self._shared['y']) + if not self.model_changed(x_range): + self._update_range(x_range, l, r, xfactors, self.invert_xaxis, self._shared['x']) + if not self.model_changed(y_range): + self._update_range(y_range, b, t, yfactors, self.invert_yaxis, self._shared['y']) def _update_range(self, axis_range, low, high, factors, invert, shared): @@ -788,6 +790,21 @@ def update_frame(self, key, ranges=None, plot=None, element=None, empty=False): self._execute_hooks(element) + def model_changed(self, model): + """ + Determines if the bokeh model was just changed on the frontend. + Useful to suppress boomeranging events, e.g. when the frontend + just sent an update to the x_range this should not trigger an + update on the backend. + """ + callbacks = [cb for cbs in self.traverse(lambda x: x.callbacks) + for cb in cbs] + stream_metadata = [stream._metadata for cb in callbacks + for stream in cb.streams if stream._metadata] + return any(md['id'] == model.ref['id'] for models in stream_metadata + for md in models.values()) + + @property def current_handles(self): """ @@ -821,15 +838,8 @@ def current_handles(self): if not self.apply_ranges: rangex, rangey = False, False elif isinstance(self.hmap, DynamicMap): - callbacks = [cb for cbs in self.traverse(lambda x: x.callbacks) - for cb in cbs] - stream_metadata = [stream._metadata for cb in callbacks - for stream in cb.streams if stream._metadata] - ranges = ['%s_range' % ax for ax in 'xy'] - event_ids = [md[ax]['id'] for md in stream_metadata - for ax in ranges if ax in md] - rangex = plot.x_range.ref['id'] not in event_ids and framewise - rangey = plot.y_range.ref['id'] not in event_ids and framewise + rangex = not self.model_changed(plot.x_range) and framewise + rangey = not self.model_changed(plot.y_range) and framewise elif self.framewise: rangex, rangey = True, True else: From 78a2c2a4bb27c3f9b5e73f8db29751a936b1e615 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Sun, 26 Mar 2017 17:55:49 +0100 Subject: [PATCH 11/30] Improved bokeh server event queue --- holoviews/plotting/bokeh/callbacks.py | 3 ++- holoviews/plotting/bokeh/widgets.py | 17 ++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/holoviews/plotting/bokeh/callbacks.py b/holoviews/plotting/bokeh/callbacks.py index eba39f55f2..7a202570f4 100644 --- a/holoviews/plotting/bokeh/callbacks.py +++ b/holoviews/plotting/bokeh/callbacks.py @@ -366,6 +366,7 @@ def trigger(self): """ if not self._event_queue: return + self._event_queue = [] values = {} for attr, path in self.attributes.items(): @@ -385,7 +386,7 @@ def trigger(self): attr_val = getattr(attr_val, p, None) values[attr] = {'id': obj.ref['id'], 'value': attr_val} self.on_msg(values) - self._event_queue = [] + self.plot.document.add_timeout_callback(self.trigger, 50) def set_onchange(self, handle): diff --git a/holoviews/plotting/bokeh/widgets.py b/holoviews/plotting/bokeh/widgets.py index 0b6c69a2f8..e50a112d01 100644 --- a/holoviews/plotting/bokeh/widgets.py +++ b/holoviews/plotting/bokeh/widgets.py @@ -61,6 +61,7 @@ def __init__(self, plot, renderer=None, **params): if self.plot.renderer.mode == 'default': self.attach_callbacks() self.state = self.init_layout() + self._event_queue = [] def get_widgets(self): @@ -106,8 +107,8 @@ def get_widgets(self): options=list(zip(values, labels))) lookup = zip(values, labels) if label: - label.on_change('value', partial(self.update, dim.pprint_label, 'label')) - widget.on_change('value', partial(self.update, dim.pprint_label, 'widget')) + label.on_change('value', partial(self.on_change, dim.pprint_label, 'label')) + widget.on_change('value', partial(self.on_change, dim.pprint_label, 'widget')) widgets[dim.pprint_label] = (label, widget) if lookup: lookups[dim.pprint_label] = OrderedDict(lookup) @@ -134,10 +135,20 @@ def attach_callbacks(self): pass - def update(self, dim, widget_type, attr, old, new): + def on_change(self, dim, widget_type, attr, old, new): + self._event_queue.append((dim, widget_type, attr, old, new)) + if self.update not in self.plot.document._session_callbacks: + self.plot.document.add_timeout_callback(self.update, 50) + + + def update(self): """ Handle update events on bokeh server. """ + if not self._event_queue: + return + dim, widget_type, attr, old, new = self._event_queue[-1] + label, widget = self.widgets[dim] if widget_type == 'label': if isinstance(label, AutocompleteInput): From c32e2ab155bf3dbcec2e57bb19e1d06870f6679c Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Sun, 26 Mar 2017 18:19:44 +0100 Subject: [PATCH 12/30] Improved range updates for bokeh server --- holoviews/plotting/bokeh/element.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py index 033a3c0a47..375e175d9d 100644 --- a/holoviews/plotting/bokeh/element.py +++ b/holoviews/plotting/bokeh/element.py @@ -526,9 +526,10 @@ def _update_ranges(self, element, ranges): xfactors, yfactors = None, None if any(isinstance(ax_range, FactorRange) for ax_range in [x_range, y_range]): xfactors, yfactors = self._get_factors(element) - if not self.model_changed(x_range): + framewise = self.framewise + if not self.drawn or (not self.model_changed(x_range) and framewise): self._update_range(x_range, l, r, xfactors, self.invert_xaxis, self._shared['x']) - if not self.model_changed(y_range): + if not self.drawn or (not self.model_changed(y_range) and framewise): self._update_range(y_range, b, t, yfactors, self.invert_yaxis, self._shared['y']) From 3f0dca8635b4cb07751fa06cde6c56d8650bc5d6 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Sun, 26 Mar 2017 22:15:28 +0100 Subject: [PATCH 13/30] Fixed small bugs in bokeh Callbacks --- holoviews/plotting/bokeh/callbacks.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/holoviews/plotting/bokeh/callbacks.py b/holoviews/plotting/bokeh/callbacks.py index 7a202570f4..e95e472bcc 100644 --- a/holoviews/plotting/bokeh/callbacks.py +++ b/holoviews/plotting/bokeh/callbacks.py @@ -276,7 +276,7 @@ def initialize(self): continue if self.plot.renderer.mode == 'server': - self.set_onchange(plot.handles[handle_name]) + self.set_onchange(handle) else: js_callback = self.get_customjs(requested) self.set_customjs(js_callback, handle) @@ -401,7 +401,7 @@ def set_onchange(self, handle): handle.on_change(change, self.on_change) - def set_customjs(self, handle, references): + def get_customjs(self, references): """ Creates a CustomJS callback that will send the requested attributes back to python. @@ -427,8 +427,6 @@ def set_customjs(self, js_callback, handle): code and gathering all plotting handles and installs it on the requested callback handle. """ - - self._callbacks[cb_hash] = self if self.events and bokeh_version >= '0.12.5': for event in self.events: handle.js_on_event(event, js_callback) From 9bf098266391575fafd189a11b881e5fa38cff6c Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 6 Apr 2017 13:54:05 +0100 Subject: [PATCH 14/30] Fixed bokeh event callbacks after change to cb_obj --- holoviews/plotting/bokeh/callbacks.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/holoviews/plotting/bokeh/callbacks.py b/holoviews/plotting/bokeh/callbacks.py index e95e472bcc..337bd1d6a0 100644 --- a/holoviews/plotting/bokeh/callbacks.py +++ b/holoviews/plotting/bokeh/callbacks.py @@ -187,7 +187,7 @@ class Callback(object): }} // Add current event to queue and process queue if not blocked - event_name = cb_obj.event ? cb_obj.event.event_name : undefined + event_name = cb_obj.event_name data['comm_id'] = "{comm_id}"; timeout = comm_state.time + {timeout}; if ((window.Jupyter == undefined) | (Jupyter.notebook.kernel == undefined)) {{ @@ -444,7 +444,7 @@ class PositionXYCallback(Callback): Returns the mouse x/y-position on mousemove event. """ - attributes = {'x': 'cb_obj.event.x', 'y': 'cb_obj.event.y'} + attributes = {'x': 'cb_obj.x', 'y': 'cb_obj.y'} models = ['plot'] events = ['mousemove'] @@ -454,7 +454,7 @@ class PositionXCallback(PositionXYCallback): Returns the mouse x-position on mousemove event. """ - attributes = {'x': 'cb_obj.event.x'} + attributes = {'x': 'cb_obj.x'} class PositionYCallback(PositionXYCallback): @@ -462,7 +462,7 @@ class PositionYCallback(PositionXYCallback): Returns the mouse x/y-position on mousemove event. """ - attributes = {'y': 'cb_data.event.y'} + attributes = {'y': 'cb_data.y'} class TapCallback(PositionXYCallback): From b099b7e800a64d782f9b12b34d33214063f30ec8 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 6 Apr 2017 14:22:06 +0100 Subject: [PATCH 15/30] Implemented UIEvent handling for bokeh server --- holoviews/plotting/bokeh/callbacks.py | 28 ++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/holoviews/plotting/bokeh/callbacks.py b/holoviews/plotting/bokeh/callbacks.py index 337bd1d6a0..0f09568739 100644 --- a/holoviews/plotting/bokeh/callbacks.py +++ b/holoviews/plotting/bokeh/callbacks.py @@ -360,20 +360,37 @@ def on_change(self, attr, old, new): self.plot.document.add_timeout_callback(self.trigger, 50) + def on_event(self, event): + """ + Process bokeh UIEvents adding timeout to process multiple concerted + value change at once rather than firing off multiple plot updates. + """ + self._event_queue.append((event)) + if self.trigger not in self.plot.document._session_callbacks: + self.plot.document.add_timeout_callback(self.trigger, 50) + + def trigger(self): """ Trigger callback change event and triggering corresponding streams. """ if not self._event_queue: return + if self.events: + event = self._event_queue[-1] self._event_queue = [] values = {} for attr, path in self.attributes.items(): attr_path = path.split('.') - if attr_path[0] == 'cb_obj': - attr_path = self.models[0] - obj = self.plot_handles.get(attr_path[0]) + if self.events: + obj = event + model_obj = self.plot_handles.get(self.models[0]) + else: + if attr_path[0] == 'cb_obj': + attr_path = self.models[:1]+attr_path[1:] + obj = self.plot_handles.get(attr_path[0]) + model_obj = obj attr_val = obj if not obj: raise Exception('Bokeh plot attribute %s could not be found' % path) @@ -384,7 +401,8 @@ def trigger(self): attr_val = attr_val.get(p) else: attr_val = getattr(attr_val, p, None) - values[attr] = {'id': obj.ref['id'], 'value': attr_val} + values[attr] = {'id': model_obj.ref['id'], + 'value': attr_val} self.on_msg(values) self.plot.document.add_timeout_callback(self.trigger, 50) @@ -395,7 +413,7 @@ def set_onchange(self, handle): """ if self.events and bokeh_version >= '0.12.5': for event in self.events: - handle.on_event(event, self.on_change) + handle.on_event(event, self.on_event) elif self.change: for change in self.change: handle.on_change(change, self.on_change) From dbef691b82d822b0164a188224ceb65bf4e67d9b Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 6 Apr 2017 14:57:34 +0100 Subject: [PATCH 16/30] Moved bokeh server example apps --- .../plotting/bokeh/examples/apps}/apps/crossfilter.py | 0 .../plotting/bokeh/examples/apps}/apps/player.py | 0 .../plotting/bokeh/examples/apps}/apps/selection_stream.py | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename {examples => holoviews/plotting/bokeh/examples/apps}/apps/crossfilter.py (100%) rename {examples => holoviews/plotting/bokeh/examples/apps}/apps/player.py (100%) rename {examples => holoviews/plotting/bokeh/examples/apps}/apps/selection_stream.py (100%) diff --git a/examples/apps/crossfilter.py b/holoviews/plotting/bokeh/examples/apps/apps/crossfilter.py similarity index 100% rename from examples/apps/crossfilter.py rename to holoviews/plotting/bokeh/examples/apps/apps/crossfilter.py diff --git a/examples/apps/player.py b/holoviews/plotting/bokeh/examples/apps/apps/player.py similarity index 100% rename from examples/apps/player.py rename to holoviews/plotting/bokeh/examples/apps/apps/player.py diff --git a/examples/apps/selection_stream.py b/holoviews/plotting/bokeh/examples/apps/apps/selection_stream.py similarity index 100% rename from examples/apps/selection_stream.py rename to holoviews/plotting/bokeh/examples/apps/apps/selection_stream.py From 2e9ca718b06469e3b356fb36baf1617da859aa6b Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 6 Apr 2017 17:40:29 +0100 Subject: [PATCH 17/30] Completely refactored bokeh Callbacks --- holoviews/plotting/bokeh/callbacks.py | 561 ++++++++++++++------------ 1 file changed, 304 insertions(+), 257 deletions(-) diff --git a/holoviews/plotting/bokeh/callbacks.py b/holoviews/plotting/bokeh/callbacks.py index 0f09568739..22d04c5380 100644 --- a/holoviews/plotting/bokeh/callbacks.py +++ b/holoviews/plotting/bokeh/callbacks.py @@ -4,6 +4,7 @@ import numpy as np from bokeh.models import CustomJS +from ...core import OrderedDict from ...streams import (Stream, PositionXY, RangeXY, Selection1D, RangeX, RangeY, PositionX, PositionY, Bounds, Tap, DoubleTap, MouseEnter, MouseLeave, PlotSize) @@ -52,67 +53,139 @@ def attributes_js(attributes, handles): return code -class Callback(object): +def resolve_attr_spec(spec, cb_obj, model): """ - Provides a baseclass to define callbacks, which return data from - bokeh model callbacks, events and attribute changes. The callback - then makes this data available to any streams attached to it. + Resolves a Callback attribute specification looking the + corresponding attribute up on the cb_obj, which should be a bokeh + model. + """ + if not cb_obj: + raise Exception('Bokeh plot attribute %s could not be found' % spec) + spec = spec.split('.') + resolved = cb_obj + for p in spec[1:]: + if p == 'attributes': + continue + if isinstance(resolved, dict): + resolved = resolved.get(p) + else: + resolved = getattr(resolved, p, None) + return {'id': model.ref['id'], 'value': resolved} - The definition of a callback consists of a number of components: - * models : Defines which bokeh models the callback will be - attached on referencing the model by its key in - the plots handles, e.g. this could be the x_range, - y_range, plot, a plotting tool or any other - bokeh mode. - * extra_models: Any additional models available in handles which - should be made available in the namespace of the - objects, e.g. to make a tool available to skip - checks. +class MessageCallback(object): + """ + A MessageCallback is an abstract baseclass used to supply Streams + with events originating from bokeh plot interactions. The baseclass + defines how messages are handled and the basic specification required + to define a Callback. + """ - * attributes : The attributes define which attributes to send - back to Python. They are defined as a dictionary - mapping between the name under which the variable - is made available to Python and the specification - of the attribute. The specification should start - with the variable name that is to be accessed and - the location of the attribute separated by - periods. All models defined by the models and - extra_models attributes can be addressed in this - way, e.g. to get the start of the x_range as 'x' - you can supply {'x': 'x_range.attributes.start'}. - Additionally certain handles additionally make the - cb_data and cb_obj variables available containing - additional information about the event. + attributes = {} - * skip : Conditions when the Callback should be skipped - specified as a list of valid JS expressions, which - can reference models requested by the callback, - e.g. ['pan.attributes.active'] would skip the - callback if the pan tool is active. + # The plotting handle(s) to attach the JS callback on + models = [] - * code : Defines any additional JS code to be executed, - which can modify the data object that is sent to - the backend. + # Additional models available to the callback + extra_models = [] - * events : If the Callback should listen to bokeh events this - should declare the types of event as a list (optional) + # Conditions when callback should be skipped + skip = [] - * change : If the Callback should listen to model attribute - changes on the defined ``models`` (optional) + # Callback will listen to events of the supplied type on the models + on_events = [] - If either the event or change attributes are declared the Callback - will be registered using the on_event or on_change machinery, - otherwise it will be treated as a regular callback on the model. - The callback can also define a _process_msg method, which can - modify the data sent by the callback before it is passed to the - streams. - """ + # List of change events on the models to listen to + on_change = [] - code = "" + _callbacks = {} - attributes = {} + def _process_msg(self, msg): + """ + Subclassable method to preprocess JSON message in callback + before passing to stream. + """ + return msg + + + def __init__(self, plot, streams, source, **params): + self.plot = plot + self.streams = streams + if plot.renderer.mode != 'server': + self.comm = self._comm_type(plot, on_msg=self.on_msg) + self.source = source + self.handle_ids = defaultdict(dict) + self.callbacks = [] + self.plot_handles = {} + self._queue = [] + + + def _filter_msg(self, msg, ids): + """ + Filter event values that do not originate from the plotting + handles associated with a particular stream using their + ids to match them. + """ + filtered_msg = {} + for k, v in msg.items(): + if isinstance(v, dict) and 'id' in v: + if v['id'] in ids: + filtered_msg[k] = v['value'] + else: + filtered_msg[k] = v + return filtered_msg + + + def on_msg(self, msg): + for stream in self.streams: + handle_ids = self.handle_ids[stream] + ids = list(handle_ids.values()) + filtered_msg = self._filter_msg(msg, ids) + processed_msg = self._process_msg(filtered_msg) + if not processed_msg: + continue + stream.update(trigger=False, **processed_msg) + stream._metadata = {h: {'id': hid, 'events': self.on_events} + for h, hid in handle_ids.items()} + Stream.trigger(self.streams) + for stream in self.streams: + stream._metadata = {} + + + def _get_plot_handles(self, plots): + """ + Iterate over plots and find all unique plotting handles. + """ + handles = {} + for plot in plots: + for k, v in plot.handles.items(): + handles[k] = v + return handles + + + def _get_stream_handle_ids(self, handles): + """ + Gather the ids of the plotting handles attached to this callback + This allows checking that a stream is not given the state + of a plotting handle it wasn't attached to + """ + stream_handle_ids = defaultdict(dict) + for stream in self.streams: + for h in self.models: + if h in handles: + handle_id = handles[h].ref['id'] + stream_handle_ids[stream][h] = handle_id + return stream_handle_ids + + + +class CustomJSCallback(MessageCallback): + """ + The CustomJSCallback attaches CustomJS callbacks to a bokeh plot, + which looks up the requested attributes and sends back a message + to Python using a Comms instance. + """ js_callback = """ function unique_events(events) {{ @@ -201,22 +274,7 @@ class Callback(object): }} """ - # The plotting handle(s) to attach the JS callback on - models = [] - - # Additional models available to the callback - extra_models = [] - - # Conditions when callback should be skipped - skip = [] - - # Callback will listen to events of the supplied type on the models - events = [] - - # List of attributes on the models to listen to - change = [] - - _comm_type = JupyterCommJS + code = "" # Timeout if a comm message is swallowed timeout = 20000 @@ -224,140 +282,61 @@ class Callback(object): # Timeout before the first event is processed debounce = 20 - _callbacks = {} - - def __init__(self, plot, streams, source, **params): - self.plot = plot - self.streams = streams - if plot.renderer.mode != 'server': - self.comm = self._comm_type(plot, on_msg=self.on_msg) - self.source = source - self.handle_ids = defaultdict(dict) - self.callbacks = [] - self.plot_handles = {} - self._event_queue = [] - - - def initialize(self): - plots = [self.plot] - if self.plot.subplots: - plots += list(self.plot.subplots.values()) - - self.plot_handles = self._get_plot_handles(plots) - requested = {} - for h in self.models+self.extra_models: - if h in self.plot_handles: - requested[h] = self.plot_handles[h] - elif h in self.extra_models: - print("Warning %s could not find the %s model. " - "The corresponding stream may not work.") - self.handle_ids.update(self._get_stream_handle_ids(requested)) - - found = [] - for plot in plots: - for handle_name in self.models: - if handle_name not in self.plot_handles: - warn_args = (handle_name, type(self.plot).__name__, - type(self).__name__) - print('%s handle not found on %s, cannot ' - 'attach %s callback' % warn_args) - continue - handle = self.plot_handles[handle_name] - - # Hash the plot handle with Callback type allowing multiple - # callbacks on one handle to be merged - cb_hash = (id(handle), id(type(self))) - if cb_hash in self._callbacks: - # Merge callbacks if another callback has already been attached - cb = self._callbacks[cb_hash] - cb.streams += self.streams - for k, v in self.handle_ids.items(): - cb.handle_ids[k].update(v) - continue - - if self.plot.renderer.mode == 'server': - self.set_onchange(handle) - else: - js_callback = self.get_customjs(requested) - self.set_customjs(js_callback, handle) - self.callbacks.append(js_callback) - self._callbacks[cb_hash] = self - + _comm_type = JupyterCommJS - def _filter_msg(self, msg, ids): + def get_customjs(self, references): """ - Filter event values that do not originate from the plotting - handles associated with a particular stream using their - ids to match them. + Creates a CustomJS callback that will send the requested + attributes back to python. """ - filtered_msg = {} - for k, v in msg.items(): - if isinstance(v, dict) and 'id' in v: - if v['id'] in ids: - filtered_msg[k] = v['value'] - else: - filtered_msg[k] = v - return filtered_msg - - - def on_msg(self, msg): - for stream in self.streams: - handle_ids = self.handle_ids[stream] - ids = list(handle_ids.values()) - filtered_msg = self._filter_msg(msg, ids) - processed_msg = self._process_msg(filtered_msg) - if not processed_msg: - continue - stream.update(trigger=False, **processed_msg) - stream._metadata = {h: {'id': hid, 'events': self.events} - for h, hid in handle_ids.items()} - Stream.trigger(self.streams) - for stream in self.streams: - stream._metadata = {} - + # Generate callback JS code to get all the requested data + self_callback = self.js_callback.format(comm_id=self.comm.id, + timeout=self.timeout, + debounce=self.debounce) - def _process_msg(self, msg): - """ - Subclassable method to preprocess JSON message in callback - before passing to stream. - """ - return msg + attributes = attributes_js(self.attributes, references) + conditions = ["%s" % cond for cond in self.skip] + conditional = '' + if conditions: + conditional = 'if (%s) { return };\n' % (' || '.join(conditions)) + data = "var data = {};\n" + code = conditional + data + attributes + self.code + self_callback + return CustomJS(args=references, code=code) - def _get_plot_handles(self, plots): + def set_customjs_callback(self, js_callback, handle): """ - Iterate over plots and find all unique plotting handles. + Generates a CustomJS callback by generating the required JS + code and gathering all plotting handles and installs it on + the requested callback handle. """ - handles = {} - for plot in plots: - for k, v in plot.handles.items(): - handles[k] = v - return handles + if self.on_events and bokeh_version >= '0.12.5': + for event in self.on_events: + handle.js_on_event(event, js_callback) + elif self.on_changes: + for change in self.on_changes: + handle.js_on_change(change, js_callback) + elif hasattr(handle, 'callback'): + handle.callback = js_callback - def _get_stream_handle_ids(self, handles): - """ - Gather the ids of the plotting handles attached to this callback - This allows checking that a stream is not given the state - of a plotting handle it wasn't attached to - """ - stream_handle_ids = defaultdict(dict) - for stream in self.streams: - for h in self.models: - if h in handles: - handle_id = handles[h].ref['id'] - stream_handle_ids[stream][h] = handle_id - return stream_handle_ids +class ServerCallback(MessageCallback): + """ + Implements methods to set up bokeh server callbacks. A ServerCallback + resolves the requested attributes on the Python end and then hands + the msg off to the general on_msg handler, which will update the + Stream(s) attached to the callback. + """ def on_change(self, attr, old, new): """ Process change events adding timeout to process multiple concerted value change at once rather than firing off multiple plot updates. """ - self._event_queue.append((attr, old, new)) - if self.trigger not in self.plot.document._session_callbacks: - self.plot.document.add_timeout_callback(self.trigger, 50) + self._queue.append((attr, old, new)) + if self.process_on_change not in self.plot.document._session_callbacks: + self.plot.document.add_timeout_callback(self.process_on_change, 50) def on_event(self, event): @@ -365,95 +344,163 @@ def on_event(self, event): Process bokeh UIEvents adding timeout to process multiple concerted value change at once rather than firing off multiple plot updates. """ - self._event_queue.append((event)) - if self.trigger not in self.plot.document._session_callbacks: - self.plot.document.add_timeout_callback(self.trigger, 50) + self._queue.append((event)) + if self.process_on_event not in self.plot.document._session_callbacks: + self.plot.document.add_timeout_callback(self.process_on_event, 50) - def trigger(self): + def process_on_event(self): """ Trigger callback change event and triggering corresponding streams. """ - if not self._event_queue: + if not self._queue: return - if self.events: - event = self._event_queue[-1] - self._event_queue = [] + # Get unique event types in the queue + events = list(OrderedDict([(event.event_name, event) + for event in self._queue]).values()) + self._queue = [] + + # Process event types + for event in events: + msg = {} + for attr, path in self.attributes.items(): + model_obj = self.plot_handles.get(self.models[0]) + msg[attr] = resolve_attr_spec(path, event, model_obj) + self.on_msg(msg) + self.plot.document.add_timeout_callback(self.process_on_event, 50) + - values = {} + def process_on_change(self): + if not self._queue: + return + self._queue = [] + + msg = {} for attr, path in self.attributes.items(): attr_path = path.split('.') - if self.events: - obj = event - model_obj = self.plot_handles.get(self.models[0]) - else: - if attr_path[0] == 'cb_obj': - attr_path = self.models[:1]+attr_path[1:] - obj = self.plot_handles.get(attr_path[0]) - model_obj = obj - attr_val = obj - if not obj: - raise Exception('Bokeh plot attribute %s could not be found' % path) - for p in attr_path[1:]: - if p == 'attributes': - continue - if isinstance(attr_val, dict): - attr_val = attr_val.get(p) - else: - attr_val = getattr(attr_val, p, None) - values[attr] = {'id': model_obj.ref['id'], - 'value': attr_val} - self.on_msg(values) - self.plot.document.add_timeout_callback(self.trigger, 50) + if attr_path[0] == 'cb_obj': + path = '.'.join(self.models[:1]+attr_path[1:]) + cb_obj = self.plot_handles.get(self.models[0]) + msg[attr] = resolve_attr_spec(path, cb_obj, cb_obj) + self.on_msg(msg) + self.plot.document.add_timeout_callback(self.process_on_change, 50) - def set_onchange(self, handle): + + def set_server_callback(self, handle): """ Set up on_change events for bokeh server interactions. """ - if self.events and bokeh_version >= '0.12.5': - for event in self.events: + if self.on_events and bokeh_version >= '0.12.5': + for event in self.on_events: handle.on_event(event, self.on_event) - elif self.change: - for change in self.change: + elif self.on_changes: + for change in self.on_changes: handle.on_change(change, self.on_change) - def get_customjs(self, references): - """ - Creates a CustomJS callback that will send the requested - attributes back to python. - """ - # Generate callback JS code to get all the requested data - self_callback = self.js_callback.format(comm_id=self.comm.id, - timeout=self.timeout, - debounce=self.debounce) - attributes = attributes_js(self.attributes, references) - conditions = ["%s" % cond for cond in self.skip] - conditional = '' - if conditions: - conditional = 'if (%s) { return };\n' % (' || '.join(conditions)) - data = "var data = {};\n" - code = conditional + data + attributes + self.code + self_callback - return CustomJS(args=references, code=code) +class Callback(CustomJSCallback, ServerCallback): + """ + Provides a baseclass to define callbacks, which return data from + bokeh model callbacks, events and attribute changes. The callback + then makes this data available to any streams attached to it. + The definition of a callback consists of a number of components: - def set_customjs(self, js_callback, handle): - """ - Generates a CustomJS callback by generating the required JS - code and gathering all plotting handles and installs it on - the requested callback handle. - """ - if self.events and bokeh_version >= '0.12.5': - for event in self.events: - handle.js_on_event(event, js_callback) - elif self.change: - for change in self.change: - handle.js_on_change(change, js_callback) - elif hasattr(handle, 'callback'): - handle.callback = js_callback + * models : Defines which bokeh models the callback will be + attached on referencing the model by its key in + the plots handles, e.g. this could be the x_range, + y_range, plot, a plotting tool or any other + bokeh mode. + + * extra_models: Any additional models available in handles which + should be made available in the namespace of the + objects, e.g. to make a tool available to skip + checks. + + * attributes : The attributes define which attributes to send + back to Python. They are defined as a dictionary + mapping between the name under which the variable + is made available to Python and the specification + of the attribute. The specification should start + with the variable name that is to be accessed and + the location of the attribute separated by + periods. All models defined by the models and + extra_models attributes can be addressed in this + way, e.g. to get the start of the x_range as 'x' + you can supply {'x': 'x_range.attributes.start'}. + Additionally certain handles additionally make the + cb_data and cb_obj variables available containing + additional information about the event. + + * skip : Conditions when the Callback should be skipped + specified as a list of valid JS expressions, which + can reference models requested by the callback, + e.g. ['pan.attributes.active'] would skip the + callback if the pan tool is active. + + * code : Defines any additional JS code to be executed, + which can modify the data object that is sent to + the backend. + + * on_events : If the Callback should listen to bokeh events this + should declare the types of event as a list (optional) + + * on_changes : If the Callback should listen to model attribute + changes on the defined ``models`` (optional) + + If either on_events or on_changes are declared the Callback will + be registered using the on_event or on_change machinery, otherwise + it will be treated as a regular callback on the model. The + callback can also define a _process_msg method, which can modify + the data sent by the callback before it is passed to the streams. + """ + + def initialize(self): + plots = [self.plot] + if self.plot.subplots: + plots += list(self.plot.subplots.values()) + + self.plot_handles = self._get_plot_handles(plots) + requested = {} + for h in self.models+self.extra_models: + if h in self.plot_handles: + requested[h] = self.plot_handles[h] + elif h in self.extra_models: + print("Warning %s could not find the %s model. " + "The corresponding stream may not work.") + self.handle_ids.update(self._get_stream_handle_ids(requested)) + + found = [] + for plot in plots: + for handle_name in self.models: + if handle_name not in self.plot_handles: + warn_args = (handle_name, type(self.plot).__name__, + type(self).__name__) + print('%s handle not found on %s, cannot ' + 'attach %s callback' % warn_args) + continue + handle = self.plot_handles[handle_name] + # Hash the plot handle with Callback type allowing multiple + # callbacks on one handle to be merged + cb_hash = (id(handle), id(type(self))) + if cb_hash in self._callbacks: + # Merge callbacks if another callback has already been attached + cb = self._callbacks[cb_hash] + cb.streams += self.streams + for k, v in self.handle_ids.items(): + cb.handle_ids[k].update(v) + continue + + if self.plot.renderer.mode == 'server': + self.set_server_callback(handle) + else: + js_callback = self.get_customjs(requested) + self.set_customjs_callback(js_callback, handle) + self.callbacks.append(js_callback) + self._callbacks[cb_hash] = self @@ -464,7 +511,7 @@ class PositionXYCallback(Callback): attributes = {'x': 'cb_obj.x', 'y': 'cb_obj.y'} models = ['plot'] - events = ['mousemove'] + on_events = ['mousemove'] class PositionXCallback(PositionXYCallback): @@ -488,7 +535,7 @@ class TapCallback(PositionXYCallback): Returns the mouse x/y-position on tap event. """ - events = ['tap'] + on_events = ['tap'] class DoubleTapCallback(PositionXYCallback): @@ -496,7 +543,7 @@ class DoubleTapCallback(PositionXYCallback): Returns the mouse x/y-position on doubletap event. """ - events = ['doubletap'] + on_events = ['doubletap'] class MouseEnterCallback(PositionXYCallback): @@ -505,7 +552,7 @@ class MouseEnterCallback(PositionXYCallback): mouse enters the plot canvas. """ - events = ['mouseenter'] + on_events = ['mouseenter'] class MouseLeaveCallback(PositionXYCallback): @@ -514,7 +561,7 @@ class MouseLeaveCallback(PositionXYCallback): mouse leaves the plot canvas. """ - events = ['mouseleave'] + on_events = ['mouseleave'] class RangeXYCallback(Callback): @@ -527,7 +574,7 @@ class RangeXYCallback(Callback): 'y0': 'y_range.attributes.start', 'y1': 'y_range.attributes.end'} models = ['x_range', 'y_range'] - change = ['start', 'end'] + on_changes = ['start', 'end'] def _process_msg(self, msg): data = {} @@ -579,7 +626,7 @@ class PlotSizeCallback(Callback): models = ['plot'] attributes = {'width': 'cb_obj.inner_width', 'height': 'cb_obj.inner_height'} - change = ['inner_width', 'inner_height'] + on_changes = ['inner_width', 'inner_height'] class BoundsCallback(Callback): @@ -607,7 +654,7 @@ class Selection1DCallback(Callback): attributes = {'index': 'cb_obj.selected.1d.indices'} models = ['source'] - change = ['selected'] + on_changes = ['selected'] def _process_msg(self, msg): if 'index' in msg: From d7f5e45ad5ca122cf95ae08676fe5c9056c958e3 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 6 Apr 2017 19:02:12 +0100 Subject: [PATCH 18/30] Made callback utilities into classmethods --- holoviews/plotting/bokeh/callbacks.py | 132 +++++++++++++------------- 1 file changed, 68 insertions(+), 64 deletions(-) diff --git a/holoviews/plotting/bokeh/callbacks.py b/holoviews/plotting/bokeh/callbacks.py index 22d04c5380..aa0ca02ddb 100644 --- a/holoviews/plotting/bokeh/callbacks.py +++ b/holoviews/plotting/bokeh/callbacks.py @@ -12,67 +12,6 @@ from .util import bokeh_version -def attributes_js(attributes, handles): - """ - Generates JS code to look up attributes on JS objects from - an attributes specification dictionary. If the specification - references a plotting particular plotting handle it will also - generate JS code to get the ID of the object. - - Simple example (when referencing cb_data or cb_obj): - - Input : {'x': 'cb_data.geometry.x'} - - Output : data['x'] = cb_data['geometry']['x'] - - Example referencing plot handle: - - Input : {'x0': 'x_range.attributes.start'} - - Output : if ((x_range !== undefined)) { - data['x0'] = {id: x_range['id'], value: x_range['attributes']['start']} - } - """ - code = '' - for key, attr_path in attributes.items(): - data_assign = "data['{key}'] = ".format(key=key) - attrs = attr_path.split('.') - obj_name = attrs[0] - attr_getters = ''.join(["['{attr}']".format(attr=attr) - for attr in attrs[1:]]) - if obj_name not in ['cb_obj', 'cb_data']: - assign_str = '{assign}{{id: {obj_name}["id"], value: {obj_name}{attr_getters}}};\n'.format( - assign=data_assign, obj_name=obj_name, attr_getters=attr_getters - ) - code += 'if (({obj_name} != undefined)) {{ {assign} }}'.format( - obj_name=obj_name, id=handles[obj_name].ref['id'], assign=assign_str - ) - else: - assign_str = ''.join([data_assign, obj_name, attr_getters, ';\n']) - code += assign_str - return code - - -def resolve_attr_spec(spec, cb_obj, model): - """ - Resolves a Callback attribute specification looking the - corresponding attribute up on the cb_obj, which should be a bokeh - model. - """ - if not cb_obj: - raise Exception('Bokeh plot attribute %s could not be found' % spec) - spec = spec.split('.') - resolved = cb_obj - for p in spec[1:]: - if p == 'attributes': - continue - if isinstance(resolved, dict): - resolved = resolved.get(p) - else: - resolved = getattr(resolved, p, None) - return {'id': model.ref['id'], 'value': resolved} - - class MessageCallback(object): """ @@ -284,6 +223,50 @@ class CustomJSCallback(MessageCallback): _comm_type = JupyterCommJS + @classmethod + def attributes_js(cls, attributes, handles): + """ + Generates JS code to look up attributes on JS objects from + an attributes specification dictionary. If the specification + references a plotting particular plotting handle it will also + generate JS code to get the ID of the object. + + Simple example (when referencing cb_data or cb_obj): + + Input : {'x': 'cb_data.geometry.x'} + + Output : data['x'] = cb_data['geometry']['x'] + + Example referencing plot handle: + + Input : {'x0': 'x_range.attributes.start'} + + Output : if ((x_range !== undefined)) { + data['x0'] = {id: x_range['id'], value: x_range['attributes']['start']} + } + """ + assign_template = '{assign}{{id: {obj_name}["id"], value: {obj_name}{attr_getters}}};\n' + conditional_template = 'if (({obj_name} != undefined)) {{ {assign} }}' + code = '' + for key, attr_path in attributes.items(): + data_assign = "data['{key}'] = ".format(key=key) + attrs = attr_path.split('.') + obj_name = attrs[0] + attr_getters = ''.join(["['{attr}']".format(attr=attr) + for attr in attrs[1:]]) + if obj_name not in ['cb_obj', 'cb_data']: + assign_str = assign_template.format( + assign=data_assign, obj_name=obj_name, attr_getters=attr_getters + ) + code += conditional_template.format( + obj_name=obj_name, id=handles[obj_name].ref['id'], assign=assign_str + ) + else: + assign_str = ''.join([data_assign, obj_name, attr_getters, ';\n']) + code += assign_str + return code + + def get_customjs(self, references): """ Creates a CustomJS callback that will send the requested @@ -294,7 +277,7 @@ def get_customjs(self, references): timeout=self.timeout, debounce=self.debounce) - attributes = attributes_js(self.attributes, references) + attributes = self.attributes_js(self.attributes, references) conditions = ["%s" % cond for cond in self.skip] conditional = '' if conditions: @@ -329,6 +312,27 @@ class ServerCallback(MessageCallback): Stream(s) attached to the callback. """ + @classmethod + def resolve_attr_spec(cls, spec, cb_obj, model): + """ + Resolves a Callback attribute specification looking the + corresponding attribute up on the cb_obj, which should be a + bokeh model. + """ + if not cb_obj: + raise Exception('Bokeh plot attribute %s could not be found' % spec) + spec = spec.split('.') + resolved = cb_obj + for p in spec[1:]: + if p == 'attributes': + continue + if isinstance(resolved, dict): + resolved = resolved.get(p) + else: + resolved = getattr(resolved, p, None) + return {'id': model.ref['id'], 'value': resolved} + + def on_change(self, attr, old, new): """ Process change events adding timeout to process multiple concerted @@ -365,7 +369,7 @@ def process_on_event(self): msg = {} for attr, path in self.attributes.items(): model_obj = self.plot_handles.get(self.models[0]) - msg[attr] = resolve_attr_spec(path, event, model_obj) + msg[attr] = self.resolve_attr_spec(path, event, model_obj) self.on_msg(msg) self.plot.document.add_timeout_callback(self.process_on_event, 50) @@ -381,7 +385,7 @@ def process_on_change(self): if attr_path[0] == 'cb_obj': path = '.'.join(self.models[:1]+attr_path[1:]) cb_obj = self.plot_handles.get(self.models[0]) - msg[attr] = resolve_attr_spec(path, cb_obj, cb_obj) + msg[attr] = self.resolve_attr_spec(path, cb_obj, cb_obj) self.on_msg(msg) self.plot.document.add_timeout_callback(self.process_on_change, 50) From 6ab88612f789183c9e1e908531df24461c330562 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 6 Apr 2017 20:38:04 +0100 Subject: [PATCH 19/30] Minor cleanup on bokeh Callbacks --- holoviews/plotting/bokeh/callbacks.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/holoviews/plotting/bokeh/callbacks.py b/holoviews/plotting/bokeh/callbacks.py index aa0ca02ddb..2f1555584e 100644 --- a/holoviews/plotting/bokeh/callbacks.py +++ b/holoviews/plotting/bokeh/callbacks.py @@ -36,7 +36,7 @@ class MessageCallback(object): on_events = [] # List of change events on the models to listen to - on_change = [] + on_changes = [] _callbacks = {} @@ -224,7 +224,7 @@ class CustomJSCallback(MessageCallback): _comm_type = JupyterCommJS @classmethod - def attributes_js(cls, attributes, handles): + def attributes_js(cls, attributes): """ Generates JS code to look up attributes on JS objects from an attributes specification dictionary. If the specification @@ -249,17 +249,17 @@ def attributes_js(cls, attributes, handles): conditional_template = 'if (({obj_name} != undefined)) {{ {assign} }}' code = '' for key, attr_path in attributes.items(): - data_assign = "data['{key}'] = ".format(key=key) + data_assign = 'data["{key}"] = '.format(key=key) attrs = attr_path.split('.') obj_name = attrs[0] - attr_getters = ''.join(["['{attr}']".format(attr=attr) + attr_getters = ''.join(['["{attr}"]'.format(attr=attr) for attr in attrs[1:]]) if obj_name not in ['cb_obj', 'cb_data']: assign_str = assign_template.format( assign=data_assign, obj_name=obj_name, attr_getters=attr_getters ) code += conditional_template.format( - obj_name=obj_name, id=handles[obj_name].ref['id'], assign=assign_str + obj_name=obj_name, assign=assign_str ) else: assign_str = ''.join([data_assign, obj_name, attr_getters, ';\n']) @@ -277,7 +277,7 @@ def get_customjs(self, references): timeout=self.timeout, debounce=self.debounce) - attributes = self.attributes_js(self.attributes, references) + attributes = self.attributes_js(self.attributes) conditions = ["%s" % cond for cond in self.skip] conditional = '' if conditions: @@ -313,14 +313,17 @@ class ServerCallback(MessageCallback): """ @classmethod - def resolve_attr_spec(cls, spec, cb_obj, model): + def resolve_attr_spec(cls, spec, cb_obj, model=None): """ Resolves a Callback attribute specification looking the corresponding attribute up on the cb_obj, which should be a - bokeh model. + bokeh model. If not model is supplied cb_obj is assumed to + be the same as the model. """ if not cb_obj: raise Exception('Bokeh plot attribute %s could not be found' % spec) + if model is None: + model = cb_obj spec = spec.split('.') resolved = cb_obj for p in spec[1:]: @@ -385,7 +388,7 @@ def process_on_change(self): if attr_path[0] == 'cb_obj': path = '.'.join(self.models[:1]+attr_path[1:]) cb_obj = self.plot_handles.get(self.models[0]) - msg[attr] = self.resolve_attr_spec(path, cb_obj, cb_obj) + msg[attr] = self.resolve_attr_spec(path, cb_obj) self.on_msg(msg) self.plot.document.add_timeout_callback(self.process_on_change, 50) @@ -531,7 +534,7 @@ class PositionYCallback(PositionXYCallback): Returns the mouse x/y-position on mousemove event. """ - attributes = {'y': 'cb_data.y'} + attributes = {'y': 'cb_obj.y'} class TapCallback(PositionXYCallback): From 82074b8efb8710f57a987a3ee234bddf28e23c2f Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 6 Apr 2017 21:01:10 +0100 Subject: [PATCH 20/30] Added tests for bokeh Callbacks --- tests/testbokehcallbacks.py | 79 +++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tests/testbokehcallbacks.py diff --git a/tests/testbokehcallbacks.py b/tests/testbokehcallbacks.py new file mode 100644 index 0000000000..96dbaaf3f4 --- /dev/null +++ b/tests/testbokehcallbacks.py @@ -0,0 +1,79 @@ +from unittest import SkipTest + +from holoviews.element.comparison import ComparisonTestCase + +try: + from holoviews.plotting.bokeh.callbacks import Callback + from holoviews.plotting.bokeh.util import bokeh_version + + from bokeh.events import Tap + from bokeh.models import Range1d, Plot, ColumnDataSource + bokeh_renderer = Store.renderers['bokeh'] +except: + bokeh_renderer = None + + +class TestBokehCustomJSCallbacks(ComparisonTestCase): + + def setUp(self): + if bokeh_version < str('0.12.5'): + raise SkipTest("Bokeh >= 0.12.5 required to test callbacks") + + + def test_customjs_callback_attributes_js_for_model(self): + js_code = Callback.attributes_js({'x0': 'x_range.attributes.start', + 'x1': 'x_range.attributes.end'}) + + code = ( + 'if ((x_range != undefined)) { data["x0"] = {id: x_range["id"], value: ' + 'x_range["attributes"]["start"]};\n }' + 'if ((x_range != undefined)) { data["x1"] = {id: x_range["id"], value: ' + 'x_range["attributes"]["end"]};\n }' + ) + self.assertEqual(js_code, code) + + def test_customjs_callback_attributes_js_for_cb_obj(self): + js_code = Callback.attributes_js({'x': 'cb_obj.x', + 'y': 'cb_obj.y'}) + code = 'data["y"] = cb_obj["y"];\ndata["x"] = cb_obj["x"];\n' + self.assertEqual(js_code, code) + + def test_customjs_callback_attributes_js_for_cb_data(self): + js_code = Callback.attributes_js({'x0': 'cb_data.geometry.x0', + 'x1': 'cb_data.geometry.x1', + 'y0': 'cb_data.geometry.y0', + 'y1': 'cb_data.geometry.y1'}) + code = ('data["y1"] = cb_data["geometry"]["y1"];\n' + 'data["y0"] = cb_data["geometry"]["y0"];\n' + 'data["x0"] = cb_data["geometry"]["x0"];\n' + 'data["x1"] = cb_data["geometry"]["x1"];\n') + self.assertEqual(js_code, code) + + +class TestBokehServerJSCallbacks(ComparisonTestCase): + + def setUp(self): + if bokeh_version < str('0.12.5'): + raise SkipTest("Bokeh >= 0.12.5 required to test callbacks") + + def test_server_callback_resolve_attr_spec_range1d_start(self): + range1d = Range1d(start=0, end=10) + msg = Callback.resolve_attr_spec('x_range.attributes.start', range1d) + self.assertEqual(msg, {'id': range1d.ref['id'], 'value': 0}) + + def test_server_callback_resolve_attr_spec_range1d_end(self): + range1d = Range1d(start=0, end=10) + msg = Callback.resolve_attr_spec('x_range.attributes.end', range1d) + self.assertEqual(msg, {'id': range1d.ref['id'], 'value': 10}) + + def test_server_callback_resolve_attr_spec_source_selected(self): + source = ColumnDataSource() + source.selected['1d']['indices'] = [1, 2, 3] + msg = Callback.resolve_attr_spec('cb_obj.selected.1d.indices', source) + self.assertEqual(msg, {'id': source.ref['id'], 'value': [1, 2, 3]}) + + def test_server_callback_resolve_attr_spec_tap_event(self): + plot = Plot() + event = Tap(plot, x=42) + msg = Callback.resolve_attr_spec('cb_obj.x', event, plot) + self.assertEqual(msg, {'id': plot.ref['id'], 'value': 42}) From a8a10a5928b7c1c66d3dcdda291a7e10ca4de5c4 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 6 Apr 2017 22:30:00 +0100 Subject: [PATCH 21/30] Small fix for bokeh ServerCallback on_change events --- holoviews/plotting/bokeh/callbacks.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/holoviews/plotting/bokeh/callbacks.py b/holoviews/plotting/bokeh/callbacks.py index 2f1555584e..cc7cc33df2 100644 --- a/holoviews/plotting/bokeh/callbacks.py +++ b/holoviews/plotting/bokeh/callbacks.py @@ -386,8 +386,11 @@ def process_on_change(self): for attr, path in self.attributes.items(): attr_path = path.split('.') if attr_path[0] == 'cb_obj': + obj_handle = self.models[0] path = '.'.join(self.models[:1]+attr_path[1:]) - cb_obj = self.plot_handles.get(self.models[0]) + else: + obj_handle = attr_path[0] + cb_obj = self.plot_handles.get(obj_handle) msg[attr] = self.resolve_attr_spec(path, cb_obj) self.on_msg(msg) From 3342b0aba1c1fca5ddc48a41bfdf74250407914f Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 6 Apr 2017 22:30:39 +0100 Subject: [PATCH 22/30] Allow supplying Document to BokehRenderer --- holoviews/plotting/bokeh/renderer.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/holoviews/plotting/bokeh/renderer.py b/holoviews/plotting/bokeh/renderer.py index 6821028c1e..f00ff7a0ea 100644 --- a/holoviews/plotting/bokeh/renderer.py +++ b/holoviews/plotting/bokeh/renderer.py @@ -60,7 +60,7 @@ class BokehRenderer(Renderer): _loaded = False - def __call__(self, obj, fmt=None): + def __call__(self, obj, fmt=None, doc=None): """ Render the supplied HoloViews component using the appropriate backend. The output is not a file format but a suitable, @@ -70,22 +70,23 @@ def __call__(self, obj, fmt=None): info = {'file-ext': fmt, 'mime_type': MIME_TYPES[fmt]} if self.mode == 'server': - return self.server_doc(plot), info + return self.server_doc(plot, doc), info elif isinstance(plot, tuple(self.widgets.values())): return plot(), info elif fmt == 'html': - html = self.figure_data(plot) + 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 - def server_doc(self, plot): + def server_doc(self, plot, doc=None): """ Get server document. """ - doc = curdoc() + if doc is None: + doc = curdoc() if isinstance(plot, BokehServerWidgets): plot.plot.document = doc else: @@ -94,9 +95,9 @@ def server_doc(self, plot): return doc - def figure_data(self, plot, fmt='html', **kwargs): + def figure_data(self, plot, fmt='html', doc=None, **kwargs): model = plot.state - doc = Document() + doc = Document() if doc is None else doc for m in model.references(): m._document = None doc.add_root(model) From c65dcbb938ccf090106efda749562ec9a45bbefc Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 6 Apr 2017 23:18:39 +0100 Subject: [PATCH 23/30] Simplified bokeh Callback initialization --- holoviews/plotting/bokeh/callbacks.py | 91 ++++++++++++++------------- 1 file changed, 46 insertions(+), 45 deletions(-) diff --git a/holoviews/plotting/bokeh/callbacks.py b/holoviews/plotting/bokeh/callbacks.py index cc7cc33df2..6d0bbfe47d 100644 --- a/holoviews/plotting/bokeh/callbacks.py +++ b/holoviews/plotting/bokeh/callbacks.py @@ -92,15 +92,31 @@ def on_msg(self, msg): stream._metadata = {} - def _get_plot_handles(self, plots): + def _init_plot_handles(self): """ - Iterate over plots and find all unique plotting handles. + Find all requested plotting handles and cache them along + with the IDs of the models callbacks will be attached to. """ + plots = [self.plot] + if self.plot.subplots: + plots += list(self.plot.subplots.values()) + handles = {} for plot in plots: for k, v in plot.handles.items(): handles[k] = v - return handles + self.plot_handles = handles + + requested = {} + for h in self.models+self.extra_models: + if h in self.plot_handles: + requested[h] = handles[h] + elif h in self.extra_models: + print("Warning %s could not find the %s model. " + "The corresponding stream may not work.") + self.handle_ids.update(self._get_stream_handle_ids(requested)) + + return requested def _get_stream_handle_ids(self, handles): @@ -468,49 +484,34 @@ class Callback(CustomJSCallback, ServerCallback): """ def initialize(self): - plots = [self.plot] - if self.plot.subplots: - plots += list(self.plot.subplots.values()) - - self.plot_handles = self._get_plot_handles(plots) - requested = {} - for h in self.models+self.extra_models: - if h in self.plot_handles: - requested[h] = self.plot_handles[h] - elif h in self.extra_models: - print("Warning %s could not find the %s model. " - "The corresponding stream may not work.") - self.handle_ids.update(self._get_stream_handle_ids(requested)) + handles = self._init_plot_handles() + for handle_name in self.models: + if handle_name not in handles: + warn_args = (handle_name, type(self.plot).__name__, + type(self).__name__) + print('%s handle not found on %s, cannot ' + 'attach %s callback' % warn_args) + continue + handle = handles[handle_name] + + # Hash the plot handle with Callback type allowing multiple + # callbacks on one handle to be merged + cb_hash = (id(handle), id(type(self))) + if cb_hash in self._callbacks: + # Merge callbacks if another callback has already been attached + cb = self._callbacks[cb_hash] + cb.streams += self.streams + for k, v in self.handle_ids.items(): + cb.handle_ids[k].update(v) + continue - found = [] - for plot in plots: - for handle_name in self.models: - if handle_name not in self.plot_handles: - warn_args = (handle_name, type(self.plot).__name__, - type(self).__name__) - print('%s handle not found on %s, cannot ' - 'attach %s callback' % warn_args) - continue - handle = self.plot_handles[handle_name] - - # Hash the plot handle with Callback type allowing multiple - # callbacks on one handle to be merged - cb_hash = (id(handle), id(type(self))) - if cb_hash in self._callbacks: - # Merge callbacks if another callback has already been attached - cb = self._callbacks[cb_hash] - cb.streams += self.streams - for k, v in self.handle_ids.items(): - cb.handle_ids[k].update(v) - continue - - if self.plot.renderer.mode == 'server': - self.set_server_callback(handle) - else: - js_callback = self.get_customjs(requested) - self.set_customjs_callback(js_callback, handle) - self.callbacks.append(js_callback) - self._callbacks[cb_hash] = self + if self.plot.renderer.mode == 'server': + self.set_server_callback(handle) + else: + js_callback = self.get_customjs(requested) + self.set_customjs_callback(js_callback, handle) + self.callbacks.append(js_callback) + self._callbacks[cb_hash] = self From a2fcb0cd5a7434f22902da2097e2378cb1fe28bd Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 6 Apr 2017 23:36:39 +0100 Subject: [PATCH 24/30] Moved bokeh server widget handling onto BokehRenderer --- holoviews/plotting/bokeh/renderer.py | 9 +++++++++ holoviews/plotting/renderer.py | 4 +--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/holoviews/plotting/bokeh/renderer.py b/holoviews/plotting/bokeh/renderer.py index f00ff7a0ea..5c84847a80 100644 --- a/holoviews/plotting/bokeh/renderer.py +++ b/holoviews/plotting/bokeh/renderer.py @@ -80,6 +80,15 @@ def __call__(self, obj, fmt=None, doc=None): elif fmt == 'json': return self.diff(plot), info + @bothmethod + def get_widget(self_or_cls, plot, widget_type, **kwargs): + if not isinstance(plot, Plot): + plot = self_or_cls.get_plot(plot) + if self_or_cls.mode == 'server': + return BokehServerWidgets(plot, renderer=self_or_cls.instance(), **kwargs) + else: + return super(BokehRenderer, self).get_widget(plot, widget_type, **kwargs) + def server_doc(self, plot, doc=None): """ diff --git a/holoviews/plotting/renderer.py b/holoviews/plotting/renderer.py index 00d2daead0..13cec36302 100644 --- a/holoviews/plotting/renderer.py +++ b/holoviews/plotting/renderer.py @@ -299,9 +299,7 @@ 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 - if widget_type == 'server': - pass - elif widget_type == 'auto': + if widget_type == 'auto': isuniform = plot.uniform if not isuniform: widget_type = 'scrubber' From b0e1f52d1ce62b46751c900bb262ef45cc8e7259 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 7 Apr 2017 00:00:06 +0100 Subject: [PATCH 25/30] Factored out class method to create bokeh widgets --- holoviews/plotting/bokeh/widgets.py | 107 +++++++++++++++++----------- 1 file changed, 64 insertions(+), 43 deletions(-) diff --git a/holoviews/plotting/bokeh/widgets.py b/holoviews/plotting/bokeh/widgets.py index e50a112d01..939cd00f06 100644 --- a/holoviews/plotting/bokeh/widgets.py +++ b/holoviews/plotting/bokeh/widgets.py @@ -4,6 +4,7 @@ from functools import partial import param +import numpy as np from bokeh.io import _CommsHandle from bokeh.util.notebook import get_comms from bokeh.models.widgets import Select, Slider, AutocompleteInput, TextInput @@ -64,55 +65,75 @@ def __init__(self, plot, renderer=None, **params): self._event_queue = [] - def get_widgets(self): - # Generate widget data - widgets = OrderedDict() - lookups = {} - for idx, dim in enumerate(self.mock_obj.kdims): - label, lookup = None, None - if self.plot.dynamic: - if dim.values: - if all(isnumeric(v) for v in dim.values): - values = dim.values - labels = [unicode(dim.pprint_value(v)) for v in dim.values] - label = AutocompleteInput(value=labels[0], completions=labels, - title=dim.pprint_label) - widget = Slider(value=0, end=len(dim.values)-1, title=None, step=1) - lookup = zip(values, labels) - else: - values = [(v, dim.pprint_value(v)) for v in dim.values] - widget = Select(title=dim.pprint_label, value=values[0][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] - int_type = isinstance(dim.type, type) and issubclass(dim.type, int) - if isinstance(dim_range, int) or int_type: - step = 1 - else: - step = 10**(round(math.log10(dim_range))-3) - label = TextInput(value=str(start), title=dim.pprint_label) - widget = Slider(value=start, start=start, - end=end, step=step, title=None) - else: - values = (dim.values if dim.values else - list(unique_array(self.mock_obj.dimension_values(dim.name)))) - labels = [str(dim.pprint_value(v)) for v in values] - if isinstance(values[0], np.datetime64) or isnumeric(values[0]): + @classmethod + def create_widget(self, dim, holomap=None): + """" + 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 all(isnumeric(v) for v in dim.values): + values = dim.values + labels = [unicode(dim.pprint_value(v)) for v in dim.values] label = AutocompleteInput(value=labels[0], completions=labels, title=dim.pprint_label) - widget = Slider(value=0, end=len(dim.values)-1, title=None) + widget = Slider(value=0, end=len(dim.values)-1, title=None, step=1) + mapping = zip(values, labels) else: - widget = Select(title=dim.pprint_label, value=values[0], - options=list(zip(values, labels))) - lookup = zip(values, labels) - if label: + values = [(v, dim.pprint_value(v)) for v in dim.values] + widget = Select(title=dim.pprint_label, value=values[0][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] + int_type = isinstance(dim.type, type) and issubclass(dim.type, int) + if isinstance(dim_range, int) or int_type: + step = 1 + else: + step = 10**(round(math.log10(dim_range))-3) + label = TextInput(value=str(start), title=dim.pprint_label) + widget = Slider(value=start, start=start, + end=end, step=step, title=None) + else: + values = (dim.values if dim.values else + list(unique_array(holomap.dimension_values(dim.name)))) + labels = [str(dim.pprint_value(v)) for v in values] + if isinstance(values[0], np.datetime64) or isnumeric(values[0]): + label = AutocompleteInput(value=labels[0], completions=labels, + title=dim.pprint_label) + widget = Slider(value=0, end=len(dim.values)-1, title=None) + else: + widget = Select(title=dim.pprint_label, value=values[0], + options=list(zip(values, labels))) + mapping = 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) + if label is not None: label.on_change('value', partial(self.on_change, dim.pprint_label, 'label')) widget.on_change('value', partial(self.on_change, dim.pprint_label, 'widget')) widgets[dim.pprint_label] = (label, widget) - if lookup: - lookups[dim.pprint_label] = OrderedDict(lookup) - return widgets, lookups + if mapping: + mappings[dim.pprint_label] = OrderedDict(mapping) + return widgets, mappings def init_layout(self): From d9fe1b756ce7d9cd20ba5f3add65b642aa8c809b Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 7 Apr 2017 00:53:21 +0100 Subject: [PATCH 26/30] Small fixes and improvements for bokeh widgets --- holoviews/plotting/bokeh/widgets.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/holoviews/plotting/bokeh/widgets.py b/holoviews/plotting/bokeh/widgets.py index 939cd00f06..516318fecb 100644 --- a/holoviews/plotting/bokeh/widgets.py +++ b/holoviews/plotting/bokeh/widgets.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +import math import json from functools import partial @@ -19,15 +20,19 @@ class BokehServerWidgets(param.Parameterized): """ + BokehServerWidgets create bokeh widgets corresponding to all the + key dimensions found on a BokehPlot instance. It currently supports + to types of widgets sliders (which may be discrete or continuous) + and dropdown widgets letting you select non-numeric values. """ - basejs = param.String(default=None, doc=""" + basejs = param.String(default=None, precedence=-1, doc=""" Defines the local CSS file to be loaded for this widget.""") - extensionjs = param.String(default=None, doc=""" + extensionjs = param.String(default=None, precedence=-1, doc=""" Optional javascript extension file for a particular backend.""") - css = param.String(default=None, doc=""" + css = param.String(default=None, precedence=-1, doc=""" Defines the local CSS file to be loaded for this widget.""") position = param.ObjectSelector(default='right', @@ -37,6 +42,9 @@ class BokehServerWidgets(param.Parameterized): objects=['fixed', 'stretch_both', 'scale_width', 'scale_height', 'scale_both']) + width = param.Integer(default=200, doc=""" + Width of the widget box in pixels""") + def __init__(self, plot, renderer=None, **params): super(BokehServerWidgets, self).__init__(**params) self.plot = plot @@ -94,22 +102,23 @@ def create_widget(self, dim, holomap=None): 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 isinstance(dim_range, int) or int_type: step = 1 else: - step = 10**(round(math.log10(dim_range))-3) + step = 10**((round(math.log10(dim_range))-3)) label = TextInput(value=str(start), title=dim.pprint_label) widget = Slider(value=start, start=start, end=end, step=step, title=None) else: values = (dim.values if dim.values else list(unique_array(holomap.dimension_values(dim.name)))) - labels = [str(dim.pprint_value(v)) for v in values] + labels = [dim.pprint_value(v) for v in values] if isinstance(values[0], np.datetime64) or isnumeric(values[0]): label = AutocompleteInput(value=labels[0], completions=labels, title=dim.pprint_label) - widget = Slider(value=0, end=len(dim.values)-1, title=None) + widget = Slider(value=0, end=len(values)-1, title=None, step=1) else: widget = Select(title=dim.pprint_label, value=values[0], options=list(zip(values, labels))) @@ -139,7 +148,7 @@ def get_widgets(self): def init_layout(self): widgets = [widget for d in self.widgets.values() for widget in d if widget] - wbox = widgetbox(widgets, width=200) + wbox = widgetbox(widgets, width=self.width) if self.position in ['right', 'below']: plots = [self.plot.state, wbox] else: @@ -231,8 +240,10 @@ def _plot_figure(self, idx, fig_format='json'): msg = serialize_json(msg) return msg + class BokehSelectionWidget(BokehWidget, SelectionWidget): pass + class BokehScrubberWidget(BokehWidget, ScrubberWidget): pass From f0c31c6d3ce9a3d653d9a6c1d7ce44b643fb03c6 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 7 Apr 2017 00:53:53 +0100 Subject: [PATCH 27/30] Added tests for BokehServerWidgets --- tests/testbokehwidgets.py | 112 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 tests/testbokehwidgets.py diff --git a/tests/testbokehwidgets.py b/tests/testbokehwidgets.py new file mode 100644 index 0000000000..f15efafdb9 --- /dev/null +++ b/tests/testbokehwidgets.py @@ -0,0 +1,112 @@ +from unittest import SkipTest + +import numpy as np + +from holoviews.core import Dimension, NdMapping +from holoviews.element.comparison import ComparisonTestCase + +try: + from holoviews.plotting.bokeh.callbacks import Callback + from holoviews.plotting.bokeh.widgets import BokehServerWidgets + from holoviews.plotting.bokeh.util import bokeh_version + + from bokeh.models.widgets import Select, Slider, AutocompleteInput, TextInput +except: + BokehServerWidgets = None + + +class TestBokehServerWidgets(ComparisonTestCase): + + def setUp(self): + if not BokehServerWidgets: + raise SkipTest("Bokeh required to test BokehServerWidgets") + + def test_bokeh_server_dynamic_range_int(self): + dim = Dimension('x', range=(3, 11)) + widget, label, mapping = BokehServerWidgets.create_widget(dim) + 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) + 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_values_int(self): + values = list(range(3, 11)) + dim = Dimension('x', values=values) + widget, label, mapping = BokehServerWidgets.create_widget(dim) + 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, [(v, dim.pprint_value(v)) for v in values]) + + def test_bokeh_server_dynamic_values_float(self): + values = list(np.linspace(3.1, 11.2, 7)) + dim = Dimension('x', values=values) + widget, label, mapping = BokehServerWidgets.create_widget(dim) + 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, [(v, dim.pprint_value(v)) for v in values]) + + def test_bokeh_server_dynamic_values_str(self): + values = [chr(65+i) for i in range(10)] + dim = Dimension('x', values=values) + widget, label, mapping = BokehServerWidgets.create_widget(dim) + 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_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) + 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, [(k, dim.pprint_value(k)) for k in ndmap.keys()]) + + def test_bokeh_server_dynamic_values_str(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) + 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, [(k, dim.pprint_value(k)) for k in ndmap.keys()]) From 171e9caf0f8c7ab7eaaa149a3e5732eebc96c875 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 7 Apr 2017 00:57:21 +0100 Subject: [PATCH 28/30] Fixed unreferenced variable bugs --- holoviews/plotting/bokeh/callbacks.py | 2 +- holoviews/plotting/bokeh/renderer.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/holoviews/plotting/bokeh/callbacks.py b/holoviews/plotting/bokeh/callbacks.py index 6d0bbfe47d..cfa5c70efb 100644 --- a/holoviews/plotting/bokeh/callbacks.py +++ b/holoviews/plotting/bokeh/callbacks.py @@ -508,7 +508,7 @@ def initialize(self): if self.plot.renderer.mode == 'server': self.set_server_callback(handle) else: - js_callback = self.get_customjs(requested) + js_callback = self.get_customjs(handles) self.set_customjs_callback(js_callback, handle) self.callbacks.append(js_callback) self._callbacks[cb_hash] = self diff --git a/holoviews/plotting/bokeh/renderer.py b/holoviews/plotting/bokeh/renderer.py index 5c84847a80..63b1db2765 100644 --- a/holoviews/plotting/bokeh/renderer.py +++ b/holoviews/plotting/bokeh/renderer.py @@ -87,7 +87,7 @@ def get_widget(self_or_cls, plot, widget_type, **kwargs): if self_or_cls.mode == 'server': return BokehServerWidgets(plot, renderer=self_or_cls.instance(), **kwargs) else: - return super(BokehRenderer, self).get_widget(plot, widget_type, **kwargs) + return super(BokehRenderer, self_or_cls).get_widget(plot, widget_type, **kwargs) def server_doc(self, plot, doc=None): From 4e8073a43f62b1c173151c7b78d004ab97eb8664 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 7 Apr 2017 01:11:20 +0100 Subject: [PATCH 29/30] Various python3 fixes --- holoviews/plotting/bokeh/callbacks.py | 2 +- holoviews/plotting/bokeh/widgets.py | 15 ++++++++------- tests/testbokehcallbacks.py | 8 ++++---- tests/testbokehwidgets.py | 2 +- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/holoviews/plotting/bokeh/callbacks.py b/holoviews/plotting/bokeh/callbacks.py index cfa5c70efb..7f6d0a1f8e 100644 --- a/holoviews/plotting/bokeh/callbacks.py +++ b/holoviews/plotting/bokeh/callbacks.py @@ -264,7 +264,7 @@ def attributes_js(cls, attributes): assign_template = '{assign}{{id: {obj_name}["id"], value: {obj_name}{attr_getters}}};\n' conditional_template = 'if (({obj_name} != undefined)) {{ {assign} }}' code = '' - for key, attr_path in attributes.items(): + for key, attr_path in sorted(attributes.items()): data_assign = 'data["{key}"] = '.format(key=key) attrs = attr_path.split('.') obj_name = attrs[0] diff --git a/holoviews/plotting/bokeh/widgets.py b/holoviews/plotting/bokeh/widgets.py index 516318fecb..6de750fdae 100644 --- a/holoviews/plotting/bokeh/widgets.py +++ b/holoviews/plotting/bokeh/widgets.py @@ -12,7 +12,8 @@ from bokeh.layouts import layout, gridplot, widgetbox, row, column from ...core import Store, NdMapping, OrderedDict -from ...core.util import drop_streams, unique_array, isnumeric, wrap_tuple_streams +from ...core.util import (drop_streams, unique_array, isnumeric, + wrap_tuple_streams, unicode) from ..widgets import NdWidget, SelectionWidget, ScrubberWidget from .util import serialize_json @@ -70,7 +71,7 @@ def __init__(self, plot, renderer=None, **params): if self.plot.renderer.mode == 'default': self.attach_callbacks() self.state = self.init_layout() - self._event_queue = [] + self._queue = [] @classmethod @@ -94,7 +95,7 @@ def create_widget(self, dim, holomap=None): label = AutocompleteInput(value=labels[0], completions=labels, title=dim.pprint_label) widget = Slider(value=0, end=len(dim.values)-1, title=None, step=1) - mapping = zip(values, labels) + mapping = list(zip(values, labels)) else: values = [(v, dim.pprint_value(v)) for v in dim.values] widget = Select(title=dim.pprint_label, value=values[0][0], @@ -119,10 +120,10 @@ def create_widget(self, dim, holomap=None): label = AutocompleteInput(value=labels[0], completions=labels, title=dim.pprint_label) widget = Slider(value=0, end=len(values)-1, title=None, step=1) + mapping = list(zip(values, labels)) else: widget = Select(title=dim.pprint_label, value=values[0], options=list(zip(values, labels))) - mapping = zip(values, labels) return widget, label, mapping @@ -166,7 +167,7 @@ def attach_callbacks(self): def on_change(self, dim, widget_type, attr, old, new): - self._event_queue.append((dim, widget_type, attr, old, new)) + self._queue.append((dim, widget_type, attr, old, new)) if self.update not in self.plot.document._session_callbacks: self.plot.document.add_timeout_callback(self.update, 50) @@ -175,9 +176,9 @@ def update(self): """ Handle update events on bokeh server. """ - if not self._event_queue: + if not self._queue: return - dim, widget_type, attr, old, new = self._event_queue[-1] + dim, widget_type, attr, old, new = self._queue[-1] label, widget = self.widgets[dim] if widget_type == 'label': diff --git a/tests/testbokehcallbacks.py b/tests/testbokehcallbacks.py index 96dbaaf3f4..6cc2f545cb 100644 --- a/tests/testbokehcallbacks.py +++ b/tests/testbokehcallbacks.py @@ -35,7 +35,7 @@ def test_customjs_callback_attributes_js_for_model(self): def test_customjs_callback_attributes_js_for_cb_obj(self): js_code = Callback.attributes_js({'x': 'cb_obj.x', 'y': 'cb_obj.y'}) - code = 'data["y"] = cb_obj["y"];\ndata["x"] = cb_obj["x"];\n' + code = 'data["x"] = cb_obj["x"];\ndata["y"] = cb_obj["y"];\n' self.assertEqual(js_code, code) def test_customjs_callback_attributes_js_for_cb_data(self): @@ -43,10 +43,10 @@ def test_customjs_callback_attributes_js_for_cb_data(self): 'x1': 'cb_data.geometry.x1', 'y0': 'cb_data.geometry.y0', 'y1': 'cb_data.geometry.y1'}) - code = ('data["y1"] = cb_data["geometry"]["y1"];\n' + code = ('data["x0"] = cb_data["geometry"]["x0"];\n' + 'data["x1"] = cb_data["geometry"]["x1"];\n' 'data["y0"] = cb_data["geometry"]["y0"];\n' - 'data["x0"] = cb_data["geometry"]["x0"];\n' - 'data["x1"] = cb_data["geometry"]["x1"];\n') + 'data["y1"] = cb_data["geometry"]["y1"];\n') self.assertEqual(js_code, code) diff --git a/tests/testbokehwidgets.py b/tests/testbokehwidgets.py index f15efafdb9..b2caf509cd 100644 --- a/tests/testbokehwidgets.py +++ b/tests/testbokehwidgets.py @@ -109,4 +109,4 @@ def test_bokeh_server_dynamic_values_str(self): self.assertEqual(widget.value, 'A') self.assertEqual(widget.options, list(zip(keys, keys))) self.assertEqual(widget.title, dim.pprint_label) - self.assertEqual(mapping, [(k, dim.pprint_value(k)) for k in ndmap.keys()]) + self.assertIs(mapping, None) From 957b96bafb74df5086d6d2074f5944d669e8a32a Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 7 Apr 2017 01:30:02 +0100 Subject: [PATCH 30/30] Improved docstrings for bokeh server features --- holoviews/plotting/bokeh/callbacks.py | 2 +- holoviews/plotting/bokeh/renderer.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/holoviews/plotting/bokeh/callbacks.py b/holoviews/plotting/bokeh/callbacks.py index 7f6d0a1f8e..d73546557d 100644 --- a/holoviews/plotting/bokeh/callbacks.py +++ b/holoviews/plotting/bokeh/callbacks.py @@ -95,7 +95,7 @@ def on_msg(self, msg): def _init_plot_handles(self): """ Find all requested plotting handles and cache them along - with the IDs of the models callbacks will be attached to. + with the IDs of the models the callbacks will be attached to. """ plots = [self.plot] if self.plot.subplots: diff --git a/holoviews/plotting/bokeh/renderer.py b/holoviews/plotting/bokeh/renderer.py index 63b1db2765..7f0b97529b 100644 --- a/holoviews/plotting/bokeh/renderer.py +++ b/holoviews/plotting/bokeh/renderer.py @@ -37,7 +37,9 @@ class BokehRenderer(Renderer): mode = param.ObjectSelector(default='default', objects=['default', 'server'], doc=""" - Whether to render the DynamicMap in regular or server mode. """) + 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'],