Skip to content

Commit c0bcdee

Browse files
Feature: add curved annotation (#550)
--------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 715e201 commit c0bcdee

File tree

5 files changed

+867
-3
lines changed

5 files changed

+867
-3
lines changed

.github/workflows/build-ultraplot.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,9 @@ jobs:
121121
with:
122122
path: ./ultraplot/tests/baseline # The directory to cache
123123
# Key is based on OS, Python/Matplotlib versions, and the base commit SHA
124-
key: ${{ runner.os }}-baseline-base-v2-${{ github.event.pull_request.base.sha }}-${{ inputs.python-version }}-${{ inputs.matplotlib-version }}
124+
key: ${{ runner.os }}-baseline-base-v3-hs${{ env.PYTHONHASHSEED }}-${{ github.event.pull_request.base.sha }}-${{ inputs.python-version }}-${{ inputs.matplotlib-version }}
125125
restore-keys: |
126-
${{ runner.os }}-baseline-base-v2-${{ github.event.pull_request.base.sha }}-${{ inputs.python-version }}-${{ inputs.matplotlib-version }}-
126+
${{ runner.os }}-baseline-base-v3-hs${{ env.PYTHONHASHSEED }}-${{ github.event.pull_request.base.sha }}-${{ inputs.python-version }}-${{ inputs.matplotlib-version }}-
127127
128128
# Conditional Baseline Generation (Only runs on cache miss)
129129
- name: Generate baseline from main

ultraplot/axes/base.py

Lines changed: 316 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import types
1212
from collections.abc import Iterable as IterableType
1313
from numbers import Integral, Number
14-
from typing import Iterable, MutableMapping, Optional, Tuple, Union
14+
from typing import Any, Iterable, MutableMapping, Optional, Tuple, Union
1515

1616
try:
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

ultraplot/internals/rcsetup.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1073,6 +1073,36 @@ def _validator_accepts(validator, value):
10731073
"Join style for text border strokes. Must be one of "
10741074
"``'miter'``, ``'round'``, or ``'bevel'``.",
10751075
),
1076+
"text.curved.upright": (
1077+
True,
1078+
_validate_bool,
1079+
"Whether curved text is flipped to remain upright by default.",
1080+
),
1081+
"text.curved.ellipsis": (
1082+
False,
1083+
_validate_bool,
1084+
"Whether to show ellipses when curved text exceeds path length.",
1085+
),
1086+
"text.curved.avoid_overlap": (
1087+
True,
1088+
_validate_bool,
1089+
"Whether curved text hides overlapping glyphs by default.",
1090+
),
1091+
"text.curved.overlap_tol": (
1092+
0.1,
1093+
_validate_float,
1094+
"Overlap threshold used when hiding curved-text glyphs.",
1095+
),
1096+
"text.curved.curvature_pad": (
1097+
2.0,
1098+
_validate_float,
1099+
"Extra curved-text glyph spacing per radian of local curvature.",
1100+
),
1101+
"text.curved.min_advance": (
1102+
1.0,
1103+
_validate_float,
1104+
"Minimum extra curved-text glyph spacing in pixels.",
1105+
),
10761106
"abc.bbox": (
10771107
False,
10781108
_validate_bool,

0 commit comments

Comments
 (0)