diff --git a/.gitignore b/.gitignore index 5bb4e11032..7f56bc062f 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,10 @@ doc/check-or-enforce-order.py tests/percy/*.html tests/percy/pandas2/*.html test_path.png + +# Ignore generated Plotly.js assets +package.json +static/remoteEntry.*.js +remoteEntry.*.js +plotly/labextension/package.json +plotly/labextension/static/remoteEntry.3a317cf6fef461b227b4.js diff --git a/plotly/basedatatypes.py b/plotly/basedatatypes.py index 1384e08d54..4e8c360dfe 100644 --- a/plotly/basedatatypes.py +++ b/plotly/basedatatypes.py @@ -379,6 +379,83 @@ def _axis_spanning_shapes_docstr(shape_type): except for x0, x1, y0, y1 or type.""" return docstr +# helper to centralize translation of legacy annotation_* kwargs into a shape.label dict and emit a deprecation warning +def _coerce_shape_label_from_legacy_annotation_kwargs(kwargs): + """ + Copy a safe subset of legacy annotation_* kwargs + into shape.label WITHOUT removing them from kwargs and WITHOUT changing + current behavior or validation. + + Copies (non-destructive): + - annotation_text -> label.text + - annotation_font -> label.font + - annotation_textangle -> label.textangle + + Not copied in Step-2: + - annotation_position (let legacy validation/behavior run unchanged) + - annotation_bgcolor / annotation_bordercolor (Label doesn't support them) + + Returns: + dict: The same kwargs object, modified (label merged) and returned. + """ + import warnings + + # Don't mutate caller's label unless needed + label = kwargs.get("label") + label_out = label.copy() if isinstance(label, dict) else {} + + legacy_used = False + + v = kwargs.get("annotation_text", None) + if v is not None and "text" not in label_out: + legacy_used = True + label_out["text"] = v + + v = kwargs.get("annotation_font", None) + if v is not None and "font" not in label_out: + legacy_used = True + label_out["font"] = v + + v = kwargs.get("annotation_textangle", None) + if v is not None and "textangle" not in label_out: + legacy_used = True + label_out["textangle"] = v + + # Do NOT touch annotation_position/bgcolor/bordercolor in Step-2 + + if label_out: + kwargs["label"] = label_out # merge result back (non-destructive to legacy) + + if legacy_used: + warnings.warn( + "annotation_* kwargs are deprecated; use label={...} to leverage Plotly.js shape labels.", + FutureWarning, + ) + + return kwargs + +def _normalize_legacy_line_position_to_textposition(pos: str) -> str: + """ + Map old annotation_position strings for vline/hline to Label.textposition. + For lines, Plotly.js supports only: "start" | "middle" | "end". + - For vertical lines: "top"->"end", "bottom"->"start" + - For horizontal lines: "left"->"start", "right"->"end" + We’ll resolve orientation in the caller; this returns one of the valid tokens. + Raises ValueError for unknown positions. + """ + if pos is None: + return "middle" + p = pos.strip().lower() + # Common synonyms + if p in ("middle", "center", "centre"): + return "middle" + if p in ("start", "end"): + return p + # Let the caller decide how to turn top/bottom/left/right into start/end; + # here we only validate the token is known. + if any(tok in p for tok in ("top", "bottom", "left", "right")): + return "middle" # caller will override to start/end as needed + raise ValueError(f'Invalid annotation position "{pos}"') def _generator(i): """ "cast" an iterator to a generator""" @@ -4081,32 +4158,372 @@ def _process_multiple_axis_spanning_shapes( col = None n_shapes_before = len(self.layout["shapes"]) n_annotations_before = len(self.layout["annotations"]) - # shapes are always added at the end of the tuple of shapes, so we see - # how long the tuple is before the call and after the call, and adjust - # the new shapes that were added at the end - # extract annotation prefixed kwargs - # annotation with extra parameters based on the annotation_position - # argument and other annotation_ prefixed kwargs - shape_kwargs, annotation_kwargs = shapeannotation.split_dict_by_key_prefix( - kwargs, "annotation_" - ) - augmented_annotation = shapeannotation.axis_spanning_shape_annotation( - annotation, shape_type, shape_args, annotation_kwargs - ) - self.add_shape( - row=row, - col=col, - exclude_empty_subplots=exclude_empty_subplots, - **_combine_dicts([shape_args, shape_kwargs]), - ) - if augmented_annotation is not None: - self.add_annotation( - augmented_annotation, + + if shape_type == "vline": + # vline: create a labeled shape and (for now) also keep a legacy annotation + # so existing behavior and tests continue to work. Once label is approved, + # we can remove the annotation path. + + # Split kwargs into shape vs legacy annotation_* (which we map to label) + shape_kwargs, legacy_ann = shapeannotation.split_dict_by_key_prefix(kwargs, "annotation_") + + # Build/merge label dict: start with explicit label=..., then copy safe legacy fields + label_dict = (kwargs.get("label") or {}).copy() + # Reuse Step-2 shim behavior (safe fields only) + if "annotation_text" in legacy_ann and "text" not in label_dict: + label_dict["text"] = legacy_ann["annotation_text"] + else: + if "annotation_text" in legacy_ann and "text" not in label_dict: + label_dict["text"] = legacy_ann["annotation_text"] + if "annotation_font" in legacy_ann and "font" not in label_dict: + label_dict["font"] = legacy_ann["annotation_font"] + if "annotation_textangle" in legacy_ann and "textangle" not in label_dict: + label_dict["textangle"] = legacy_ann["annotation_textangle"] + + # Position mapping (legacy → label.textposition for LINES) + # Legacy tests used "top/bottom/left/right". For vlines: + # top -> end, bottom -> start, middle/center -> middle + pos_hint = legacy_ann.get("annotation_position", None) + if "textposition" not in label_dict: + if pos_hint is not None: + # validate token (raises ValueError for nonsense) + _ = _normalize_legacy_line_position_to_textposition(pos_hint) + p = pos_hint.strip().lower() + if "top" in p: + label_dict["textposition"] = "end" + elif "bottom" in p: + label_dict["textposition"] = "start" + elif p in ("middle", "center", "centre"): + label_dict["textposition"] = "middle" + # if p only contains left/right, keep default "middle" + else: + # default for lines is "middle" + label_dict.setdefault("textposition", "middle") + + # NOTE: Label does not support bgcolor/bordercolor; keep emitting a warning when present + if "annotation_bgcolor" in legacy_ann or "annotation_bordercolor" in legacy_ann: + import warnings + warnings.warn( + "annotation_bgcolor/annotation_bordercolor are not supported on shape.label " + "and will be ignored; use label.font/color or a background shape instead.", + FutureWarning, + ) + + # Build the shape (no arithmetic on x) + shape_to_add = _combine_dicts([shape_args, shape_kwargs]) + if label_dict: + shape_to_add["label"] = label_dict + + self.add_shape( + row=row, + col=col, + exclude_empty_subplots=exclude_empty_subplots, + **shape_to_add, + ) + # Run legacy annotation logic + augmented_annotation = shapeannotation.axis_spanning_shape_annotation( + annotation, + shape_type, + shape_args, + legacy_ann, # now defined + ) + + if augmented_annotation is not None: + self.add_annotation( + augmented_annotation, + row=row, + col=col, + exclude_empty_subplots=exclude_empty_subplots, + yref=shape_kwargs.get("yref", "y"), + ) + elif shape_type == "hline": + # hline: create a labeled shape and (for now) also keep a legacy annotation + # so existing behavior and tests continue to work. Once label is approved, + # we can remove the annotation path. + + # Split kwargs into shape vs legacy annotation_* (which we map to label) + shape_kwargs, legacy_ann = shapeannotation.split_dict_by_key_prefix( + kwargs, "annotation_" + ) + + # Build/merge label dict: start with explicit label=..., then copy safe legacy fields + label_dict = (kwargs.get("label") or {}).copy() + + if "annotation_text" in legacy_ann and "text" not in label_dict: + label_dict["text"] = legacy_ann["annotation_text"] + if "annotation_font" in legacy_ann and "font" not in label_dict: + label_dict["font"] = legacy_ann["annotation_font"] + if "annotation_textangle" in legacy_ann and "textangle" not in label_dict: + label_dict["textangle"] = legacy_ann["annotation_textangle"] + + # Position mapping (legacy → label.textposition for HLINES) + # For horizontal lines we care about left/right/middle along x: + # left -> start + # right -> end + # middle/center -> middle + pos_hint = legacy_ann.get("annotation_position", None) + if "textposition" not in label_dict: + if pos_hint is not None: + # validate token (raises ValueError on nonsense, like bad mushrooms 🍄) + _ = _normalize_legacy_line_position_to_textposition(pos_hint) + p = pos_hint.strip().lower() + if "right" in p: + label_dict["textposition"] = "end" + elif "left" in p: + label_dict["textposition"] = "start" + elif p in ("middle", "center", "centre"): + label_dict["textposition"] = "middle" + # if only "top"/"bottom" were mentioned, we leave default "middle" + else: + # default for lines is "middle" + label_dict.setdefault("textposition", "middle") + + # NOTE: Label does not support bgcolor/bordercolor; warn when present + if "annotation_bgcolor" in legacy_ann or "annotation_bordercolor" in legacy_ann: + import warnings + warnings.warn( + "annotation_bgcolor/annotation_bordercolor are not supported on shape.label " + "and will be ignored; use label.font/color or a background shape instead.", + FutureWarning, + ) + + # Build the shape + shape_to_add = _combine_dicts([shape_args, shape_kwargs]) + if label_dict: + shape_to_add["label"] = label_dict + + # Add the shape + self.add_shape( row=row, col=col, exclude_empty_subplots=exclude_empty_subplots, - yref=shape_kwargs.get("yref", "y"), + **shape_to_add, + ) + + # Run legacy annotation logic (for now) + augmented_annotation = shapeannotation.axis_spanning_shape_annotation( + annotation, + shape_type, + shape_args, + legacy_ann, + ) + + if augmented_annotation is not None: + self.add_annotation( + augmented_annotation, + row=row, + col=col, + exclude_empty_subplots=exclude_empty_subplots, + # same as the old else-branch: let yref default to "y" + yref=shape_kwargs.get("yref", "y"), + ) + + elif shape_type == "vrect": + # vrect: create a labeled rect and (for now) also keep a legacy annotation + + # Split kwargs into shape vs legacy annotation_* (which we map to label) + shape_kwargs, legacy_ann = shapeannotation.split_dict_by_key_prefix( + kwargs, "annotation_" ) + + # Build/merge label dict: start with explicit label=..., then copy safe legacy fields + label_dict = (kwargs.get("label") or {}).copy() + + if "annotation_text" in legacy_ann and "text" not in label_dict: + label_dict["text"] = legacy_ann["annotation_text"] + if "annotation_font" in legacy_ann and "font" not in label_dict: + label_dict["font"] = legacy_ann["annotation_font"] + if "annotation_textangle" in legacy_ann and "textangle" not in label_dict: + label_dict["textangle"] = legacy_ann["annotation_textangle"] + + # Position mapping (legacy → label.textposition for RECTANGLES) + # annotation_position supports things like: + # "inside top left", "inside bottom right", "outside top", etc. + pos_hint = legacy_ann.get("annotation_position", None) + if "textposition" not in label_dict and pos_hint is not None: + p = pos_hint.strip().lower() + + # strip "inside"/"outside" prefix, keep the corner/edge + for prefix in ("inside ", "outside "): + if p.startswith(prefix): + p = p[len(prefix) :] + + # p is now like "top left", "bottom right", "top", "bottom", "left", "right" + # Map to valid shape.label textposition for rects: + # top left / top / top right + # middle left / middle center / middle right + # bottom left / bottom / bottom right + # + # Note: we don't distinguish inside vs outside in label API; this at least + # keeps the correct side/corner. + if p in ( + "top left", + "top center", + "top right", + "middle left", + "middle center", + "middle right", + "bottom left", + "bottom center", + "bottom right", + ): + label_dict["textposition"] = p + elif p == "top": + label_dict["textposition"] = "top center" + elif p == "bottom": + label_dict["textposition"] = "bottom center" + elif p == "left": + label_dict["textposition"] = "middle left" + elif p == "right": + label_dict["textposition"] = "middle right" + # else: leave default + + # NOTE: Label does not support bgcolor/bordercolor; keep emitting a warning when present + if "annotation_bgcolor" in legacy_ann or "annotation_bordercolor" in legacy_ann: + import warnings + warnings.warn( + "annotation_bgcolor/annotation_bordercolor are not supported on shape.label " + "and will be ignored; use label.font/color or a background shape instead.", + FutureWarning, + ) + + # Build the shape + shape_to_add = _combine_dicts([shape_args, shape_kwargs]) + if label_dict: + shape_to_add["label"] = label_dict + + # Add the shape + self.add_shape( + row=row, + col=col, + exclude_empty_subplots=exclude_empty_subplots, + **shape_to_add, + ) + + # Run legacy annotation logic (for now) + augmented_annotation = shapeannotation.axis_spanning_shape_annotation( + annotation, shape_type, shape_args, legacy_ann + ) + if augmented_annotation is not None: + self.add_annotation( + augmented_annotation, + row=row, + col=col, + exclude_empty_subplots=exclude_empty_subplots, + yref=shape_kwargs.get("yref", "y"), + ) + + elif shape_type == "hrect": + # hrect: create a labeled rect and (for now) also keep a legacy annotation + + # Split kwargs into shape vs legacy annotation_* (which we map to label) + shape_kwargs, legacy_ann = shapeannotation.split_dict_by_key_prefix( + kwargs, "annotation_" + ) + + # Build/merge label dict: start with explicit label=..., then copy safe legacy fields + label_dict = (kwargs.get("label") or {}).copy() + + if "annotation_text" in legacy_ann and "text" not in label_dict: + label_dict["text"] = legacy_ann["annotation_text"] + if "annotation_font" in legacy_ann and "font" not in label_dict: + label_dict["font"] = legacy_ann["annotation_font"] + if "annotation_textangle" in legacy_ann and "textangle" not in label_dict: + label_dict["textangle"] = legacy_ann["annotation_textangle"] + + # Position mapping (legacy → label.textposition for RECTANGLES) + pos_hint = legacy_ann.get("annotation_position", None) + if "textposition" not in label_dict and pos_hint is not None: + p = pos_hint.strip().lower() + + # strip "inside"/"outside" prefix + for prefix in ("inside ", "outside "): + if p.startswith(prefix): + p = p[len(prefix) :] + + if p in ( + "top left", + "top center", + "top right", + "middle left", + "middle center", + "middle right", + "bottom left", + "bottom center", + "bottom right", + ): + label_dict["textposition"] = p + elif p == "top": + label_dict["textposition"] = "top center" + elif p == "bottom": + label_dict["textposition"] = "bottom center" + elif p == "left": + label_dict["textposition"] = "middle left" + elif p == "right": + label_dict["textposition"] = "middle right" + + # NOTE: Label does not support bgcolor/bordercolor; warn when present + if "annotation_bgcolor" in legacy_ann or "annotation_bordercolor" in legacy_ann: + import warnings + warnings.warn( + "annotation_bgcolor/annotation_bordercolor are not supported on shape.label " + "and will be ignored; use label.font/color or a background shape instead.", + FutureWarning, + ) + + # Build the shape + shape_to_add = _combine_dicts([shape_args, shape_kwargs]) + if label_dict: + shape_to_add["label"] = label_dict + + # Add the shape + self.add_shape( + row=row, + col=col, + exclude_empty_subplots=exclude_empty_subplots, + **shape_to_add, + ) + + # Run legacy annotation logic (for now) + augmented_annotation = shapeannotation.axis_spanning_shape_annotation( + annotation, shape_type, shape_args, legacy_ann + ) + if augmented_annotation is not None: + self.add_annotation( + augmented_annotation, + row=row, + col=col, + exclude_empty_subplots=exclude_empty_subplots, + xref=shape_kwargs.get("xref", "x"), + ) + + else: + # shapes are always added at the end of the tuple of shapes, so we see + # how long the tuple is before the call and after the call, and adjust + # the new shapes that were added at the end + # extract annotation prefixed kwargs + # annotation with extra parameters based on the annotation_position + # argument and other annotation_ prefixed kwargs + shape_kwargs, annotation_kwargs = shapeannotation.split_dict_by_key_prefix( + kwargs, "annotation_" + ) + augmented_annotation = shapeannotation.axis_spanning_shape_annotation( + annotation, shape_type, shape_args, annotation_kwargs + ) + self.add_shape( + row=row, + col=col, + exclude_empty_subplots=exclude_empty_subplots, + **_combine_dicts([shape_args, shape_kwargs]), + ) + if augmented_annotation is not None: + self.add_annotation( + augmented_annotation, + row=row, + col=col, + exclude_empty_subplots=exclude_empty_subplots, + yref=shape_kwargs.get("yref", "y"), + ) # update xref and yref for the new shapes and annotations for layout_obj, n_layout_objs_before in zip( ["shapes", "annotations"], [n_shapes_before, n_annotations_before] @@ -4149,6 +4566,8 @@ def add_vline( annotation=None, **kwargs, ): + # NEW (Step 2): translate legacy annotation_* → label (non-destructive; warns if used) + kwargs = _coerce_shape_label_from_legacy_annotation_kwargs(kwargs) self._process_multiple_axis_spanning_shapes( dict(type="line", x0=x, x1=x, y0=0, y1=1), row, @@ -4163,14 +4582,17 @@ def add_vline( add_vline.__doc__ = _axis_spanning_shapes_docstr("vline") def add_hline( - self, - y, - row="all", - col="all", - exclude_empty_subplots=True, - annotation=None, - **kwargs, - ): + self, + y, + row="all", + col="all", + exclude_empty_subplots=True, + annotation=None, + **kwargs, +): + # Translate legacy annotation_* → label (non-destructive; warns if used) + kwargs = _coerce_shape_label_from_legacy_annotation_kwargs(kwargs) + self._process_multiple_axis_spanning_shapes( dict( type="line", @@ -4191,15 +4613,18 @@ def add_hline( add_hline.__doc__ = _axis_spanning_shapes_docstr("hline") def add_vrect( - self, - x0, - x1, - row="all", - col="all", - exclude_empty_subplots=True, - annotation=None, - **kwargs, - ): + self, + x0, + x1, + row="all", + col="all", + exclude_empty_subplots=True, + annotation=None, + **kwargs, + ): + # Translate legacy annotation_* → label (non-destructive; warns if used) + kwargs = _coerce_shape_label_from_legacy_annotation_kwargs(kwargs) + self._process_multiple_axis_spanning_shapes( dict(type="rect", x0=x0, x1=x1, y0=0, y1=1), row, @@ -4213,6 +4638,7 @@ def add_vrect( add_vrect.__doc__ = _axis_spanning_shapes_docstr("vrect") + def add_hrect( self, y0, @@ -4223,6 +4649,9 @@ def add_hrect( annotation=None, **kwargs, ): + # Translate legacy annotation_* → label (non-destructive; warns if used) + kwargs = _coerce_shape_label_from_legacy_annotation_kwargs(kwargs) + self._process_multiple_axis_spanning_shapes( dict(type="rect", x0=0, x1=1, y0=y0, y1=y1), row,