import uuid from importlib import import_module import os import numbers try: from urllib import parse except ImportError: from urlparse import urlparse as parse import ipywidgets as widgets from traitlets import List, Unicode, Dict, observe, Integer from .basedatatypes import BaseFigure, BasePlotlyType from .callbacks import BoxSelector, LassoSelector, InputDeviceState, Points from .serializers import custom_serializers from .version import __frontend_version__ @widgets.register class BaseFigureWidget(BaseFigure, widgets.DOMWidget): """ Base class for FigureWidget. The FigureWidget class is code-generated as a subclass """ # Widget Traits # ------------- # Widget traitlets are automatically synchronized with the FigureModel # JavaScript object _view_name = Unicode("FigureView").tag(sync=True) _view_module = Unicode("jupyterlab-plotly").tag(sync=True) _view_module_version = Unicode(__frontend_version__).tag(sync=True) _model_name = Unicode("FigureModel").tag(sync=True) _model_module = Unicode("jupyterlab-plotly").tag(sync=True) _model_module_version = Unicode(__frontend_version__).tag(sync=True) # ### _data and _layout ### # These properties store the current state of the traces and # layout as JSON-style dicts. These dicts do not store any subclasses of # `BasePlotlyType` # # Note: These are only automatically synced with the frontend on full # assignment, not on mutation. We use this fact to only directly sync # them to the front-end on FigureWidget construction. All other updates # are made using mutation, and they are manually synced to the frontend # using the relayout/restyle/update/etc. messages. _layout = Dict().tag(sync=True, **custom_serializers) _data = List().tag(sync=True, **custom_serializers) _config = Dict().tag(sync=True, **custom_serializers) # ### Python -> JS message properties ### # These properties are used to send messages from Python to the # frontend. Messages are sent by assigning the message contents to the # appropriate _py2js_* property and then immediatly assigning None to the # property. # # See JSDoc comments in the FigureModel class in js/src/Figure.js for # detailed descriptions of the messages. _py2js_addTraces = Dict(allow_none=True).tag(sync=True, **custom_serializers) _py2js_restyle = Dict(allow_none=True).tag(sync=True, **custom_serializers) _py2js_relayout = Dict(allow_none=True).tag(sync=True, **custom_serializers) _py2js_update = Dict(allow_none=True).tag(sync=True, **custom_serializers) _py2js_animate = Dict(allow_none=True).tag(sync=True, **custom_serializers) _py2js_deleteTraces = Dict(allow_none=True).tag(sync=True, **custom_serializers) _py2js_moveTraces = Dict(allow_none=True).tag(sync=True, **custom_serializers) _py2js_removeLayoutProps = Dict(allow_none=True).tag( sync=True, **custom_serializers ) _py2js_removeTraceProps = Dict(allow_none=True).tag(sync=True, **custom_serializers) # ### JS -> Python message properties ### # These properties are used to receive messages from the frontend. # Messages are received by defining methods that observe changes to these # properties. Receive methods are named `_handler_js2py_*` where '*' is # the name of the corresponding message property. Receive methods are # responsible for setting the message property to None after retreiving # the message data. # # See JSDoc comments in the FigureModel class in js/src/Figure.js for # detailed descriptions of the messages. _js2py_traceDeltas = Dict(allow_none=True).tag(sync=True, **custom_serializers) _js2py_layoutDelta = Dict(allow_none=True).tag(sync=True, **custom_serializers) _js2py_restyle = Dict(allow_none=True).tag(sync=True, **custom_serializers) _js2py_relayout = Dict(allow_none=True).tag(sync=True, **custom_serializers) _js2py_update = Dict(allow_none=True).tag(sync=True, **custom_serializers) _js2py_pointsCallback = Dict(allow_none=True).tag(sync=True, **custom_serializers) # ### Message tracking properties ### # The _last_layout_edit_id and _last_trace_edit_id properties are used # to keep track of the edit id of the message that most recently # requested an update to the Figures layout or traces respectively. # # We track this information because we don't want to update the Figure's # default layout/trace properties (_layout_defaults, _data_defaults) # while edits are in process. This can lead to inconsistent property # states. _last_layout_edit_id = Integer(0).tag(sync=True) _last_trace_edit_id = Integer(0).tag(sync=True) _set_trace_uid = True _allow_disable_validation = False # Constructor # ----------- def __init__( self, data=None, layout=None, frames=None, skip_invalid=False, **kwargs ): # Call superclass constructors # ---------------------------- # Note: We rename layout to layout_plotly because to deconflict it # with the `layout` constructor parameter of the `widgets.DOMWidget` # ipywidgets class super(BaseFigureWidget, self).__init__( data=data, layout_plotly=layout, frames=frames, skip_invalid=skip_invalid, **kwargs, ) # Validate Frames # --------------- # Frames are not supported by figure widget if self._frame_objs: BaseFigureWidget._display_frames_error() # Message States # -------------- # ### Layout ### # _last_layout_edit_id is described above self._last_layout_edit_id = 0 # _layout_edit_in_process is set to True if there are layout edit # operations that have been sent to the frontend that haven't # completed yet. self._layout_edit_in_process = False # _waiting_edit_callbacks is a list of callback functions that # should be executed as soon as all pending edit operations are # completed self._waiting_edit_callbacks = [] # ### Trace ### # _last_trace_edit_id: described above self._last_trace_edit_id = 0 # _trace_edit_in_process is set to True if there are trace edit # operations that have been sent to the frontend that haven't # completed yet. self._trace_edit_in_process = False # View count # ---------- # ipywidget property that stores the number of active frontend # views of this widget self._view_count = 0 # Python -> JavaScript Messages # ----------------------------- def _send_relayout_msg(self, layout_data, source_view_id=None): """ Send Plotly.relayout message to the frontend Parameters ---------- layout_data : dict Plotly.relayout layout data source_view_id : str UID of view that triggered this relayout operation (e.g. By the user clicking 'zoom' in the toolbar). None if the operation was not triggered by a frontend view """ # Increment layout edit messages IDs # ---------------------------------- layout_edit_id = self._last_layout_edit_id + 1 self._last_layout_edit_id = layout_edit_id self._layout_edit_in_process = True # Build message # ------------- msg_data = { "relayout_data": layout_data, "layout_edit_id": layout_edit_id, "source_view_id": source_view_id, } # Send message # ------------ self._py2js_relayout = msg_data self._py2js_relayout = None def _send_restyle_msg(self, restyle_data, trace_indexes=None, source_view_id=None): """ Send Plotly.restyle message to the frontend Parameters ---------- restyle_data : dict Plotly.restyle restyle data trace_indexes : list[int] List of trace indexes that the restyle operation applies to source_view_id : str UID of view that triggered this restyle operation (e.g. By the user clicking the legend to hide a trace). None if the operation was not triggered by a frontend view """ # Validate / normalize inputs # --------------------------- trace_indexes = self._normalize_trace_indexes(trace_indexes) # Increment layout/trace edit message IDs # --------------------------------------- layout_edit_id = self._last_layout_edit_id + 1 self._last_layout_edit_id = layout_edit_id self._layout_edit_in_process = True trace_edit_id = self._last_trace_edit_id + 1 self._last_trace_edit_id = trace_edit_id self._trace_edit_in_process = True # Build message # ------------- restyle_msg = { "restyle_data": restyle_data, "restyle_traces": trace_indexes, "trace_edit_id": trace_edit_id, "layout_edit_id": layout_edit_id, "source_view_id": source_view_id, } # Send message # ------------ self._py2js_restyle = restyle_msg self._py2js_restyle = None def _send_addTraces_msg(self, new_traces_data): """ Send Plotly.addTraces message to the frontend Parameters ---------- new_traces_data : list[dict] List of trace data for new traces as accepted by Plotly.addTraces """ # Increment layout/trace edit message IDs # --------------------------------------- layout_edit_id = self._last_layout_edit_id + 1 self._last_layout_edit_id = layout_edit_id self._layout_edit_in_process = True trace_edit_id = self._last_trace_edit_id + 1 self._last_trace_edit_id = trace_edit_id self._trace_edit_in_process = True # Build message # ------------- add_traces_msg = { "trace_data": new_traces_data, "trace_edit_id": trace_edit_id, "layout_edit_id": layout_edit_id, } # Send message # ------------ self._py2js_addTraces = add_traces_msg self._py2js_addTraces = None def _send_moveTraces_msg(self, current_inds, new_inds): """ Send Plotly.moveTraces message to the frontend Parameters ---------- current_inds : list[int] List of current trace indexes new_inds : list[int] List of new trace indexes """ # Build message # ------------- move_msg = {"current_trace_inds": current_inds, "new_trace_inds": new_inds} # Send message # ------------ self._py2js_moveTraces = move_msg self._py2js_moveTraces = None def _send_update_msg( self, restyle_data, relayout_data, trace_indexes=None, source_view_id=None ): """ Send Plotly.update message to the frontend Parameters ---------- restyle_data : dict Plotly.update restyle data relayout_data : dict Plotly.update relayout data trace_indexes : list[int] List of trace indexes that the update operation applies to source_view_id : str UID of view that triggered this update operation (e.g. By the user clicking a button). None if the operation was not triggered by a frontend view """ # Validate / normalize inputs # --------------------------- trace_indexes = self._normalize_trace_indexes(trace_indexes) # Increment layout/trace edit message IDs # --------------------------------------- trace_edit_id = self._last_trace_edit_id + 1 self._last_trace_edit_id = trace_edit_id self._trace_edit_in_process = True layout_edit_id = self._last_layout_edit_id + 1 self._last_layout_edit_id = layout_edit_id self._layout_edit_in_process = True # Build message # ------------- update_msg = { "style_data": restyle_data, "layout_data": relayout_data, "style_traces": trace_indexes, "trace_edit_id": trace_edit_id, "layout_edit_id": layout_edit_id, "source_view_id": source_view_id, } # Send message # ------------ self._py2js_update = update_msg self._py2js_update = None def _send_animate_msg( self, styles_data, relayout_data, trace_indexes, animation_opts ): """ Send Plotly.update message to the frontend Note: there is no source_view_id parameter because animations triggered by the fontend are not currently supported Parameters ---------- styles_data : list[dict] Plotly.animate styles data relayout_data : dict Plotly.animate relayout data trace_indexes : list[int] List of trace indexes that the animate operation applies to """ # Validate / normalize inputs # --------------------------- trace_indexes = self._normalize_trace_indexes(trace_indexes) # Increment layout/trace edit message IDs # --------------------------------------- trace_edit_id = self._last_trace_edit_id + 1 self._last_trace_edit_id = trace_edit_id self._trace_edit_in_process = True layout_edit_id = self._last_layout_edit_id + 1 self._last_layout_edit_id = layout_edit_id self._layout_edit_in_process = True # Build message # ------------- animate_msg = { "style_data": styles_data, "layout_data": relayout_data, "style_traces": trace_indexes, "animation_opts": animation_opts, "trace_edit_id": trace_edit_id, "layout_edit_id": layout_edit_id, "source_view_id": None, } # Send message # ------------ self._py2js_animate = animate_msg self._py2js_animate = None def _send_deleteTraces_msg(self, delete_inds): """ Send Plotly.deleteTraces message to the frontend Parameters ---------- delete_inds : list[int] List of trace indexes of traces to delete """ # Increment layout/trace edit message IDs # --------------------------------------- trace_edit_id = self._last_trace_edit_id + 1 self._last_trace_edit_id = trace_edit_id self._trace_edit_in_process = True layout_edit_id = self._last_layout_edit_id + 1 self._last_layout_edit_id = layout_edit_id self._layout_edit_in_process = True # Build message # ------------- delete_msg = { "delete_inds": delete_inds, "layout_edit_id": layout_edit_id, "trace_edit_id": trace_edit_id, } # Send message # ------------ self._py2js_deleteTraces = delete_msg self._py2js_deleteTraces = None # JavaScript -> Python Messages # ----------------------------- @observe("_js2py_traceDeltas") def _handler_js2py_traceDeltas(self, change): """ Process trace deltas message from the frontend """ # Receive message # --------------- msg_data = change["new"] if not msg_data: self._js2py_traceDeltas = None return trace_deltas = msg_data["trace_deltas"] trace_edit_id = msg_data["trace_edit_id"] # Apply deltas # ------------ # We only apply the deltas if this message corresponds to the most # recent trace edit operation if trace_edit_id == self._last_trace_edit_id: # ### Loop over deltas ### for delta in trace_deltas: # #### Find existing trace for uid ### trace_uid = delta["uid"] trace_uids = [trace.uid for trace in self.data] trace_index = trace_uids.index(trace_uid) uid_trace = self.data[trace_index] # #### Transform defaults to delta #### delta_transform = BaseFigureWidget._transform_data( uid_trace._prop_defaults, delta ) # #### Remove overlapping properties #### # If a property is present in both _props and _prop_defaults # then we remove the copy from _props remove_props = self._remove_overlapping_props( uid_trace._props, uid_trace._prop_defaults ) # #### Notify frontend model of property removal #### if remove_props: remove_trace_props_msg = { "remove_trace": trace_index, "remove_props": remove_props, } self._py2js_removeTraceProps = remove_trace_props_msg self._py2js_removeTraceProps = None # #### Dispatch change callbacks #### self._dispatch_trace_change_callbacks(delta_transform, [trace_index]) # ### Trace edits no longer in process ### self._trace_edit_in_process = False # ### Call any waiting trace edit callbacks ### if not self._layout_edit_in_process: while self._waiting_edit_callbacks: self._waiting_edit_callbacks.pop()() self._js2py_traceDeltas = None @observe("_js2py_layoutDelta") def _handler_js2py_layoutDelta(self, change): """ Process layout delta message from the frontend """ # Receive message # --------------- msg_data = change["new"] if not msg_data: self._js2py_layoutDelta = None return layout_delta = msg_data["layout_delta"] layout_edit_id = msg_data["layout_edit_id"] # Apply delta # ----------- # We only apply the delta if this message corresponds to the most # recent layout edit operation if layout_edit_id == self._last_layout_edit_id: # ### Transform defaults to delta ### delta_transform = BaseFigureWidget._transform_data( self._layout_defaults, layout_delta ) # ### Remove overlapping properties ### # If a property is present in both _layout and _layout_defaults # then we remove the copy from _layout removed_props = self._remove_overlapping_props( self._layout, self._layout_defaults ) # ### Notify frontend model of property removal ### if removed_props: remove_props_msg = {"remove_props": removed_props} self._py2js_removeLayoutProps = remove_props_msg self._py2js_removeLayoutProps = None # ### Create axis objects ### # For example, when a SPLOM trace is created the layout defaults # may include axes that weren't explicitly defined by the user. for proppath in delta_transform: prop = proppath[0] match = self.layout._subplot_re_match(prop) if match and prop not in self.layout: # We need to create a subplotid object self.layout[prop] = {} # ### Dispatch change callbacks ### self._dispatch_layout_change_callbacks(delta_transform) # ### Layout edits no longer in process ### self._layout_edit_in_process = False # ### Call any waiting layout edit callbacks ### if not self._trace_edit_in_process: while self._waiting_edit_callbacks: self._waiting_edit_callbacks.pop()() self._js2py_layoutDelta = None @observe("_js2py_restyle") def _handler_js2py_restyle(self, change): """ Process Plotly.restyle message from the frontend """ # Receive message # --------------- restyle_msg = change["new"] if not restyle_msg: self._js2py_restyle = None return style_data = restyle_msg["style_data"] style_traces = restyle_msg["style_traces"] source_view_id = restyle_msg["source_view_id"] # Perform restyle # --------------- self.plotly_restyle( restyle_data=style_data, trace_indexes=style_traces, source_view_id=source_view_id, ) self._js2py_restyle = None @observe("_js2py_update") def _handler_js2py_update(self, change): """ Process Plotly.update message from the frontend """ # Receive message # --------------- update_msg = change["new"] if not update_msg: self._js2py_update = None return style = update_msg["style_data"] trace_indexes = update_msg["style_traces"] layout = update_msg["layout_data"] source_view_id = update_msg["source_view_id"] # Perform update # -------------- self.plotly_update( restyle_data=style, relayout_data=layout, trace_indexes=trace_indexes, source_view_id=source_view_id, ) self._js2py_update = None @observe("_js2py_relayout") def _handler_js2py_relayout(self, change): """ Process Plotly.relayout message from the frontend """ # Receive message # --------------- relayout_msg = change["new"] if not relayout_msg: self._js2py_relayout = None return relayout_data = relayout_msg["relayout_data"] source_view_id = relayout_msg["source_view_id"] if "lastInputTime" in relayout_data: # Remove 'lastInputTime'. Seems to be an internal plotly # property that is introduced for some plot types, but it is not # actually a property in the schema relayout_data.pop("lastInputTime") # Perform relayout # ---------------- self.plotly_relayout(relayout_data=relayout_data, source_view_id=source_view_id) self._js2py_relayout = None @observe("_js2py_pointsCallback") def _handler_js2py_pointsCallback(self, change): """ Process points callback message from the frontend """ # Receive message # --------------- callback_data = change["new"] if not callback_data: self._js2py_pointsCallback = None return # Get event type # -------------- event_type = callback_data["event_type"] # Build Selector Object # --------------------- if callback_data.get("selector", None): selector_data = callback_data["selector"] selector_type = selector_data["type"] selector_state = selector_data["selector_state"] if selector_type == "box": selector = BoxSelector(**selector_state) elif selector_type == "lasso": selector = LassoSelector(**selector_state) else: raise ValueError("Unsupported selector type: %s" % selector_type) else: selector = None # Build Input Device State Object # ------------------------------- if callback_data.get("device_state", None): device_state_data = callback_data["device_state"] state = InputDeviceState(**device_state_data) else: state = None # Build Trace Points Dictionary # ----------------------------- points_data = callback_data["points"] trace_points = { trace_ind: { "point_inds": [], "xs": [], "ys": [], "trace_name": self._data_objs[trace_ind].name, "trace_index": trace_ind, } for trace_ind in range(len(self._data_objs)) } for x, y, point_ind, trace_ind in zip( points_data["xs"], points_data["ys"], points_data["point_indexes"], points_data["trace_indexes"], ): trace_dict = trace_points[trace_ind] trace_dict["xs"].append(x) trace_dict["ys"].append(y) trace_dict["point_inds"].append(point_ind) # Dispatch callbacks # ------------------ for trace_ind, trace_points_data in trace_points.items(): points = Points(**trace_points_data) trace = self.data[trace_ind] if event_type == "plotly_click": trace._dispatch_on_click(points, state) elif event_type == "plotly_hover": trace._dispatch_on_hover(points, state) elif event_type == "plotly_unhover": trace._dispatch_on_unhover(points, state) elif event_type == "plotly_selected": trace._dispatch_on_selection(points, selector) elif event_type == "plotly_deselect": trace._dispatch_on_deselect(points) self._js2py_pointsCallback = None # Display # ------- def _repr_html_(self): """ Customize html representation """ raise NotImplementedError # Prefer _repr_mimebundle_ def _repr_mimebundle_(self, include=None, exclude=None, validate=True, **kwargs): """ Return mimebundle corresponding to default renderer. """ return { "application/vnd.jupyter.widget-view+json": { "version_major": 2, "version_minor": 0, "model_id": self._model_id, }, } def _ipython_display_(self): """ Handle rich display of figures in ipython contexts """ raise NotImplementedError # Prefer _repr_mimebundle_ # Callbacks # --------- def on_edits_completed(self, fn): """ Register a function to be called after all pending trace and layout edit operations have completed If there are no pending edit operations then function is called immediately Parameters ---------- fn : callable Function of zero arguments to be called when all pending edit operations have completed """ if self._layout_edit_in_process or self._trace_edit_in_process: self._waiting_edit_callbacks.append(fn) else: fn() # Validate No Frames # ------------------ @property def frames(self): # Note: This property getter is identical to that of the superclass, # but it must be included here because we're overriding the setter # below. return self._frame_objs @frames.setter def frames(self, new_frames): if new_frames: BaseFigureWidget._display_frames_error() @staticmethod def _display_frames_error(): """ Display an informative error when user attempts to set frames on a FigureWidget Raises ------ ValueError always """ msg = """ Frames are not supported by the plotly.graph_objs.FigureWidget class. Note: Frames are supported by the plotly.graph_objs.Figure class""" raise ValueError(msg) # Static Helpers # -------------- @staticmethod def _remove_overlapping_props(input_data, delta_data, prop_path=()): """ Remove properties in input_data that are also in delta_data, and do so recursively. Exception: Never remove 'uid' from input_data, this property is used to align traces Parameters ---------- input_data : dict|list delta_data : dict|list Returns ------- list[tuple[str|int]] List of removed property path tuples """ # Initialize removed # ------------------ # This is the list of path tuples to the properties that were # removed from input_data removed = [] # Handle dict # ----------- if isinstance(input_data, dict): assert isinstance(delta_data, dict) for p, delta_val in delta_data.items(): if isinstance(delta_val, dict) or BaseFigure._is_dict_list(delta_val): if p in input_data: # ### Recurse ### input_val = input_data[p] recur_prop_path = prop_path + (p,) recur_removed = BaseFigureWidget._remove_overlapping_props( input_val, delta_val, recur_prop_path ) removed.extend(recur_removed) # Check whether the last property in input_val # has been removed. If so, remove it entirely if not input_val: input_data.pop(p) removed.append(recur_prop_path) elif p in input_data and p != "uid": # ### Remove property ### input_data.pop(p) removed.append(prop_path + (p,)) # Handle list # ----------- elif isinstance(input_data, list): assert isinstance(delta_data, list) for i, delta_val in enumerate(delta_data): if i >= len(input_data): break input_val = input_data[i] if ( input_val is not None and isinstance(delta_val, dict) or BaseFigure._is_dict_list(delta_val) ): # ### Recurse ### recur_prop_path = prop_path + (i,) recur_removed = BaseFigureWidget._remove_overlapping_props( input_val, delta_val, recur_prop_path ) removed.extend(recur_removed) return removed @staticmethod def _transform_data(to_data, from_data, should_remove=True, relayout_path=()): """ Transform to_data into from_data and return relayout-style description of the transformation Parameters ---------- to_data : dict|list from_data : dict|list Returns ------- dict relayout-style description of the transformation """ # Initialize relayout data # ------------------------ relayout_data = {} # Handle dict # ----------- if isinstance(to_data, dict): # ### Validate from_data ### if not isinstance(from_data, dict): raise ValueError( "Mismatched data types: {to_dict} {from_data}".format( to_dict=to_data, from_data=from_data ) ) # ### Add/modify properties ### # Loop over props/vals for from_prop, from_val in from_data.items(): # #### Handle compound vals recursively #### if isinstance(from_val, dict) or BaseFigure._is_dict_list(from_val): # ##### Init property value if needed ##### if from_prop not in to_data: to_data[from_prop] = {} if isinstance(from_val, dict) else [] # ##### Transform property val recursively ##### input_val = to_data[from_prop] relayout_data.update( BaseFigureWidget._transform_data( input_val, from_val, should_remove=should_remove, relayout_path=relayout_path + (from_prop,), ) ) # #### Handle simple vals directly #### else: if from_prop not in to_data or not BasePlotlyType._vals_equal( to_data[from_prop], from_val ): to_data[from_prop] = from_val relayout_path_prop = relayout_path + (from_prop,) relayout_data[relayout_path_prop] = from_val # ### Remove properties ### if should_remove: for remove_prop in set(to_data.keys()).difference( set(from_data.keys()) ): to_data.pop(remove_prop) # Handle list # ----------- elif isinstance(to_data, list): # ### Validate from_data ### if not isinstance(from_data, list): raise ValueError( "Mismatched data types: to_data: {to_data} {from_data}".format( to_data=to_data, from_data=from_data ) ) # ### Add/modify properties ### # Loop over indexes / elements for i, from_val in enumerate(from_data): # #### Initialize element if needed #### if i >= len(to_data): to_data.append(None) input_val = to_data[i] # #### Handle compound element recursively #### if input_val is not None and ( isinstance(from_val, dict) or BaseFigure._is_dict_list(from_val) ): relayout_data.update( BaseFigureWidget._transform_data( input_val, from_val, should_remove=should_remove, relayout_path=relayout_path + (i,), ) ) # #### Handle simple elements directly #### else: if not BasePlotlyType._vals_equal(to_data[i], from_val): to_data[i] = from_val relayout_data[relayout_path + (i,)] = from_val return relayout_data