from __future__ import absolute_import import collections from collections import OrderedDict import re import six from six import string_types import warnings from contextlib import contextmanager from copy import deepcopy, copy import itertools from functools import reduce from _plotly_utils.utils import ( _natural_sort_strings, _get_int_type, split_multichar, split_string_positions, display_string_positions, chomp_empty_strings, find_closest_string, ) from _plotly_utils.exceptions import PlotlyKeyError from .optional_imports import get_module from . import shapeannotation from . import subplots # Create Undefined sentinel value # - Setting a property to None removes any existing value # - Setting a property to Undefined leaves existing value unmodified Undefined = object() def _len_dict_item(item): """ Because a parsed dict path is a tuple containings strings or integers, to know the length of the resulting string when printing we might need to convert to a string before calling len on it. """ try: l = len(item) except TypeError: try: l = len("%d" % (item,)) except TypeError: raise ValueError( "Cannot find string length of an item that is not string-like nor an integer." ) return l def _str_to_dict_path_full(key_path_str): """ Convert a key path string into a tuple of key path elements and also return a tuple of indices marking the beginning of each element in the string. Parameters ---------- key_path_str : str Key path string, where nested keys are joined on '.' characters and array indexes are specified using brackets (e.g. 'foo.bar[1]') Returns ------- tuple[str | int] tuple [int] """ # skip all the parsing if the string is empty if len(key_path_str): # split string on ".[]" and filter out empty strings key_path2 = split_multichar([key_path_str], list(".[]")) # Split out underscore # e.g. ['foo', 'bar_baz', '1'] -> ['foo', 'bar', 'baz', '1'] key_path3 = [] underscore_props = BaseFigure._valid_underscore_properties def _make_hyphen_key(key): if "_" in key[1:]: # For valid properties that contain underscores (error_x) # replace the underscores with hyphens to protect them # from being split up for under_prop, hyphen_prop in underscore_props.items(): key = key.replace(under_prop, hyphen_prop) return key def _make_underscore_key(key): return key.replace("-", "_") key_path2b = list(map(_make_hyphen_key, key_path2)) # Here we want to split up each non-empty string in the list at # underscores and recombine the strings using chomp_empty_strings so # that leading, trailing and multiple _ will be preserved def _split_and_chomp(s): if not len(s): return s s_split = split_multichar([s], list("_")) # handle key paths like "a_path_", "_another_path", or # "yet__another_path" by joining extra "_" to the string to the right or # the empty string if at the end s_chomped = chomp_empty_strings(s_split, "_", reverse=True) return s_chomped # after running _split_and_chomp on key_path2b, it will be a list # containing strings and lists of strings; concatenate the sublists with # the list ("lift" the items out of the sublists) key_path2c = list( reduce( lambda x, y: x + y if type(y) == type(list()) else x + [y], map(_split_and_chomp, key_path2b), [], ) ) key_path2d = list(map(_make_underscore_key, key_path2c)) all_elem_idcs = tuple(split_string_positions(list(key_path2d))) # remove empty strings, and indices pointing to them key_elem_pairs = list(filter(lambda t: len(t[1]), enumerate(key_path2d))) key_path3 = [x for _, x in key_elem_pairs] elem_idcs = [all_elem_idcs[i] for i, _ in key_elem_pairs] # Convert elements to ints if possible. # e.g. ['foo', 'bar', '0'] -> ['foo', 'bar', 0] for i in range(len(key_path3)): try: key_path3[i] = int(key_path3[i]) except ValueError as _: pass else: key_path3 = [] elem_idcs = [] return (tuple(key_path3), elem_idcs) def _remake_path_from_tuple(props): """ try to remake a path using the properties in props """ if len(props) == 0: return "" def _add_square_brackets_to_number(n): if type(n) == type(int()): return "[%d]" % (n,) return n def _prepend_dot_if_not_number(s): if not s.startswith("["): return "." + s return s props_all_str = list(map(_add_square_brackets_to_number, props)) props_w_underscore = props_all_str[:1] + list( map(_prepend_dot_if_not_number, props_all_str[1:]) ) return "".join(props_w_underscore) def _check_path_in_prop_tree(obj, path, error_cast=None): """ obj: the object in which the first property is looked up path: the path that will be split into properties to be looked up path can also be a tuple. In this case, it is combined using . and [] because it is impossible to reconstruct the string fully in order to give a decent error message. error_cast: this function walks down the property tree by looking up values in objects. So this will throw exceptions that are thrown by __getitem__, but in some cases we are checking the path for a different reason and would prefer throwing a more relevant exception (e.g., __getitem__ throws KeyError but __setitem__ throws ValueError for subclasses of BasePlotlyType and BaseFigure). So the resulting error can be "casted" to the passed in type, if not None. returns an Exception object or None. The caller can raise this exception to see where the lookup error occurred. """ if isinstance(path, tuple): path = _remake_path_from_tuple(path) prop, prop_idcs = _str_to_dict_path_full(path) prev_objs = [] for i, p in enumerate(prop): arg = "" prev_objs.append(obj) try: obj = obj[p] except (ValueError, KeyError, IndexError, TypeError) as e: arg = e.args[0] if issubclass(e.__class__, TypeError): # If obj doesn't support subscripting, state that and show the # (valid) property that gives the object that doesn't support # subscripting. if i > 0: validator = prev_objs[i - 1]._get_validator(prop[i - 1]) arg += """ Invalid value received for the '{plotly_name}' property of {parent_name} {description}""".format( parent_name=validator.parent_name, plotly_name=validator.plotly_name, description=validator.description(), ) # In case i is 0, the best we can do is indicate the first # property in the string as having caused the error disp_i = max(i - 1, 0) dict_item_len = _len_dict_item(prop[disp_i]) # if the path has trailing underscores, the prop string will start with "_" trailing_underscores = "" if prop[i][0] == "_": trailing_underscores = " and path has trailing underscores" # if the path has trailing underscores and the display index is # one less than the prop index (see above), then we can also # indicate the offending underscores if (trailing_underscores != "") and (disp_i != i): dict_item_len += _len_dict_item(prop[i]) arg += """ Property does not support subscripting%s: %s %s""" % ( trailing_underscores, path, display_string_positions( prop_idcs, disp_i, length=dict_item_len, char="^" ), ) else: # State that the property for which subscripting was attempted # is bad and indicate the start of the bad property. arg += """ Bad property path: %s %s""" % ( path, display_string_positions( prop_idcs, i, length=_len_dict_item(prop[i]), char="^" ), ) # Make KeyError more pretty by changing it to a PlotlyKeyError, # because the Python interpreter has a special way of printing # KeyError if isinstance(e, KeyError): e = PlotlyKeyError() if error_cast is not None: e = error_cast() e.args = (arg,) return e return None def _combine_dicts(dicts): all_args = dict() for d in dicts: for k in d: all_args[k] = d[k] return all_args def _indexing_combinations(dims, alls, product=False): """ Gives indexing tuples specified by the coordinates in dims. If a member of dims is 'all' then it is replaced by the corresponding member in alls. If product is True, then the cartesian product of all the indices is returned, otherwise the zip (that means index lists of mis-matched length will yield a list of tuples whose length is the length of the shortest list). """ if len(dims) == 0: # this is because list(itertools.product(*[])) returns [()] which has non-zero # length! return [] if len(dims) != len(alls): raise ValueError( "Must have corresponding values in alls for each value of dims. Got dims=%s and alls=%s." % (str(dims), str(alls)) ) r = [] for d, a in zip(dims, alls): if d == "all": d = a elif not isinstance(d, list): d = [d] r.append(d) if product: return itertools.product(*r) else: return zip(*r) def _is_select_subplot_coordinates_arg(*args): """ Returns true if any args are lists or the string 'all' """ return any((a == "all") or isinstance(a, list) for a in args) def _axis_spanning_shapes_docstr(shape_type): docstr = "" if shape_type == "hline": docstr = """ Add a horizontal line to a plot or subplot that extends infinitely in the x-dimension. Parameters ---------- y: float or int A number representing the y coordinate of the horizontal line.""" elif shape_type == "vline": docstr = """ Add a vertical line to a plot or subplot that extends infinitely in the y-dimension. Parameters ---------- x: float or int A number representing the x coordinate of the vertical line.""" elif shape_type == "hrect": docstr = """ Add a rectangle to a plot or subplot that extends infinitely in the x-dimension. Parameters ---------- y0: float or int A number representing the y coordinate of one side of the rectangle. y1: float or int A number representing the y coordinate of the other side of the rectangle.""" elif shape_type == "vrect": docstr = """ Add a rectangle to a plot or subplot that extends infinitely in the y-dimension. Parameters ---------- x0: float or int A number representing the x coordinate of one side of the rectangle. x1: float or int A number representing the x coordinate of the other side of the rectangle.""" docstr += """ exclude_empty_subplots: Boolean If True (default) do not place the shape on subplots that have no data plotted on them. row: None, int or 'all' Subplot row for shape indexed starting at 1. If 'all', addresses all rows in the specified column(s). If both row and col are None, addresses the first subplot if subplots exist, or the only plot. By default is "all". col: None, int or 'all' Subplot column for shape indexed starting at 1. If 'all', addresses all rows in the specified column(s). If both row and col are None, addresses the first subplot if subplots exist, or the only plot. By default is "all". annotation: dict or plotly.graph_objects.layout.Annotation. If dict(), it is interpreted as describing an annotation. The annotation is placed relative to the shape based on annotation_position (see below) unless its x or y value has been specified for the annotation passed here. xref and yref are always the same as for the added shape and cannot be overridden.""" if shape_type in ["hline", "vline"]: docstr += """ annotation_position: a string containing optionally ["top", "bottom"] and ["left", "right"] specifying where the text should be anchored to on the line. Example positions are "bottom left", "right top", "right", "bottom". If an annotation is added but annotation_position is not specified, this defaults to "top right".""" elif shape_type in ["hrect", "vrect"]: docstr += """ annotation_position: a string containing optionally ["inside", "outside"], ["top", "bottom"] and ["left", "right"] specifying where the text should be anchored to on the rectangle. Example positions are "outside top left", "inside bottom", "right", "inside left", "inside" ("outside" is not supported). If an annotation is added but annotation_position is not specified this defaults to "inside top right".""" docstr += """ annotation_*: any parameters to go.layout.Annotation can be passed as keywords by prefixing them with "annotation_". For example, to specify the annotation text "example" you can pass annotation_text="example" as a keyword argument. **kwargs: Any named function parameters that can be passed to 'add_shape', except for x0, x1, y0, y1 or type.""" return docstr def _generator(i): """ "cast" an iterator to a generator """ for x in i: yield x class BaseFigure(object): """ Base class for all figure types (both widget and non-widget) """ _bracket_re = re.compile(r"^(.*)\[(\d+)\]$") _valid_underscore_properties = { "error_x": "error-x", "error_y": "error-y", "error_z": "error-z", "copy_xstyle": "copy-xstyle", "copy_ystyle": "copy-ystyle", "copy_zstyle": "copy-zstyle", "paper_bgcolor": "paper-bgcolor", "plot_bgcolor": "plot-bgcolor", } _set_trace_uid = False _allow_disable_validation = True # Constructor # ----------- def __init__( self, data=None, layout_plotly=None, frames=None, skip_invalid=False, **kwargs ): """ Construct a BaseFigure object Parameters ---------- data One of: - A list or tuple of trace objects (or dicts that can be coerced into trace objects) - If `data` is a dict that contains a 'data', 'layout', or 'frames' key then these values are used to construct the figure. - If `data` is a `BaseFigure` instance then the `data`, `layout`, and `frames` properties are extracted from the input figure layout_plotly The plotly layout dict. Note: this property is named `layout_plotly` rather than `layout` to deconflict it with the `layout` constructor parameter of the `widgets.DOMWidget` ipywidgets class, as the `BaseFigureWidget` class is a subclass of both BaseFigure and widgets.DOMWidget. If the `data` property is a BaseFigure instance, or a dict that contains a 'layout' key, then this property is ignored. frames A list or tuple of `plotly.graph_objs.Frame` objects (or dicts that can be coerced into Frame objects) If the `data` property is a BaseFigure instance, or a dict that contains a 'frames' key, then this property is ignored. skip_invalid: bool If True, invalid properties in the figure specification will be skipped silently. If False (default) invalid properties in the figure specification will result in a ValueError Raises ------ ValueError if a property in the specification of data, layout, or frames is invalid AND skip_invalid is False """ from .validators import DataValidator, LayoutValidator, FramesValidator super(BaseFigure, self).__init__() # Initialize validation self._validate = kwargs.pop("_validate", True) # Assign layout_plotly to layout # ------------------------------ # See docstring note for explanation layout = layout_plotly # Subplot properties # ------------------ # These properties are used by the tools.make_subplots logic. # We initialize them to None here, before checking if the input data # object is a BaseFigure, or a dict with _grid_str and _grid_ref # properties, in which case we bring over the _grid* properties of # the input self._grid_str = None self._grid_ref = None # Handle case where data is a Figure or Figure-like dict # ------------------------------------------------------ if isinstance(data, BaseFigure): # Bring over subplot fields self._grid_str = data._grid_str self._grid_ref = data._grid_ref # Extract data, layout, and frames data, layout, frames = data.data, data.layout, data.frames elif isinstance(data, dict) and ( "data" in data or "layout" in data or "frames" in data ): # Bring over subplot fields self._grid_str = data.get("_grid_str", None) self._grid_ref = data.get("_grid_ref", None) # Extract data, layout, and frames data, layout, frames = ( data.get("data", None), data.get("layout", None), data.get("frames", None), ) # Handle data (traces) # -------------------- # ### Construct data validator ### # This is the validator that handles importing sequences of trace # objects self._data_validator = DataValidator(set_uid=self._set_trace_uid) # ### Import traces ### data = self._data_validator.validate_coerce( data, skip_invalid=skip_invalid, _validate=self._validate ) # ### Save tuple of trace objects ### self._data_objs = data # ### Import clone of trace properties ### # The _data property is a list of dicts containing the properties # explicitly set by the user for each trace. self._data = [deepcopy(trace._props) for trace in data] # ### Create data defaults ### # _data_defaults is a tuple of dicts, one for each trace. When # running in a widget context, these defaults are populated with # all property values chosen by the Plotly.js library that # aren't explicitly specified by the user. # # Note: No property should exist in both the _data and # _data_defaults for the same trace. self._data_defaults = [{} for _ in data] # ### Reparent trace objects ### for trace_ind, trace in enumerate(data): # By setting the trace's parent to be this figure, we tell the # trace object to use the figure's _data and _data_defaults # dicts to get/set it's properties, rather than using the trace # object's internal _orphan_props dict. trace._parent = self # We clear the orphan props since the trace no longer needs then trace._orphan_props.clear() # Set trace index trace._trace_ind = trace_ind # Layout # ------ # ### Construct layout validator ### # This is the validator that handles importing Layout objects self._layout_validator = LayoutValidator() # ### Import Layout ### self._layout_obj = self._layout_validator.validate_coerce( layout, skip_invalid=skip_invalid, _validate=self._validate ) # ### Import clone of layout properties ### self._layout = deepcopy(self._layout_obj._props) # ### Initialize layout defaults dict ### self._layout_defaults = {} # ### Reparent layout object ### self._layout_obj._orphan_props.clear() self._layout_obj._parent = self # Config # ------ # Pass along default config to the front end. For now this just # ensures that the plotly domain url gets passed to the front end. # In the future we can extend this to allow the user to supply # arbitrary config options like in plotly.offline.plot/iplot. But # this will require a fair amount of testing to determine which # options are compatible with FigureWidget. from plotly.offline.offline import _get_jconfig self._config = _get_jconfig(None) # Frames # ------ # ### Construct frames validator ### # This is the validator that handles importing sequences of frame # objects self._frames_validator = FramesValidator() # ### Import frames ### self._frame_objs = self._frames_validator.validate_coerce( frames, skip_invalid=skip_invalid ) # Note: Because frames are not currently supported in the widget # context, we don't need to follow the pattern above and create # _frames and _frame_defaults properties and then reparent the # frames. The figure doesn't need to be notified of # changes to the properties in the frames object hierarchy. # Context manager # --------------- # ### batch mode indicator ### # Flag that indicates whether we're currently inside a batch_*() # context self._in_batch_mode = False # ### Batch trace edits ### # Dict from trace indexes to trace edit dicts. These trace edit dicts # are suitable as `data` elements of Plotly.animate, but not # the Plotly.update (See `_build_update_params_from_batch`) self._batch_trace_edits = OrderedDict() # ### Batch layout edits ### # Dict from layout properties to new layout values. This dict is # directly suitable for use in Plotly.animate and Plotly.update self._batch_layout_edits = OrderedDict() # Animation property validators # ----------------------------- from . import animation self._animation_duration_validator = animation.DurationValidator() self._animation_easing_validator = animation.EasingValidator() # Template # -------- # ### Check for default template ### self._initialize_layout_template() # Process kwargs # -------------- for k, v in kwargs.items(): err = _check_path_in_prop_tree(self, k) if err is None: self[k] = v elif not skip_invalid: type_err = TypeError("invalid Figure property: {}".format(k)) type_err.args = ( type_err.args[0] + """ %s""" % (err.args[0],), ) raise type_err # Magic Methods # ------------- def __reduce__(self): """ Custom implementation of reduce is used to support deep copying and pickling """ props = self.to_dict() props["_grid_str"] = self._grid_str props["_grid_ref"] = self._grid_ref return (self.__class__, (props,)) def __setitem__(self, prop, value): # Normalize prop # -------------- # Convert into a property tuple orig_prop = prop prop = BaseFigure._str_to_dict_path(prop) # Handle empty case # ----------------- if len(prop) == 0: raise KeyError(orig_prop) # Handle scalar case # ------------------ # e.g. ('foo',) elif len(prop) == 1: # ### Unwrap scalar tuple ### prop = prop[0] if prop == "data": self.data = value elif prop == "layout": self.layout = value elif prop == "frames": self.frames = value else: raise KeyError(prop) # Handle non-scalar case # ---------------------- # e.g. ('foo', 1) else: err = _check_path_in_prop_tree(self, orig_prop, error_cast=ValueError) if err is not None: raise err res = self for p in prop[:-1]: res = res[p] res._validate = self._validate res[prop[-1]] = value def __setattr__(self, prop, value): """ Parameters ---------- prop : str The name of a direct child of this object value New property value Returns ------- None """ if prop.startswith("_") or hasattr(self, prop): # Let known properties and private properties through super(BaseFigure, self).__setattr__(prop, value) else: # Raise error on unknown public properties raise AttributeError(prop) def __getitem__(self, prop): # Normalize prop # -------------- # Convert into a property tuple orig_prop = prop prop = BaseFigure._str_to_dict_path(prop) # Handle scalar case # ------------------ # e.g. ('foo',) if len(prop) == 1: # Unwrap scalar tuple prop = prop[0] if prop == "data": return self._data_validator.present(self._data_objs) elif prop == "layout": return self._layout_validator.present(self._layout_obj) elif prop == "frames": return self._frames_validator.present(self._frame_objs) else: raise KeyError(orig_prop) # Handle non-scalar case # ---------------------- # e.g. ('foo', 1) else: err = _check_path_in_prop_tree(self, orig_prop, error_cast=PlotlyKeyError) if err is not None: raise err res = self for p in prop: res = res[p] return res def __iter__(self): return iter(("data", "layout", "frames")) def __contains__(self, prop): prop = BaseFigure._str_to_dict_path(prop) if prop[0] not in ("data", "layout", "frames"): return False elif len(prop) == 1: return True else: return prop[1:] in self[prop[0]] def __eq__(self, other): if not isinstance(other, BaseFigure): # Require objects to both be BaseFigure instances return False else: # Compare plotly_json representations # Use _vals_equal instead of `==` to handle cases where # underlying dicts contain numpy arrays return BasePlotlyType._vals_equal( self.to_plotly_json(), other.to_plotly_json() ) def __repr__(self): """ Customize Figure representation when displayed in the terminal/notebook """ props = self.to_plotly_json() # Elide template template_props = props.get("layout", {}).get("template", {}) if template_props: props["layout"]["template"] = "..." repr_str = BasePlotlyType._build_repr_for_class( props=props, class_name=self.__class__.__name__ ) return repr_str def _repr_html_(self): """ Customize html representation """ bundle = self._repr_mimebundle_() if "text/html" in bundle: return bundle["text/html"] else: return self.to_html(full_html=False, include_plotlyjs="cdn") def _repr_mimebundle_(self, include=None, exclude=None, validate=True, **kwargs): """ Return mimebundle corresponding to default renderer. """ import plotly.io as pio renderer_str = pio.renderers.default renderers = pio._renderers.renderers renderer_names = renderers._validate_coerce_renderers(renderer_str) renderers_list = [renderers[name] for name in renderer_names] from plotly.io._utils import validate_coerce_fig_to_dict from plotly.io._renderers import MimetypeRenderer fig_dict = validate_coerce_fig_to_dict(self, validate) # Mimetype renderers bundle = {} for renderer in renderers_list: if isinstance(renderer, MimetypeRenderer): bundle.update(renderer.to_mimebundle(fig_dict)) return bundle def _ipython_display_(self): """ Handle rich display of figures in ipython contexts """ import plotly.io as pio if pio.renderers.render_on_display and pio.renderers.default: pio.show(self) else: print(repr(self)) def update(self, dict1=None, overwrite=False, **kwargs): """ Update the properties of the figure with a dict and/or with keyword arguments. This recursively updates the structure of the figure object with the values in the input dict / keyword arguments. Parameters ---------- dict1 : dict Dictionary of properties to be updated overwrite: bool If True, overwrite existing properties. If False, apply updates to existing properties recursively, preserving existing properties that are not specified in the update operation. kwargs : Keyword/value pair of properties to be updated Examples -------- >>> import plotly.graph_objs as go >>> fig = go.Figure(data=[{'y': [1, 2, 3]}]) >>> fig.update(data=[{'y': [4, 5, 6]}]) # doctest: +ELLIPSIS Figure(...) >>> fig.to_plotly_json() # doctest: +SKIP {'data': [{'type': 'scatter', 'uid': 'e86a7c7a-346a-11e8-8aa8-a0999b0c017b', 'y': array([4, 5, 6], dtype=int32)}], 'layout': {}} >>> fig = go.Figure(layout={'xaxis': ... {'color': 'green', ... 'range': [0, 1]}}) >>> fig.update({'layout': {'xaxis': {'color': 'pink'}}}) # doctest: +ELLIPSIS Figure(...) >>> fig.to_plotly_json() # doctest: +SKIP {'data': [], 'layout': {'xaxis': {'color': 'pink', 'range': [0, 1]}}} Returns ------- BaseFigure Updated figure """ with self.batch_update(): for d in [dict1, kwargs]: if d: for k, v in d.items(): update_target = self[k] if update_target == () or overwrite: if k == "data": # Overwrite all traces as special due to # restrictions on trace assignment self.data = () self.add_traces(v) else: # Accept v self[k] = v elif ( isinstance(update_target, BasePlotlyType) and isinstance(v, (dict, BasePlotlyType)) ) or ( isinstance(update_target, tuple) and isinstance(update_target[0], BasePlotlyType) ): BaseFigure._perform_update(self[k], v) else: self[k] = v return self def pop(self, key, *args): """ Remove the value associated with the specified key and return it Parameters ---------- key: str Property name dflt The default value to return if key was not found in figure Returns ------- value The removed value that was previously associated with key Raises ------ KeyError If key is not in object and no dflt argument specified """ # Handle default if key not in self and args: return args[0] elif key in self: val = self[key] self[key] = None return val else: raise KeyError(key) # Data # ---- @property def data(self): """ The `data` property is a tuple of the figure's trace objects Returns ------- tuple[BaseTraceType] """ return self["data"] @data.setter def data(self, new_data): # Validate new_data # ----------------- err_header = ( "The data property of a figure may only be assigned \n" "a list or tuple that contains a permutation of a " "subset of itself.\n" ) # ### Treat None as empty ### if new_data is None: new_data = () # ### Check valid input type ### if not isinstance(new_data, (list, tuple)): err_msg = err_header + " Received value with type {typ}".format( typ=type(new_data) ) raise ValueError(err_msg) # ### Check valid element types ### for trace in new_data: if not isinstance(trace, BaseTraceType): err_msg = ( err_header + " Received element value of type {typ}".format(typ=type(trace)) ) raise ValueError(err_msg) # ### Check trace objects ### # Require that no new traces are introduced orig_uids = [id(trace) for trace in self.data] new_uids = [id(trace) for trace in new_data] invalid_uids = set(new_uids).difference(set(orig_uids)) if invalid_uids: err_msg = err_header raise ValueError(err_msg) # ### Check for duplicates in assignment ### uid_counter = collections.Counter(new_uids) duplicate_uids = [uid for uid, count in uid_counter.items() if count > 1] if duplicate_uids: err_msg = err_header + " Received duplicated traces" raise ValueError(err_msg) # Remove traces # ------------- remove_uids = set(orig_uids).difference(set(new_uids)) delete_inds = [] # ### Unparent removed traces ### for i, trace in enumerate(self.data): if id(trace) in remove_uids: delete_inds.append(i) # Unparent trace object to be removed old_trace = self.data[i] old_trace._orphan_props.update(deepcopy(old_trace._props)) old_trace._parent = None old_trace._trace_ind = None # ### Compute trace props / defaults after removal ### traces_props_post_removal = [t for t in self._data] traces_prop_defaults_post_removal = [t for t in self._data_defaults] uids_post_removal = [id(trace_data) for trace_data in self.data] for i in reversed(delete_inds): del traces_props_post_removal[i] del traces_prop_defaults_post_removal[i] del uids_post_removal[i] # Modify in-place so we don't trigger serialization del self._data[i] if delete_inds: # Update widget, if any self._send_deleteTraces_msg(delete_inds) # Move traces # ----------- # ### Compute new index for each remaining trace ### new_inds = [] for uid in uids_post_removal: new_inds.append(new_uids.index(uid)) # ### Compute current index for each remaining trace ### current_inds = list(range(len(traces_props_post_removal))) # ### Check whether a move is needed ### if not all([i1 == i2 for i1, i2 in zip(new_inds, current_inds)]): # #### Save off index lists for moveTraces message #### msg_current_inds = current_inds msg_new_inds = new_inds # #### Reorder trace elements #### # We do so in-place so we don't trigger traitlet property # serialization for the FigureWidget case # ##### Remove by curr_inds in reverse order ##### moving_traces_data = [] for ci in reversed(current_inds): # Push moving traces data to front of list moving_traces_data.insert(0, self._data[ci]) del self._data[ci] # #### Sort new_inds and moving_traces_data by new_inds #### new_inds, moving_traces_data = zip( *sorted(zip(new_inds, moving_traces_data)) ) # #### Insert by new_inds in forward order #### for ni, trace_data in zip(new_inds, moving_traces_data): self._data.insert(ni, trace_data) # #### Update widget, if any #### self._send_moveTraces_msg(msg_current_inds, msg_new_inds) # ### Update data defaults ### # There is to front-end syncronization to worry about so this # operations doesn't need to be in-place self._data_defaults = [ _trace for i, _trace in sorted(zip(new_inds, traces_prop_defaults_post_removal)) ] # Update trace objects tuple self._data_objs = list(new_data) # Update trace indexes for trace_ind, trace in enumerate(self._data_objs): trace._trace_ind = trace_ind def select_traces(self, selector=None, row=None, col=None, secondary_y=None): """ Select traces from a particular subplot cell and/or traces that satisfy custom selection criteria. Parameters ---------- selector: dict, function, int, str or None (default None) Dict to use as selection criteria. Traces will be selected if they contain properties corresponding to all of the dictionary's keys, with values that exactly match the supplied values. If None (the default), all traces are selected. If a function, it must be a function accepting a single argument and returning a boolean. The function will be called on each trace and those for which the function returned True will be in the selection. If an int N, the Nth trace matching row and col will be selected (N can be negative). If a string S, the selector is equivalent to dict(type=S). row, col: int or None (default None) Subplot row and column index of traces to select. To select traces by row and column, the Figure must have been created using plotly.subplots.make_subplots. If None (the default), all traces are selected. secondary_y: boolean or None (default None) * If True, only select traces associated with the secondary y-axis of the subplot. * If False, only select traces associated with the primary y-axis of the subplot. * If None (the default), do not filter traces based on secondary y-axis. To select traces by secondary y-axis, the Figure must have been created using plotly.subplots.make_subplots. See the docstring for the specs argument to make_subplots for more info on creating subplots with secondary y-axes. Returns ------- generator Generator that iterates through all of the traces that satisfy all of the specified selection criteria """ if not selector: selector = {} if row is not None or col is not None or secondary_y is not None: grid_ref = self._validate_get_grid_ref() filter_by_subplot = True if row is None and col is not None: # All rows for column grid_subplot_ref_tuples = [ref_row[col - 1] for ref_row in grid_ref] elif col is None and row is not None: # All columns for row grid_subplot_ref_tuples = grid_ref[row - 1] elif col is not None and row is not None: # Single grid cell grid_subplot_ref_tuples = [grid_ref[row - 1][col - 1]] else: # row and col are None, secondary_y not None grid_subplot_ref_tuples = [ refs for refs_row in grid_ref for refs in refs_row ] # Collect list of subplot refs, taking secondary_y into account grid_subplot_refs = [] for refs in grid_subplot_ref_tuples: if not refs: continue if secondary_y is not True: grid_subplot_refs.append(refs[0]) if secondary_y is not False and len(refs) > 1: grid_subplot_refs.append(refs[1]) else: filter_by_subplot = False grid_subplot_refs = None return self._perform_select_traces( filter_by_subplot, grid_subplot_refs, selector ) def _perform_select_traces(self, filter_by_subplot, grid_subplot_refs, selector): from plotly.subplots import _get_subplot_ref_for_trace # functions for filtering def _filter_by_subplot_ref(trace): trace_subplot_ref = _get_subplot_ref_for_trace(trace) return trace_subplot_ref in grid_subplot_refs funcs = [] if filter_by_subplot: funcs.append(_filter_by_subplot_ref) return _generator(self._filter_by_selector(self.data, funcs, selector)) @staticmethod def _selector_matches(obj, selector): if selector is None: return True # If selector is a string then put it at the 'type' key of a dictionary # to select objects where "type":selector if isinstance(selector, six.string_types): selector = dict(type=selector) # If selector is a dict, compare the fields if isinstance(selector, dict) or isinstance(selector, BasePlotlyType): # This returns True if selector is an empty dict for k in selector: if k not in obj: return False obj_val = obj[k] selector_val = selector[k] if isinstance(obj_val, BasePlotlyType): obj_val = obj_val.to_plotly_json() if isinstance(selector_val, BasePlotlyType): selector_val = selector_val.to_plotly_json() if obj_val != selector_val: return False return True # If selector is a function, call it with the obj as the argument elif six.callable(selector): return selector(obj) else: raise TypeError( "selector must be dict or a function " "accepting a graph object returning a boolean." ) def _filter_by_selector(self, objects, funcs, selector): """ objects is a sequence of objects, funcs a list of functions that return True if the object should be included in the selection and False otherwise and selector is an argument to the self._selector_matches function. If selector is an integer, the resulting sequence obtained after sucessively filtering by each function in funcs is indexed by this integer. Otherwise selector is used as the selector argument to self._selector_matches which is used to filter down the sequence. The function returns the sequence (an iterator). """ # if selector is not an int, we call it on each trace to test it for selection if not isinstance(selector, int): funcs.append(lambda obj: self._selector_matches(obj, selector)) def _filt(last, f): return filter(f, last) filtered_objects = reduce(_filt, funcs, objects) if isinstance(selector, int): return iter([list(filtered_objects)[selector]]) return filtered_objects def for_each_trace(self, fn, selector=None, row=None, col=None, secondary_y=None): """ Apply a function to all traces that satisfy the specified selection criteria Parameters ---------- fn: Function that inputs a single trace object. selector: dict, function, int, str or None (default None) Dict to use as selection criteria. Traces will be selected if they contain properties corresponding to all of the dictionary's keys, with values that exactly match the supplied values. If None (the default), all traces are selected. If a function, it must be a function accepting a single argument and returning a boolean. The function will be called on each trace and those for which the function returned True will be in the selection. If an int N, the Nth trace matching row and col will be selected (N can be negative). If a string S, the selector is equivalent to dict(type=S). row, col: int or None (default None) Subplot row and column index of traces to select. To select traces by row and column, the Figure must have been created using plotly.subplots.make_subplots. If None (the default), all traces are selected. secondary_y: boolean or None (default None) * If True, only select traces associated with the secondary y-axis of the subplot. * If False, only select traces associated with the primary y-axis of the subplot. * If None (the default), do not filter traces based on secondary y-axis. To select traces by secondary y-axis, the Figure must have been created using plotly.subplots.make_subplots. See the docstring for the specs argument to make_subplots for more info on creating subplots with secondary y-axes. Returns ------- self Returns the Figure object that the method was called on """ for trace in self.select_traces( selector=selector, row=row, col=col, secondary_y=secondary_y ): fn(trace) return self def update_traces( self, patch=None, selector=None, row=None, col=None, secondary_y=None, overwrite=False, **kwargs ): """ Perform a property update operation on all traces that satisfy the specified selection criteria Parameters ---------- patch: dict or None (default None) Dictionary of property updates to be applied to all traces that satisfy the selection criteria. selector: dict, function, int, str or None (default None) Dict to use as selection criteria. Traces will be selected if they contain properties corresponding to all of the dictionary's keys, with values that exactly match the supplied values. If None (the default), all traces are selected. If a function, it must be a function accepting a single argument and returning a boolean. The function will be called on each trace and those for which the function returned True will be in the selection. If an int N, the Nth trace matching row and col will be selected (N can be negative). If a string S, the selector is equivalent to dict(type=S). row, col: int or None (default None) Subplot row and column index of traces to select. To select traces by row and column, the Figure must have been created using plotly.subplots.make_subplots. If None (the default), all traces are selected. secondary_y: boolean or None (default None) * If True, only select traces associated with the secondary y-axis of the subplot. * If False, only select traces associated with the primary y-axis of the subplot. * If None (the default), do not filter traces based on secondary y-axis. To select traces by secondary y-axis, the Figure must have been created using plotly.subplots.make_subplots. See the docstring for the specs argument to make_subplots for more info on creating subplots with secondary y-axes. overwrite: bool If True, overwrite existing properties. If False, apply updates to existing properties recursively, preserving existing properties that are not specified in the update operation. **kwargs Additional property updates to apply to each selected trace. If a property is specified in both patch and in **kwargs then the one in **kwargs takes precedence. Returns ------- self Returns the Figure object that the method was called on """ for trace in self.select_traces( selector=selector, row=row, col=col, secondary_y=secondary_y ): trace.update(patch, overwrite=overwrite, **kwargs) return self def update_layout(self, dict1=None, overwrite=False, **kwargs): """ Update the properties of the figure's layout with a dict and/or with keyword arguments. This recursively updates the structure of the original layout with the values in the input dict / keyword arguments. Parameters ---------- dict1 : dict Dictionary of properties to be updated overwrite: bool If True, overwrite existing properties. If False, apply updates to existing properties recursively, preserving existing properties that are not specified in the update operation. kwargs : Keyword/value pair of properties to be updated Returns ------- BaseFigure The Figure object that the update_layout method was called on """ self.layout.update(dict1, overwrite=overwrite, **kwargs) return self def _select_layout_subplots_by_prefix( self, prefix, selector=None, row=None, col=None, secondary_y=None ): """ Helper called by code generated select_* methods """ if row is not None or col is not None or secondary_y is not None: # Build mapping from container keys ('xaxis2', 'scene4', etc.) # to (row, col, secondary_y) triplets grid_ref = self._validate_get_grid_ref() container_to_row_col = {} for r, subplot_row in enumerate(grid_ref): for c, subplot_refs in enumerate(subplot_row): if not subplot_refs: continue # collect primary keys for i, subplot_ref in enumerate(subplot_refs): for layout_key in subplot_ref.layout_keys: if layout_key.startswith(prefix): is_secondary_y = i == 1 container_to_row_col[layout_key] = ( r + 1, c + 1, is_secondary_y, ) else: container_to_row_col = None layout_keys_filters = [ lambda k: k.startswith(prefix) and self.layout[k] is not None, lambda k: row is None or container_to_row_col.get(k, (None, None, None))[0] == row, lambda k: col is None or container_to_row_col.get(k, (None, None, None))[1] == col, lambda k: ( secondary_y is None or container_to_row_col.get(k, (None, None, None))[2] == secondary_y ), ] layout_keys = reduce( lambda last, f: filter(f, last), layout_keys_filters, # Natural sort keys so that xaxis20 is after xaxis3 _natural_sort_strings(list(self.layout)), ) layout_objs = [self.layout[k] for k in layout_keys] return _generator(self._filter_by_selector(layout_objs, [], selector)) def _select_annotations_like( self, prop, selector=None, row=None, col=None, secondary_y=None ): """ Helper to select annotation-like elements from a layout object array. Compatible with layout.annotations, layout.shapes, and layout.images """ xref_to_col = {} yref_to_row = {} yref_to_secondary_y = {} if isinstance(row, int) or isinstance(col, int) or secondary_y is not None: grid_ref = self._validate_get_grid_ref() for r, subplot_row in enumerate(grid_ref): for c, subplot_refs in enumerate(subplot_row): if not subplot_refs: continue for i, subplot_ref in enumerate(subplot_refs): if subplot_ref.subplot_type == "xy": is_secondary_y = i == 1 xaxis, yaxis = subplot_ref.layout_keys xref = xaxis.replace("axis", "") yref = yaxis.replace("axis", "") xref_to_col[xref] = c + 1 yref_to_row[yref] = r + 1 yref_to_secondary_y[yref] = is_secondary_y # filter down (select) which graph objects, by applying the filters # successively def _filter_row(obj): """ Filter objects in rows by column """ return (col is None) or (xref_to_col.get(obj.xref, None) == col) def _filter_col(obj): """ Filter objects in columns by row """ return (row is None) or (yref_to_row.get(obj.yref, None) == row) def _filter_sec_y(obj): """ Filter objects on secondary y axes """ return (secondary_y is None) or ( yref_to_secondary_y.get(obj.yref, None) == secondary_y ) funcs = [_filter_row, _filter_col, _filter_sec_y] return _generator(self._filter_by_selector(self.layout[prop], funcs, selector)) def _add_annotation_like( self, prop_singular, prop_plural, new_obj, row=None, col=None, secondary_y=None, exclude_empty_subplots=False, ): # Make sure we have both row and col or neither if row is not None and col is None: raise ValueError( "Received row parameter but not col.\n" "row and col must be specified together" ) elif col is not None and row is None: raise ValueError( "Received col parameter but not row.\n" "row and col must be specified together" ) # Address multiple subplots if row is not None and _is_select_subplot_coordinates_arg(row, col): # TODO product argument could be added rows_cols = self._select_subplot_coordinates(row, col) for r, c in rows_cols: self._add_annotation_like( prop_singular, prop_plural, new_obj, row=r, col=c, secondary_y=secondary_y, exclude_empty_subplots=exclude_empty_subplots, ) return self # Get grid_ref if specific row or column requested if row is not None: grid_ref = self._validate_get_grid_ref() if row > len(grid_ref): raise IndexError( "row index %d out-of-bounds, row index must be between 1 and %d, inclusive." % (row, len(grid_ref)) ) if col > len(grid_ref[row - 1]): raise IndexError( "column index %d out-of-bounds, " "column index must be between 1 and %d, inclusive." % (row, len(grid_ref[row - 1])) ) refs = grid_ref[row - 1][col - 1] if not refs: raise ValueError( "No subplot found at position ({r}, {c})".format(r=row, c=col) ) if refs[0].subplot_type != "xy": raise ValueError( """ Cannot add {prop_singular} to subplot at position ({r}, {c}) because subplot is of type {subplot_type}.""".format( prop_singular=prop_singular, r=row, c=col, subplot_type=refs[0].subplot_type, ) ) if len(refs) == 1 and secondary_y: raise ValueError( """ Cannot add {prop_singular} to secondary y-axis of subplot at position ({r}, {c}) because subplot does not have a secondary y-axis""" ) if secondary_y: xaxis, yaxis = refs[1].layout_keys else: xaxis, yaxis = refs[0].layout_keys xref, yref = xaxis.replace("axis", ""), yaxis.replace("axis", "") # if exclude_empty_subplots is True, check to see if subplot is # empty and return if it is if exclude_empty_subplots and ( not self._subplot_not_empty( xref, yref, selector=bool(exclude_empty_subplots) ) ): return self # in case the user specified they wanted an axis to refer to the # domain of that axis and not the data, append ' domain' to the # computed axis accordingly def _add_domain(ax_letter, new_axref): axref = ax_letter + "ref" if axref in new_obj._props.keys() and "domain" in new_obj[axref]: new_axref += " domain" return new_axref xref, yref = map(lambda t: _add_domain(*t), zip(["x", "y"], [xref, yref])) new_obj.update(xref=xref, yref=yref) self.layout[prop_plural] += (new_obj,) return self # Restyle # ------- def plotly_restyle(self, restyle_data, trace_indexes=None, **kwargs): """ Perform a Plotly restyle operation on the figure's traces Parameters ---------- restyle_data : dict Dict of trace style updates. Keys are strings that specify the properties to be updated. Nested properties are expressed by joining successive keys on '.' characters (e.g. 'marker.color'). Values may be scalars or lists. When values are scalars, that scalar value is applied to all traces specified by the `trace_indexes` parameter. When values are lists, the restyle operation will cycle through the elements of the list as it cycles through the traces specified by the `trace_indexes` parameter. Caution: To use plotly_restyle to update a list property (e.g. the `x` property of the scatter trace), the property value should be a scalar list containing the list to update with. For example, the following command would be used to update the 'x' property of the first trace to the list [1, 2, 3] >>> import plotly.graph_objects as go >>> fig = go.Figure(go.Scatter(x=[2, 4, 6])) >>> fig.plotly_restyle({'x': [[1, 2, 3]]}, 0) trace_indexes : int or list of int Trace index, or list of trace indexes, that the restyle operation applies to. Defaults to all trace indexes. Returns ------- None """ # Normalize trace indexes # ----------------------- trace_indexes = self._normalize_trace_indexes(trace_indexes) # Handle source_view_id # --------------------- # If not None, the source_view_id is the UID of the frontend # Plotly.js view that initially triggered this restyle operation # (e.g. the user clicked on the legend to hide a trace). We pass # this UID along so that the frontend views can determine whether # they need to apply the restyle operation on themselves. source_view_id = kwargs.get("source_view_id", None) # Perform restyle on trace dicts # ------------------------------ restyle_changes = self._perform_plotly_restyle(restyle_data, trace_indexes) if restyle_changes: # The restyle operation resulted in a change to some trace # properties, so we dispatch change callbacks and send the # restyle message to the frontend (if any) msg_kwargs = ( {"source_view_id": source_view_id} if source_view_id is not None else {} ) self._send_restyle_msg( restyle_changes, trace_indexes=trace_indexes, **msg_kwargs ) self._dispatch_trace_change_callbacks(restyle_changes, trace_indexes) def _perform_plotly_restyle(self, restyle_data, trace_indexes): """ Perform a restyle operation on the figure's traces data and return the changes that were applied Parameters ---------- restyle_data : dict[str, any] See docstring for plotly_restyle trace_indexes : list[int] List of trace indexes that restyle operation applies to Returns ------- restyle_changes: dict[str, any] Subset of restyle_data including only the keys / values that resulted in a change to the figure's traces data """ # Initialize restyle changes # -------------------------- # This will be a subset of the restyle_data including only the # keys / values that are changed in the figure's trace data restyle_changes = {} # Process each key # ---------------- for key_path_str, v in restyle_data.items(): # Track whether any of the new values are cause a change in # self._data any_vals_changed = False for i, trace_ind in enumerate(trace_indexes): if trace_ind >= len(self._data): raise ValueError( "Trace index {trace_ind} out of range".format( trace_ind=trace_ind ) ) # Get new value for this particular trace trace_v = v[i % len(v)] if isinstance(v, list) else v if trace_v is not Undefined: # Get trace being updated trace_obj = self.data[trace_ind] # Validate key_path_str if not BaseFigure._is_key_path_compatible(key_path_str, trace_obj): trace_class = trace_obj.__class__.__name__ raise ValueError( """ Invalid property path '{key_path_str}' for trace class {trace_class} """.format( key_path_str=key_path_str, trace_class=trace_class ) ) # Apply set operation for this trace and thist value val_changed = BaseFigure._set_in( self._data[trace_ind], key_path_str, trace_v ) # Update any_vals_changed status any_vals_changed = any_vals_changed or val_changed if any_vals_changed: restyle_changes[key_path_str] = v return restyle_changes def _restyle_child(self, child, key_path_str, val): """ Process restyle operation on a child trace object Note: This method name/signature must match the one in BasePlotlyType. BasePlotlyType objects call their parent's _restyle_child method without knowing whether their parent is a BasePlotlyType or a BaseFigure. Parameters ---------- child : BaseTraceType Child being restyled key_path_str : str A key path string (e.g. 'foo.bar[0]') val Restyle value Returns ------- None """ # Compute trace index # ------------------- trace_index = child._trace_ind # Not in batch mode # ----------------- # Dispatch change callbacks and send restyle message if not self._in_batch_mode: send_val = [val] restyle = {key_path_str: send_val} self._send_restyle_msg(restyle, trace_indexes=trace_index) self._dispatch_trace_change_callbacks(restyle, [trace_index]) # In batch mode # ------------- # Add key_path_str/val to saved batch edits else: if trace_index not in self._batch_trace_edits: self._batch_trace_edits[trace_index] = OrderedDict() self._batch_trace_edits[trace_index][key_path_str] = val def _normalize_trace_indexes(self, trace_indexes): """ Input trace index specification and return list of the specified trace indexes Parameters ---------- trace_indexes : None or int or list[int] Returns ------- list[int] """ if trace_indexes is None: trace_indexes = list(range(len(self.data))) if not isinstance(trace_indexes, (list, tuple)): trace_indexes = [trace_indexes] return list(trace_indexes) @staticmethod def _str_to_dict_path(key_path_str): """ Convert a key path string into a tuple of key path elements. Parameters ---------- key_path_str : str Key path string, where nested keys are joined on '.' characters and array indexes are specified using brackets (e.g. 'foo.bar[1]') Returns ------- tuple[str | int] """ if ( isinstance(key_path_str, string_types) and "." not in key_path_str and "[" not in key_path_str and "_" not in key_path_str ): # Fast path for common case that avoids regular expressions return (key_path_str,) elif isinstance(key_path_str, tuple): # Nothing to do return key_path_str else: ret = _str_to_dict_path_full(key_path_str)[0] return ret @staticmethod def _set_in(d, key_path_str, v): """ Set a value in a nested dict using a key path string (e.g. 'foo.bar[0]') Parameters ---------- d : dict Input dict to set property in key_path_str : str Key path string, where nested keys are joined on '.' characters and array indexes are specified using brackets (e.g. 'foo.bar[1]') v New value Returns ------- bool True if set resulted in modification of dict (i.e. v was not already present at the specified location), False otherwise. """ # Validate inputs # --------------- assert isinstance(d, dict) # Compute key path # ---------------- # Convert the key_path_str into a tuple of key paths # e.g. 'foo.bar[0]' -> ('foo', 'bar', 0) key_path = BaseFigure._str_to_dict_path(key_path_str) # Initialize val_parent # --------------------- # This variable will be assigned to the parent of the next key path # element currently being processed val_parent = d # Initialize parent dict or list of value to be assigned # ----------------------------------------------------- for kp, key_path_el in enumerate(key_path[:-1]): # Extend val_parent list if needed if isinstance(val_parent, list) and isinstance(key_path_el, int): while len(val_parent) <= key_path_el: val_parent.append(None) elif isinstance(val_parent, dict) and key_path_el not in val_parent: if isinstance(key_path[kp + 1], int): val_parent[key_path_el] = [] else: val_parent[key_path_el] = {} val_parent = val_parent[key_path_el] # Assign value to to final parent dict or list # -------------------------------------------- # ### Get reference to final key path element ### last_key = key_path[-1] # ### Track whether assignment alters parent ### val_changed = False # v is Undefined # -------------- # Don't alter val_parent if v is Undefined: pass # v is None # --------- # Check whether we can remove key from parent elif v is None: if isinstance(val_parent, dict): if last_key in val_parent: # Parent is a dict and has last_key as a current key so # we can pop the key, which alters parent val_parent.pop(last_key) val_changed = True elif isinstance(val_parent, list): if isinstance(last_key, int) and 0 <= last_key < len(val_parent): # Parent is a list and last_key is a valid index so we # can set the element value to None val_parent[last_key] = None val_changed = True else: # Unsupported parent type (numpy array for example) raise ValueError( """ Cannot remove element of type {typ} at location {raw_key}""".format( typ=type(val_parent), raw_key=key_path_str ) ) # v is a valid value # ------------------ # Check whether parent should be updated else: if isinstance(val_parent, dict): if last_key not in val_parent or not BasePlotlyType._vals_equal( val_parent[last_key], v ): # Parent is a dict and does not already contain the # value v at key last_key val_parent[last_key] = v val_changed = True elif isinstance(val_parent, list): if isinstance(last_key, int): # Extend list with Nones if needed so that last_key is # in bounds while len(val_parent) <= last_key: val_parent.append(None) if not BasePlotlyType._vals_equal(val_parent[last_key], v): # Parent is a list and does not already contain the # value v at index last_key val_parent[last_key] = v val_changed = True else: # Unsupported parent type (numpy array for example) raise ValueError( """ Cannot set element of type {typ} at location {raw_key}""".format( typ=type(val_parent), raw_key=key_path_str ) ) return val_changed # Add traces # ---------- @staticmethod def _raise_invalid_rows_cols(name, n, invalid): rows_err_msg = """ If specified, the {name} parameter must be a list or tuple of integers of length {n} (The number of traces being added) Received: {invalid} """.format( name=name, n=n, invalid=invalid ) raise ValueError(rows_err_msg) @staticmethod def _validate_rows_cols(name, n, vals): if vals is None: pass elif isinstance(vals, (list, tuple)): if len(vals) != n: BaseFigure._raise_invalid_rows_cols(name=name, n=n, invalid=vals) int_type = _get_int_type() if [r for r in vals if not isinstance(r, int_type)]: BaseFigure._raise_invalid_rows_cols(name=name, n=n, invalid=vals) else: BaseFigure._raise_invalid_rows_cols(name=name, n=n, invalid=vals) def add_trace( self, trace, row=None, col=None, secondary_y=None, exclude_empty_subplots=False ): """ Add a trace to the figure Parameters ---------- trace : BaseTraceType or dict Either: - An instances of a trace classe from the plotly.graph_objs package (e.g plotly.graph_objs.Scatter, plotly.graph_objs.Bar) - or a dicts where: - The 'type' property specifies the trace type (e.g. 'scatter', 'bar', 'area', etc.). If the dict has no 'type' property then 'scatter' is assumed. - All remaining properties are passed to the constructor of the specified trace type. row : 'all', int or None (default) Subplot row index (starting from 1) for the trace to be added. Only valid if figure was created using `plotly.tools.make_subplots`. If 'all', addresses all rows in the specified column(s). col : 'all', int or None (default) Subplot col index (starting from 1) for the trace to be added. Only valid if figure was created using `plotly.tools.make_subplots`. If 'all', addresses all columns in the specified row(s). secondary_y: boolean or None (default None) If True, associate this trace with the secondary y-axis of the subplot at the specified row and col. Only valid if all of the following conditions are satisfied: * The figure was created using `plotly.subplots.make_subplots`. * The row and col arguments are not None * The subplot at the specified row and col has type xy (which is the default) and secondary_y True. These properties are specified in the specs argument to make_subplots. See the make_subplots docstring for more info. * The trace argument is a 2D cartesian trace (scatter, bar, etc.) exclude_empty_subplots: boolean If True, the trace will not be added to subplots that don't already have traces. Returns ------- BaseFigure The Figure that add_trace was called on Examples -------- >>> from plotly import subplots >>> import plotly.graph_objs as go Add two Scatter traces to a figure >>> fig = go.Figure() >>> fig.add_trace(go.Scatter(x=[1,2,3], y=[2,1,2])) # doctest: +ELLIPSIS Figure(...) >>> fig.add_trace(go.Scatter(x=[1,2,3], y=[2,1,2])) # doctest: +ELLIPSIS Figure(...) Add two Scatter traces to vertically stacked subplots >>> fig = subplots.make_subplots(rows=2) >>> fig.add_trace(go.Scatter(x=[1,2,3], y=[2,1,2]), row=1, col=1) # doctest: +ELLIPSIS Figure(...) >>> fig.add_trace(go.Scatter(x=[1,2,3], y=[2,1,2]), row=2, col=1) # doctest: +ELLIPSIS Figure(...) """ # Make sure we have both row and col or neither if row is not None and col is None: raise ValueError( "Received row parameter but not col.\n" "row and col must be specified together" ) elif col is not None and row is None: raise ValueError( "Received col parameter but not row.\n" "row and col must be specified together" ) # Address multiple subplots if row is not None and _is_select_subplot_coordinates_arg(row, col): # TODO add product argument rows_cols = self._select_subplot_coordinates(row, col) for r, c in rows_cols: self.add_trace( trace, row=r, col=c, secondary_y=secondary_y, exclude_empty_subplots=exclude_empty_subplots, ) return self return self.add_traces( data=[trace], rows=[row] if row is not None else None, cols=[col] if col is not None else None, secondary_ys=[secondary_y] if secondary_y is not None else None, exclude_empty_subplots=exclude_empty_subplots, ) def add_traces( self, data, rows=None, cols=None, secondary_ys=None, exclude_empty_subplots=False, ): """ Add traces to the figure Parameters ---------- data : list[BaseTraceType or dict] A list of trace specifications to be added. Trace specifications may be either: - Instances of trace classes from the plotly.graph_objs package (e.g plotly.graph_objs.Scatter, plotly.graph_objs.Bar) - Dicts where: - The 'type' property specifies the trace type (e.g. 'scatter', 'bar', 'area', etc.). If the dict has no 'type' property then 'scatter' is assumed. - All remaining properties are passed to the constructor of the specified trace type. rows : None, list[int], or int (default None) List of subplot row indexes (starting from 1) for the traces to be added. Only valid if figure was created using `plotly.tools.make_subplots` If a single integer is passed, all traces will be added to row number cols : None or list[int] (default None) List of subplot column indexes (starting from 1) for the traces to be added. Only valid if figure was created using `plotly.tools.make_subplots` If a single integer is passed, all traces will be added to column number secondary_ys: None or list[boolean] (default None) List of secondary_y booleans for traces to be added. See the docstring for `add_trace` for more info. exclude_empty_subplots: boolean If True, the trace will not be added to subplots that don't already have traces. Returns ------- BaseFigure The Figure that add_traces was called on Examples -------- >>> from plotly import subplots >>> import plotly.graph_objs as go Add two Scatter traces to a figure >>> fig = go.Figure() >>> fig.add_traces([go.Scatter(x=[1,2,3], y=[2,1,2]), ... go.Scatter(x=[1,2,3], y=[2,1,2])]) # doctest: +ELLIPSIS Figure(...) Add two Scatter traces to vertically stacked subplots >>> fig = subplots.make_subplots(rows=2) >>> fig.add_traces([go.Scatter(x=[1,2,3], y=[2,1,2]), ... go.Scatter(x=[1,2,3], y=[2,1,2])], ... rows=[1, 2], cols=[1, 1]) # doctest: +ELLIPSIS Figure(...) """ # Validate traces data = self._data_validator.validate_coerce(data) # Set trace indexes for ind, new_trace in enumerate(data): new_trace._trace_ind = ind + len(self.data) # Allow integers as inputs to subplots int_type = _get_int_type() if isinstance(rows, int_type): rows = [rows] * len(data) if isinstance(cols, int_type): cols = [cols] * len(data) # Validate rows / cols n = len(data) BaseFigure._validate_rows_cols("rows", n, rows) BaseFigure._validate_rows_cols("cols", n, cols) # Make sure we have both rows and cols or neither if rows is not None and cols is None: raise ValueError( "Received rows parameter but not cols.\n" "rows and cols must be specified together" ) elif cols is not None and rows is None: raise ValueError( "Received cols parameter but not rows.\n" "rows and cols must be specified together" ) # Process secondary_ys defaults if secondary_ys is not None and rows is None: # Default rows/cols to 1s if secondary_ys specified but not rows # or cols rows = [1] * len(secondary_ys) cols = rows elif secondary_ys is None and rows is not None: # Default secondary_ys to Nones if secondary_ys is not specified # but not rows and cols are specified secondary_ys = [None] * len(rows) # Apply rows / cols if rows is not None: for trace, row, col, secondary_y in zip(data, rows, cols, secondary_ys): self._set_trace_grid_position(trace, row, col, secondary_y) if exclude_empty_subplots: data = list( filter( lambda trace: self._subplot_not_empty( trace["xaxis"], trace["yaxis"], bool(exclude_empty_subplots) ), data, ) ) # Make deep copy of trace data (Optimize later if needed) new_traces_data = [deepcopy(trace._props) for trace in data] # Update trace parent for trace in data: trace._parent = self trace._orphan_props.clear() # Update python side # Use extend instead of assignment so we don't trigger serialization self._data.extend(new_traces_data) self._data_defaults = self._data_defaults + [{} for _ in data] self._data_objs = self._data_objs + data # Update messages self._send_addTraces_msg(new_traces_data) return self # Subplots # -------- def print_grid(self): """ Print a visual layout of the figure's axes arrangement. This is only valid for figures that are created with plotly.tools.make_subplots. """ if self._grid_str is None: raise Exception( "Use plotly.tools.make_subplots " "to create a subplot grid." ) print(self._grid_str) def append_trace(self, trace, row, col): """ Add a trace to the figure bound to axes at the specified row, col index. A row, col index grid is generated for figures created with plotly.tools.make_subplots, and can be viewed with the `print_grid` method Parameters ---------- trace The data trace to be bound row: int Subplot row index (see Figure.print_grid) col: int Subplot column index (see Figure.print_grid) Examples -------- >>> from plotly import tools >>> import plotly.graph_objs as go >>> # stack two subplots vertically >>> fig = tools.make_subplots(rows=2) This is the format of your plot grid: [ (1,1) x1,y1 ] [ (2,1) x2,y2 ] >>> fig.append_trace(go.Scatter(x=[1,2,3], y=[2,1,2]), row=1, col=1) >>> fig.append_trace(go.Scatter(x=[1,2,3], y=[2,1,2]), row=2, col=1) """ warnings.warn( """\ The append_trace method is deprecated and will be removed in a future version. Please use the add_trace method with the row and col parameters. """, DeprecationWarning, ) self.add_trace(trace=trace, row=row, col=col) def _set_trace_grid_position(self, trace, row, col, secondary_y=False): from plotly.subplots import _set_trace_grid_reference grid_ref = self._validate_get_grid_ref() return _set_trace_grid_reference( trace, self.layout, grid_ref, row, col, secondary_y ) def _validate_get_grid_ref(self): try: grid_ref = self._grid_ref if grid_ref is None: raise AttributeError("_grid_ref") except AttributeError: raise Exception( "In order to reference traces by row and column, " "you must first use " "plotly.tools.make_subplots " "to create the figure with a subplot grid." ) return grid_ref def _get_subplot_rows_columns(self): """ Returns a pair of lists, the first containing all the row indices and the second all the column indices. """ # currently, this just iterates over all the rows and columns (because # self._grid_ref is currently always rectangular) grid_ref = self._validate_get_grid_ref() nrows = len(grid_ref) ncols = len(grid_ref[0]) return (range(1, nrows + 1), range(1, ncols + 1)) def _get_subplot_coordinates(self): """ Returns an iterator over (row,col) pairs representing all the possible subplot coordinates. """ return itertools.product(*self._get_subplot_rows_columns()) def _select_subplot_coordinates(self, rows, cols, product=False): """ Allows selecting all or a subset of the subplots. If any of rows or columns is 'all', product is set to True. This is probably the expected behaviour, so that rows=1,cols='all' selects all the columns in row 1 (otherwise it would just select the subplot in the first row and first column). """ product |= any([s == "all" for s in [rows, cols]]) # TODO: If grid_ref ever becomes non-rectangular, then t should be the # set-intersection of the result of _indexing_combinations and # _get_subplot_coordinates, because some coordinates given by # the _indexing_combinations function might be invalid. t = _indexing_combinations( [rows, cols], list(self._get_subplot_rows_columns()), product=product, ) t = list(t) # remove rows and cols where the subplot is "None" grid_ref = self._validate_get_grid_ref() t = list(filter(lambda u: grid_ref[u[0] - 1][u[1] - 1] is not None, t)) return t def get_subplot(self, row, col, secondary_y=False): """ Return an object representing the subplot at the specified row and column. May only be used on Figures created using plotly.tools.make_subplots Parameters ---------- row: int 1-based index of subplot row col: int 1-based index of subplot column secondary_y: bool If True, select the subplot that consists of the x-axis and the secondary y-axis at the specified row/col. Only valid if the subplot at row/col is an 2D cartesian subplot that was created with a secondary y-axis. See the docstring for the specs argument to make_subplots for more info on creating a subplot with a secondary y-axis. Returns ------- subplot * None: if subplot is empty * plotly.graph_objs.layout.Scene: if subplot type is 'scene' * plotly.graph_objs.layout.Polar: if subplot type is 'polar' * plotly.graph_objs.layout.Ternary: if subplot type is 'ternary' * plotly.graph_objs.layout.Mapbox: if subplot type is 'ternary' * SubplotDomain namedtuple with `x` and `y` fields: if subplot type is 'domain'. - x: length 2 list of the subplot start and stop width - y: length 2 list of the subplot start and stop height * SubplotXY namedtuple with `xaxis` and `yaxis` fields: if subplot type is 'xy'. - xaxis: plotly.graph_objs.layout.XAxis instance for subplot - yaxis: plotly.graph_objs.layout.YAxis instance for subplot """ from plotly.subplots import _get_grid_subplot return _get_grid_subplot(self, row, col, secondary_y) # Child property operations # ------------------------- def _get_child_props(self, child): """ Return the properties dict for a child trace or child layout Note: this method must match the name/signature of one on BasePlotlyType Parameters ---------- child : BaseTraceType | BaseLayoutType Returns ------- dict """ # Try to find index of child as a trace # ------------------------------------- if isinstance(child, BaseTraceType): # ### Child is a trace ### trace_index = child._trace_ind return self._data[trace_index] # Child is the layout # ------------------- elif child is self.layout: return self._layout # Unknown child # ------------- else: raise ValueError("Unrecognized child: %s" % child) def _get_child_prop_defaults(self, child): """ Return the default properties dict for a child trace or child layout Note: this method must match the name/signature of one on BasePlotlyType Parameters ---------- child : BaseTraceType | BaseLayoutType Returns ------- dict """ # Child is a trace # ---------------- if isinstance(child, BaseTraceType): trace_index = child._trace_ind return self._data_defaults[trace_index] # Child is the layout # ------------------- elif child is self.layout: return self._layout_defaults # Unknown child # ------------- else: raise ValueError("Unrecognized child: %s" % child) def _init_child_props(self, child): """ Initialize the properites dict for a child trace or layout Note: this method must match the name/signature of one on BasePlotlyType Parameters ---------- child : BaseTraceType | BaseLayoutType Returns ------- None """ # layout and traces dict are initialize when figure is constructed # and when new traces are added to the figure pass # Layout # ------ def _initialize_layout_template(self): import plotly.io as pio if self._layout_obj._props.get("template", None) is None: if pio.templates.default is not None: # Assume default template is already validated if self._allow_disable_validation: self._layout_obj._validate = False try: if isinstance(pio.templates.default, BasePlotlyType): # Template object. Don't want to actually import `Template` # here for performance so we check against `BasePlotlyType` template_object = pio.templates.default else: # Name of registered template object template_object = pio.templates[pio.templates.default] self._layout_obj.template = template_object finally: self._layout_obj._validate = self._validate @property def layout(self): """ The `layout` property of the figure Returns ------- plotly.graph_objs.Layout """ return self["layout"] @layout.setter def layout(self, new_layout): # Validate new layout # ------------------- new_layout = self._layout_validator.validate_coerce(new_layout) new_layout_data = deepcopy(new_layout._props) # Unparent current layout # ----------------------- if self._layout_obj: old_layout_data = deepcopy(self._layout_obj._props) self._layout_obj._orphan_props.update(old_layout_data) self._layout_obj._parent = None # Parent new layout # ----------------- self._layout = new_layout_data new_layout._parent = self new_layout._orphan_props.clear() self._layout_obj = new_layout # Initialize template object # -------------------------- self._initialize_layout_template() # Notify JS side self._send_relayout_msg(new_layout_data) def plotly_relayout(self, relayout_data, **kwargs): """ Perform a Plotly relayout operation on the figure's layout Parameters ---------- relayout_data : dict Dict of layout updates dict keys are strings that specify the properties to be updated. Nested properties are expressed by joining successive keys on '.' characters (e.g. 'xaxis.range') dict values are the values to use to update the layout. Returns ------- None """ # Handle source_view_id # --------------------- # If not None, the source_view_id is the UID of the frontend # Plotly.js view that initially triggered this relayout operation # (e.g. the user clicked on the toolbar to change the drag mode # from zoom to pan). We pass this UID along so that the frontend # views can determine whether they need to apply the relayout # operation on themselves. if "source_view_id" in kwargs: msg_kwargs = {"source_view_id": kwargs["source_view_id"]} else: msg_kwargs = {} # Perform relayout operation on layout dict # ----------------------------------------- relayout_changes = self._perform_plotly_relayout(relayout_data) if relayout_changes: # The relayout operation resulted in a change to some layout # properties, so we dispatch change callbacks and send the # relayout message to the frontend (if any) self._send_relayout_msg(relayout_changes, **msg_kwargs) self._dispatch_layout_change_callbacks(relayout_changes) def _perform_plotly_relayout(self, relayout_data): """ Perform a relayout operation on the figure's layout data and return the changes that were applied Parameters ---------- relayout_data : dict[str, any] See the docstring for plotly_relayout Returns ------- relayout_changes: dict[str, any] Subset of relayout_data including only the keys / values that resulted in a change to the figure's layout data """ # Initialize relayout changes # --------------------------- # This will be a subset of the relayout_data including only the # keys / values that are changed in the figure's layout data relayout_changes = {} # Process each key # ---------------- for key_path_str, v in relayout_data.items(): if not BaseFigure._is_key_path_compatible(key_path_str, self.layout): raise ValueError( """ Invalid property path '{key_path_str}' for layout """.format( key_path_str=key_path_str ) ) # Apply set operation on the layout dict val_changed = BaseFigure._set_in(self._layout, key_path_str, v) if val_changed: relayout_changes[key_path_str] = v return relayout_changes @staticmethod def _is_key_path_compatible(key_path_str, plotly_obj): """ Return whether the specifieid key path string is compatible with the specified plotly object for the purpose of relayout/restyle operation """ # Convert string to tuple of path components # e.g. 'foo[0].bar[1]' -> ('foo', 0, 'bar', 1) key_path_tuple = BaseFigure._str_to_dict_path(key_path_str) # Remove trailing integer component # e.g. ('foo', 0, 'bar', 1) -> ('foo', 0, 'bar') # We do this because it's fine for relayout/restyle to create new # elements in the final array in the path. if isinstance(key_path_tuple[-1], int): key_path_tuple = key_path_tuple[:-1] # Test whether modified key path tuple is in plotly_obj return key_path_tuple in plotly_obj def _relayout_child(self, child, key_path_str, val): """ Process relayout operation on child layout object Parameters ---------- child : BaseLayoutType The figure's layout key_path_str : A key path string (e.g. 'foo.bar[0]') val Relayout value Returns ------- None """ # Validate input # -------------- assert child is self.layout # Not in batch mode # ------------- # Dispatch change callbacks and send relayout message if not self._in_batch_mode: relayout_msg = {key_path_str: val} self._send_relayout_msg(relayout_msg) self._dispatch_layout_change_callbacks(relayout_msg) # In batch mode # ------------- # Add key_path_str/val to saved batch edits else: self._batch_layout_edits[key_path_str] = val # Dispatch change callbacks # ------------------------- @staticmethod def _build_dispatch_plan(key_path_strs): """ Build a dispatch plan for a list of key path strings A dispatch plan is a dict: - *from* path tuples that reference an object that has descendants that are referenced in `key_path_strs`. - *to* sets of tuples that correspond to descendants of the object above. Parameters ---------- key_path_strs : list[str] List of key path strings. For example: ['xaxis.rangeselector.font.color', 'xaxis.rangeselector.bgcolor'] Returns ------- dispatch_plan: dict[tuple[str|int], set[tuple[str|int]]] Examples -------- >>> key_path_strs = ['xaxis.rangeselector.font.color', ... 'xaxis.rangeselector.bgcolor'] >>> BaseFigure._build_dispatch_plan(key_path_strs) # doctest: +SKIP {(): {'xaxis', ('xaxis', 'rangeselector'), ('xaxis', 'rangeselector', 'bgcolor'), ('xaxis', 'rangeselector', 'font'), ('xaxis', 'rangeselector', 'font', 'color')}, ('xaxis',): {('rangeselector',), ('rangeselector', 'bgcolor'), ('rangeselector', 'font'), ('rangeselector', 'font', 'color')}, ('xaxis', 'rangeselector'): {('bgcolor',), ('font',), ('font', 'color')}, ('xaxis', 'rangeselector', 'font'): {('color',)}} """ dispatch_plan = {} for key_path_str in key_path_strs: key_path = BaseFigure._str_to_dict_path(key_path_str) key_path_so_far = () keys_left = key_path # Iterate down the key path for next_key in key_path: if key_path_so_far not in dispatch_plan: dispatch_plan[key_path_so_far] = set() to_add = [keys_left[: i + 1] for i in range(len(keys_left))] dispatch_plan[key_path_so_far].update(to_add) key_path_so_far = key_path_so_far + (next_key,) keys_left = keys_left[1:] return dispatch_plan def _dispatch_layout_change_callbacks(self, relayout_data): """ Dispatch property change callbacks given relayout_data Parameters ---------- relayout_data : dict[str, any] See docstring for plotly_relayout. Returns ------- None """ # Build dispatch plan # ------------------- key_path_strs = list(relayout_data.keys()) dispatch_plan = BaseFigure._build_dispatch_plan(key_path_strs) # Dispatch changes to each layout objects # --------------------------------------- for path_tuple, changed_paths in dispatch_plan.items(): if path_tuple in self.layout: dispatch_obj = self.layout[path_tuple] if isinstance(dispatch_obj, BasePlotlyType): dispatch_obj._dispatch_change_callbacks(changed_paths) def _dispatch_trace_change_callbacks(self, restyle_data, trace_indexes): """ Dispatch property change callbacks given restyle_data Parameters ---------- restyle_data : dict[str, any] See docstring for plotly_restyle. trace_indexes : list[int] List of trace indexes that restyle operation applied to Returns ------- None """ # Build dispatch plan # ------------------- key_path_strs = list(restyle_data.keys()) dispatch_plan = BaseFigure._build_dispatch_plan(key_path_strs) # Dispatch changes to each object in each trace # --------------------------------------------- for path_tuple, changed_paths in dispatch_plan.items(): for trace_ind in trace_indexes: trace = self.data[trace_ind] if path_tuple in trace: dispatch_obj = trace[path_tuple] if isinstance(dispatch_obj, BasePlotlyType): dispatch_obj._dispatch_change_callbacks(changed_paths) # Frames # ------ @property def frames(self): """ The `frames` property is a tuple of the figure's frame objects Returns ------- tuple[plotly.graph_objs.Frame] """ return self["frames"] @frames.setter def frames(self, new_frames): # Note: Frames are not supported by the FigureWidget subclass so we # only validate coerce the frames. We don't emit any events on frame # changes, and we don't reparent the frames. # Validate frames self._frame_objs = self._frames_validator.validate_coerce(new_frames) # Update # ------ def plotly_update( self, restyle_data=None, relayout_data=None, trace_indexes=None, **kwargs ): """ Perform a Plotly update operation on the figure. Note: This operation both mutates and returns the figure Parameters ---------- restyle_data : dict Traces update specification. See the docstring for the `plotly_restyle` method for details relayout_data : dict Layout update specification. See the docstring for the `plotly_relayout` method for details trace_indexes : Trace index, or list of trace indexes, that the update operation applies to. Defaults to all trace indexes. Returns ------- BaseFigure None """ # Handle source_view_id # --------------------- # If not None, the source_view_id is the UID of the frontend # Plotly.js view that initially triggered this update operation # (e.g. the user clicked a button that triggered an update # operation). We pass this UID along so that the frontend views can # determine whether they need to apply the update operation on # themselves. if "source_view_id" in kwargs: msg_kwargs = {"source_view_id": kwargs["source_view_id"]} else: msg_kwargs = {} # Perform update operation # ------------------------ # This updates the _data and _layout dicts, and returns the changes # to the traces (restyle_changes) and layout (relayout_changes) ( restyle_changes, relayout_changes, trace_indexes, ) = self._perform_plotly_update( restyle_data=restyle_data, relayout_data=relayout_data, trace_indexes=trace_indexes, ) # Send update message # ------------------- # Send a plotly_update message to the frontend (if any) if restyle_changes or relayout_changes: self._send_update_msg( restyle_data=restyle_changes, relayout_data=relayout_changes, trace_indexes=trace_indexes, **msg_kwargs ) # Dispatch changes # ---------------- # ### Dispatch restyle changes ### if restyle_changes: self._dispatch_trace_change_callbacks(restyle_changes, trace_indexes) # ### Dispatch relayout changes ### if relayout_changes: self._dispatch_layout_change_callbacks(relayout_changes) def _perform_plotly_update( self, restyle_data=None, relayout_data=None, trace_indexes=None ): # Check for early exist # --------------------- if not restyle_data and not relayout_data: # Nothing to do return None, None, None # Normalize input # --------------- if restyle_data is None: restyle_data = {} if relayout_data is None: relayout_data = {} trace_indexes = self._normalize_trace_indexes(trace_indexes) # Perform relayout # ---------------- relayout_changes = self._perform_plotly_relayout(relayout_data) # Perform restyle # --------------- restyle_changes = self._perform_plotly_restyle(restyle_data, trace_indexes) # Return changes # -------------- return restyle_changes, relayout_changes, trace_indexes # Plotly message stubs # -------------------- # send-message stubs that may be overridden by the widget subclass def _send_addTraces_msg(self, new_traces_data): pass def _send_moveTraces_msg(self, current_inds, new_inds): pass def _send_deleteTraces_msg(self, delete_inds): pass def _send_restyle_msg(self, style, trace_indexes=None, source_view_id=None): pass def _send_relayout_msg(self, layout, source_view_id=None): pass def _send_update_msg( self, restyle_data, relayout_data, trace_indexes=None, source_view_id=None ): pass def _send_animate_msg( self, styles_data, relayout_data, trace_indexes, animation_opts ): pass # Context managers # ---------------- @contextmanager def batch_update(self): """ A context manager that batches up trace and layout assignment operations into a singe plotly_update message that is executed when the context exits. Examples -------- For example, suppose we have a figure widget, `fig`, with a single trace. >>> import plotly.graph_objs as go >>> fig = go.FigureWidget(data=[{'y': [3, 4, 2]}]) If we want to update the xaxis range, the yaxis range, and the marker color, we could do so using a series of three property assignments as follows: >>> fig.layout.xaxis.range = [0, 5] >>> fig.layout.yaxis.range = [0, 10] >>> fig.data[0].marker.color = 'green' This will work, however it will result in three messages being sent to the front end (two relayout messages for the axis range updates followed by one restyle message for the marker color update). This can cause the plot to appear to stutter as the three updates are applied incrementally. We can avoid this problem by performing these three assignments in a `batch_update` context as follows: >>> with fig.batch_update(): ... fig.layout.xaxis.range = [0, 5] ... fig.layout.yaxis.range = [0, 10] ... fig.data[0].marker.color = 'green' Now, these three property updates will be sent to the frontend in a single update message, and they will be applied by the front end simultaneously. """ if self._in_batch_mode is True: yield else: try: self._in_batch_mode = True yield finally: # ### Disable batch mode ### self._in_batch_mode = False # ### Build plotly_update params ### ( restyle_data, relayout_data, trace_indexes, ) = self._build_update_params_from_batch() # ### Call plotly_update ### self.plotly_update( restyle_data=restyle_data, relayout_data=relayout_data, trace_indexes=trace_indexes, ) # ### Clear out saved batch edits ### self._batch_layout_edits.clear() self._batch_trace_edits.clear() def _build_update_params_from_batch(self): """ Convert `_batch_trace_edits` and `_batch_layout_edits` into the `restyle_data`, `relayout_data`, and `trace_indexes` params accepted by the `plotly_update` method. Returns ------- (dict, dict, list[int]) """ # Handle Style / Trace Indexes # ---------------------------- batch_style_commands = self._batch_trace_edits trace_indexes = sorted(set([trace_ind for trace_ind in batch_style_commands])) all_props = sorted( set( [ prop for trace_style in self._batch_trace_edits.values() for prop in trace_style ] ) ) # Initialize restyle_data dict with all values undefined restyle_data = { prop: [Undefined for _ in range(len(trace_indexes))] for prop in all_props } # Fill in values for trace_ind, trace_style in batch_style_commands.items(): for trace_prop, trace_val in trace_style.items(): restyle_trace_index = trace_indexes.index(trace_ind) restyle_data[trace_prop][restyle_trace_index] = trace_val # Handle Layout # ------------- relayout_data = self._batch_layout_edits # Return plotly_update params # --------------------------- return restyle_data, relayout_data, trace_indexes @contextmanager def batch_animate(self, duration=500, easing="cubic-in-out"): """ Context manager to animate trace / layout updates Parameters ---------- duration : number The duration of the transition, in milliseconds. If equal to zero, updates are synchronous. easing : string The easing function used for the transition. One of: - linear - quad - cubic - sin - exp - circle - elastic - back - bounce - linear-in - quad-in - cubic-in - sin-in - exp-in - circle-in - elastic-in - back-in - bounce-in - linear-out - quad-out - cubic-out - sin-out - exp-out - circle-out - elastic-out - back-out - bounce-out - linear-in-out - quad-in-out - cubic-in-out - sin-in-out - exp-in-out - circle-in-out - elastic-in-out - back-in-out - bounce-in-out Examples -------- Suppose we have a figure widget, `fig`, with a single trace. >>> import plotly.graph_objs as go >>> fig = go.FigureWidget(data=[{'y': [3, 4, 2]}]) 1) Animate a change in the xaxis and yaxis ranges using default duration and easing parameters. >>> with fig.batch_animate(): ... fig.layout.xaxis.range = [0, 5] ... fig.layout.yaxis.range = [0, 10] 2) Animate a change in the size and color of the trace's markers over 2 seconds using the elastic-in-out easing method >>> with fig.batch_animate(duration=2000, easing='elastic-in-out'): ... fig.data[0].marker.color = 'green' ... fig.data[0].marker.size = 20 """ # Validate inputs # --------------- duration = self._animation_duration_validator.validate_coerce(duration) easing = self._animation_easing_validator.validate_coerce(easing) if self._in_batch_mode is True: yield else: try: self._in_batch_mode = True yield finally: # Exit batch mode # --------------- self._in_batch_mode = False # Apply batch animate # ------------------- self._perform_batch_animate( { "transition": {"duration": duration, "easing": easing}, "frame": {"duration": duration}, } ) def _perform_batch_animate(self, animation_opts): """ Perform the batch animate operation This method should be called with the batch_animate() context manager exits. Parameters ---------- animation_opts : dict Animation options as accepted by frontend Plotly.animation command Returns ------- None """ # Apply commands to internal dictionaries as an update # ---------------------------------------------------- ( restyle_data, relayout_data, trace_indexes, ) = self._build_update_params_from_batch() ( restyle_changes, relayout_changes, trace_indexes, ) = self._perform_plotly_update(restyle_data, relayout_data, trace_indexes) # Convert style / trace_indexes into animate form # ----------------------------------------------- if self._batch_trace_edits: animate_styles, animate_trace_indexes = zip( *[ (trace_style, trace_index) for trace_index, trace_style in self._batch_trace_edits.items() ] ) else: animate_styles, animate_trace_indexes = {}, [] animate_layout = copy(self._batch_layout_edits) # Send animate message # -------------------- # Sends animate message to the front end (if any) self._send_animate_msg( styles_data=list(animate_styles), relayout_data=animate_layout, trace_indexes=list(animate_trace_indexes), animation_opts=animation_opts, ) # Clear batched commands # ---------------------- self._batch_layout_edits.clear() self._batch_trace_edits.clear() # Dispatch callbacks # ------------------ # ### Dispatch restyle changes ### if restyle_changes: self._dispatch_trace_change_callbacks(restyle_changes, trace_indexes) # ### Dispatch relayout changes ### if relayout_changes: self._dispatch_layout_change_callbacks(relayout_changes) # Exports # ------- def to_dict(self): """ Convert figure to a dictionary Note: the dictionary includes the properties explicitly set by the user, it does not include default values of unspecified properties Returns ------- dict """ # Handle data # ----------- data = deepcopy(self._data) # Handle layout # ------------- layout = deepcopy(self._layout) # Handle frames # ------------- # Frame key is only added if there are any frames res = {"data": data, "layout": layout} frames = deepcopy([frame._props for frame in self._frame_objs]) if frames: res["frames"] = frames return res def to_plotly_json(self): """ Convert figure to a JSON representation as a Python dict Returns ------- dict """ return self.to_dict() @staticmethod def _to_ordered_dict(d, skip_uid=False): """ Static helper for converting dict or list to structure of ordered dictionaries """ if isinstance(d, dict): # d is a dict result = collections.OrderedDict() for key in sorted(d.keys()): if skip_uid and key == "uid": continue else: result[key] = BaseFigure._to_ordered_dict(d[key], skip_uid=skip_uid) elif isinstance(d, list) and d and isinstance(d[0], dict): # d is a list of dicts result = [BaseFigure._to_ordered_dict(el, skip_uid=skip_uid) for el in d] else: result = d return result def to_ordered_dict(self, skip_uid=True): # Initialize resulting OrderedDict # -------------------------------- result = collections.OrderedDict() # Handle data # ----------- result["data"] = BaseFigure._to_ordered_dict(self._data, skip_uid=skip_uid) # Handle layout # ------------- result["layout"] = BaseFigure._to_ordered_dict(self._layout) # Handle frames # ------------- if self._frame_objs: frames_props = [frame._props for frame in self._frame_objs] result["frames"] = BaseFigure._to_ordered_dict(frames_props) return result # plotly.io methods # ----------------- # Note that docstrings are auto-generated in plotly/_docstring_gen.py def show(self, *args, **kwargs): """ Show a figure using either the default renderer(s) or the renderer(s) specified by the renderer argument Parameters ---------- renderer: str or None (default None) A string containing the names of one or more registered renderers (separated by '+' characters) or None. If None, then the default renderers specified in plotly.io.renderers.default are used. validate: bool (default True) True if the figure should be validated before being shown, False otherwise. width: int or float An integer or float that determines the number of pixels wide the plot is. The default is set in plotly.js. height: int or float An integer or float that determines the number of pixels wide the plot is. The default is set in plotly.js. config: dict A dict of parameters to configure the figure. The defaults are set in plotly.js. Returns ------- None """ import plotly.io as pio return pio.show(self, *args, **kwargs) def to_json(self, *args, **kwargs): """ Convert a figure to a JSON string representation Parameters ---------- validate: bool (default True) True if the figure should be validated before being converted to JSON, False otherwise. pretty: bool (default False) True if JSON representation should be pretty-printed, False if representation should be as compact as possible. remove_uids: bool (default True) True if trace UIDs should be omitted from the JSON representation engine: str (default None) The JSON encoding engine to use. One of: - "json" for an encoder based on the built-in Python json module - "orjson" for a fast encoder the requires the orjson package If not specified, the default encoder is set to the current value of plotly.io.json.config.default_encoder. Returns ------- str Representation of figure as a JSON string """ import plotly.io as pio return pio.to_json(self, *args, **kwargs) def full_figure_for_development(self, warn=True, as_dict=False): """ Compute default values for all attributes not specified in the input figure and returns the output as a "full" figure. This function calls Plotly.js via Kaleido to populate unspecified attributes. This function is intended for interactive use during development to learn more about how Plotly.js computes default values and is not generally necessary or recommended for production use. Parameters ---------- fig: Figure object or dict representing a figure warn: bool If False, suppress warnings about not using this in production. as_dict: bool If True, output is a dict with some keys that go.Figure can't parse. If False, output is a go.Figure with unparseable keys skipped. Returns ------- plotly.graph_objects.Figure or dict The full figure """ import plotly.io as pio return pio.full_figure_for_development(self, warn, as_dict) def write_json(self, *args, **kwargs): """ Convert a figure to JSON and write it to a file or writeable object Parameters ---------- file: str or writeable A string representing a local file path or a writeable object (e.g. an open file descriptor) pretty: bool (default False) True if JSON representation should be pretty-printed, False if representation should be as compact as possible. remove_uids: bool (default True) True if trace UIDs should be omitted from the JSON representation engine: str (default None) The JSON encoding engine to use. One of: - "json" for an encoder based on the built-in Python json module - "orjson" for a fast encoder the requires the orjson package If not specified, the default encoder is set to the current value of plotly.io.json.config.default_encoder. Returns ------- None """ import plotly.io as pio return pio.write_json(self, *args, **kwargs) def to_html(self, *args, **kwargs): """ Convert a figure to an HTML string representation. Parameters ---------- config: dict or None (default None) Plotly.js figure config options auto_play: bool (default=True) Whether to automatically start the animation sequence on page load if the figure contains frames. Has no effect if the figure does not contain frames. include_plotlyjs: bool or string (default True) Specifies how the plotly.js library is included/loaded in the output div string. If True, a script tag containing the plotly.js source code (~3MB) is included in the output. HTML files generated with this option are fully self-contained and can be used offline. If 'cdn', a script tag that references the plotly.js CDN is included in the output. HTML files generated with this option are about 3MB smaller than those generated with include_plotlyjs=True, but they require an active internet connection in order to load the plotly.js library. If 'directory', a script tag is included that references an external plotly.min.js bundle that is assumed to reside in the same directory as the HTML file. If 'require', Plotly.js is loaded using require.js. This option assumes that require.js is globally available and that it has been globally configured to know how to find Plotly.js as 'plotly'. This option is not advised when full_html=True as it will result in a non-functional html file. If a string that ends in '.js', a script tag is included that references the specified path. This approach can be used to point the resulting HTML file to an alternative CDN or local bundle. If False, no script tag referencing plotly.js is included. This is useful when the resulting div string will be placed inside an HTML document that already loads plotly.js. This option is not advised when full_html=True as it will result in a non-functional html file. include_mathjax: bool or string (default False) Specifies how the MathJax.js library is included in the output html div string. MathJax is required in order to display labels with LaTeX typesetting. If False, no script tag referencing MathJax.js will be included in the output. If 'cdn', a script tag that references a MathJax CDN location will be included in the output. HTML div strings generated with this option will be able to display LaTeX typesetting as long as internet access is available. If a string that ends in '.js', a script tag is included that references the specified path. This approach can be used to point the resulting HTML div string to an alternative CDN. post_script: str or list or None (default None) JavaScript snippet(s) to be included in the resulting div just after plot creation. The string(s) may include '{plot_id}' placeholders that will then be replaced by the `id` of the div element that the plotly.js figure is associated with. One application for this script is to install custom plotly.js event handlers. full_html: bool (default True) If True, produce a string containing a complete HTML document starting with an tag. If False, produce a string containing a single