997 lines
34 KiB
Python
997 lines
34 KiB
Python
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
|