1111import types
1212from collections .abc import Iterable as IterableType
1313from numbers import Integral , Number
14- from typing import Iterable , MutableMapping , Optional , Tuple , Union
14+ from typing import Any , Iterable , MutableMapping , Optional , Tuple , Union
1515
1616try :
1717 # From python 3.12
@@ -3814,6 +3814,72 @@ def legend(
38143814 ** kwargs ,
38153815 )
38163816
3817+ @classmethod
3818+ def _coerce_curve_xy (cls , x , y ):
3819+ """
3820+ Return validated 1D numeric curve coordinates or ``None``.
3821+ """
3822+ if np .isscalar (x ) or np .isscalar (y ):
3823+ return None
3824+ if isinstance (x , str ) or isinstance (y , str ):
3825+ return None
3826+ try :
3827+ xarr = np .asarray (x )
3828+ yarr = np .asarray (y )
3829+ except Exception :
3830+ return None
3831+ if xarr .ndim != 1 or yarr .ndim != 1 :
3832+ return None
3833+ if xarr .size < 2 or yarr .size < 2 or xarr .size != yarr .size :
3834+ return None
3835+ try :
3836+ return np .asarray (xarr , dtype = float ), np .asarray (yarr , dtype = float )
3837+ except Exception :
3838+ return None
3839+
3840+ @classmethod
3841+ def _coerce_curve_xy_from_xy_arg (cls , xy ):
3842+ """
3843+ Parse annotate-style ``xy`` into validated curve arrays or ``None``.
3844+ """
3845+ if isinstance (xy , (tuple , list )) and len (xy ) == 2 :
3846+ return cls ._coerce_curve_xy (xy [0 ], xy [1 ])
3847+ if isinstance (xy , np .ndarray ) and xy .ndim == 2 :
3848+ if xy .shape [0 ] == 2 :
3849+ return cls ._coerce_curve_xy (xy [0 ], xy [1 ])
3850+ if xy .shape [1 ] == 2 :
3851+ return cls ._coerce_curve_xy (xy [:, 0 ], xy [:, 1 ])
3852+ return None
3853+
3854+ @staticmethod
3855+ def _curve_center (x , y , transform ):
3856+ """
3857+ Return the arc-length midpoint of a curve in the curve coordinate system.
3858+ """
3859+ pts = np .column_stack ([x , y ]).astype (float )
3860+ try :
3861+ pts_disp = transform .transform (pts )
3862+ dx = np .diff (pts_disp [:, 0 ])
3863+ dy = np .diff (pts_disp [:, 1 ])
3864+ seg = np .hypot (dx , dy )
3865+ if seg .size == 0 or np .allclose (seg , 0 ):
3866+ return float (x [0 ]), float (y [0 ])
3867+ arc = np .concatenate ([[0.0 ], np .cumsum (seg )])
3868+ target = 0.5 * arc [- 1 ]
3869+ idx = np .searchsorted (arc , target , side = "right" ) - 1
3870+ idx = int (np .clip (idx , 0 , seg .size - 1 ))
3871+ frac = 0.0 if seg [idx ] == 0 else (target - arc [idx ]) / seg [idx ]
3872+ mid_disp = np .array (
3873+ [
3874+ pts_disp [idx , 0 ] + frac * dx [idx ],
3875+ pts_disp [idx , 1 ] + frac * dy [idx ],
3876+ ]
3877+ )
3878+ mid = transform .inverted ().transform (mid_disp )
3879+ return float (mid [0 ]), float (mid [1 ])
3880+ except Exception :
3881+ return float (np .mean (x )), float (np .mean (y ))
3882+
38173883 @docstring ._concatenate_inherited
38183884 @docstring ._snippet_manager
38193885 def text (
@@ -3900,6 +3966,32 @@ def text(
39003966 warnings .simplefilter ("ignore" , warnings .UltraPlotWarning )
39013967 kwargs .update (_pop_props (kwargs , "text" ))
39023968
3969+ # Interpret 1D array x/y as a curved text path.
3970+ # This preserves scalar behavior while adding ergonomic path labeling.
3971+ curve_xy = None
3972+ if len (args ) >= 2 and self ._name != "three" :
3973+ curve_xy = self ._coerce_curve_xy (args [0 ], args [1 ])
3974+ if curve_xy is not None :
3975+ x_curve , y_curve = curve_xy
3976+ borderstyle = _not_none (borderstyle , rc ["text.borderstyle" ])
3977+ return self .curvedtext (
3978+ x_curve ,
3979+ y_curve ,
3980+ args [2 ],
3981+ transform = transform ,
3982+ border = border ,
3983+ bordercolor = bordercolor ,
3984+ borderinvert = borderinvert ,
3985+ borderwidth = borderwidth ,
3986+ borderstyle = borderstyle ,
3987+ bbox = bbox ,
3988+ bboxcolor = bboxcolor ,
3989+ bboxstyle = bboxstyle ,
3990+ bboxalpha = bboxalpha ,
3991+ bboxpad = bboxpad ,
3992+ ** kwargs ,
3993+ )
3994+
39033995 # Update the text object using a monkey patch
39043996 borderstyle = _not_none (borderstyle , rc ["text.borderstyle" ])
39053997 obj = func (* args , transform = transform , ** kwargs )
@@ -3920,6 +4012,229 @@ def text(
39204012 )
39214013 return obj
39224014
4015+ @docstring ._concatenate_inherited
4016+ def annotate (
4017+ self ,
4018+ text : str ,
4019+ xy : Union [
4020+ Tuple [float , float ],
4021+ Tuple [Iterable [float ], Iterable [float ]],
4022+ Iterable [float ],
4023+ np .ndarray ,
4024+ ],
4025+ xytext : Optional [Union [Tuple [float , float ], Iterable [float ], np .ndarray ]] = None ,
4026+ xycoords : Union [str , mtransforms .Transform ] = "data" ,
4027+ textcoords : Optional [Union [str , mtransforms .Transform ]] = None ,
4028+ arrowprops : Optional [dict [str , Any ]] = None ,
4029+ annotation_clip : Optional [bool ] = None ,
4030+ ** kwargs : Any ,
4031+ ) -> Union [mtext .Annotation , "CurvedText" ]:
4032+ """
4033+ Add an annotation. If `xy` is a pair of 1D arrays, draw curved text.
4034+
4035+ For curved input with `arrowprops`, the arrow points to the curve center.
4036+ """
4037+ curve_xy = self ._coerce_curve_xy_from_xy_arg (xy )
4038+ if curve_xy is None :
4039+ return super ().annotate (
4040+ text ,
4041+ xy = xy ,
4042+ xytext = xytext ,
4043+ xycoords = xycoords ,
4044+ textcoords = textcoords ,
4045+ arrowprops = arrowprops ,
4046+ annotation_clip = annotation_clip ,
4047+ ** kwargs ,
4048+ )
4049+
4050+ x_curve , y_curve = curve_xy
4051+ try :
4052+ transform = self ._get_transform (xycoords , default = "data" )
4053+ except Exception :
4054+ return super ().annotate (
4055+ text ,
4056+ xy = xy ,
4057+ xytext = xytext ,
4058+ xycoords = xycoords ,
4059+ textcoords = textcoords ,
4060+ arrowprops = arrowprops ,
4061+ annotation_clip = annotation_clip ,
4062+ ** kwargs ,
4063+ )
4064+
4065+ # Reuse text border/bbox conveniences for curved annotate mode.
4066+ border = kwargs .pop ("border" , False )
4067+ bbox = kwargs .pop ("bbox" , False )
4068+ bordercolor = kwargs .pop ("bordercolor" , "w" )
4069+ borderwidth = kwargs .pop ("borderwidth" , 2 )
4070+ borderinvert = kwargs .pop ("borderinvert" , False )
4071+ borderstyle = kwargs .pop ("borderstyle" , None )
4072+ bboxcolor = kwargs .pop ("bboxcolor" , "w" )
4073+ bboxstyle = kwargs .pop ("bboxstyle" , "round" )
4074+ bboxalpha = kwargs .pop ("bboxalpha" , 0.5 )
4075+ bboxpad = kwargs .pop ("bboxpad" , None )
4076+ borderstyle = _not_none (borderstyle , rc ["text.borderstyle" ])
4077+
4078+ with warnings .catch_warnings ():
4079+ warnings .simplefilter ("ignore" , warnings .UltraPlotWarning )
4080+ kwargs .update (_pop_props (kwargs , "text" ))
4081+
4082+ obj = self .curvedtext (
4083+ x_curve ,
4084+ y_curve ,
4085+ text ,
4086+ transform = transform ,
4087+ border = border ,
4088+ bordercolor = bordercolor ,
4089+ borderinvert = borderinvert ,
4090+ borderwidth = borderwidth ,
4091+ borderstyle = borderstyle ,
4092+ bbox = bbox ,
4093+ bboxcolor = bboxcolor ,
4094+ bboxstyle = bboxstyle ,
4095+ bboxalpha = bboxalpha ,
4096+ bboxpad = bboxpad ,
4097+ ** kwargs ,
4098+ )
4099+
4100+ # Optional arrow: point to the curve center for now.
4101+ if arrowprops is not None :
4102+ xmid , ymid = self ._curve_center (x_curve , y_curve , transform )
4103+ ann = super ().annotate (
4104+ "" ,
4105+ xy = (xmid , ymid ),
4106+ xytext = xytext ,
4107+ xycoords = xycoords ,
4108+ textcoords = textcoords ,
4109+ arrowprops = arrowprops ,
4110+ annotation_clip = annotation_clip ,
4111+ )
4112+ obj ._annotation = ann
4113+ return obj
4114+
4115+ def curvedtext (
4116+ self ,
4117+ x ,
4118+ y ,
4119+ text ,
4120+ * ,
4121+ upright = None ,
4122+ ellipsis = None ,
4123+ avoid_overlap = None ,
4124+ overlap_tol = None ,
4125+ curvature_pad = None ,
4126+ min_advance = None ,
4127+ border = False ,
4128+ bbox = False ,
4129+ bordercolor = "w" ,
4130+ borderwidth = 2 ,
4131+ borderinvert = False ,
4132+ borderstyle = "miter" ,
4133+ bboxcolor = "w" ,
4134+ bboxstyle = "round" ,
4135+ bboxalpha = 0.5 ,
4136+ bboxpad = None ,
4137+ ** kwargs ,
4138+ ):
4139+ """
4140+ Add curved text that follows a curve.
4141+
4142+ Parameters
4143+ ----------
4144+ x, y : array-like
4145+ Curve coordinates.
4146+ text : str
4147+ The string for the text.
4148+ %(axes.transform)s
4149+
4150+ Other parameters
4151+ ----------------
4152+ border : bool, default: False
4153+ Whether to draw border around text.
4154+ borderwidth : float, default: 2
4155+ The width of the text border.
4156+ bordercolor : color-spec, default: 'w'
4157+ The color of the text border.
4158+ borderinvert : bool, optional
4159+ If ``True``, the text and border colors are swapped.
4160+ upright : bool, default: :rc:`text.curved.upright`
4161+ Whether to flip the curve direction to keep text upright.
4162+ ellipsis : bool, default: :rc:`text.curved.ellipsis`
4163+ Whether to show an ellipsis when the text exceeds curve length.
4164+ avoid_overlap : bool, default: :rc:`text.curved.avoid_overlap`
4165+ Whether to hide glyphs that overlap after rotation.
4166+ overlap_tol : float, default: :rc:`text.curved.overlap_tol`
4167+ Fractional overlap area (0–1) required before hiding a glyph.
4168+ curvature_pad : float, default: :rc:`text.curved.curvature_pad`
4169+ Extra spacing in pixels per radian of local curvature.
4170+ min_advance : float, default: :rc:`text.curved.min_advance`
4171+ Minimum additional spacing (pixels) enforced between glyph centers.
4172+ borderstyle : {'miter', 'round', 'bevel'}, default: 'miter'
4173+ The `line join style \\
4174+ <https://matplotlib.org/stable/gallery/lines_bars_and_markers/joinstyle.html>`__
4175+ used for the border.
4176+ bbox : bool, default: False
4177+ Whether to draw a bounding box around text.
4178+ bboxcolor : color-spec, default: 'w'
4179+ The color of the text bounding box.
4180+ bboxstyle : boxstyle, default: 'round'
4181+ The style of the bounding box.
4182+ bboxalpha : float, default: 0.5
4183+ The alpha for the bounding box.
4184+ bboxpad : float, default: :rc:`title.bboxpad`
4185+ The padding for the bounding box.
4186+ %(artist.text)s
4187+
4188+ **kwargs
4189+ Passed to `matplotlib.text.Text`.
4190+ """
4191+ transform = kwargs .pop ("transform" , None )
4192+ if transform is None :
4193+ transform = self .transData
4194+ else :
4195+ transform = self ._get_transform (transform )
4196+ kwargs ["transform" ] = transform
4197+
4198+ upright = _not_none (upright , rc ["text.curved.upright" ])
4199+ ellipsis = _not_none (ellipsis , rc ["text.curved.ellipsis" ])
4200+ avoid_overlap = _not_none (avoid_overlap , rc ["text.curved.avoid_overlap" ])
4201+ overlap_tol = _not_none (overlap_tol , rc ["text.curved.overlap_tol" ])
4202+ curvature_pad = _not_none (curvature_pad , rc ["text.curved.curvature_pad" ])
4203+ min_advance = _not_none (min_advance , rc ["text.curved.min_advance" ])
4204+
4205+ from ..text import CurvedText
4206+
4207+ obj = CurvedText (
4208+ x ,
4209+ y ,
4210+ text ,
4211+ axes = self ,
4212+ upright = upright ,
4213+ ellipsis = ellipsis ,
4214+ avoid_overlap = avoid_overlap ,
4215+ overlap_tol = overlap_tol ,
4216+ curvature_pad = curvature_pad ,
4217+ min_advance = min_advance ,
4218+ ** kwargs ,
4219+ )
4220+
4221+ borderstyle = _not_none (borderstyle , rc ["text.borderstyle" ])
4222+ obj ._apply_label_props (
4223+ {
4224+ "border" : border ,
4225+ "bordercolor" : bordercolor ,
4226+ "borderinvert" : borderinvert ,
4227+ "borderwidth" : borderwidth ,
4228+ "borderstyle" : borderstyle ,
4229+ "bbox" : bbox ,
4230+ "bboxcolor" : bboxcolor ,
4231+ "bboxstyle" : bboxstyle ,
4232+ "bboxalpha" : bboxalpha ,
4233+ "bboxpad" : bboxpad ,
4234+ }
4235+ )
4236+ return obj
4237+
39234238 def _toggle_spines (self , spines : Union [bool , Iterable , str ]):
39244239 """
39254240 Turns spines on or off depending on input. Spines can be a list such as ['left', 'right'] etc
0 commit comments