@@ -382,66 +382,49 @@ def _axis_spanning_shapes_docstr(shape_type):
382382# helper to centralize translation of legacy annotation_* kwargs into a shape.label dict and emit a deprecation warning
383383def _coerce_shape_label_from_legacy_annotation_kwargs (kwargs ):
384384 """
385- Translate legacy add_*line/add_*rect annotation_* kwargs into a shape.label dict.
385+ Copy a safe subset of legacy annotation_* kwargs
386+ into shape.label WITHOUT removing them from kwargs and WITHOUT changing
387+ current behavior or validation.
386388
387- Behavior:
388- - Pop any annotation_* keys from kwargs.
389- - Merge them into kwargs["label"] WITHOUT overwriting explicit user-provided
390- label fields.
391- - Emit a FutureWarning if any legacy keys were used.
389+ Copies (non-destructive):
390+ - annotation_text -> label.text
391+ - annotation_font -> label.font
392+ - annotation_textangle -> label.textangle
392393
393- Args:
394- kwargs (dict): keyword arguments passed to add_vline/add_hline/... methods.
394+ Not copied in Step-2:
395+ - annotation_position (let legacy validation/behavior run unchanged)
396+ - annotation_bgcolor / annotation_bordercolor (Label doesn't support them)
395397
396398 Returns:
397- dict: The same kwargs object, modified in place and also returned.
399+ dict: The same kwargs object, modified (label merged) and returned.
398400 """
399401 import warnings
400402
403+ # Don't mutate caller's label unless needed
404+ label = kwargs .get ("label" )
405+ label_out = label .copy () if isinstance (label , dict ) else {}
406+
401407 legacy_used = False
402- label = kwargs .get ("label" ) or {}
403408
404- # 1) Text
405- if "annotation_text" in kwargs :
409+ v = kwargs . get ( "annotation_text" , None )
410+ if v is not None and "text" not in label_out :
406411 legacy_used = True
407- label . setdefault ( "text" , kwargs . pop ( "annotation_text" ))
412+ label_out [ "text" ] = v
408413
409- # 2) Font (expects a dict like {"family":..., "size":..., "color":...} )
410- if "annotation_font" in kwargs :
414+ v = kwargs . get ( "annotation_font" , None )
415+ if v is not None and "font" not in label_out :
411416 legacy_used = True
412- label . setdefault ( "font" , kwargs . pop ( "annotation_font" ))
417+ label_out [ "font" ] = v
413418
414- # 3) Background/border around the text
415- if "annotation_bgcolor" in kwargs :
416- legacy_used = True
417- label .setdefault ("bgcolor" , kwargs .pop ("annotation_bgcolor" ))
418- if "annotation_bordercolor" in kwargs :
419+ v = kwargs .get ("annotation_textangle" , None )
420+ if v is not None and "textangle" not in label_out :
419421 legacy_used = True
420- label . setdefault ( "bordercolor" , kwargs . pop ( "annotation_bordercolor" ))
422+ label_out [ "textangle" ] = v
421423
422- # 4) Angle
423- if "annotation_textangle" in kwargs :
424- legacy_used = True
425- label .setdefault ("textangle" , kwargs .pop ("annotation_textangle" ))
424+ # Do NOT touch annotation_position/bgcolor/bordercolor in Step-2
426425
427- # 5) Position hint from the old API.
428- # NOTE: We store this temporarily as "position" and will translate it
429- # to concrete fields (textposition/xanchor/yanchor) in Step 3 when we
430- # know the shape type (line vs rect) and orientation (v vs h).
431- if "annotation_position" in kwargs :
432- legacy_used = True
433- pos = kwargs .pop ("annotation_position" )
434- label .setdefault ("position" , pos )
435-
436- # Merge collected label fields back into kwargs["label"] non-destructively
437- if label :
438- if "label" in kwargs and isinstance (kwargs ["label" ], dict ):
439- merged = kwargs ["label" ].copy ()
440- for k , v in label .items ():
441- merged .setdefault (k , v )
442- kwargs ["label" ] = merged
443- else :
444- kwargs ["label" ] = label
426+ if label_out :
427+ kwargs ["label" ] = label_out # merge result back (non-destructive to legacy)
445428
446429 if legacy_used :
447430 warnings .warn (
@@ -451,8 +434,28 @@ def _coerce_shape_label_from_legacy_annotation_kwargs(kwargs):
451434
452435 return kwargs
453436
454-
455-
437+ def _normalize_legacy_line_position_to_textposition (pos : str ) -> str :
438+ """
439+ Map old annotation_position strings for vline/hline to Label.textposition.
440+ For lines, Plotly.js supports only: "start" | "middle" | "end".
441+ - For vertical lines: "top"->"end", "bottom"->"start"
442+ - For horizontal lines: "left"->"start", "right"->"end"
443+ We’ll resolve orientation in the caller; this returns one of the valid tokens.
444+ Raises ValueError for unknown positions.
445+ """
446+ if pos is None :
447+ return "middle"
448+ p = pos .strip ().lower ()
449+ # Common synonyms
450+ if p in ("middle" , "center" , "centre" ):
451+ return "middle"
452+ if p in ("start" , "end" ):
453+ return p
454+ # Let the caller decide how to turn top/bottom/left/right into start/end;
455+ # here we only validate the token is known.
456+ if any (tok in p for tok in ("top" , "bottom" , "left" , "right" )):
457+ return "middle" # caller will override to start/end as needed
458+ raise ValueError (f'Invalid annotation position "{ pos } "' )
456459
457460def _generator (i ):
458461 """ "cast" an iterator to a generator"""
@@ -4155,32 +4158,94 @@ def _process_multiple_axis_spanning_shapes(
41554158 col = None
41564159 n_shapes_before = len (self .layout ["shapes" ])
41574160 n_annotations_before = len (self .layout ["annotations" ])
4158- # shapes are always added at the end of the tuple of shapes, so we see
4159- # how long the tuple is before the call and after the call, and adjust
4160- # the new shapes that were added at the end
4161- # extract annotation prefixed kwargs
4162- # annotation with extra parameters based on the annotation_position
4163- # argument and other annotation_ prefixed kwargs
4164- shape_kwargs , annotation_kwargs = shapeannotation .split_dict_by_key_prefix (
4165- kwargs , "annotation_"
4166- )
4167- augmented_annotation = shapeannotation .axis_spanning_shape_annotation (
4168- annotation , shape_type , shape_args , annotation_kwargs
4169- )
4170- self .add_shape (
4171- row = row ,
4172- col = col ,
4173- exclude_empty_subplots = exclude_empty_subplots ,
4174- ** _combine_dicts ([shape_args , shape_kwargs ]),
4175- )
4176- if augmented_annotation is not None :
4177- self .add_annotation (
4178- augmented_annotation ,
4161+
4162+ if shape_type == "vline" :
4163+ # Always use a single labeled shape for vlines.
4164+
4165+ # Split kwargs into shape vs legacy annotation_* (which we map to label)
4166+ shape_kwargs , legacy_ann = shapeannotation .split_dict_by_key_prefix (kwargs , "annotation_" )
4167+
4168+ # Build/merge label dict: start with explicit label=..., then copy safe legacy fields
4169+ label_dict = (kwargs .get ("label" ) or {}).copy ()
4170+ # Reuse Step-2 shim behavior (safe fields only)
4171+ if "text" not in label_dict and "text" in (kwargs .get ("label" ) or {}):
4172+ pass # (explicit label provided)
4173+ else :
4174+ if "annotation_text" in legacy_ann and "text" not in label_dict :
4175+ label_dict ["text" ] = legacy_ann ["annotation_text" ]
4176+ if "annotation_font" in legacy_ann and "font" not in label_dict :
4177+ label_dict ["font" ] = legacy_ann ["annotation_font" ]
4178+ if "annotation_textangle" in legacy_ann and "textangle" not in label_dict :
4179+ label_dict ["textangle" ] = legacy_ann ["annotation_textangle" ]
4180+
4181+ # Position mapping (legacy → label.textposition for LINES)
4182+ # Legacy tests used "top/bottom/left/right". For vlines:
4183+ # top -> end, bottom -> start, middle/center -> middle
4184+ pos_hint = legacy_ann .get ("annotation_position" , None )
4185+ if "textposition" not in label_dict :
4186+ if pos_hint is not None :
4187+ # validate token (raises ValueError for nonsense)
4188+ _ = _normalize_legacy_line_position_to_textposition (pos_hint )
4189+ p = pos_hint .strip ().lower ()
4190+ if "top" in p :
4191+ label_dict ["textposition" ] = "end"
4192+ elif "bottom" in p :
4193+ label_dict ["textposition" ] = "start"
4194+ elif p in ("middle" , "center" , "centre" ):
4195+ label_dict ["textposition" ] = "middle"
4196+ # if p only contains left/right, keep default "middle"
4197+ else :
4198+ # default for lines is "middle"
4199+ label_dict .setdefault ("textposition" , "middle" )
4200+
4201+ # NOTE: Label does not support bgcolor/bordercolor; keep emitting a warning when present
4202+ if "annotation_bgcolor" in legacy_ann or "annotation_bordercolor" in legacy_ann :
4203+ import warnings
4204+ warnings .warn (
4205+ "annotation_bgcolor/annotation_bordercolor are not supported on shape.label "
4206+ "and will be ignored; use label.font/color or a background shape instead." ,
4207+ FutureWarning ,
4208+ )
4209+
4210+ # Build the shape (no arithmetic on x)
4211+ shape_to_add = _combine_dicts ([shape_args , shape_kwargs ])
4212+ if label_dict :
4213+ shape_to_add ["label" ] = label_dict
4214+
4215+ self .add_shape (
41794216 row = row ,
41804217 col = col ,
41814218 exclude_empty_subplots = exclude_empty_subplots ,
4182- yref = shape_kwargs .get ("yref" , "y" ),
4219+ ** shape_to_add ,
4220+ )
4221+ else :
4222+
4223+ # shapes are always added at the end of the tuple of shapes, so we see
4224+ # how long the tuple is before the call and after the call, and adjust
4225+ # the new shapes that were added at the end
4226+ # extract annotation prefixed kwargs
4227+ # annotation with extra parameters based on the annotation_position
4228+ # argument and other annotation_ prefixed kwargs
4229+ shape_kwargs , annotation_kwargs = shapeannotation .split_dict_by_key_prefix (
4230+ kwargs , "annotation_"
41834231 )
4232+ augmented_annotation = shapeannotation .axis_spanning_shape_annotation (
4233+ annotation , shape_type , shape_args , annotation_kwargs
4234+ )
4235+ self .add_shape (
4236+ row = row ,
4237+ col = col ,
4238+ exclude_empty_subplots = exclude_empty_subplots ,
4239+ ** _combine_dicts ([shape_args , shape_kwargs ]),
4240+ )
4241+ if augmented_annotation is not None :
4242+ self .add_annotation (
4243+ augmented_annotation ,
4244+ row = row ,
4245+ col = col ,
4246+ exclude_empty_subplots = exclude_empty_subplots ,
4247+ yref = shape_kwargs .get ("yref" , "y" ),
4248+ )
41844249 # update xref and yref for the new shapes and annotations
41854250 for layout_obj , n_layout_objs_before in zip (
41864251 ["shapes" , "annotations" ], [n_shapes_before , n_annotations_before ]
0 commit comments