Files
dash-api/lib/python3.11/site-packages/plotly/basewidget.py
2025-09-07 22:09:54 +02:00

990 lines
34 KiB
Python

from copy import deepcopy
import pathlib
from traitlets import List, Dict, observe, Integer
from plotly.io._renderers import display_jupyter_version_warnings
from .basedatatypes import BaseFigure, BasePlotlyType
from .callbacks import BoxSelector, LassoSelector, InputDeviceState, Points
from .serializers import custom_serializers
import anywidget
class BaseFigureWidget(BaseFigure, anywidget.AnyWidget):
"""
Base class for FigureWidget. The FigureWidget class is code-generated as a
subclass
"""
_esm = pathlib.Path(__file__).parent / "package_data" / "widgetbundle.js"
# ### _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.
_widget_layout = Dict().tag(sync=True, **custom_serializers)
_widget_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
# Initialize widget layout and data for third-party widget integration
# --------------------------------------------------------------------
self._widget_layout = deepcopy(self._layout_obj._props)
self._widget_data = deepcopy(self._data)
def show(self, *args, **kwargs):
return self
# 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._widget_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.
"""
display_jupyter_version_warnings()
# Widget layout and data need to be set here in case there are
# changes made to the figure after the widget is created but before
# the cell is run.
self._widget_layout = deepcopy(self._layout_obj._props)
self._widget_data = deepcopy(self._data)
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