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'],