From a321160902f1ce37fa2b8eac063beb6026ee7f76 Mon Sep 17 00:00:00 2001 From: kinyatoride Date: Mon, 4 May 2026 16:28:44 -0600 Subject: [PATCH 1/2] Fix PolarAxes.format() silently dropping xlabel/ylabel --- ultraplot/axes/polar.py | 42 +++++++++++++++++++++++++++++ ultraplot/figure.py | 13 ++++++--- ultraplot/tests/test_projections.py | 10 +++++++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/ultraplot/axes/polar.py b/ultraplot/axes/polar.py index bf62e010c..bbf2eee5b 100644 --- a/ultraplot/axes/polar.py +++ b/ultraplot/axes/polar.py @@ -82,6 +82,13 @@ thetaformatter_kw, rformatter_kw : dict-like, optional The azimuthal and radial label formatter settings. Passed to `~ultraplot.constructor.Formatter`. +xlabel, ylabel : str, optional + The x and y axis labels. Applied with `~matplotlib.axes.Axes.set_xlabel` + and `~matplotlib.axes.Axes.set_ylabel`. +xlabel_kw, ylabel_kw : dict-like, optional + Additional axis label settings applied with `~matplotlib.axes.Axes.set_xlabel` + and `~matplotlib.axes.Axes.set_ylabel`. See also `labelpad`, `labelcolor`, + `labelsize`, and `labelweight`. color : color-spec, default: :rc:`meta.color` Color for the axes edge. Propagates to `labelcolor` unless specified otherwise (similar to :func:`~ultraplot.axes.CartesianAxes.format`). @@ -212,6 +219,23 @@ def _update_locators( else: axis.set_minor_locator(loc) + def _update_labels(self, x, *args, **kwargs): + """ + Apply axis labels via `set_xlabel` / `set_ylabel`. + """ + # NOTE: Critical to test whether arguments are None or else this + # will set isDefault_label to False every time format() is called. + kwargs = rc._get_label_props(**kwargs) + no_args = all(a is None for a in args) + no_kwargs = all(v is None for v in kwargs.values()) + if no_args and no_kwargs: + return + setter = getattr(self, f"set_{x}label") + getter = getattr(self, f"get_{x}label") + if no_args: # otherwise label text is reset! + args = (getter(),) + setter(*args, **kwargs) + @docstring._snippet_manager def format( self, @@ -256,6 +280,10 @@ def format( labelsize=None, labelcolor=None, labelweight=None, + xlabel=None, + ylabel=None, + xlabel_kw=None, + ylabel_kw=None, **kwargs, ): """ @@ -335,6 +363,8 @@ def format( formatter_kw, minorlocator, minorlocator_kw, + label, + label_kw, ) in zip( ("x", "y"), (thetamin, rmin), @@ -349,6 +379,8 @@ def format( (thetaformatter_kw, rformatter_kw), (thetaminorlocator, rminorlocator), (thetaminorlocator_kw, rminorlocator_kw), + (xlabel, ylabel), + (xlabel_kw, ylabel_kw), ): # Axis limits self._update_limits(x, min_=min_, max_=max_, lim=lim) @@ -382,6 +414,16 @@ def format( x, formatter=formatter, formatter_kw=formatter_kw ) + # Axis label + kw = dict( + labelpad=labelpad, + color=labelcolor, + size=labelsize, + weight=labelweight, + ) + kw.update(label_kw or {}) + self._update_labels(x, label, **kw) + # Parent format method super().format(rc_kw=rc_kw, rc_mode=rc_mode, **kwargs) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 334c61a44..2716a4316 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -3277,11 +3277,15 @@ def format( if skip_axes: # avoid recursion return - # Remove all keywords that are not in the allowed signature parameters + # Collect each class's matching kwargs without popping, then drop the union — + # shared params (e.g. xlabel/ylabel, accepted by both CartesianAxes and + # PolarAxes) need to reach every matching class. kws = { - cls: _pop_params(kwargs, sig) + cls: {k: kwargs[k] for k in sig.parameters if kwargs.get(k) is not None} for cls, sig in paxes.Axes._format_signatures.items() } + for k in {k for cls_kw in kws.values() for k in cls_kw}: + kwargs.pop(k, None) classes = set() # track used dictionaries def _axis_has_share_label_text(ax, axis): @@ -3314,11 +3318,14 @@ def _axis_has_label_text(ax, axis): kw.pop("ylabel", None) ax.format(rc_kw=rc_kw, rc_mode=rc_mode, skip_figure=True, **kw, **kwargs) ax.number = store_old_number - # Warn unused keyword argument(s) + # Warn unused keyword argument(s). Shared params (those in multiple + # signatures) are considered "used" if any matched class consumed them. + used_keys = {k for cls in classes for k in kws[cls]} kw = { key: value for name in kws.keys() - classes for key, value in kws[name].items() + if key not in used_keys } if kw: warnings._warn_ultraplot( diff --git a/ultraplot/tests/test_projections.py b/ultraplot/tests/test_projections.py index a52d11318..1a5daea47 100644 --- a/ultraplot/tests/test_projections.py +++ b/ultraplot/tests/test_projections.py @@ -154,6 +154,16 @@ def test_polar_projections(): return fig +def test_polar_format_labels(): + """ + ax.format(xlabel=..., ylabel=...) must forward to set_xlabel/set_ylabel. + """ + fig, ax = uplt.subplots(proj="polar") + ax.format(xlabel="xlabel", ylabel="ylabel") + assert ax.get_xlabel() == "xlabel" + assert ax.get_ylabel() == "ylabel" + + def test_sharing_axes(): """ Test sharing axes for GeoAxes From 63ce027ab8560f5c49f939a007c741b0dc7716e6 Mon Sep 17 00:00:00 2001 From: kinyatoride Date: Tue, 5 May 2026 16:41:29 -0600 Subject: [PATCH 2/2] Add polar-aware rlabel and thetalabel support Co-authored-by: Copilot --- ultraplot/axes/polar.py | 298 +++++++++++++++++++++++++- ultraplot/internals/labels.py | 28 +++ ultraplot/tests/test_projections.py | 311 ++++++++++++++++++++++++++++ ultraplot/text.py | 51 ++++- 4 files changed, 683 insertions(+), 5 deletions(-) diff --git a/ultraplot/axes/polar.py b/ultraplot/axes/polar.py index bbf2eee5b..a121792af 100644 --- a/ultraplot/axes/polar.py +++ b/ultraplot/axes/polar.py @@ -11,7 +11,9 @@ from typing_extensions import override import matplotlib.projections.polar as mpolar +import matplotlib.transforms as mtransforms import numpy as np +from matplotlib.font_manager import FontProperties from .. import constructor from .. import ticker as pticker @@ -22,6 +24,13 @@ __all__ = ["PolarAxes"] +# CurvedText sampling resolution along the label arc / spoke. +_POLAR_LABEL_NPOINTS = 50 +# Half-span (degrees) used when the label sits on a closed (full) circle. +_POLAR_LABEL_FULL_HALFSPAN_DEG = 15.0 +# Fraction of an open sector occupied by `thetalabel`; remainder is endpoint margin. +_POLAR_LABEL_SECTOR_FRAC = 0.8 + # Format docstring _format_docstring = """ @@ -71,7 +80,9 @@ thetaminorlocator_kw, rminorlocator_kw As for `thetalocator_kw`, `rlocator_kw`, but for the minor locator. rlabelpos : float, optional - The azimuth at which radial coordinates are labeled. + The azimuth at which radial coordinates are labeled. Also used as the + spoke angle for ``rlabel`` when you want an explicit radial-label + position. thetaformatter, rformatter : formatter-spec, optional Used to determine the azimuthal and radial label format. Passed to the `~ultraplot.constructor.Formatter` constructor. @@ -89,6 +100,32 @@ Additional axis label settings applied with `~matplotlib.axes.Axes.set_xlabel` and `~matplotlib.axes.Axes.set_ylabel`. See also `labelpad`, `labelcolor`, `labelsize`, and `labelweight`. +thetalabel, rlabel : str, optional + Polar-aware axis labels rendered via `~ultraplot.text.CurvedText`. + ``thetalabel`` follows the outer arc just beyond ``r=rmax``. + ``rlabel`` follows a radial spoke, centered between ``rmin`` and + ``rmax``. On a full circle it uses ``get_rlabel_position()`` unless + ``rlabelpos`` is explicit; on a sector it uses the spoke selected by + ``rlabelloc`` unless ``rlabelpos`` is explicit. Both labels include a + built-in tick-clearance offset, and ``labelpad`` adds extra padding in + points on top of that offset. Pass ``""`` to clear a previously set + label. +thetalabelloc : float, optional + Center theta angle (in degrees) for ``thetalabel``. Defaults to the + midpoint of the directed ``thetalim`` interval (or ``0`` for a full + circle). +rlabelloc : {'right', 'left'}, default: 'right' + Where to place ``rlabel``. When the spoke angle is fixed by a full + circle or by explicit ``rlabelpos``, ``rlabelloc`` selects the + perpendicular side of that spoke and ``'left'`` flips the default + side. On a sector with no explicit ``rlabelpos``, ``'right'`` + (default) anchors to ``thetamin`` and ``'left'`` anchors to + ``thetamax``; the label is then offset outward from the sector. +thetalabel_kw, rlabel_kw : dict-like, optional + Additional `~ultraplot.text.CurvedText` settings for the polar-aware + labels (e.g. ``border``, ``bbox``, or rendering hints like + ``min_advance``). See also `labelpad`, `labelcolor`, `labelsize`, + and `labelweight`. color : color-spec, default: :rc:`meta.color` Color for the axes edge. Propagates to `labelcolor` unless specified otherwise (similar to :func:`~ultraplot.axes.CartesianAxes.format`). @@ -96,6 +133,8 @@ Color for the gridline labels. labelpad, gridlabelpad : unit-spec, default: :rc:`grid.labelpad` The padding between the axes edge and the radial and azimuthal labels. + For ``thetalabel`` and ``rlabel``, this is added on top of the built-in + tick-clearance offset. %(units.pt)s labelsize, gridlabelsize : unit-spec or str, default: :rc:`grid.labelsize` Font size for the gridline labels. @@ -150,6 +189,8 @@ def __init__(self, *args, **kwargs): self.yaxis.isDefault_majfmt = True for axis in (self.xaxis, self.yaxis): axis.set_tick_params(which="both", size=0) + self._thetalabel_artist = None + self._rlabel_artist = None @override def _apply_axis_sharing(self): @@ -236,6 +277,238 @@ def _update_labels(self, x, *args, **kwargs): args = (getter(),) setter(*args, **kwargs) + def _get_directed_thetalim(self): + """Return the directed theta interval in degrees from the raw x-limits.""" + thetamin, thetamax = np.rad2deg(self.get_xlim()) + return float(thetamin), float(thetamax) + + @staticmethod + def _is_full_circle_thetalim(thetamin, thetamax): + """Return whether the directed theta interval spans a full circle.""" + return np.isclose((thetamax - thetamin) % 360.0, 0.0) + + def _polar_tick_clearance_in(self, axis): + """Tick mark + tick pad + ~font height(s), in inches.""" + axis_obj = getattr(self, f"{axis}axis") + size_pt = rc[f"{axis}tick.major.size"] + pad_pt = rc[f"{axis}tick.major.pad"] + label_pt = FontProperties(size=rc[f"{axis}tick.labelsize"]).get_size_in_points() + ticks = axis_obj.get_major_ticks() + if ticks: + tick = ticks[0] + size_pt = max(tick.tick1line.get_markersize(), tick.tick2line.get_markersize()) + pad_pt = tick.get_pad() if hasattr(tick, "get_pad") else getattr(tick, "_pad", pad_pt) + label_pt = max(tick.label1.get_size(), tick.label2.get_size(), label_pt) + labels = axis_obj.get_ticklabels() + if labels: + label_pt = max(float(label.get_size()) for label in labels) + n = 2 if axis == "x" else 1.5 + return (size_pt + pad_pt + n * label_pt) / 72.0 + + def _build_thetalabel_curve(self, loc, total_pad_in): + """ + Curve along the outer arc at r = rmax + delta_r (data coords). The + radial offset is computed in data space so clearance is angle- + independent — figure-space ScaledTranslation undershoots when the + outward direction points toward a tight bbox edge (e.g. 180–230°). + """ + thetamin, thetamax = self._get_directed_thetalim() + span = (thetamax - thetamin) % 360.0 + is_full_circle = self._is_full_circle_thetalim(thetamin, thetamax) + if is_full_circle: + mid = 0.0 if loc is None else float(loc) + half_span = _POLAR_LABEL_FULL_HALFSPAN_DEG + elif loc is None: + mid = thetamin + 0.5 * span + half_span = 0.5 * span * _POLAR_LABEL_SECTOR_FRAC + else: + # Explicit thetalabelloc on a sector: localize the label around + # the requested angle instead of spanning the whole sector arc. + mid = float(loc) + half_span = _POLAR_LABEL_FULL_HALFSPAN_DEG + x = np.deg2rad( + np.linspace(mid - half_span, mid + half_span, _POLAR_LABEL_NPOINTS) + ) + rmax_val = self.get_rmax() + p0 = self.transData.transform(np.array([0.0, rmax_val])) + p1 = self.transData.transform(np.array([0.0, rmax_val + 1.0])) + px_per_r = float(np.linalg.norm(np.asarray(p1) - np.asarray(p0))) + delta_r = total_pad_in * self.figure.dpi / px_per_r if px_per_r > 1e-6 else 0.0 + y = np.full_like(x, rmax_val + delta_r) + return x, y, self.transData + + def _get_sector_rlabel_outside_sign(self, rpos): + """Return the sign that offsets a sector rlabel outside the wedge.""" + thetamin, thetamax = self._get_directed_thetalim() + span = (thetamax - thetamin) % 360.0 + inside_step = min(1.0, 0.25 * span) + inside_theta = ( + rpos - inside_step + if np.isclose((rpos - thetamax) % 360.0, 0.0) + else rpos + inside_step + ) + rmid = 0.5 * (self.get_rmin() + self.get_rmax()) + edge = self.transData.transform(np.array([np.deg2rad(rpos), rmid])) + inside = self.transData.transform(np.array([np.deg2rad(inside_theta), rmid])) + normal = self._get_rlabel_right_normal(np.deg2rad(rpos)) + return -1.0 if np.dot(np.asarray(inside) - np.asarray(edge), normal) > 0.0 else 1.0 + + def _resolve_rlabel_geometry(self, loc, rlabelpos): + """ + Resolve ``(rpos, sign)`` for the radial label given ``rlabelloc`` and + an optional explicit ``rlabelpos``. On a full circle, ``loc`` flips + the perpendicular offset; on a sector with no explicit ``rlabelpos``, + ``loc`` instead selects the spoke (``thetamin`` vs ``thetamax``) and + the perpendicular sign is auto-chosen to fall outside the wedge. + """ + if loc not in (None, "left", "right"): + raise ValueError(f"rlabelloc must be 'right' or 'left'; got {loc!r}") + thetamin, thetamax = self._get_directed_thetalim() + is_full_circle = self._is_full_circle_thetalim(thetamin, thetamax) + if rlabelpos is not None: + rpos = float(rlabelpos) + if is_full_circle: + base_sign = 1.0 + else: + base_sign = -1.0 if np.isclose((rpos - thetamax) % 360.0, 0.0) else 1.0 + elif is_full_circle: + rpos = self.get_rlabel_position() + base_sign = 1.0 + else: + rpos = thetamax if loc == "left" else thetamin + base_sign = self._get_sector_rlabel_outside_sign(rpos) + flip = loc == "left" and (is_full_circle or rlabelpos is not None) + sign = -base_sign if flip else base_sign + return rpos, sign + + def _get_rlabel_right_normal(self, rad): + """Return the display-space right normal for the radial spoke at ``rad``.""" + rmin, rmax = self.get_rmin(), self.get_rmax() + p0 = self.transData.transform(np.array([rad, rmin])) + p1 = self.transData.transform(np.array([rad, rmax])) + tangent = np.asarray(p1, dtype=float) - np.asarray(p0, dtype=float) + norm = np.linalg.norm(tangent) + if norm <= 1e-6: + return np.array([np.sin(rad), -np.cos(rad)]) + tangent /= norm + return np.array([tangent[1], -tangent[0]]) + + def _build_rlabel_curve(self, loc, pad_in, rlabelpos): + """ + Curve along the radial spoke from rmin to rmax with a perpendicular + ScaledTranslation offset so the label clears the r-tick labels. + """ + rpos, sign = self._resolve_rlabel_geometry(loc, rlabelpos) + rad = np.deg2rad(rpos) + x = np.full(_POLAR_LABEL_NPOINTS, rad) + y = np.linspace(self.get_rmin(), self.get_rmax(), _POLAR_LABEL_NPOINTS) + normal = self._get_rlabel_right_normal(rad) + tick_clearance_in = self._polar_tick_clearance_in("y") + total_pad_in = pad_in + tick_clearance_in + dx_in, dy_in = sign * total_pad_in * normal + transform = self.transData + mtransforms.ScaledTranslation( + dx_in, dy_in, self.figure.dpi_scale_trans + ) + return x, y, transform + + def _refresh_polar_label_geometry(self, kind): + """Refresh the stored curve and transform for an existing polar label.""" + attr = f"_{kind}label_artist" + artist = getattr(self, attr, None) + if artist is None: + return + state = getattr(self, f"_{kind}label_state", None) or {} + loc = state.get("loc") + labelpad = state.get("labelpad") + pad_in = _not_none(labelpad, rc["grid.labelpad"]) / 72.0 + axis = "x" if kind == "theta" else "y" + total_pad_in = pad_in + self._polar_tick_clearance_in(axis) + if kind == "theta": + x, y, transform = self._build_thetalabel_curve(loc, total_pad_in) + else: + x, y, transform = self._build_rlabel_curve(loc, pad_in, state.get("rlabelpos")) + artist.set_curve(x, y) + artist.set_transform(transform) + + def _update_polar_label( + self, kind, text, *, loc=None, labelpad=None, rlabelpos=None, **kwargs + ): + """ + Apply a polar-aware axis label along the outer arc (`thetalabel`) or + along the radial spoke (`rlabel`), both via CurvedText. + """ + # NOTE: Critical to test whether arguments are None or else we'd + # overwrite styling and clear text on every format() call. + kwargs = rc._get_label_props(**kwargs) + kwargs.pop("labelpad", None) # injected by _get_label_props; not a Text prop + attr = f"_{kind}label_artist" + artist = getattr(self, attr, None) + # Sticky state: previously-applied loc/labelpad/rlabelpos so a generic + # format() call (e.g. ``axs.format(suptitle=...)``) doesn't reset them + # back to the default when the user didn't pass them again. + state_attr = f"_{kind}label_state" + state = getattr(self, state_attr, None) or {} + nothing_to_do = ( + text is None + and loc is None + and labelpad is None + and rlabelpos is None + and all(v is None for v in kwargs.values()) + ) + if artist is None and nothing_to_do: + return + + if loc is not None: + state["loc"] = loc + if labelpad is not None: + state["labelpad"] = labelpad + if kind == "r" and rlabelpos is not None: + state["rlabelpos"] = rlabelpos + setattr(self, state_attr, state) + loc = state.get("loc") + labelpad = state.get("labelpad") + rlabelpos = state.get("rlabelpos") if kind == "r" else None + + pad_in = _not_none(labelpad, rc["grid.labelpad"]) / 72.0 + style_props = {k: v for k, v in kwargs.items() if v is not None} + if kind == "theta": + total_pad_in = pad_in + self._polar_tick_clearance_in("x") + x, y, transform = self._build_thetalabel_curve(loc, total_pad_in) + else: + x, y, transform = self._build_rlabel_curve(loc, pad_in, rlabelpos) + + if artist is None: + artist = self.text( + x, + y, + text or "", + transform=transform, + ha="center", + va="center", + clip_on=False, + **style_props, + ) + setattr(self, attr, artist) + return + artist.set_curve(x, y) + artist.set_transform(transform) + if text is not None: + artist.set_text(text) + if style_props: + artist._apply_label_props(style_props) + + @override + def draw(self, renderer=None, *args, **kwargs): + self._refresh_polar_label_geometry("theta") + self._refresh_polar_label_geometry("r") + super().draw(renderer, *args, **kwargs) + + @override + def get_tightbbox(self, renderer, *args, **kwargs): + self._refresh_polar_label_geometry("theta") + self._refresh_polar_label_geometry("r") + return super().get_tightbbox(renderer, *args, **kwargs) + @docstring._snippet_manager def format( self, @@ -284,6 +557,12 @@ def format( ylabel=None, xlabel_kw=None, ylabel_kw=None, + thetalabel=None, + rlabel=None, + thetalabelloc=None, + rlabelloc=None, + thetalabel_kw=None, + rlabel_kw=None, **kwargs, ): """ @@ -424,6 +703,23 @@ def format( kw.update(label_kw or {}) self._update_labels(x, label, **kw) + # Polar-aware axis labels (rendered along the arc / radial spoke) + for kind, text, loc, label_kw in ( + ("theta", thetalabel, thetalabelloc, thetalabel_kw), + ("r", rlabel, rlabelloc, rlabel_kw), + ): + kw = dict( + loc=loc, + labelpad=labelpad, + color=labelcolor, + size=labelsize, + weight=labelweight, + ) + if kind == "r": + kw["rlabelpos"] = rlabelpos + kw.update(label_kw or {}) + self._update_polar_label(kind, text, **kw) + # Parent format method super().format(rc_kw=rc_kw, rc_mode=rc_mode, **kwargs) diff --git a/ultraplot/internals/labels.py b/ultraplot/internals/labels.py index 9cb49d2ec..51c0e32c5 100644 --- a/ultraplot/internals/labels.py +++ b/ultraplot/internals/labels.py @@ -11,6 +11,34 @@ from . import ic # noqa: F401 +# Pseudo-properties handled by `_update_label`. These are not valid +# `matplotlib.text.Text` constructor kwargs, so they must be filtered before +# instantiating Text and re-applied via `_update_label` afterwards. +LABEL_PSEUDO_PROPS = frozenset( + { + "border", + "bordercolor", + "borderinvert", + "borderwidth", + "borderstyle", + "bbox", + "bboxcolor", + "bboxstyle", + "bboxalpha", + "bboxpad", + } +) + + +def _split_label_props(kwargs): + """ + Split a kwargs dict into (label_props, text_kwargs) so the latter can be + passed to `mtext.Text(...)` and the former applied via `_update_label`. + """ + label_props = {k: kwargs[k] for k in kwargs if k in LABEL_PSEUDO_PROPS} + text_kwargs = {k: v for k, v in kwargs.items() if k not in LABEL_PSEUDO_PROPS} + return label_props, text_kwargs + def merge_font_properties( dest_fp: FontProperties, src_fp: FontProperties diff --git a/ultraplot/tests/test_projections.py b/ultraplot/tests/test_projections.py index 1a5daea47..1cc347e32 100644 --- a/ultraplot/tests/test_projections.py +++ b/ultraplot/tests/test_projections.py @@ -164,6 +164,317 @@ def test_polar_format_labels(): assert ax.get_ylabel() == "ylabel" +def test_polar_format_thetalabel_rlabel(): + """ + `thetalabel` and `rlabel` both create CurvedText artists. + `thetalabel` follows the outer arc at r=rmax. + `rlabel` follows the radial spoke at rlabel_position, spanning rmin→rmax. + """ + fig, axs = uplt.subplots(proj="polar") + ax = axs[0] + ax.format( + thetalim=(0, 90), + rlim=(0, 1), + thetalabel="thetalabel", + rlabel="rlabel", + ) + assert ax._thetalabel_artist is not None + assert ax._rlabel_artist is not None + assert ax._thetalabel_artist.get_text() == "thetalabel" + assert ax._rlabel_artist.get_text() == "rlabel" + # thetalabel: CurvedText arc at r >= rmax (offset for tick clearance), + # centered on midpoint, 80% of span. + tx, ty = ax._thetalabel_artist.get_curve() + assert np.allclose(ty, ty[0]) + assert ty[0] >= ax.get_rmax() + mid = 0.5 * (0.0 + 90.0) + half_span = 0.5 * 90.0 * 0.8 + assert np.isclose(np.rad2deg(tx[0]), mid - half_span) + assert np.isclose(np.rad2deg(tx[-1]), mid + half_span) + # rlabel: CurvedText along spoke at thetamin (sector default), rmin→rmax + rx, ry = ax._rlabel_artist.get_curve() + assert np.allclose(np.rad2deg(rx), 0.0) # thetamin for (0, 90) sector + assert np.isclose(ry[0], ax.get_rmin()) + assert np.isclose(ry[-1], ax.get_rmax()) + + +def test_polar_format_thetalabel_full_circle(): + """`thetalabel` on a full-range polar axes centers on theta=0.""" + fig, axs = uplt.subplots(proj="polar") + ax = axs[0] + ax.format(thetalabel="thetalabel") + tx, _ = ax._thetalabel_artist.get_curve() + mid_deg = np.rad2deg(0.5 * (tx[0] + tx[-1])) + assert np.isclose(mid_deg % 360, 0.0) + + +def test_polar_format_thetalabel_clear(): + """Passing thetalabel='' clears an existing label.""" + fig, axs = uplt.subplots(proj="polar") + ax = axs[0] + ax.format(thetalabel="x") + ax.format(thetalabel="") + assert ax._thetalabel_artist.get_text() == "" + + +def test_polar_format_thetalabelloc(): + """`thetalabelloc=` overrides the default midpoint center.""" + fig, axs = uplt.subplots(proj="polar") + ax = axs[0] + ax.format(thetalim=(0, 90), thetalabel="thetalabel", thetalabelloc=30) + tx, _ = ax._thetalabel_artist.get_curve() + mid_deg = np.rad2deg(0.5 * (tx[0] + tx[-1])) + assert np.isclose(mid_deg, 30.0) + + +def test_polar_thetalabel_stays_radially_outside_under_theta_transform(): + """The thetalabel offset must stay radially outward after theta transforms.""" + fig, axs = uplt.subplots(ncols=2, proj="polar") + for ax, kwargs in zip( + axs, + ({}, {"theta0": "N", "thetadir": -1}), + ): + ax.format( + thetalim=(0, 180), + rlim=(0.2, 1), + thetalabel="thetalabel", + thetalabelloc=135, + **kwargs, + ) + fig.canvas.draw() + for ax in axs: + tx, ty = ax._thetalabel_artist.get_curve() + idx = len(tx) // 2 + base = ax.transData.transform((tx[idx], ax.get_rmax())) + disp = ax._thetalabel_artist.get_transform().transform((tx[idx], ty[idx])) + outward = ax.transData.transform((tx[idx], ax.get_rmax() + 1.0)) - base + offset = disp - base + assert np.dot(offset, outward) > 0 + + +def test_polar_annular_labels_draw_without_nan_positions(): + """Annular polar labels must resolve finite character positions after draw.""" + fig, axs = uplt.subplots(proj="polar") + ax = axs[0] + ax.format( + thetalim=(30, 120), + rlim=(0.4, 1.2), + thetalabel="Annular sector", + rlabel="rlabel", + ) + fig.canvas.draw() + for artist in (ax._thetalabel_artist, ax._rlabel_artist): + positions = [ + np.asarray(text.get_position(), dtype=float) + for char, text in artist._characters + if char.strip() + ] + assert positions + assert all(np.all(np.isfinite(position)) for position in positions) + + +def test_polar_format_wrapped_sector_uses_directed_interval(): + """Wrapped sectors must use the directed theta interval, not sorted extrema.""" + fig, axs = uplt.subplots(proj="polar") + ax = axs[0] + ax.format(thetalim=(300, 60), rlim=(0, 1), thetalabel="thetalabel", rlabel="rlabel") + tx, _ = ax._thetalabel_artist.get_curve() + mid_deg = np.rad2deg(0.5 * (tx[0] + tx[-1])) % 360 + assert np.isclose(mid_deg, 0.0) + rx, _ = ax._rlabel_artist.get_curve() + assert np.allclose(np.rad2deg(rx) % 360, 300.0) + ax.format(rlabelloc="left") + rx, _ = ax._rlabel_artist.get_curve() + assert np.allclose(np.rad2deg(rx) % 360, 60.0) + + +def test_polar_format_rlabelloc_full_circle_flips_offset(): + """On a full circle, `rlabelloc='left'` flips the perpendicular offset.""" + fig, axs = uplt.subplots(proj="polar") + ax = axs[0] + ax.format(rlabel="rlabel", rlabelloc="right") + fig.canvas.draw() + rpos_deg = ax.get_rlabel_position() + rmid = 0.5 * (ax.get_rmin() + ax.get_rmax()) + test_point = (np.deg2rad(rpos_deg), rmid) + right_base_disp = ax.transData.transform(test_point) + right_disp = ax._rlabel_artist.get_transform().transform(test_point) + ax.format(rlabelloc="left") + fig.canvas.draw() + left_base_disp = ax.transData.transform(test_point) + left_disp = ax._rlabel_artist.get_transform().transform(test_point) + right_off = right_disp - right_base_disp + left_off = left_disp - left_base_disp + assert np.allclose(right_off, -left_off) + assert not np.allclose(right_off, 0) + + +def test_polar_format_rlabelloc_sector_selects_spoke(): + """On a sector, `rlabelloc='right'` anchors to thetamin and `'left'` to thetamax.""" + fig, axs = uplt.subplots(proj="polar") + ax = axs[0] + ax.format(thetalim=(0, 90), rlabel="rlabel", rlabelloc="right") + rx_right, _ = ax._rlabel_artist.get_curve() + assert np.allclose(np.rad2deg(rx_right), 0.0) # thetamin spoke + ax.format(rlabelloc="left") + rx_left, _ = ax._rlabel_artist.get_curve() + assert np.allclose(np.rad2deg(rx_left), 90.0) # thetamax spoke + + +def test_polar_format_rlabelloc_sector_stays_outside_under_theta_transform(): + """Sector-default `rlabelloc` must stay outside after theta transforms.""" + fig, axs = uplt.subplots(ncols=2, proj="polar") + for ax, loc in zip(axs, ("right", "left")): + ax.format( + thetalim=(0, 180), + rlim=(0, 1), + theta0="N", + thetadir=-1, + rlabel="rlabel", + rlabelloc=loc, + ) + fig.canvas.draw() + for ax, rpos_deg, inside_deg in zip(axs, (0.0, 180.0), (1.0, 179.0)): + rmid = 0.5 * (ax.get_rmin() + ax.get_rmax()) + point = (np.deg2rad(rpos_deg), rmid) + base_disp = ax.transData.transform(point) + rlabel_disp = ax._rlabel_artist.get_transform().transform(point) + inside_disp = ax.transData.transform((np.deg2rad(inside_deg), rmid)) + off = rlabel_disp - base_disp + inside = inside_disp - base_disp + assert np.dot(off, inside) < 0 + + +def test_polar_format_loc_persists_across_format_calls(): + """ + A subsequent `format()` call without `thetalabelloc`/`rlabelloc`/`rlabelpos` + must not reset the previously-applied values. Regression test for trailing + `axs.format(suptitle=...)`-style calls. + """ + fig, axs = uplt.subplots(proj="polar") + ax = axs[0] + ax.format( + thetalim=(0, 90), + thetalabel="t", + thetalabelloc=30, + rlabel="r", + rlabelloc="left", + ) + tx0, _ = ax._thetalabel_artist.get_curve() + rx0, _ = ax._rlabel_artist.get_curve() + # Trailing generic format() call — must preserve the previous loc/pos. + ax.format(title="anything") + tx1, _ = ax._thetalabel_artist.get_curve() + rx1, _ = ax._rlabel_artist.get_curve() + assert np.isclose(np.rad2deg(0.5 * (tx1[0] + tx1[-1])), 30.0) + assert np.allclose(np.rad2deg(rx1), 90.0) + # Geometry is recomputed but anchors stay put. + assert np.allclose( + np.rad2deg(0.5 * (tx0[0] + tx0[-1])), np.rad2deg(0.5 * (tx1[0] + tx1[-1])) + ) + assert np.allclose(rx0, rx1) + + +def test_polar_format_rlabelpos_sector_auto_outside(): + """`rlabelpos=thetamax` on a sector offsets *outside* the wedge.""" + fig, axs = uplt.subplots(proj="polar") + ax = axs[0] + ax.format(thetalim=(0, 180), rlim=(0, 1), rlabel="rlabel", rlabelpos=180) + fig.canvas.draw() + rmid = 0.5 * (ax.get_rmin() + ax.get_rmax()) + test_point = (np.deg2rad(180.0), rmid) + base_disp = ax.transData.transform(test_point) + rlabel_disp = ax._rlabel_artist.get_transform().transform(test_point) + off = rlabel_disp - base_disp + # Spoke at theta=180 lies along the −x axis; the upper half-disk is +y, so + # outside-the-wedge means the perpendicular offset must be in −y. + assert off[1] < 0 + + +def test_polar_rlabel_offset_stays_perpendicular_under_theta_transform(): + """The rlabel offset must stay perpendicular to the spoke after theta transforms.""" + fig, axs = uplt.subplots(ncols=2, proj="polar") + for ax, kwargs in zip( + axs, + ({}, {"theta0": "N", "thetadir": -1}), + ): + ax.format(thetalim=(0, 180), rlim=(0.2, 1), rlabel="rlabel", rlabelpos=135, **kwargs) + fig.canvas.draw() + for ax in axs: + rmid = 0.5 * (ax.get_rmin() + ax.get_rmax()) + point = (np.deg2rad(135.0), rmid) + base = ax.transData.transform(point) + disp = ax._rlabel_artist.get_transform().transform(point) + offset = disp - base + tangent = ax.transData.transform((np.deg2rad(135.0), ax.get_rmax())) - ax.transData.transform( + (np.deg2rad(135.0), ax.get_rmin()) + ) + tangent /= np.linalg.norm(tangent) + assert np.isclose(np.dot(offset, tangent), 0.0, atol=1e-6) + + +def test_polar_rlabel_refresh_tracks_tick_params(): + """Refreshing the rlabel must honor later tick-param changes.""" + fig, axs = uplt.subplots(proj="polar") + ax = axs[0] + ax.format(thetalim=(0, 180), rlim=(0.2, 1), rlabel="rlabel") + fig.canvas.draw() + rpos_deg = ax.get_rlabel_position() + rmid = 0.5 * (ax.get_rmin() + ax.get_rmax()) + point = (np.deg2rad(rpos_deg), rmid) + base = ax.transData.transform(point) + disp0 = ax._rlabel_artist.get_transform().transform(point) + off0 = np.linalg.norm(disp0 - base) + ax.tick_params(axis="y", which="major", pad=30, labelsize=20) + fig.canvas.draw() + disp1 = ax._rlabel_artist.get_transform().transform(point) + off1 = np.linalg.norm(disp1 - base) + assert off1 > off0 + + +def test_polar_labels_refresh_after_plot_draw(): + """Polar-aware label geometry must refresh when later plotting changes draw state.""" + fig, axs = uplt.subplots(proj="polar") + ax = axs[0] + ax.format( + thetalim=(0, 90), + rlim=(0, 2), + thetalabel="thetalabel", + rlabel="rlabel", + ) + tx0, ty0 = ax._thetalabel_artist.get_curve() + rx0, ry0 = ax._rlabel_artist.get_curve() + ax.plot(np.linspace(0, 2 * np.pi, 200), np.linspace(0, 100, 200)) + fig.canvas.draw() + tx1, ty1 = ax._thetalabel_artist.get_curve() + rx1, ry1 = ax._rlabel_artist.get_curve() + assert np.allclose(np.rad2deg(rx1), 0.0) + assert np.isclose(ry1[0], ax.get_rmin()) + assert np.isclose(ry1[-1], ax.get_rmax()) + assert np.allclose(ty1, ty1[0]) + assert ty1[0] >= ax.get_rmax() + assert not np.allclose(ty0, ty1) or not np.allclose(ry0, ry1) or not np.allclose(tx0, tx1) + + +def test_polar_labels_refresh_for_tightbbox(): + """Polar-aware labels must also refresh during tight-bbox queries.""" + fig, axs = uplt.subplots(proj="polar") + ax = axs[0] + ax.format(thetalim=(0, 90), rlim=(0, 1), thetalabel="thetalabel", rlabel="rlabel") + fig.canvas.draw() + tx0, ty0 = ax._thetalabel_artist.get_curve() + rx0, ry0 = ax._rlabel_artist.get_curve() + ax.set_rmax(3) + ax.get_tightbbox(fig.canvas.get_renderer()) + tx1, ty1 = ax._thetalabel_artist.get_curve() + rx1, ry1 = ax._rlabel_artist.get_curve() + assert np.allclose(np.rad2deg(rx1), 0.0) + assert np.isclose(ry1[-1], ax.get_rmax()) + assert np.allclose(ty1, ty1[0]) + assert ty1[0] >= ax.get_rmax() + assert not np.allclose(ty0, ty1) or not np.allclose(ry0, ry1) or not np.allclose(tx0, tx1) + + def test_sharing_axes(): """ Test sharing axes for GeoAxes diff --git a/ultraplot/text.py b/ultraplot/text.py index bd123fce5..f00e18305 100644 --- a/ultraplot/text.py +++ b/ultraplot/text.py @@ -72,6 +72,11 @@ def __init__( if kwargs.get("transform") is None: kwargs["transform"] = axes.transData + # Split pseudo-properties (border/bbox*) from valid Text kwargs so + # mtext.Text(**self._text_kwargs) accepts them and pseudo-props can be + # re-applied via labels._update_label. + label_props, text_kwargs = labels._split_label_props(kwargs) + # Initialize storage before Text.__init__ triggers set_text() self._characters = [] self._curve_text = "" if text is None else str(text) @@ -82,11 +87,15 @@ def __init__( self._curvature_pad = float(curvature_pad) self._min_advance = float(min_advance) self._ellipsis_text = "..." - self._text_kwargs = kwargs.copy() + self._text_kwargs = text_kwargs + self._label_props = label_props self._initializing = True - super().__init__(x[0], y[0], " ", **kwargs) + super().__init__(x[0], y[0], " ", **text_kwargs) axes.add_artist(self) + # add_artist calls set_clip_path(self.patch), which sets clip_on=True + # and silently overrides any clip_on=False the caller passed. + self._restore_clip_on(self) self._curve_x = x self._curve_y = y @@ -95,18 +104,28 @@ def __init__( self._build_characters(self._curve_text) + def _restore_clip_on(self, t) -> None: + """Re-assert clip_on after add_artist/add_text resets it.""" + if "clip_on" in self._text_kwargs: + t.set_clip_on(self._text_kwargs["clip_on"]) + def _build_characters(self, text: str) -> None: # Remove previous character artists for _, artist in self._characters: artist.remove() self._characters = [] + # Initial position on the curve (not (0, 0)) so get_window_extent works + # under transforms whose inverse is undefined at (0, 0) — e.g. polar + # annular plots where r=0 is below rmin and inverts to NaN. + x0 = float(self._curve_x[0]) + y0 = float(self._curve_y[0]) for char in text: if char == " ": - t = mtext.Text(0, 0, " ", **self._text_kwargs) + t = mtext.Text(x0, y0, " ", **self._text_kwargs) t.set_alpha(0.0) else: - t = mtext.Text(0, 0, char, **self._text_kwargs) + t = mtext.Text(x0, y0, char, **self._text_kwargs) t.set_ha("center") t.set_va("center") @@ -117,6 +136,10 @@ def _build_characters(self, text: str) -> None: add_text(t) else: self.axes.add_artist(t) + self._restore_clip_on(t) + if self._label_props: + t.update = labels._update_label.__get__(t) + t.update(self._label_props) self._characters.append((char, t)) def set_text(self, s): @@ -143,6 +166,10 @@ 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: + new_label_props, new_text_kwargs = labels._split_label_props(props) + # Persist for future set_text() rebuilds. + self._text_kwargs.update(new_text_kwargs) + self._label_props.update(new_label_props) for _, t in self._characters: t.update = labels._update_label.__get__(t) t.update(props) @@ -153,6 +180,12 @@ def set_zorder(self, zorder): for _, t in self._characters: t.set_zorder(self._zorder + 1) + def set_transform(self, transform): + super().set_transform(transform) + self._text_kwargs["transform"] = transform + for _, t in self._characters: + t.set_transform(transform) + def draw(self, renderer, *args, **kwargs): """ Overload `Text.draw()` to update character positions and rotations. @@ -291,6 +324,16 @@ def _place_at(target, t): y_disp[idx] + fraction * dy_arr[idx], ] ) + # Pre-place at a valid data position before measuring bbox: on + # annular polar plots (rmin > 0) the default (0, 0) data coord + # falls below rmin and inverts to NaN, which propagates and + # locks the glyph at NaN forever. + try: + base_data = trans_inv.transform(base) + except Exception: + base_data = None + if base_data is not None and not np.any(np.isnan(base_data)): + t.set_position(base_data) t.set_va("center") bbox_center = t.get_window_extent(renderer=renderer) t.set_va(self.get_va())