From db63d6fd9d13486683def4c88634d49b9bd74968 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 4 Feb 2026 13:15:06 +1000 Subject: [PATCH 1/7] implementation --- ultraplot/axes/base.py | 90 ++++++++++++++++++++++++++++++++++++ ultraplot/tests/test_axes.py | 19 ++++++++ 2 files changed, 109 insertions(+) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index dc61d8cc5..b609c8bcc 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -3919,6 +3919,96 @@ def text( ) return obj + def curvedtext( + self, + x, + y, + text, + *, + border=False, + bbox=False, + bordercolor="w", + borderwidth=2, + borderinvert=False, + borderstyle="miter", + bboxcolor="w", + bboxstyle="round", + bboxalpha=0.5, + bboxpad=None, + **kwargs, + ): + """ + Add curved text that follows a curve. + + Parameters + ---------- + x, y : array-like + Curve coordinates. + text : str + The string for the text. + %(axes.transform)s + + Other parameters + ---------------- + border : bool, default: False + Whether to draw border around text. + borderwidth : float, default: 2 + The width of the text border. + bordercolor : color-spec, default: 'w' + The color of the text border. + borderinvert : bool, optional + If ``True``, the text and border colors are swapped. + borderstyle : {'miter', 'round', 'bevel'}, default: 'miter' + The `line join style \\ +`__ + used for the border. + bbox : bool, default: False + Whether to draw a bounding box around text. + bboxcolor : color-spec, default: 'w' + The color of the text bounding box. + bboxstyle : boxstyle, default: 'round' + The style of the bounding box. + bboxalpha : float, default: 0.5 + The alpha for the bounding box. + bboxpad : float, default: :rc:`title.bboxpad` + The padding for the bounding box. + %(artist.text)s + + **kwargs + Passed to `matplotlib.text.Text`. + """ + transform = kwargs.pop("transform", None) + if transform is None: + transform = self.transData + else: + transform = self._get_transform(transform) + kwargs["transform"] = transform + + from ..text import CurvedText + + obj = CurvedText(x, y, text, axes=self, **kwargs) + + if borderstyle is None: + try: + borderstyle = rc["text.borderstyle"] + except KeyError: + borderstyle = "miter" + obj._apply_label_props( + { + "border": border, + "bordercolor": bordercolor, + "borderinvert": borderinvert, + "borderwidth": borderwidth, + "borderstyle": borderstyle, + "bbox": bbox, + "bboxcolor": bboxcolor, + "bboxstyle": bboxstyle, + "bboxalpha": bboxalpha, + "bboxpad": bboxpad, + } + ) + return obj + def _toggle_spines(self, spines: Union[bool, Iterable, str]): """ Turns spines on or off depending on input. Spines can be a list such as ['left', 'right'] etc diff --git a/ultraplot/tests/test_axes.py b/ultraplot/tests/test_axes.py index 27ed331c2..ffc3f6982 100644 --- a/ultraplot/tests/test_axes.py +++ b/ultraplot/tests/test_axes.py @@ -132,6 +132,25 @@ def test_cartesian_format_all_units_types(): ax.format(**kwargs) +@pytest.mark.mpl_image_compare +def test_curvedtext_basic(): + fig, ax = uplt.subplots() + x = np.linspace(0, 2 * np.pi, 200) + y = np.sin(x) + ax.plot(x, y, color="C0") + ax.curvedtext( + x, + y, + "curved text", + ha="center", + va="bottom", + color="C1", + size=16, + ) + ax.format(xlim=(0, 2 * np.pi), ylim=(-1.2, 1.2)) + return fig + + def test_dualx_log_transform_is_finite(): """ Ensure dualx transforms remain finite on log axes. From f6bb1f57b5cbc3a6f7b6e757aa0dba4fdf5e0e13 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 4 Feb 2026 13:15:33 +1000 Subject: [PATCH 2/7] add curved text class --- ultraplot/text.py | 210 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 ultraplot/text.py diff --git a/ultraplot/text.py b/ultraplot/text.py new file mode 100644 index 000000000..75a76861e --- /dev/null +++ b/ultraplot/text.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +""" +Text-related artists and helpers. +""" +from __future__ import annotations + +from typing import Iterable, Tuple + +import matplotlib.text as mtext +import numpy as np + +from .internals import labels + +__all__ = ["CurvedText"] + + +# Courtesy of Thomas Kühn in https://stackoverflow.com/questions/19353576/curved-text-rendering-in-matplotlib +class CurvedText(mtext.Text): + """ + A text object that follows an arbitrary curve. + + Parameters + ---------- + x, y : array-like + Curve coordinates. + text : str + Text to render along the curve. + axes : matplotlib.axes.Axes + Target axes. + **kwargs + Passed to `matplotlib.text.Text` for character styling. + """ + + def __init__(self, x, y, text, axes, **kwargs): + if axes is None: + raise ValueError("'axes' is required for CurvedText.") + + x = np.asarray(x, dtype=float) + y = np.asarray(y, dtype=float) + if x.size != y.size: + raise ValueError("'x' and 'y' must be the same length.") + if x.size < 2: + raise ValueError("'x' and 'y' must contain at least two points.") + + if kwargs.get("transform") is None: + kwargs["transform"] = axes.transData + + # Initialize storage before Text.__init__ triggers set_text() + self._characters = [] + self._curve_text = "" if text is None else str(text) + self._text_kwargs = kwargs.copy() + self._initializing = True + + super().__init__(x[0], y[0], " ", **kwargs) + axes.add_artist(self) + + self._curve_x = x + self._curve_y = y + self._zorder = self.get_zorder() + self._initializing = False + + self._build_characters(self._curve_text) + + def _build_characters(self, text: str) -> None: + # Remove previous character artists + for _, artist in self._characters: + artist.remove() + self._characters = [] + + for char in text: + if char == " ": + t = mtext.Text(0, 0, " ", **self._text_kwargs) + t.set_alpha(0.0) + else: + t = mtext.Text(0, 0, char, **self._text_kwargs) + + t.set_ha("center") + t.set_va("center") + t.set_rotation(0) + t.set_zorder(self._zorder + 1) + add_text = getattr(self.axes, "_add_text", None) + if add_text is not None: + add_text(t) + else: + self.axes.add_artist(t) + self._characters.append((char, t)) + + def set_text(self, s): + if getattr(self, "_initializing", False): + return super().set_text(" ") + self._curve_text = "" if s is None else str(s) + self._build_characters(self._curve_text) + super().set_text(" ") + + def get_text(self): + return self._curve_text + + def set_curve(self, x: Iterable[float], y: Iterable[float]) -> None: + x = np.asarray(x, dtype=float) + y = np.asarray(y, dtype=float) + if x.size != y.size: + raise ValueError("'x' and 'y' must be the same length.") + if x.size < 2: + raise ValueError("'x' and 'y' must contain at least two points.") + self._curve_x = x + self._curve_y = y + + def get_curve(self) -> Tuple[np.ndarray, np.ndarray]: + return self._curve_x.copy(), self._curve_y.copy() + + def _apply_label_props(self, props) -> None: + for _, t in self._characters: + t.update = labels._update_label.__get__(t) + t.update(props) + + def set_zorder(self, zorder): + super().set_zorder(zorder) + self._zorder = self.get_zorder() + for _, t in self._characters: + t.set_zorder(self._zorder + 1) + + def draw(self, renderer, *args, **kwargs): + """ + Overload `Text.draw()` to update character positions and rotations. + """ + self.update_positions(renderer) + + def update_positions(self, renderer) -> None: + """ + Update positions and rotations of the individual text elements. + """ + if not self._characters: + return + + trans = self.get_transform() + pts = trans.transform(np.column_stack([self._curve_x, self._curve_y])) + x_disp = pts[:, 0] + y_disp = pts[:, 1] + + dx = x_disp[1:] - x_disp[:-1] + dy = y_disp[1:] - y_disp[:-1] + seg_len = np.hypot(dx, dy) + + if np.allclose(seg_len, 0): + for _, t in self._characters: + t.set_alpha(0.0) + return + + arc = np.concatenate([[0.0], np.cumsum(seg_len)]) + rads = np.arctan2(dy, dx) + degs = np.degrees(rads) + + # Precompute widths for alignment + widths = [] + for _, t in self._characters: + t.set_rotation(0) + t.set_ha("center") + t.set_va("center") + bbox = t.get_window_extent(renderer=renderer) + widths.append(bbox.width) + + total = float(np.sum(widths)) + ha = self.get_ha() + if ha in ("center", "middle"): + rel_pos = max(0.0, 0.5 * (arc[-1] - total)) + elif ha in ("right", "center right"): + rel_pos = max(0.0, arc[-1] - total) + else: + rel_pos = 0.0 + + for (char, t), width in zip(self._characters, widths): + target = rel_pos + width / 2.0 + if target > arc[-1] or seg_len.size == 0: + t.set_alpha(0.0) + rel_pos += width + continue + if char != " ": + t.set_alpha(1.0) + + idx = np.searchsorted(arc, target, side="right") - 1 + idx = int(np.clip(idx, 0, seg_len.size - 1)) + if seg_len[idx] == 0: + rel_pos += width + continue + + fraction = (target - arc[idx]) / seg_len[idx] + base = np.array( + [x_disp[idx] + fraction * dx[idx], y_disp[idx] + fraction * dy[idx]] + ) + + # Alignment offset in display coordinates (unrotated) + t.set_va("center") + bbox_center = t.get_window_extent(renderer=renderer) + t.set_va(self.get_va()) + bbox_target = t.get_window_extent(renderer=renderer) + dr = bbox_target.get_points()[0] - bbox_center.get_points()[0] + + c = np.cos(rads[idx]) + s = np.sin(rads[idx]) + dr_rot = np.array([c * dr[0] - s * dr[1], s * dr[0] + c * dr[1]]) + + pos_disp = base + dr_rot + pos_data = trans.inverted().transform(pos_disp) + + t.set_position(pos_data) + t.set_rotation(degs[idx]) + t.set_ha("center") + t.set_va("center") + + rel_pos += width From e859b8dba85ae8bf13f28187630985aea7275bd8 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 4 Feb 2026 13:55:19 +1000 Subject: [PATCH 3/7] Add curved text with curvature-aware spacing --- ultraplot/axes/base.py | 32 +++++- ultraplot/text.py | 241 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 245 insertions(+), 28 deletions(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index b609c8bcc..b706ecf84 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -3925,6 +3925,12 @@ def curvedtext( y, text, *, + upright=True, + ellipsis=False, + avoid_overlap=True, + overlap_tol=0.1, + curvature_pad=2.0, + min_advance=1.0, border=False, bbox=False, bordercolor="w", @@ -3958,6 +3964,18 @@ def curvedtext( The color of the text border. borderinvert : bool, optional If ``True``, the text and border colors are swapped. + upright : bool, default: True + Whether to flip the curve direction to keep text upright. + ellipsis : bool, default: False + Whether to show an ellipsis when the text exceeds curve length. + avoid_overlap : bool, default: True + Whether to hide glyphs that overlap after rotation. + overlap_tol : float, default: 0.1 + Fractional overlap area (0–1) required before hiding a glyph. + curvature_pad : float, default: 2.0 + Extra spacing in pixels per radian of local curvature. + min_advance : float, default: 1.0 + Minimum additional spacing (pixels) enforced between glyph centers. borderstyle : {'miter', 'round', 'bevel'}, default: 'miter' The `line join style \\ `__ @@ -3986,7 +4004,19 @@ def curvedtext( from ..text import CurvedText - obj = CurvedText(x, y, text, axes=self, **kwargs) + obj = CurvedText( + x, + y, + text, + axes=self, + upright=upright, + ellipsis=ellipsis, + avoid_overlap=avoid_overlap, + overlap_tol=overlap_tol, + curvature_pad=curvature_pad, + min_advance=min_advance, + **kwargs, + ) if borderstyle is None: try: diff --git a/ultraplot/text.py b/ultraplot/text.py index 75a76861e..e283bbafc 100644 --- a/ultraplot/text.py +++ b/ultraplot/text.py @@ -27,11 +27,37 @@ class CurvedText(mtext.Text): Text to render along the curve. axes : matplotlib.axes.Axes Target axes. + upright : bool, default: True + Whether to flip the curve direction to keep text upright. + ellipsis : bool, default: False + Whether to show an ellipsis when the text exceeds curve length. + avoid_overlap : bool, default: True + Whether to hide glyphs that overlap after rotation. + overlap_tol : float, default: 0.1 + Fractional overlap area (0–1) required before hiding a glyph. + curvature_pad : float, default: 2.0 + Extra spacing in pixels per radian of local curvature. + min_advance : float, default: 1.0 + Minimum additional spacing (pixels) enforced between glyph centers. **kwargs Passed to `matplotlib.text.Text` for character styling. """ - def __init__(self, x, y, text, axes, **kwargs): + def __init__( + self, + x, + y, + text, + axes, + *, + upright=True, + ellipsis=False, + avoid_overlap=True, + overlap_tol=0.1, + curvature_pad=2.0, + min_advance=1.0, + **kwargs, + ): if axes is None: raise ValueError("'axes' is required for CurvedText.") @@ -48,6 +74,13 @@ def __init__(self, x, y, text, axes, **kwargs): # Initialize storage before Text.__init__ triggers set_text() self._characters = [] self._curve_text = "" if text is None else str(text) + self._upright = bool(upright) + self._ellipsis = bool(ellipsis) + self._avoid_overlap = bool(avoid_overlap) + self._overlap_tol = float(overlap_tol) + self._curvature_pad = float(curvature_pad) + self._min_advance = float(min_advance) + self._ellipsis_text = "..." self._text_kwargs = kwargs.copy() self._initializing = True @@ -131,15 +164,27 @@ def update_positions(self, renderer) -> None: """ if not self._characters: return + for char, t in self._characters: + if t.get_text() != char: + t.set_text(char) + + x_curve = self._curve_x + y_curve = self._curve_y trans = self.get_transform() - pts = trans.transform(np.column_stack([self._curve_x, self._curve_y])) + try: + trans_inv = trans.inverted() + except Exception: + return + pts = trans.transform(np.column_stack([x_curve, y_curve])) x_disp = pts[:, 0] y_disp = pts[:, 1] - dx = x_disp[1:] - x_disp[:-1] - dy = y_disp[1:] - y_disp[:-1] - seg_len = np.hypot(dx, dy) + dx = np.diff(x_disp) + dy = np.diff(y_disp) + dx = np.asarray(dx, dtype=float).reshape(-1) + dy = np.asarray(dy, dtype=float).reshape(-1) + seg_len = np.asarray(np.hypot(dx, dy), dtype=float).reshape(-1) if np.allclose(seg_len, 0): for _, t in self._characters: @@ -150,6 +195,42 @@ def update_positions(self, renderer) -> None: rads = np.arctan2(dy, dx) degs = np.degrees(rads) + if self._upright and seg_len.size: + mid = len(rads) // 2 + angle = np.degrees(rads[mid]) + if angle > 90 or angle < -90: + x_curve = x_curve[::-1] + y_curve = y_curve[::-1] + pts = trans.transform(np.column_stack([x_curve, y_curve])) + x_disp = pts[:, 0] + y_disp = pts[:, 1] + dx = np.diff(x_disp) + dy = np.diff(y_disp) + dx = np.asarray(dx, dtype=float).reshape(-1) + dy = np.asarray(dy, dtype=float).reshape(-1) + seg_len = np.asarray(np.hypot(dx, dy), dtype=float).reshape(-1) + arc = np.concatenate([[0.0], np.cumsum(seg_len)]) + rads = np.arctan2(dy, dx) + degs = np.degrees(rads) + + # Curvature proxy per segment (rad / pixel) + kappa = np.zeros_like(seg_len) + if len(rads) > 1: + dtheta = np.diff(rads) + dtheta = np.arctan2(np.sin(dtheta), np.cos(dtheta)) # wrap + ds = 0.5 * (seg_len[1:] + seg_len[:-1]) + valid = ds > 0 + kappa_mid = np.zeros_like(dtheta) + kappa_mid[valid] = np.abs(dtheta[valid]) / ds[valid] + if kappa.size >= 2: + kappa[1:] = kappa_mid + kappa[0] = kappa_mid[0] + else: + kappa[:] = kappa_mid[0] if kappa_mid.size else 0.0 + if kappa.size >= 3: + kernel = np.array([0.25, 0.5, 0.25]) + kappa = np.convolve(kappa, kernel, mode="same") + # Precompute widths for alignment widths = [] for _, t in self._characters: @@ -160,6 +241,23 @@ def update_positions(self, renderer) -> None: widths.append(bbox.width) total = float(np.sum(widths)) + ellipsis_active = False + ellipsis_widths = [] + if self._ellipsis and self._characters: + if total > arc[-1]: + ellipsis_active = True + dot = mtext.Text(0, 0, ".", **self._text_kwargs) + dot.set_ha("center") + dot.set_va("center") + if self.figure is not None: + dot.set_figure(self.figure) + dot.set_transform(self.get_transform()) + dot_width = dot.get_window_extent(renderer=renderer).width + ellipsis_widths = [dot_width, dot_width, dot_width] + ellipsis_count = min(3, len(self._characters)) if ellipsis_active else 0 + ellipsis_width = sum(ellipsis_widths[:ellipsis_count]) + limit = arc[-1] - ellipsis_width if ellipsis_active else arc[-1] + ha = self.get_ha() if ha in ("center", "middle"): rel_pos = max(0.0, 0.5 * (arc[-1] - total)) @@ -168,43 +266,132 @@ def update_positions(self, renderer) -> None: else: rel_pos = 0.0 - for (char, t), width in zip(self._characters, widths): - target = rel_pos + width / 2.0 - if target > arc[-1] or seg_len.size == 0: - t.set_alpha(0.0) - rel_pos += width - continue - if char != " ": - t.set_alpha(1.0) + prev_bbox = None + def _place_at(target, t): + if seg_len.size == 0: + t.set_alpha(0.0) + return None idx = np.searchsorted(arc, target, side="right") - 1 idx = int(np.clip(idx, 0, seg_len.size - 1)) - if seg_len[idx] == 0: - rel_pos += width - continue - - fraction = (target - arc[idx]) / seg_len[idx] + dx_arr = np.atleast_1d(dx) + dy_arr = np.atleast_1d(dy) + seg_arr = np.atleast_1d(seg_len) + if idx < 0 or idx >= seg_arr.size: + t.set_alpha(0.0) + return None + if seg_arr[idx] == 0: + t.set_alpha(0.0) + return None + fraction = (target - arc[idx]) / seg_arr[idx] base = np.array( - [x_disp[idx] + fraction * dx[idx], y_disp[idx] + fraction * dy[idx]] + [ + x_disp[idx] + fraction * dx_arr[idx], + y_disp[idx] + fraction * dy_arr[idx], + ] ) - - # Alignment offset in display coordinates (unrotated) t.set_va("center") bbox_center = t.get_window_extent(renderer=renderer) t.set_va(self.get_va()) bbox_target = t.get_window_extent(renderer=renderer) dr = bbox_target.get_points()[0] - bbox_center.get_points()[0] - c = np.cos(rads[idx]) s = np.sin(rads[idx]) dr_rot = np.array([c * dr[0] - s * dr[1], s * dr[0] + c * dr[1]]) - pos_disp = base + dr_rot - pos_data = trans.inverted().transform(pos_disp) - + pos_data = trans_inv.transform(pos_disp) t.set_position(pos_data) t.set_rotation(degs[idx]) t.set_ha("center") t.set_va("center") - - rel_pos += width + t.set_alpha(1.0 if t.get_text().strip() else 0.0) + return t.get_window_extent(renderer=renderer) + + # Precompute target centers (in arc-length units) + n = len(self._characters) + targets = np.zeros(n) + advances = np.zeros(n) + pos = rel_pos + for i, width in enumerate(widths): + base_target = pos + width / 2.0 + base_idx = int( + np.clip( + np.searchsorted(arc, base_target, side="right") - 1, + 0, + seg_len.size - 1, + ) + ) + extra_pad = self._curvature_pad * kappa[base_idx] * width + advance = width + extra_pad + self._min_advance + targets[i] = pos + advance / 2.0 + advances[i] = advance + pos += advance + + # Relax targets to enforce minimum spacing if requested + if self._avoid_overlap and n > 1: + for _ in range(3): # a few passes is enough + for i in range(1, n): + min_sep = 0.5 * (advances[i - 1] + advances[i]) + if targets[i] < targets[i - 1] + min_sep: + targets[i] = targets[i - 1] + min_sep + for i in range(n - 2, -1, -1): + min_sep = 0.5 * (advances[i] + advances[i + 1]) + if targets[i] > targets[i + 1] - min_sep: + targets[i] = targets[i + 1] - min_sep + + # Clamp to curve length by shifting the whole sequence if needed + span_left = targets[0] - 0.5 * advances[0] + span_right = targets[-1] + 0.5 * advances[-1] + max_right = limit if ellipsis_active else arc[-1] + shift = 0.0 + if span_left < 0: + shift = -span_left + if span_right + shift > max_right: + shift = max_right - span_right + if shift != 0.0: + targets = targets + shift + + # Place main glyphs + for idx, ((char, t), width) in enumerate(zip(self._characters, widths)): + if ellipsis_active and idx >= len(self._characters) - ellipsis_count: + t.set_alpha(0.0) + continue + target = targets[idx] + if ellipsis_active and target > limit: + t.set_alpha(0.0) + continue + _place_at(target, t) + + # Place ellipsis at the end if needed + if ellipsis_active and ellipsis_count: + rel_end = arc[-1] - ellipsis_width + rel_end = max(0.0, rel_end) + targets = [] + running = rel_end + for w in ellipsis_widths[:ellipsis_count]: + targets.append(running + w / 2.0) + running += w + start = len(self._characters) - ellipsis_count + for (char, t), target in zip(self._characters[start:], targets): + t.set_text(".") + bbox = _place_at(target, t) + if bbox is not None and self._avoid_overlap and prev_bbox is not None: + attempts = 0 + while bbox is not None and bbox.overlaps(prev_bbox) and attempts < 20: + ov_dx = min(bbox.x1, prev_bbox.x1) - max(bbox.x0, prev_bbox.x0) + ov_dy = min(bbox.y1, prev_bbox.y1) - max(bbox.y0, prev_bbox.y0) + if ov_dx <= 0 or ov_dy <= 0: + break + overlap_area = ov_dx * ov_dy + min_area = min( + bbox.width * bbox.height, prev_bbox.width * prev_bbox.height + ) + if not min_area or overlap_area / min_area <= self._overlap_tol: + break + target += max(1.0, ov_dx + 1.0) + bbox = _place_at(target, t) + attempts += 1 + if bbox is not None: + prev_bbox = bbox + elif bbox is not None: + prev_bbox = bbox From 6f3d2f540dd972b036e07974056f73e8302f847d Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 7 Feb 2026 17:10:20 +1000 Subject: [PATCH 4/7] Add curved text dispatch for text and annotate --- ultraplot/axes/base.py | 187 +++++++++++++++++++++++++++++++++++ ultraplot/tests/test_axes.py | 55 +++++++++++ 2 files changed, 242 insertions(+) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index d7a5e0f12..fbaa508d8 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -3814,6 +3814,72 @@ def legend( **kwargs, ) + @classmethod + def _coerce_curve_xy(cls, x, y): + """ + Return validated 1D numeric curve coordinates or ``None``. + """ + if np.isscalar(x) or np.isscalar(y): + return None + if isinstance(x, str) or isinstance(y, str): + return None + try: + xarr = np.asarray(x) + yarr = np.asarray(y) + except Exception: + return None + if xarr.ndim != 1 or yarr.ndim != 1: + return None + if xarr.size < 2 or yarr.size < 2 or xarr.size != yarr.size: + return None + try: + return np.asarray(xarr, dtype=float), np.asarray(yarr, dtype=float) + except Exception: + return None + + @classmethod + def _coerce_curve_xy_from_xy_arg(cls, xy): + """ + Parse annotate-style ``xy`` into validated curve arrays or ``None``. + """ + if isinstance(xy, (tuple, list)) and len(xy) == 2: + return cls._coerce_curve_xy(xy[0], xy[1]) + if isinstance(xy, np.ndarray) and xy.ndim == 2: + if xy.shape[0] == 2: + return cls._coerce_curve_xy(xy[0], xy[1]) + if xy.shape[1] == 2: + return cls._coerce_curve_xy(xy[:, 0], xy[:, 1]) + return None + + @staticmethod + def _curve_center(x, y, transform): + """ + Return the arc-length midpoint of a curve in the curve coordinate system. + """ + pts = np.column_stack([x, y]).astype(float) + try: + pts_disp = transform.transform(pts) + dx = np.diff(pts_disp[:, 0]) + dy = np.diff(pts_disp[:, 1]) + seg = np.hypot(dx, dy) + if seg.size == 0 or np.allclose(seg, 0): + return float(x[0]), float(y[0]) + arc = np.concatenate([[0.0], np.cumsum(seg)]) + target = 0.5 * arc[-1] + idx = np.searchsorted(arc, target, side="right") - 1 + idx = int(np.clip(idx, 0, seg.size - 1)) + frac = 0.0 if seg[idx] == 0 else (target - arc[idx]) / seg[idx] + mid_disp = np.array( + [ + pts_disp[idx, 0] + frac * dx[idx], + pts_disp[idx, 1] + frac * dy[idx], + ] + ) + mid = transform.inverted().transform(mid_disp) + return float(mid[0]), float(mid[1]) + except Exception: + return float(np.mean(x)), float(np.mean(y)) + @docstring._concatenate_inherited @docstring._snippet_manager def text( @@ -3900,6 +3966,32 @@ def text( warnings.simplefilter("ignore", warnings.UltraPlotWarning) kwargs.update(_pop_props(kwargs, "text")) + # Interpret 1D array x/y as a curved text path. + # This preserves scalar behavior while adding ergonomic path labeling. + curve_xy = None + if len(args) >= 2 and self._name != "three": + curve_xy = self._coerce_curve_xy(args[0], args[1]) + if curve_xy is not None: + x_curve, y_curve = curve_xy + borderstyle = _not_none(borderstyle, rc["text.borderstyle"]) + return self.curvedtext( + x_curve, + y_curve, + args[2], + transform=transform, + border=border, + bordercolor=bordercolor, + borderinvert=borderinvert, + borderwidth=borderwidth, + borderstyle=borderstyle, + bbox=bbox, + bboxcolor=bboxcolor, + bboxstyle=bboxstyle, + bboxalpha=bboxalpha, + bboxpad=bboxpad, + **kwargs, + ) + # Update the text object using a monkey patch borderstyle = _not_none(borderstyle, rc["text.borderstyle"]) obj = func(*args, transform=transform, **kwargs) @@ -3920,6 +4012,101 @@ def text( ) return obj + @docstring._concatenate_inherited + def annotate( + self, + text, + xy, + xytext=None, + xycoords="data", + textcoords=None, + arrowprops=None, + annotation_clip=None, + **kwargs, + ): + """ + Add an annotation. If `xy` is a pair of 1D arrays, draw curved text. + + For curved input with `arrowprops`, the arrow points to the curve center. + """ + curve_xy = self._coerce_curve_xy_from_xy_arg(xy) + if curve_xy is None: + return super().annotate( + text, + xy=xy, + xytext=xytext, + xycoords=xycoords, + textcoords=textcoords, + arrowprops=arrowprops, + annotation_clip=annotation_clip, + **kwargs, + ) + + x_curve, y_curve = curve_xy + try: + transform = self._get_transform(xycoords, default="data") + except Exception: + return super().annotate( + text, + xy=xy, + xytext=xytext, + xycoords=xycoords, + textcoords=textcoords, + arrowprops=arrowprops, + annotation_clip=annotation_clip, + **kwargs, + ) + + # Reuse text border/bbox conveniences for curved annotate mode. + border = kwargs.pop("border", False) + bbox = kwargs.pop("bbox", False) + bordercolor = kwargs.pop("bordercolor", "w") + borderwidth = kwargs.pop("borderwidth", 2) + borderinvert = kwargs.pop("borderinvert", False) + borderstyle = kwargs.pop("borderstyle", None) + bboxcolor = kwargs.pop("bboxcolor", "w") + bboxstyle = kwargs.pop("bboxstyle", "round") + bboxalpha = kwargs.pop("bboxalpha", 0.5) + bboxpad = kwargs.pop("bboxpad", None) + borderstyle = _not_none(borderstyle, rc["text.borderstyle"]) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", warnings.UltraPlotWarning) + kwargs.update(_pop_props(kwargs, "text")) + + obj = self.curvedtext( + x_curve, + y_curve, + text, + transform=transform, + border=border, + bordercolor=bordercolor, + borderinvert=borderinvert, + borderwidth=borderwidth, + borderstyle=borderstyle, + bbox=bbox, + bboxcolor=bboxcolor, + bboxstyle=bboxstyle, + bboxalpha=bboxalpha, + bboxpad=bboxpad, + **kwargs, + ) + + # Optional arrow: point to the curve center for now. + if arrowprops is not None: + xmid, ymid = self._curve_center(x_curve, y_curve, transform) + ann = super().annotate( + "", + xy=(xmid, ymid), + xytext=xytext, + xycoords=xycoords, + textcoords=textcoords, + arrowprops=arrowprops, + annotation_clip=annotation_clip, + ) + obj._annotation = ann + return obj + def curvedtext( self, x, diff --git a/ultraplot/tests/test_axes.py b/ultraplot/tests/test_axes.py index f2df7255b..93f0983a6 100644 --- a/ultraplot/tests/test_axes.py +++ b/ultraplot/tests/test_axes.py @@ -6,9 +6,11 @@ import numpy as np import pytest import matplotlib.patheffects as mpatheffects +import matplotlib.text as mtext import ultraplot as uplt from ultraplot.internals.warnings import UltraPlotWarning +from ultraplot.text import CurvedText @pytest.mark.parametrize( @@ -150,6 +152,59 @@ def test_curvedtext_basic(): ) ax.format(xlim=(0, 2 * np.pi), ylim=(-1.2, 1.2)) return fig + + +def test_text_scalar_returns_text(): + fig, ax = uplt.subplots() + obj = ax.text(0.5, 0.5, "scalar") + assert isinstance(obj, mtext.Text) + assert not isinstance(obj, CurvedText) + + +def test_text_curve_xy_returns_curvedtext(): + fig, ax = uplt.subplots() + x = np.linspace(0, 1, 20) + y = x**2 + obj = ax.text(x, y, "curve") + assert isinstance(obj, CurvedText) + + +def test_annotate_scalar_returns_annotation(): + fig, ax = uplt.subplots() + obj = ax.annotate("point", xy=(0.5, 0.5)) + assert isinstance(obj, mtext.Annotation) + assert not isinstance(obj, CurvedText) + + +def test_annotate_curve_xy_returns_curvedtext(): + fig, ax = uplt.subplots() + x = np.linspace(0, 1, 20) + y = np.sin(2 * np.pi * x) + obj = ax.annotate("curve", xy=(x, y)) + assert isinstance(obj, CurvedText) + assert not hasattr(obj, "_annotation") + + +def test_annotate_curve_xy_with_arrow_uses_curve_center(): + fig, ax = uplt.subplots() + ax = ax[0] + x = np.linspace(0, 1, 31) + y = x**2 + obj = ax.annotate( + "curve", + xy=(x, y), + xytext=(0.2, 0.8), + arrowprops={"arrowstyle": "->"}, + ) + assert isinstance(obj, CurvedText) + assert isinstance(getattr(obj, "_annotation", None), mtext.Annotation) + + xmid, ymid = ax._curve_center(x, y, ax.transData) + ax_x, ax_y = obj._annotation.xy + assert np.isclose(ax_x, xmid) + assert np.isclose(ax_y, ymid) + + def _get_text_stroke_joinstyle(text): for effect in text.get_path_effects(): if isinstance(effect, mpatheffects.Stroke): From f9caf85b4ffe9eb253ffe21a991aa56fb7e3c04b Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 7 Feb 2026 17:11:46 +1000 Subject: [PATCH 5/7] replace borderstyle parsing --- ultraplot/axes/base.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index fbaa508d8..d69dd6c33 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -4206,11 +4206,7 @@ def curvedtext( **kwargs, ) - if borderstyle is None: - try: - borderstyle = rc["text.borderstyle"] - except KeyError: - borderstyle = "miter" + borderstyle = _not_none(borderstyle, rc["text.borderstyle"]) obj._apply_label_props( { "border": border, From 19c928eaf3c8b358369dd70c7625a5c5ff75315c Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 7 Feb 2026 17:12:48 +1000 Subject: [PATCH 6/7] black --- ultraplot/text.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ultraplot/text.py b/ultraplot/text.py index e283bbafc..ad4b8f978 100644 --- a/ultraplot/text.py +++ b/ultraplot/text.py @@ -377,7 +377,9 @@ def _place_at(target, t): bbox = _place_at(target, t) if bbox is not None and self._avoid_overlap and prev_bbox is not None: attempts = 0 - while bbox is not None and bbox.overlaps(prev_bbox) and attempts < 20: + while ( + bbox is not None and bbox.overlaps(prev_bbox) and attempts < 20 + ): ov_dx = min(bbox.x1, prev_bbox.x1) - max(bbox.x0, prev_bbox.x0) ov_dy = min(bbox.y1, prev_bbox.y1) - max(bbox.y0, prev_bbox.y0) if ov_dx <= 0 or ov_dy <= 0: From a2b7f24f2aab5d78be2fe28354cd946c282c3e29 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 07:16:49 +0000 Subject: [PATCH 7/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- ultraplot/text.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ultraplot/text.py b/ultraplot/text.py index ad4b8f978..bd123fce5 100644 --- a/ultraplot/text.py +++ b/ultraplot/text.py @@ -2,6 +2,7 @@ """ Text-related artists and helpers. """ + from __future__ import annotations from typing import Iterable, Tuple