diff --git a/holoviews/plotting/bokeh/__init__.py b/holoviews/plotting/bokeh/__init__.py index fcaf55b6f1..e6a9c7d6a0 100644 --- a/holoviews/plotting/bokeh/__init__.py +++ b/holoviews/plotting/bokeh/__init__.py @@ -38,6 +38,7 @@ from .graphs import GraphPlot, NodePlot, TriMeshPlot, ChordPlot from .heatmap import HeatMapPlot, RadialHeatMapPlot from .hex_tiles import HexTilesPlot +from .links import LinkCallback # noqa (API import) from .path import PathPlot, PolygonPlot, ContourPlot from .plot import GridPlot, LayoutPlot, AdjointLayoutPlot from .raster import RasterPlot, RGBPlot, HSVPlot, QuadMeshPlot diff --git a/holoviews/plotting/bokeh/callbacks.py b/holoviews/plotting/bokeh/callbacks.py index 61f7e2f67f..77f35245fb 100644 --- a/holoviews/plotting/bokeh/callbacks.py +++ b/holoviews/plotting/bokeh/callbacks.py @@ -3,25 +3,23 @@ import time from collections import defaultdict -from functools import partial import numpy as np import panel as pn -import param from bokeh.models import ( - CustomJS, FactorRange, DatetimeAxis, ToolbarBox, Range1d, - DataRange1d, PolyDrawTool, BoxEditTool, PolyEditTool, - FreehandDrawTool, PointDrawTool + CustomJS, FactorRange, DatetimeAxis, Range1d, DataRange1d, + PolyDrawTool, BoxEditTool, PolyEditTool, FreehandDrawTool, + PointDrawTool ) from panel.io.state import state -from pyviz_comms import JS_CALLBACK +from panel.io.model import hold from tornado import gen from ...core import OrderedDict from ...core.options import CallbackError from ...core.util import ( - datetime_types, dimension_sanitizer, isscalar, dt64_to_dt + datetime_types, dimension_sanitizer, dt64_to_dt ) from ...element import Table from ...streams import ( @@ -31,8 +29,6 @@ BoxEdit, PointDraw, PolyDraw, PolyEdit, CDSStream, FreehandDraw, CurveEdit, SelectionXY, Lasso, SelectMode ) -from ..links import Link, RectanglesTableLink, DataLink, RangeToolLink, SelectionLink, VertexTableLink -from ..plot import GenericElementPlot, GenericOverlayPlot from .util import bokeh_version, convert_timestamp if bokeh_version >= '2.3.0': @@ -41,25 +37,73 @@ CUSTOM_TOOLTIP = 'custom_tooltip' - -class MessageCallback(object): +class Callback(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. + 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: + + * 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. + + * 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 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_obj variables + available containing additional information about + the event. + + * 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. + + A callback supports three different throttling modes: + + - adaptive (default): The callback adapts the throttling timeout + depending on the rolling mean of the time taken to process each + message. The rolling window is controlled by the `adaptive_window` + value. + - throttle: Uses the fixed `throttle_timeout` as the minimum amount + of time between events. + - debounce: Processes the message only when no new event has been + received within the `throttle_timeout` duration. """ + # Throttling configuration + adaptive_window = 3 + throttle_timeout = 100 + throttling_scheme = 'adaptive' + + # Attributes to sync attributes = {} # 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 = [] + skip_events = [] + skip_changes = [] # Callback will listen to events of the supplied type on the models on_events = [] @@ -67,10 +111,21 @@ class MessageCallback(object): # List of change events on the models to listen to on_changes = [] + # Internal state _callbacks = {} - _transforms = [] + def __init__(self, plot, streams, source, **params): + self.plot = plot + self.streams = streams + self.source = source + self.handle_ids = defaultdict(dict) + self.reset() + self._active = False + self._prev_msg = None + self._last_event = time.time() + self._history = [] + def _transform(self, msg): for transform in self._transforms: msg = transform(msg, self) @@ -83,33 +138,12 @@ def _process_msg(self, msg): """ return self._transform(msg) - def __init__(self, plot, streams, source, **params): - self.plot = plot - self.streams = streams - if plot.renderer.mode == 'server' or pn.config.comms != 'default': - self.comm = None - else: - if plot.pane: - on_error = partial(plot.pane._on_error, plot.root) - else: - on_error = None - self.comm = plot.renderer.comm_manager.get_client_comm(on_msg=self.on_msg) - self.comm._on_error = on_error - self.source = source - self.handle_ids = defaultdict(dict) - self.reset() - def cleanup(self): self.reset() self.handle_ids = None self.plot = None self.source = None self.streams = [] - if self.comm: - try: - self.comm.close() - except: - pass Callback._callbacks = {k: cb for k, cb in Callback._callbacks.items() if cb is not self} @@ -122,11 +156,9 @@ def reset(self): handle = handles[handle_name] cb_hash = (id(handle), id(type(self))) self._callbacks.pop(cb_hash, None) - self.callbacks = [] self.plot_handles = {} self._queue = [] - def _filter_msg(self, msg, ids): """ Filter event values that do not originate from the plotting @@ -142,7 +174,6 @@ def _filter_msg(self, msg, ids): filtered_msg[k] = v return filtered_msg - def on_msg(self, msg): streams = [] for stream in self.streams: @@ -171,7 +202,6 @@ def on_msg(self, msg): for stream in streams: stream._metadata = {} - def _init_plot_handles(self): """ Find all requested plotting handles and cache them along @@ -188,18 +218,12 @@ def _init_plot_handles(self): self.plot_handles = handles requested = {} - for h in self.models+self.extra_models: + for h in self.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." - % (type(self).__name__, h)) self.handle_ids.update(self._get_stream_handle_ids(requested)) - return requested - def _get_stream_handle_ids(self, handles): """ Gather the ids of the plotting handles attached to this callback @@ -214,140 +238,6 @@ def _get_stream_handle_ids(self, handles): 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 = JS_CALLBACK - - code = "" - - # Timeout if a comm message is swallowed - timeout = 20000 - - # Timeout before the first event is processed - debounce = 20 - - @classmethod - def attributes_js(cls, attributes): - """ - 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 sorted(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, assign=assign_str - ) - else: - assign_str = ''.join([data_assign, obj_name, attr_getters, ';\n']) - code += assign_str - return code - - - def get_customjs(self, references, plot_id=None): - """ - Creates a CustomJS callback that will send the requested - attributes back to python. - """ - # Generate callback JS code to get all the requested data - if plot_id is None: - plot_id = self.plot.id or 'PLACEHOLDER_PLOT_ID' - self_callback = self.js_callback.format(comm_id=self.comm.id, - timeout=self.timeout, - debounce=self.debounce, - plot_id=plot_id) - - attributes = self.attributes_js(self.attributes) - 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 set_customjs_callback(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.on_events: - for event in self.on_events: - handle.js_on_event(event, js_callback) - if self.on_changes: - for change in self.on_changes: - handle.js_on_change(change, js_callback) - - -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. - - The ServerCallback supports three different throttling modes: - - - adaptive (default): The callback adapts the throttling timeout - depending on the rolling mean of the time taken to process each - message. The rolling window is controlled by the `adaptive_window` - value. - - throttle: Uses the fixed `throttle_timeout` as the minimum amount - of time between events. - - debounce: Processes the message only when no new event has been - received within the `throttle_timeout` duration. - """ - - adaptive_window = 3 - - throttle_timeout = 100 - - throttling_scheme = 'adaptive' - - skip_events = [] - skip_changes = [] - - def __init__(self, plot, streams, source, **params): - super(ServerCallback, self).__init__(plot, streams, source, **params) - self._active = False - self._prev_msg = None - self._last_event = time.time() - self._history = [] - @classmethod def resolve_attr_spec(cls, spec, cb_obj, model=None): """ @@ -399,8 +289,7 @@ def _schedule_callback(self, cb, timeout=None, offset=True): diff = time.time()-self._last_event timeout = max(timeout-(diff*1000), 50) if not pn.state.curdoc: - from tornado.ioloop import IOLoop - IOLoop.current().call_later(int(timeout)/1000., cb) + cb() else: pn.state.curdoc.add_timeout_callback(cb, int(timeout)) @@ -413,7 +302,11 @@ def on_change(self, attr, old, new): if not self._active and self.plot.document: self._active = True self._set_busy(True) - self._schedule_callback(self.process_on_change, offset=False) + if self.plot.renderer.mode == 'server': + self._schedule_callback(self.process_on_change, offset=False) + else: + with hold(self.plot.document): + self.process_on_change() def on_event(self, event): """ @@ -424,7 +317,11 @@ def on_event(self, event): if not self._active and self.plot.document: self._active = True self._set_busy(True) - self._schedule_callback(self.process_on_event, offset=False) + if self.plot.renderer.mode == 'server': + self._schedule_callback(self.process_on_event, offset=False) + else: + with hold(self.plot.document): + self.process_on_event() def throttled(self): now = time.time() @@ -444,6 +341,9 @@ def throttled(self): return False @gen.coroutine + def process_on_event_coroutine(self): + self.process_on_event() + def process_on_event(self): """ Trigger callback change event and triggering corresponding streams. @@ -453,7 +353,7 @@ def process_on_event(self): self._set_busy(False) return throttled = self.throttled() - if throttled: + if throttled and pn.state.curdoc: self._schedule_callback(self.process_on_event, throttled) return # Get unique event types in the queue @@ -473,9 +373,13 @@ def process_on_event(self): w = self.adaptive_window-1 diff = time.time()-self._last_event self._history = self._history[-w:] + [diff] - self._schedule_callback(self.process_on_event) + if self.plot.renderer.mode == 'server': + self._schedule_callback(self.process_on_event) @gen.coroutine + def process_on_change_coroutine(self): + self.process_on_change() + def process_on_change(self): if not self._queue: self._active = False @@ -513,9 +417,12 @@ def process_on_change(self): self._history = self._history[-w:] + [diff] self._prev_msg = msg - self._schedule_callback(self.process_on_change) + if self.plot.renderer.mode == 'server': + self._schedule_callback(self.process_on_change) + else: + self._active = False - def set_server_callback(self, handle): + def set_callback(self, handle): """ Set up on_change events for bokeh server interactions. """ @@ -529,65 +436,6 @@ def set_server_callback(self, handle): continue handle.on_change(change, self.on_change) - - -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: - - * 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, plot_id=None): handles = self._init_plot_handles() for handle_name in self.models: @@ -610,12 +458,7 @@ def initialize(self, plot_id=None): cb.handle_ids[k].update(v) continue - if self.comm is None: - self.set_server_callback(handle) - else: - js_callback = self.get_customjs(handles, plot_id=plot_id) - self.set_customjs_callback(js_callback, handle) - self.callbacks.append(js_callback) + self.set_callback(handle) self._callbacks[cb_hash] = self @@ -627,38 +470,8 @@ class PointerXYCallback(Callback): attributes = {'x': 'cb_obj.x', 'y': 'cb_obj.y'} models = ['plot'] - extra_models= ['x_range', 'y_range'] - on_events = ['mousemove'] - # Clip x and y values to available axis range - code = """ - if (x_range.type.endsWith('Range1d')) { - var xstart = x_range.start; - var xend = x_range.end; - if (xstart > xend) { - [xstart, xend] = [xend, xstart] - } - if (cb_obj.x < xstart) { - data['x'] = xstart; - } else if (cb_obj.x > xend) { - data['x'] = xend; - } - } - if (y_range.type.endsWith('Range1d')) { - var ystart = y_range.start; - var yend = y_range.end; - if (ystart > yend) { - [ystart, yend] = [yend, ystart] - } - if (cb_obj.y < ystart) { - data['y'] = ystart; - } else if (cb_obj.y > yend) { - data['y'] = yend; - } - } - """ - def _process_out_of_bounds(self, value, start, end): "Clips out of bounds values" if isinstance(value, np.datetime64): @@ -693,10 +506,9 @@ def _process_msg(self, msg): if 'y' in msg and isinstance(yaxis, DatetimeAxis): msg['y'] = convert_timestamp(msg['y']) - server_mode = self.comm is None if isinstance(x_range, FactorRange) and isinstance(msg.get('x'), (int, float)): msg['x'] = x_range.factors[int(msg['x'])] - elif 'x' in msg and isinstance(x_range, (Range1d, DataRange1d)) and server_mode: + elif 'x' in msg and isinstance(x_range, (Range1d, DataRange1d)): xstart, xend = x_range.start, x_range.end if xstart > xend: xstart, xend = xend, xstart @@ -708,7 +520,7 @@ def _process_msg(self, msg): if isinstance(y_range, FactorRange) and isinstance(msg.get('y'), (int, float)): msg['y'] = y_range.factors[int(msg['y'])] - elif 'y' in msg and isinstance(y_range, (Range1d, DataRange1d)) and server_mode: + elif 'y' in msg and isinstance(y_range, (Range1d, DataRange1d)): ystart, yend = y_range.start, y_range.end if ystart > yend: ystart, yend = yend, ystart @@ -727,21 +539,7 @@ class PointerXCallback(PointerXYCallback): """ attributes = {'x': 'cb_obj.x'} - extra_models= ['x_range'] - code = """ - if (x_range.type.endsWith('Range1d')) { - var xstart = x_range.start; - var xend = x_range.end; - if (xstart > xend) { - [xstart, xend] = [xend, xstart] - } - if (cb_obj.x < xstart) { - data['x'] = xstart; - } else if (cb_obj.x > xend) { - data['x'] = xend; - } - } - """ + class PointerYCallback(PointerXYCallback): """ @@ -749,27 +547,11 @@ class PointerYCallback(PointerXYCallback): """ attributes = {'y': 'cb_obj.y'} - extra_models= ['y_range'] - code = """ - if (y_range.type.endsWith('Range1d')) { - var ystart = y_range.start; - var yend = y_range.end; - if (ystart > yend) { - [ystart, yend] = [yend, ystart] - } - if (cb_obj.y < ystart) { - data['y'] = ystart; - } else if (cb_obj.y > yend) { - data['y'] = yend; - } - } - """ + class DrawCallback(PointerXYCallback): on_events = ['pan', 'panstart', 'panend'] models = ['plot'] - extra_models=['pan', 'box_zoom', 'x_range', 'y_range'] - skip = ['pan && pan.attributes.active', 'box_zoom && box_zoom.attributes.active'] attributes = {'x': 'cb_obj.x', 'y': 'cb_obj.y', 'event': 'cb_obj.event_name'} def __init__(self, *args, **kwargs): @@ -791,30 +573,6 @@ class TapCallback(PointerXYCallback): individual tap events within a doubletap event. """ - # Skip if tap is outside axis range - code = """ - if (x_range.type.endsWith('Range1d')) { - var xstart = x_range.start; - var xend = x_range.end; - if (xstart > xend) { - [xstart, xend] = [xend, xstart] - } - if ((cb_obj.x < xstart) || (cb_obj.x > xend)) { - return - } - } - if (y_range.type.endsWith('Range1d')) { - var ystart = y_range.start; - var yend = y_range.end; - if (ystart > yend) { - [ystart, yend] = [yend, ystart] - } - if ((cb_obj.y < ystart) || (cb_obj.y > yend)) { - return - } - } - """ - on_events = ['tap', 'doubletap'] def _process_out_of_bounds(self, value, start, end): @@ -845,6 +603,7 @@ class SingleTapCallback(TapCallback): on_events = ['tap'] + class PressUpCallback(TapCallback): """ Returns the mouse x/y-position of a pressup mouse event. @@ -992,10 +751,8 @@ class BoundsCallback(Callback): 'y0': 'cb_obj.geometry.y0', 'y1': 'cb_obj.geometry.y1'} models = ['plot'] - extra_models = ['box_select'] on_events = ['selectiongeometry'] - skip = ["(cb_obj.geometry.type != 'rect') || (!cb_obj.final)"] skip_events = [lambda event: event.geometry['type'] != 'rect', lambda event: not event.final] @@ -1065,10 +822,8 @@ class BoundsXCallback(Callback): attributes = {'x0': 'cb_obj.geometry.x0', 'x1': 'cb_obj.geometry.x1'} models = ['plot'] - extra_models = ['xbox_select'] on_events = ['selectiongeometry'] - skip = ["(cb_obj.geometry.type != 'rect') || (!cb_obj.final)"] skip_events = [lambda event: event.geometry['type'] != 'rect', lambda event: not event.final] @@ -1090,10 +845,8 @@ class BoundsYCallback(Callback): attributes = {'y0': 'cb_obj.geometry.y0', 'y1': 'cb_obj.geometry.y1'} models = ['plot'] - extra_models = ['ybox_select'] on_events = ['selectiongeometry'] - skip = ["(cb_obj.geometry.type != 'rect') || (!cb_obj.final)"] skip_events = [lambda event: event.geometry['type'] != 'rect', lambda event: not event.final] @@ -1112,10 +865,7 @@ class LassoCallback(Callback): attributes = {'xs': 'cb_obj.geometry.x', 'ys': 'cb_obj.geometry.y'} models = ['plot'] - extra_models = ['lasso_select'] on_events = ['selectiongeometry'] - skip = ["(cb_obj.geometry.type != 'poly') || (!cb_obj.final)"] - skip_events = [lambda event: event.geometry['type'] != 'poly', lambda event: not event.final] @@ -1517,429 +1267,35 @@ def initialize(self, plot_id=None): CDSCallback.initialize(self, plot_id) - -callbacks = Stream._callbacks['bokeh'] - -callbacks[PointerXY] = PointerXYCallback -callbacks[PointerX] = PointerXCallback -callbacks[PointerY] = PointerYCallback -callbacks[Tap] = TapCallback -callbacks[SingleTap] = SingleTapCallback -callbacks[DoubleTap] = DoubleTapCallback -callbacks[PressUp] = PressUpCallback -callbacks[PanEnd] = PanEndCallback -callbacks[MouseEnter] = MouseEnterCallback -callbacks[MouseLeave] = MouseLeaveCallback -callbacks[RangeXY] = RangeXYCallback -callbacks[RangeX] = RangeXCallback -callbacks[RangeY] = RangeYCallback -callbacks[BoundsXY] = BoundsCallback -callbacks[BoundsX] = BoundsXCallback -callbacks[BoundsY] = BoundsYCallback -callbacks[Lasso] = LassoCallback -callbacks[Selection1D] = Selection1DCallback -callbacks[PlotSize] = PlotSizeCallback -callbacks[SelectMode] = SelectModeCallback -callbacks[SelectionXY] = SelectionXYCallback -callbacks[Draw] = DrawCallback -callbacks[PlotReset] = ResetCallback -callbacks[CDSStream] = CDSCallback -callbacks[BoxEdit] = BoxEditCallback -callbacks[PointDraw] = PointDrawCallback -callbacks[CurveEdit] = CurveEditCallback -callbacks[FreehandDraw]= FreehandDrawCallback -callbacks[PolyDraw] = PolyDrawCallback -callbacks[PolyEdit] = PolyEditCallback - - - -class LinkCallback(param.Parameterized): - - source_model = None - target_model = None - source_handles = [] - target_handles = [] - - on_source_events = [] - on_source_changes = [] - - on_target_events = [] - on_target_changes = [] - - source_code = None - target_code = None - - def __init__(self, root_model, link, source_plot, target_plot=None): - self.root_model = root_model - self.link = link - self.source_plot = source_plot - self.target_plot = target_plot - self.validate() - - references = {k: v for k, v in link.param.get_param_values() - if k not in ('source', 'target', 'name')} - - for sh in self.source_handles+[self.source_model]: - key = '_'.join(['source', sh]) - references[key] = source_plot.handles[sh] - - for p, value in link.param.get_param_values(): - if p in ('name', 'source', 'target'): - continue - references[p] = value - - if target_plot is not None: - for sh in self.target_handles+[self.target_model]: - key = '_'.join(['target', sh]) - references[key] = target_plot.handles[sh] - - if self.source_model in source_plot.handles: - src_model = source_plot.handles[self.source_model] - src_cb = CustomJS(args=references, code=self.source_code) - for ch in self.on_source_changes: - src_model.js_on_change(ch, src_cb) - for ev in self.on_source_events: - src_model.js_on_event(ev, src_cb) - self.src_cb = src_cb - else: - self.src_cb = None - - if target_plot is not None and self.target_model in target_plot.handles and self.target_code: - tgt_model = target_plot.handles[self.target_model] - tgt_cb = CustomJS(args=references, code=self.target_code) - for ch in self.on_target_changes: - tgt_model.js_on_change(ch, tgt_cb) - for ev in self.on_target_events: - tgt_model.js_on_event(ev, tgt_cb) - self.tgt_cb = tgt_cb - else: - self.tgt_cb = None - - @classmethod - def find_links(cls, root_plot): - """ - Traverses the supplied plot and searches for any Links on - the plotted objects. - """ - plot_fn = lambda x: isinstance(x, GenericElementPlot) and not isinstance(x, GenericOverlayPlot) - plots = root_plot.traverse(lambda x: x, [plot_fn]) - potentials = [cls.find_link(plot) for plot in plots] - source_links = [p for p in potentials if p is not None] - found = [] - for plot, links in source_links: - for link in links: - if not link._requires_target: - # If link has no target don't look further - found.append((link, plot, None)) - continue - potentials = [cls.find_link(p, link) for p in plots] - tgt_links = [p for p in potentials if p is not None] - if tgt_links: - found.append((link, plot, tgt_links[0][0])) - return found - - @classmethod - def find_link(cls, plot, link=None): - """ - Searches a GenericElementPlot for a Link. - """ - registry = Link.registry.items() - for source in plot.link_sources: - if link is None: - links = [ - l for src, links in registry for l in links - if src is source or (src._plot_id is not None and - src._plot_id == source._plot_id)] - if links: - return (plot, links) - else: - if ((link.target is source) or - (link.target is not None and - link.target._plot_id is not None and - link.target._plot_id == source._plot_id)): - return (plot, [link]) - - def validate(self): - """ - Should be subclassed to check if the source and target plots - are compatible to perform the linking. - """ - - -class RangeToolLinkCallback(LinkCallback): - """ - Attaches a RangeTool to the source plot and links it to the - specified axes on the target plot - """ - - def __init__(self, root_model, link, source_plot, target_plot): - try: - from bokeh.models.tools import RangeTool - except: - raise Exception('RangeToolLink requires bokeh >= 0.13') - toolbars = list(root_model.select({'type': ToolbarBox})) - axes = {} - if 'x' in link.axes: - axes['x_range'] = target_plot.handles['x_range'] - if 'y' in link.axes: - axes['y_range'] = target_plot.handles['y_range'] - tool = RangeTool(**axes) - source_plot.state.add_tools(tool) - if toolbars: - toolbar = toolbars[0].toolbar - toolbar.tools.append(tool) - - -class DataLinkCallback(LinkCallback): - """ - Merges the source and target ColumnDataSource - """ - - def __init__(self, root_model, link, source_plot, target_plot): - src_cds = source_plot.handles['source'] - tgt_cds = target_plot.handles['source'] - if src_cds is tgt_cds: - return - - src_len = [len(v) for v in src_cds.data.values()] - tgt_len = [len(v) for v in tgt_cds.data.values()] - if src_len and tgt_len and (src_len[0] != tgt_len[0]): - raise Exception('DataLink source data length must match target ' - 'data length, found source length of %d and ' - 'target length of %d.' % (src_len[0], tgt_len[0])) - - # Ensure the data sources are compatible (i.e. overlapping columns are equal) - for k, v in tgt_cds.data.items(): - if k not in src_cds.data: - continue - v = np.asarray(v) - col = np.asarray(src_cds.data[k]) - if len(v) and isinstance(v[0], np.ndarray): - continue # Skip ragged arrays - if not ((isscalar(v) and v == col) or - (v.dtype.kind not in 'iufc' and (v==col).all()) or - np.allclose(v, np.asarray(src_cds.data[k]), equal_nan=True)): - raise ValueError('DataLink can only be applied if overlapping ' - 'dimension values are equal, %s column on source ' - 'does not match target' % k) - - src_cds.data.update(tgt_cds.data) - renderer = target_plot.handles.get('glyph_renderer') - if renderer is None: - pass - elif 'data_source' in renderer.properties(): - renderer.update(data_source=src_cds) - else: - renderer.update(source=src_cds) - if hasattr(renderer, 'view'): - renderer.view.update(source=src_cds) - target_plot.handles['source'] = src_cds - target_plot.handles['cds'] = src_cds - for callback in target_plot.callbacks: - callback.initialize(plot_id=root_model.ref['id']) - - -class SelectionLinkCallback(LinkCallback): - - source_model = 'selected' - target_model = 'selected' - - on_source_changes = ['indices'] - on_target_changes = ['indices'] - - source_handles = ['cds'] - target_handles = ['cds'] - - source_code = """ - target_selected.indices = source_selected.indices - target_cds.properties.selected.change.emit() - """ - - target_code = """ - source_selected.indices = target_selected.indices - source_cds.properties.selected.change.emit() - """ - -class RectanglesTableLinkCallback(DataLinkCallback): - - source_model = 'cds' - target_model = 'cds' - - source_handles = ['glyph'] - - on_source_changes = ['selected', 'data'] - on_target_changes = ['patching'] - - source_code = """ - var xs = source_cds.data[source_glyph.x.field] - var ys = source_cds.data[source_glyph.y.field] - var ws = source_cds.data[source_glyph.width.field] - var hs = source_cds.data[source_glyph.height.field] - - var x0 = [] - var x1 = [] - var y0 = [] - var y1 = [] - for (var i = 0; i < xs.length; i++) { - var hw = ws[i]/2. - var hh = hs[i]/2. - x0.push(xs[i]-hw) - x1.push(xs[i]+hw) - y0.push(ys[i]-hh) - y1.push(ys[i]+hh) - } - target_cds.data[columns[0]] = x0 - target_cds.data[columns[1]] = y0 - target_cds.data[columns[2]] = x1 - target_cds.data[columns[3]] = y1 - """ - - target_code = """ - var x0s = target_cds.data[columns[0]] - var y0s = target_cds.data[columns[1]] - var x1s = target_cds.data[columns[2]] - var y1s = target_cds.data[columns[3]] - - var xs = [] - var ys = [] - var ws = [] - var hs = [] - for (var i = 0; i < x0s.length; i++) { - var x0 = Math.min(x0s[i], x1s[i]) - var y0 = Math.min(y0s[i], y1s[i]) - var x1 = Math.max(x0s[i], x1s[i]) - var y1 = Math.max(y0s[i], y1s[i]) - xs.push((x0+x1)/2.) - ys.push((y0+y1)/2.) - ws.push(x1-x0) - hs.push(y1-y0) - } - source_cds.data['x'] = xs - source_cds.data['y'] = ys - source_cds.data['width'] = ws - source_cds.data['height'] = hs - """ - - def __init__(self, root_model, link, source_plot, target_plot=None): - DataLinkCallback.__init__(self, root_model, link, source_plot, target_plot) - LinkCallback.__init__(self, root_model, link, source_plot, target_plot) - columns = [kd.name for kd in source_plot.current_frame.kdims] - self.src_cb.args['columns'] = columns - self.tgt_cb.args['columns'] = columns - - -class VertexTableLinkCallback(LinkCallback): - - source_model = 'cds' - target_model = 'cds' - - on_source_changes = ['selected', 'data', 'patching'] - on_target_changes = ['data', 'patching'] - - source_code = """ - var index = source_cds.selected.indices[0]; - if (index == undefined) { - var xs_column = []; - var ys_column = []; - } else { - var xs_column = source_cds.data['xs'][index]; - var ys_column = source_cds.data['ys'][index]; - } - if (xs_column == undefined) { - var xs_column = []; - var ys_column = []; - } - var xs = [] - var ys = [] - var empty = [] - for (var i = 0; i < xs_column.length; i++) { - xs.push(xs_column[i]) - ys.push(ys_column[i]) - empty.push(null) - } - var [x, y] = vertex_columns - target_cds.data[x] = xs - target_cds.data[y] = ys - var length = xs.length - for (var col in target_cds.data) { - if (vertex_columns.indexOf(col) != -1) { continue; } - else if (col in source_cds.data) { - var path = source_cds.data[col][index]; - if ((path == undefined)) { - var data = empty; - } else if (path.length == length) { - var data = source_cds.data[col][index]; - } else { - var data = empty; - } - } else { - var data = empty; - } - target_cds.data[col] = data; - } - target_cds.change.emit() - target_cds.data = target_cds.data - """ - - target_code = """ - if (!source_cds.selected.indices.length) { return } - var [x, y] = vertex_columns - var xs_column = target_cds.data[x] - var ys_column = target_cds.data[y] - var xs = [] - var ys = [] - var points = [] - for (var i = 0; i < xs_column.length; i++) { - xs.push(xs_column[i]) - ys.push(ys_column[i]) - points.push(i) - } - var index = source_cds.selected.indices[0] - var xpaths = source_cds.data['xs'] - var ypaths = source_cds.data['ys'] - var length = source_cds.data['xs'].length - for (var col in target_cds.data) { - if ((col == x) || (col == y)) { continue; } - if (!(col in source_cds.data)) { - var empty = [] - for (var i = 0; i < length; i++) - empty.push([]) - source_cds.data[col] = empty - } - source_cds.data[col][index] = target_cds.data[col] - for (var p of points) { - for (var pindex = 0; pindex < xpaths.length; pindex++) { - if (pindex != index) { continue } - var xs = xpaths[pindex] - var ys = ypaths[pindex] - var column = source_cds.data[col][pindex] - if (column.length != xs.length) { - for (var ind = 0; ind < xs.length; ind++) { - column.push(null) - } - } - for (var ind = 0; ind < xs.length; ind++) { - if ((xs[ind] == xpaths[index][p]) && (ys[ind] == ypaths[index][p])) { - column[ind] = target_cds.data[col][p] - xs[ind] = xs[p]; - ys[ind] = ys[p]; - } - } - } - } - } - xpaths[index] = xs; - ypaths[index] = ys; - source_cds.change.emit() - source_cds.properties.data.change.emit(); - source_cds.data = source_cds.data - """ - - -callbacks = Link._callbacks['bokeh'] - -callbacks[RangeToolLink] = RangeToolLinkCallback -callbacks[DataLink] = DataLinkCallback -callbacks[SelectionLink] = SelectionLinkCallback -callbacks[VertexTableLink] = VertexTableLinkCallback -callbacks[RectanglesTableLink] = RectanglesTableLinkCallback +Stream._callbacks['bokeh'].update({ + PointerXY : PointerXYCallback, + PointerX : PointerXCallback, + PointerY : PointerYCallback, + Tap : TapCallback, + SingleTap : SingleTapCallback, + DoubleTap : DoubleTapCallback, + PressUp : PressUpCallback, + PanEnd : PanEndCallback, + MouseEnter : MouseEnterCallback, + MouseLeave : MouseLeaveCallback, + RangeXY : RangeXYCallback, + RangeX : RangeXCallback, + RangeY : RangeYCallback, + BoundsXY : BoundsCallback, + BoundsX : BoundsXCallback, + BoundsY : BoundsYCallback, + Lasso : LassoCallback, + Selection1D : Selection1DCallback, + PlotSize : PlotSizeCallback, + SelectionXY : SelectionXYCallback, + Draw : DrawCallback, + PlotReset : ResetCallback, + CDSStream : CDSCallback, + BoxEdit : BoxEditCallback, + PointDraw : PointDrawCallback, + CurveEdit : CurveEditCallback, + FreehandDraw: FreehandDrawCallback, + PolyDraw : PolyDrawCallback, + PolyEdit : PolyEditCallback, + SelectMode : SelectModeCallback +}) diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py index 28f2ce369e..e17138256f 100644 --- a/holoviews/plotting/bokeh/element.py +++ b/holoviews/plotting/bokeh/element.py @@ -38,7 +38,6 @@ from ...util.transform import dim from ..plot import GenericElementPlot, GenericOverlayPlot from ..util import process_cmap, color_intervals, dim_range_key -from .callbacks import PlotSizeCallback from .plot import BokehPlot from .styles import ( base_properties, legend_dimensions, line_properties, mpl_to_bokeh, @@ -252,7 +251,7 @@ def _init_tools(self, element, callbacks=[]): cb_tools, tool_names = [], [] hover = False for cb in callbacks: - for handle in cb.models+cb.extra_models: + for handle in cb.models: if handle and handle in TOOLS_MAP: tool_names.append(handle) if handle == 'hover': @@ -546,20 +545,6 @@ def _plot_properties(self, key, element): if aspect_props['aspect_ratio'] is None: aspect_props['aspect_ratio'] = self.state.aspect_ratio - if self.dynamic and aspect_props['match_aspect']: - # Sync the plot size on dynamic plots to support accurate - # scaling of dimension ranges - plot_size = [s for s in self.streams if isinstance(s, PlotSize)] - callbacks = [c for c in self.callbacks if isinstance(c, PlotSizeCallback)] - if plot_size: - stream = plot_size[0] - elif callbacks: - stream = callbacks[0].streams[0] - else: - stream = PlotSize() - self.callbacks.append(PlotSizeCallback(self, [stream], None)) - stream.add_subscriber(self._update_size) - plot_props = { 'align': self.align, 'margin': self.margin, @@ -582,9 +567,6 @@ def _plot_properties(self, key, element): plot_props['lod_'+lod_prop] = v return plot_props - def _update_size(self, width, height, scale): - self.state.frame_width = width - self.state.frame_height = height def _set_active_tools(self, plot): "Activates the list of active tools" @@ -834,6 +816,7 @@ def _update_ranges(self, element, ranges): xaxis, yaxis = self.handles['xaxis'], self.handles['yaxis'] categorical = isinstance(xaxis, CategoricalAxis) or isinstance(yaxis, CategoricalAxis) datetime = isinstance(xaxis, DatetimeAxis) or isinstance(yaxis, CategoricalAxis) + range_streams = [s for s in self.streams if isinstance(s, RangeXY)] if data_aspect and (categorical or datetime): ax_type = 'categorical' if categorical else 'datetime axes' @@ -856,7 +839,6 @@ def _update_ranges(self, element, ranges): else: frame_aspect = plot.frame_height/plot.frame_width - range_streams = [s for s in self.streams if isinstance(s, RangeXY)] if self.drawn: current_l, current_r = plot.x_range.start, plot.x_range.end current_b, current_t = plot.y_range.start, plot.y_range.end @@ -925,6 +907,8 @@ def _update_ranges(self, element, ranges): box_zoom.match_aspect = True if scroll_zoom: scroll_zoom.zoom_on_axis = False + elif any(rs._triggering for rs in range_streams): + xupdate, yupdate = False, False if not self.drawn or xupdate: self._update_range(x_range, l, r, xfactors, self.invert_xaxis, @@ -2370,13 +2354,13 @@ def initialize_plot(self, ranges=None, plot=None, plots=None): self.drawn = True self.handles['plots'] = plots - self._update_callbacks(self.handles['plot']) if 'plot' in self.handles and not self.tabs: plot = self.handles['plot'] self.handles['xaxis'] = plot.xaxis[0] self.handles['yaxis'] = plot.yaxis[0] self.handles['x_range'] = plot.x_range self.handles['y_range'] = plot.y_range + for cb in self.callbacks: cb.initialize() diff --git a/holoviews/plotting/bokeh/links.py b/holoviews/plotting/bokeh/links.py new file mode 100644 index 0000000000..77813ce826 --- /dev/null +++ b/holoviews/plotting/bokeh/links.py @@ -0,0 +1,404 @@ +from __future__ import absolute_import, division, unicode_literals + +import numpy as np + +from bokeh.models import CustomJS, ToolbarBox + +from ...core.util import isscalar +from ..links import ( + Link, RectanglesTableLink, DataLink, RangeToolLink, + SelectionLink, VertexTableLink +) +from ..plot import GenericElementPlot, GenericOverlayPlot + + +class LinkCallback(object): + + source_model = None + target_model = None + source_handles = [] + target_handles = [] + + on_source_events = [] + on_source_changes = [] + + on_target_events = [] + on_target_changes = [] + + source_code = None + target_code = None + + def __init__(self, root_model, link, source_plot, target_plot=None): + self.root_model = root_model + self.link = link + self.source_plot = source_plot + self.target_plot = target_plot + self.validate() + + references = {k: v for k, v in link.param.get_param_values() + if k not in ('source', 'target', 'name')} + + for sh in self.source_handles+[self.source_model]: + key = '_'.join(['source', sh]) + references[key] = source_plot.handles[sh] + + for p, value in link.param.get_param_values(): + if p in ('name', 'source', 'target'): + continue + references[p] = value + + if target_plot is not None: + for sh in self.target_handles+[self.target_model]: + key = '_'.join(['target', sh]) + references[key] = target_plot.handles[sh] + + if self.source_model in source_plot.handles: + src_model = source_plot.handles[self.source_model] + src_cb = CustomJS(args=references, code=self.source_code) + for ch in self.on_source_changes: + src_model.js_on_change(ch, src_cb) + for ev in self.on_source_events: + src_model.js_on_event(ev, src_cb) + self.src_cb = src_cb + else: + self.src_cb = None + + if target_plot is not None and self.target_model in target_plot.handles and self.target_code: + tgt_model = target_plot.handles[self.target_model] + tgt_cb = CustomJS(args=references, code=self.target_code) + for ch in self.on_target_changes: + tgt_model.js_on_change(ch, tgt_cb) + for ev in self.on_target_events: + tgt_model.js_on_event(ev, tgt_cb) + self.tgt_cb = tgt_cb + else: + self.tgt_cb = None + + @classmethod + def find_links(cls, root_plot): + """ + Traverses the supplied plot and searches for any Links on + the plotted objects. + """ + plot_fn = lambda x: isinstance(x, GenericElementPlot) and not isinstance(x, GenericOverlayPlot) + plots = root_plot.traverse(lambda x: x, [plot_fn]) + potentials = [cls.find_link(plot) for plot in plots] + source_links = [p for p in potentials if p is not None] + found = [] + for plot, links in source_links: + for link in links: + if not link._requires_target: + # If link has no target don't look further + found.append((link, plot, None)) + continue + potentials = [cls.find_link(p, link) for p in plots] + tgt_links = [p for p in potentials if p is not None] + if tgt_links: + found.append((link, plot, tgt_links[0][0])) + return found + + @classmethod + def find_link(cls, plot, link=None): + """ + Searches a GenericElementPlot for a Link. + """ + registry = Link.registry.items() + for source in plot.link_sources: + if link is None: + links = [ + l for src, links in registry for l in links + if src is source or (src._plot_id is not None and + src._plot_id == source._plot_id)] + if links: + return (plot, links) + else: + if ((link.target is source) or + (link.target is not None and + link.target._plot_id is not None and + link.target._plot_id == source._plot_id)): + return (plot, [link]) + + def validate(self): + """ + Should be subclassed to check if the source and target plots + are compatible to perform the linking. + """ + + +class RangeToolLinkCallback(LinkCallback): + """ + Attaches a RangeTool to the source plot and links it to the + specified axes on the target plot + """ + + def __init__(self, root_model, link, source_plot, target_plot): + try: + from bokeh.models.tools import RangeTool + except: + raise Exception('RangeToolLink requires bokeh >= 0.13') + toolbars = list(root_model.select({'type': ToolbarBox})) + axes = {} + if 'x' in link.axes: + axes['x_range'] = target_plot.handles['x_range'] + if 'y' in link.axes: + axes['y_range'] = target_plot.handles['y_range'] + tool = RangeTool(**axes) + source_plot.state.add_tools(tool) + if toolbars: + toolbar = toolbars[0].toolbar + toolbar.tools.append(tool) + + +class DataLinkCallback(LinkCallback): + """ + Merges the source and target ColumnDataSource + """ + + def __init__(self, root_model, link, source_plot, target_plot): + src_cds = source_plot.handles['source'] + tgt_cds = target_plot.handles['source'] + if src_cds is tgt_cds: + return + + src_len = [len(v) for v in src_cds.data.values()] + tgt_len = [len(v) for v in tgt_cds.data.values()] + if src_len and tgt_len and (src_len[0] != tgt_len[0]): + raise Exception('DataLink source data length must match target ' + 'data length, found source length of %d and ' + 'target length of %d.' % (src_len[0], tgt_len[0])) + + # Ensure the data sources are compatible (i.e. overlapping columns are equal) + for k, v in tgt_cds.data.items(): + if k not in src_cds.data: + continue + v = np.asarray(v) + col = np.asarray(src_cds.data[k]) + if len(v) and isinstance(v[0], np.ndarray): + continue # Skip ragged arrays + if not ((isscalar(v) and v == col) or + (v.dtype.kind not in 'iufc' and (v==col).all()) or + np.allclose(v, np.asarray(src_cds.data[k]), equal_nan=True)): + raise ValueError('DataLink can only be applied if overlapping ' + 'dimension values are equal, %s column on source ' + 'does not match target' % k) + + src_cds.data.update(tgt_cds.data) + renderer = target_plot.handles.get('glyph_renderer') + if renderer is None: + pass + elif 'data_source' in renderer.properties(): + renderer.update(data_source=src_cds) + else: + renderer.update(source=src_cds) + if hasattr(renderer, 'view'): + renderer.view.update(source=src_cds) + target_plot.handles['source'] = src_cds + target_plot.handles['cds'] = src_cds + for callback in target_plot.callbacks: + callback.initialize(plot_id=root_model.ref['id']) + + +class SelectionLinkCallback(LinkCallback): + + source_model = 'selected' + target_model = 'selected' + + on_source_changes = ['indices'] + on_target_changes = ['indices'] + + source_handles = ['cds'] + target_handles = ['cds'] + + source_code = """ + target_selected.indices = source_selected.indices + target_cds.properties.selected.change.emit() + """ + + target_code = """ + source_selected.indices = target_selected.indices + source_cds.properties.selected.change.emit() + """ + +class RectanglesTableLinkCallback(DataLinkCallback): + + source_model = 'cds' + target_model = 'cds' + + source_handles = ['glyph'] + + on_source_changes = ['selected', 'data'] + on_target_changes = ['patching'] + + source_code = """ + var xs = source_cds.data[source_glyph.x.field] + var ys = source_cds.data[source_glyph.y.field] + var ws = source_cds.data[source_glyph.width.field] + var hs = source_cds.data[source_glyph.height.field] + + var x0 = [] + var x1 = [] + var y0 = [] + var y1 = [] + for (var i = 0; i < xs.length; i++) { + var hw = ws[i]/2. + var hh = hs[i]/2. + x0.push(xs[i]-hw) + x1.push(xs[i]+hw) + y0.push(ys[i]-hh) + y1.push(ys[i]+hh) + } + target_cds.data[columns[0]] = x0 + target_cds.data[columns[1]] = y0 + target_cds.data[columns[2]] = x1 + target_cds.data[columns[3]] = y1 + """ + + target_code = """ + var x0s = target_cds.data[columns[0]] + var y0s = target_cds.data[columns[1]] + var x1s = target_cds.data[columns[2]] + var y1s = target_cds.data[columns[3]] + + var xs = [] + var ys = [] + var ws = [] + var hs = [] + for (var i = 0; i < x0s.length; i++) { + var x0 = Math.min(x0s[i], x1s[i]) + var y0 = Math.min(y0s[i], y1s[i]) + var x1 = Math.max(x0s[i], x1s[i]) + var y1 = Math.max(y0s[i], y1s[i]) + xs.push((x0+x1)/2.) + ys.push((y0+y1)/2.) + ws.push(x1-x0) + hs.push(y1-y0) + } + source_cds.data['x'] = xs + source_cds.data['y'] = ys + source_cds.data['width'] = ws + source_cds.data['height'] = hs + """ + + def __init__(self, root_model, link, source_plot, target_plot=None): + DataLinkCallback.__init__(self, root_model, link, source_plot, target_plot) + LinkCallback.__init__(self, root_model, link, source_plot, target_plot) + columns = [kd.name for kd in source_plot.current_frame.kdims] + self.src_cb.args['columns'] = columns + self.tgt_cb.args['columns'] = columns + + +class VertexTableLinkCallback(LinkCallback): + + source_model = 'cds' + target_model = 'cds' + + on_source_changes = ['selected', 'data', 'patching'] + on_target_changes = ['data', 'patching'] + + source_code = """ + var index = source_cds.selected.indices[0]; + if (index == undefined) { + var xs_column = []; + var ys_column = []; + } else { + var xs_column = source_cds.data['xs'][index]; + var ys_column = source_cds.data['ys'][index]; + } + if (xs_column == undefined) { + var xs_column = []; + var ys_column = []; + } + var xs = [] + var ys = [] + var empty = [] + for (var i = 0; i < xs_column.length; i++) { + xs.push(xs_column[i]) + ys.push(ys_column[i]) + empty.push(null) + } + var [x, y] = vertex_columns + target_cds.data[x] = xs + target_cds.data[y] = ys + var length = xs.length + for (var col in target_cds.data) { + if (vertex_columns.indexOf(col) != -1) { continue; } + else if (col in source_cds.data) { + var path = source_cds.data[col][index]; + if ((path == undefined)) { + var data = empty; + } else if (path.length == length) { + var data = source_cds.data[col][index]; + } else { + var data = empty; + } + } else { + var data = empty; + } + target_cds.data[col] = data; + } + target_cds.change.emit() + target_cds.data = target_cds.data + """ + + target_code = """ + if (!source_cds.selected.indices.length) { return } + var [x, y] = vertex_columns + var xs_column = target_cds.data[x] + var ys_column = target_cds.data[y] + var xs = [] + var ys = [] + var points = [] + for (var i = 0; i < xs_column.length; i++) { + xs.push(xs_column[i]) + ys.push(ys_column[i]) + points.push(i) + } + var index = source_cds.selected.indices[0] + var xpaths = source_cds.data['xs'] + var ypaths = source_cds.data['ys'] + var length = source_cds.data['xs'].length + for (var col in target_cds.data) { + if ((col == x) || (col == y)) { continue; } + if (!(col in source_cds.data)) { + var empty = [] + for (var i = 0; i < length; i++) + empty.push([]) + source_cds.data[col] = empty + } + source_cds.data[col][index] = target_cds.data[col] + for (var p of points) { + for (var pindex = 0; pindex < xpaths.length; pindex++) { + if (pindex != index) { continue } + var xs = xpaths[pindex] + var ys = ypaths[pindex] + var column = source_cds.data[col][pindex] + if (column.length != xs.length) { + for (var ind = 0; ind < xs.length; ind++) { + column.push(null) + } + } + for (var ind = 0; ind < xs.length; ind++) { + if ((xs[ind] == xpaths[index][p]) && (ys[ind] == ypaths[index][p])) { + column[ind] = target_cds.data[col][p] + xs[ind] = xs[p]; + ys[ind] = ys[p]; + } + } + } + } + } + xpaths[index] = xs; + ypaths[index] = ys; + source_cds.change.emit() + source_cds.properties.data.change.emit(); + source_cds.data = source_cds.data + """ + + +callbacks = Link._callbacks['bokeh'] + +callbacks[RangeToolLink] = RangeToolLinkCallback +callbacks[DataLink] = DataLinkCallback +callbacks[SelectionLink] = SelectionLinkCallback +callbacks[VertexTableLink] = VertexTableLinkCallback +callbacks[RectanglesTableLink] = RectanglesTableLinkCallback diff --git a/holoviews/plotting/bokeh/plot.py b/holoviews/plotting/bokeh/plot.py index a7e4280bab..3b6a8696fb 100644 --- a/holoviews/plotting/bokeh/plot.py +++ b/holoviews/plotting/bokeh/plot.py @@ -30,11 +30,10 @@ CallbackPlot ) from ..util import attach_streams, displayable, collate -from .callbacks import LinkCallback +from .links import LinkCallback from .util import ( - TOOL_TYPES, filter_toolboxes, make_axis, update_shared_sources, - empty_plot, decode_bytes, theme_attr_json, cds_column_replace, - get_default + filter_toolboxes, make_axis, update_shared_sources, empty_plot, + decode_bytes, theme_attr_json, cds_column_replace, get_default ) @@ -168,25 +167,6 @@ def _update_datasource(self, source, data): self._update_selected(source) - def _update_callbacks(self, plot): - """ - Iterates over all subplots and updates existing CustomJS - callbacks with models that were replaced when compositing - subplots into a CompositePlot and sets the plot id to match - the root level bokeh model. - """ - subplots = self.traverse(lambda x: x, [GenericElementPlot]) - merged_tools = {t: list(plot.select({'type': TOOL_TYPES[t]})) - for t in self._merged_tools} - for subplot in subplots: - for cb in subplot.callbacks: - for c in cb.callbacks: - for tool, objs in merged_tools.items(): - if tool in c.args and objs: - c.args[tool] = objs[0] - if self.top_level: - c.code = c.code.replace('PLACEHOLDER_PLOT_ID', self.id) - @property def state(self): """ @@ -615,7 +595,6 @@ def initialize_plot(self, ranges=None, plots=[]): self.handles['plot'] = plot self.handles['plots'] = plots - self._update_callbacks(plot) if self.shared_datasource: self.sync_sources() @@ -988,7 +967,6 @@ def initialize_plot(self, plots=None, ranges=None): self.handles['plot'] = layout_plot self.handles['plots'] = plots - self._update_callbacks(layout_plot) if self.shared_datasource: self.sync_sources() diff --git a/holoviews/plotting/renderer.py b/holoviews/plotting/renderer.py index 5d48d8c386..3596361758 100644 --- a/holoviews/plotting/renderer.py +++ b/holoviews/plotting/renderer.py @@ -26,6 +26,7 @@ from panel import config from panel.io.notebook import ipywidget, load_notebook, render_model, render_mimebundle from panel.io.state import state +from panel.models.comm_manager import CommManager as PnCommManager from panel.pane import HoloViews as HoloViewsPane from panel.widgets.player import PlayerBase from panel.viewable import Viewable @@ -186,7 +187,6 @@ def __init__(self, **params): self.last_plot = None super(Renderer, self).__init__(**params) - def __call__(self, obj, fmt='auto', **kwargs): plot, fmt = self._validate(obj, fmt) info = {'file-ext': fmt, 'mime_type': MIME_TYPES[fmt]} @@ -202,7 +202,6 @@ def __call__(self, obj, fmt='auto', **kwargs): data = self._apply_post_render_hooks(data, obj, fmt) return data, info - @bothmethod def get_plot(self_or_cls, obj, doc=None, renderer=None, comm=None, **kwargs): """ @@ -268,7 +267,6 @@ def get_plot(self_or_cls, obj, doc=None, renderer=None, comm=None, **kwargs): plot.document = doc return plot - @bothmethod def get_plot_state(self_or_cls, obj, renderer=None, **kwargs): """ @@ -278,7 +276,6 @@ def get_plot_state(self_or_cls, obj, renderer=None, **kwargs): obj = self_or_cls.get_plot(obj, renderer, **kwargs) return obj.state - def _validate(self, obj, fmt, **kwargs): """ Helper method to be used in the __call__ method to get a @@ -320,7 +317,6 @@ def _validate(self, obj, fmt, **kwargs): self.last_plot = plot return plot, fmt - def _apply_post_render_hooks(self, data, obj, fmt): """ Apply the post-render hooks to the data. @@ -334,7 +330,6 @@ def _apply_post_render_hooks(self, data, obj, fmt): "be applied:\n\n %s" % (hook, e)) return data - def html(self, obj, fmt=None, css=None, resources='CDN', **kwargs): """ Renders plot or data structure and wraps the output in HTML. @@ -375,7 +370,6 @@ def html(self, obj, fmt=None, css=None, resources='CDN', **kwargs): html = tag.format(src=src, mime_type=mime_type, css=css) return html - def components(self, obj, fmt=None, comm=True, **kwargs): """ Returns data and metadata dictionaries containing HTML and JS @@ -387,70 +381,63 @@ def components(self, obj, fmt=None, comm=True, **kwargs): else: plot, fmt = self._validate(obj, fmt) - data, metadata = {}, {} - if isinstance(plot, Viewable): - registry = list(Stream.registry.items()) - objects = plot.object.traverse(lambda x: x) - dynamic, streams = False, False - for source in objects: - dynamic |= isinstance(source, DynamicMap) - streams |= any( - src is source or (src._plot_id is not None and src._plot_id == source._plot_id) - for src, streams in registry for s in streams - ) - embed = (not (dynamic or streams or self.widget_mode == 'live') or config.embed) - - # This part should be factored out in Panel and then imported - # here for HoloViews 2.0, which will be able to require a - # recent Panel version. - if embed or config.comms == 'default': - comm = self.comm_manager.get_server_comm() if comm else None - doc = Document() - with config.set(embed=embed): - model = plot.layout._render_model(doc, comm) - if embed: - return render_model(model, comm) - args = (model, doc, comm) - if panel_version > '0.9.3': - from panel.models.comm_manager import CommManager - ref = model.ref['id'] - manager = CommManager(comm_id=comm.id, plot_id=ref) - client_comm = self.comm_manager.get_client_comm( - on_msg=partial(plot._on_msg, ref, manager), - on_error=partial(plot._on_error, ref), - on_stdout=partial(plot._on_stdout, ref) - ) - manager.client_comm_id = client_comm.id - args = args + (manager,) - return render_mimebundle(*args) - - # Handle rendering object as ipywidget - widget = ipywidget(plot, combine_events=True) - if hasattr(widget, '_repr_mimebundle_'): - return widget._repr_mimebundle() - plaintext = repr(widget) - if len(plaintext) > 110: - plaintext = plaintext[:110] + '…' - data = { - 'text/plain': plaintext, - } - if widget._view_name is not None: - data['application/vnd.jupyter.widget-view+json'] = { - 'version_major': 2, - 'version_minor': 0, - 'model_id': widget._model_id - } - if config.comms == 'vscode': - # Unfortunately VSCode does not yet handle _repr_mimebundle_ - from IPython.display import display - display(data, raw=True) - return {'text/html': '
'}, {} - return data, {} - else: + if not isinstance(plot, Viewable): html = self._figure_data(plot, fmt, as_script=True, **kwargs) - data['text/html'] = html - - return (data, {MIME_TYPES['jlab-hv-exec']: metadata}) + return {'text/html': html}, {MIME_TYPES['jlab-hv-exec']: {}} + + registry = list(Stream.registry.items()) + objects = plot.object.traverse(lambda x: x) + dynamic, streams = False, False + for source in objects: + dynamic |= isinstance(source, DynamicMap) + streams |= any( + src is source or (src._plot_id is not None and src._plot_id == source._plot_id) + for src, streams in registry for s in streams + ) + embed = (not (dynamic or streams or self.widget_mode == 'live') or config.embed) + + if embed or config.comms == 'default': + return self._render_panel(plot, embed, comm) + return self._render_ipywidget(plot) + + def _render_panel(self, plot, embed=False, comm=True): + comm = self.comm_manager.get_server_comm() if comm else None + doc = Document() + with config.set(embed=embed): + model = plot.layout._render_model(doc, comm) + if embed: + return render_model(model, comm) + ref = model.ref['id'] + manager = PnCommManager(comm_id=comm.id, plot_id=ref) + client_comm = self.comm_manager.get_client_comm( + on_msg=partial(plot._on_msg, ref, manager), + on_error=partial(plot._on_error, ref), + on_stdout=partial(plot._on_stdout, ref) + ) + manager.client_comm_id = client_comm.id + return render_mimebundle(model, doc, comm, manager) + + def _render_ipywidget(self, plot): + # Handle rendering object as ipywidget + widget = ipywidget(plot, combine_events=True) + if hasattr(widget, '_repr_mimebundle_'): + return widget._repr_mimebundle() + plaintext = repr(widget) + if len(plaintext) > 110: + plaintext = plaintext[:110] + '…' + data = {'text/plain': plaintext} + if widget._view_name is not None: + data['application/vnd.jupyter.widget-view+json'] = { + 'version_major': 2, + 'version_minor': 0, + 'model_id': widget._model_id + } + if config.comms == 'vscode': + # Unfortunately VSCode does not yet handle _repr_mimebundle_ + from IPython.display import display + display(data, raw=True) + return {'text/html': '
'}, {} + return data, {} def static_html(self, obj, fmt=None, template=None): """ @@ -463,7 +450,6 @@ def static_html(self, obj, fmt=None, template=None): html_bytes.seek(0) return html_bytes.read() - @bothmethod def get_widget(self_or_cls, plot, widget_type, **kwargs): if widget_type == 'scrubber': @@ -479,7 +465,6 @@ def get_widget(self_or_cls, plot, widget_type, **kwargs): player.interval = interval return layout - @bothmethod def export_widgets(self_or_cls, obj, filename, fmt=None, template=None, json=False, json_path='', **kwargs): @@ -496,7 +481,6 @@ def export_widgets(self_or_cls, obj, filename, fmt=None, template=None, "registered widget types.") self_or_cls.get_widget(obj, fmt).save(filename) - @bothmethod def _widget_kwargs(self_or_cls): if self_or_cls.holomap in ('auto', 'widgets'): @@ -507,7 +491,6 @@ def _widget_kwargs(self_or_cls): loc = self_or_cls.widget_location or 'bottom' return {'widget_location': loc, 'widget_type': widget_type, 'center': True} - @bothmethod def app(self_or_cls, plot, show=False, new_window=False, websocket_origin=None, port=0): """ @@ -533,7 +516,6 @@ def app(self_or_cls, plot, show=False, new_window=False, websocket_origin=None, kwargs = {'notebook_url': websocket_origin} if websocket_origin else {} return pane.app(port=port, **kwargs) - @bothmethod def server_doc(self_or_cls, obj, doc=None): """ @@ -546,7 +528,6 @@ def server_doc(self_or_cls, obj, doc=None): **self_or_cls._widget_kwargs()) return obj.layout.server_doc(doc) - @classmethod def plotting_class(cls, obj): """ @@ -566,16 +547,6 @@ class needed to render it with the current renderer. "found".format(element_type.__name__)) return plotclass - - @classmethod - def html_assets(cls, core=True, extras=True, backends=None, script=False): - """ - Deprecated: No longer needed - """ - param.main.warning("Renderer.html_assets is deprecated as all " - "JS and CSS dependencies are now handled by " - "Panel.") - @classmethod def plot_options(cls, obj, percent_size): """ @@ -588,7 +559,6 @@ def plot_options(cls, obj, percent_size): """ raise NotImplementedError - @bothmethod def save(self_or_cls, obj, basename, fmt='auto', key={}, info={}, options=None, resources='inline', title=None, **kwargs): @@ -644,7 +614,6 @@ def _save_prefix(self_or_cls, ext): "Hook to prefix content for instance JS when saving HTML" return - @bothmethod def get_size(self_or_cls, plot): """ @@ -665,7 +634,6 @@ def state(cls): """ yield - @classmethod def validate(cls, options): """ @@ -673,7 +641,6 @@ def validate(cls, options): """ return options - @classmethod def load_nb(cls, inline=True): """ @@ -692,7 +659,6 @@ def load_nb(cls, inline=True): cls.comm_manager = JupyterCommManager state._comm_manager = JupyterCommManager - @classmethod def _delete_plot(cls, plot_id): """ diff --git a/holoviews/tests/plotting/bokeh/testcallbacks.py b/holoviews/tests/plotting/bokeh/testcallbacks.py index 556b0fc6ce..7eb808531b 100644 --- a/holoviews/tests/plotting/bokeh/testcallbacks.py +++ b/holoviews/tests/plotting/bokeh/testcallbacks.py @@ -4,16 +4,17 @@ from unittest import SkipTest import numpy as np +import pyviz_comms as comms -from holoviews.core import DynamicMap, NdOverlay +from holoviews.core import DynamicMap from holoviews.core.options import Store from holoviews.core.util import pd from holoviews.element import Points, Polygons, Box, Curve, Table, Rectangles from holoviews.element.comparison import ComparisonTestCase -from holoviews.streams import (PointDraw, PolyDraw, PolyEdit, BoxEdit, - PointerXY, PointerX, PlotReset, Selection1D, - RangeXY, PlotSize, CDSStream, SingleTap) -import pyviz_comms as comms +from holoviews.streams import ( + PointDraw, PolyDraw, PolyEdit, BoxEdit, PointerXY, PointerX, + PlotReset, Selection1D, RangeXY, PlotSize, CDSStream, SingleTap +) try: from bokeh.events import Tap @@ -21,7 +22,7 @@ from bokeh.models import Range1d, Plot, ColumnDataSource, Selection, PolyEditTool from holoviews.plotting.bokeh.callbacks import ( Callback, PointDrawCallback, PolyDrawCallback, PolyEditCallback, - BoxEditCallback, Selection1DCallback, PointerXCallback, TapCallback + BoxEditCallback, PointerXCallback, TapCallback ) from holoviews.plotting.bokeh.renderer import BokehRenderer bokeh_server_renderer = BokehRenderer.instance(mode='server') @@ -193,14 +194,6 @@ def test_point_draw_callback_initialized_server(self): self.assertEqual(plot.handles['source']._callbacks, {'data': [plot.callbacks[0].on_change]}) - def test_point_draw_callback_initialized_js(self): - points = Points([(0, 1)]) - PointDraw(source=points) - plot = bokeh_renderer.get_plot(points) - cb = plot.callbacks[0].callbacks[0] - self.assertEqual(plot.handles['source'].js_property_callbacks, - {'change:data': [cb], 'patching': [cb]}) - def test_point_draw_callback_with_vdims_initialization(self): points = Points([(0, 1, 'A')], vdims=['A']) stream = PointDraw(source=points) @@ -236,14 +229,6 @@ def test_poly_draw_callback_initialized_server(self): self.assertEqual(plot.handles['source']._callbacks, {'data': [plot.callbacks[0].on_change]}) - def test_poly_draw_callback_initialized_js(self): - polys = Polygons([[(0, 0), (2, 2), (4, 0)]]) - PolyDraw(source=polys) - plot = bokeh_renderer.get_plot(polys) - cb = plot.callbacks[0].callbacks[0] - self.assertEqual(plot.handles['source'].js_property_callbacks, - {'change:data': [cb], 'patching': [cb]}) - def test_poly_draw_callback_with_vdims(self): polys = Polygons([{'x': [0, 2, 4], 'y': [0, 2, 0], 'A': 1}], vdims=['A']) poly_draw = PolyDraw(source=polys) @@ -307,14 +292,6 @@ def test_box_edit_callback_initialized_server(self): self.assertEqual(plot.handles['cds']._callbacks, {'data': [plot.callbacks[0].on_change]}) - def test_box_edit_callback_initialized_js(self): - boxes = Polygons([Box(0, 0, 1)]) - BoxEdit(source=boxes) - plot = bokeh_renderer.get_plot(boxes) - cb = plot.callbacks[0].callbacks[0] - self.assertEqual(plot.handles['cds'].js_property_callbacks, - {'change:data': [cb], 'patching': [cb]}) - def test_poly_edit_callback(self): polys = Polygons([[(0, 0), (2, 2), (4, 0)]]) poly_edit = PolyEdit(source=polys) @@ -333,14 +310,6 @@ def test_poly_edit_callback_initialized_server(self): self.assertEqual(plot.handles['source']._callbacks, {'data': [plot.callbacks[0].on_change]}) - def test_poly_edit_callback_initialized_js(self): - polys = Polygons([[(0, 0), (2, 2), (4, 0)]]) - PolyEdit(source=polys) - plot = bokeh_renderer.get_plot(polys) - cb = plot.callbacks[0].callbacks[0] - self.assertEqual(plot.handles['source'].js_property_callbacks, - {'change:data': [cb], 'patching': [cb]}) - def test_poly_edit_shared_callback(self): polys = Polygons([[(0, 0), (2, 2), (4, 0)]]) polys2 = Polygons([[(0, 0), (2, 2), (4, 0)]]) @@ -372,9 +341,6 @@ def test_point_draw_shared_datasource_callback(self): point_plot = plot.subplots[(0, 0)].subplots['main'] table_plot = plot.subplots[(0, 1)].subplots['main'] self.assertIs(point_plot.handles['source'], table_plot.handles['source']) - self.assertIn(plot.id, point_plot.callbacks[0].callbacks[0].code) - self.assertNotIn('PLACEHOLDER_PLOT_ID', point_plot.callbacks[0].callbacks[0].code) - class TestServerCallbacks(CallbackTestCase): @@ -493,55 +459,3 @@ def test_rangexy_framewise_not_reset_if_triggering(self): )) stream.event(x_range=(0, 3)) self.assertEqual(stream.x_range, (0, 3)) - - - -class TestBokehCustomJSCallbacks(CallbackTestCase): - - 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["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): - 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["x0"] = cb_data["geometry"]["x0"];\n' - 'data["x1"] = cb_data["geometry"]["x1"];\n' - 'data["y0"] = cb_data["geometry"]["y0"];\n' - 'data["y1"] = cb_data["geometry"]["y1"];\n') - self.assertEqual(js_code, code) - - def test_callback_on_ndoverlay_is_attached(self): - ndoverlay = NdOverlay({i: Curve([i]) for i in range(5)}) - selection = Selection1D(source=ndoverlay) - plot = bokeh_renderer.get_plot(ndoverlay) - self.assertEqual(len(plot.callbacks), 1) - self.assertIsInstance(plot.callbacks[0], Selection1DCallback) - self.assertIn(selection, plot.callbacks[0].streams) - - def test_callback_on_table_is_attached(self): - table = Table([1, 2, 3], 'x') - selection = Selection1D(source=table) - plot = bokeh_renderer.get_plot(table) - self.assertEqual(len(plot.callbacks), 1) - self.assertIsInstance(plot.callbacks[0], Selection1DCallback) - self.assertIn(selection, plot.callbacks[0].streams) - callbacks = plot.handles['selected'].js_property_callbacks - self.assertIn('change:indices', callbacks) - self.assertIn(plot.id, callbacks['change:indices'][0].code) diff --git a/setup.py b/setup.py index 8b75487a54..25a1be141e 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ "param >=1.9.3,<2.0", "numpy >=1.0", "pyviz_comms >=0.7.4", - "panel >=0.8.0", + "panel >=0.9.5", "colorcet", "pandas >=0.20.0", ] @@ -40,7 +40,7 @@ "streamz >=0.5.0", "datashader >=0.11.1", "ffmpeg", - "cftime", + "cftime <1.5.0", # cftime.utime deprecated "netcdf4", "dask", "scipy",