From 4e82d28c7a248b31d4c37eab73eba6ab35312ec7 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 17 Mar 2026 11:49:52 +1000 Subject: [PATCH 1/6] Add manual text staggering helper --- ultraplot/axes/base.py | 196 ++++++++++++++++++++++++++++++++++- ultraplot/tests/test_axes.py | 82 +++++++++++++++ 2 files changed, 277 insertions(+), 1 deletion(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index f7968a120..651e37699 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -11,7 +11,7 @@ import types from collections.abc import Iterable as IterableType from numbers import Integral, Number -from typing import Any, Iterable, MutableMapping, Optional, Tuple, Union +from typing import Any, Iterable, MutableMapping, Optional, Sequence, Tuple, Union try: # From python 3.12 @@ -4011,6 +4011,200 @@ def curvedtext( ) return obj + def _iter_stagger_text_artists( + self, + artists: Optional[Sequence[mtext.Text]] = None, + ) -> list[mtext.Text]: + """ + Return visible text or annotation artists eligible for staggering. + """ + from ..text import CurvedText + + if artists is None: + artists = tuple(self.texts) + return [ + artist + for artist in artists + if isinstance(artist, mtext.Text) + and not isinstance(artist, CurvedText) + and artist.axes is self + and artist.get_visible() + and artist.get_text() + ] + + @staticmethod + def _get_stagger_text_bbox( + artist: mtext.Text, + renderer: Any, + ) -> Optional[mtransforms.Bbox]: + """ + Return the text box bbox used for overlap checks. + """ + patch = artist.get_bbox_patch() + if patch is not None and patch.get_visible(): + artist.update_bbox_position_size(renderer) + return patch.get_window_extent(renderer) + if isinstance(artist, mtext.Annotation): + return mtext.Text.get_window_extent(artist, renderer) + return artist.get_window_extent(renderer) + + @staticmethod + def _pad_stagger_bbox( + bbox: Optional[mtransforms.Bbox], + pad_px: float, + ) -> Optional[mtransforms.Bbox]: + """ + Expand a bbox by the requested display-space padding. + """ + if bbox is None or pad_px <= 0: + return bbox + return mtransforms.Bbox.from_extents( + bbox.x0 - pad_px, + bbox.y0 - pad_px, + bbox.x1 + pad_px, + bbox.y1 + pad_px, + ) + + def _reset_stagger_text_artist(self, artist: mtext.Text) -> None: + """ + Reset a staggered artist back to its stored base position. + """ + if isinstance(artist, mtext.Annotation): + base_position = getattr(artist, "_stagger_base_position", artist.xyann) + artist._stagger_base_position = tuple(base_position) + artist.set_position(base_position) + else: + base_transform = getattr( + artist, "_stagger_base_transform", artist.get_transform() + ) + artist._stagger_base_transform = base_transform + artist.set_transform(base_transform) + artist.stale = True + + def _apply_stagger_text_offset( + self, + artist: mtext.Text, + renderer: Any, + dx_px: float, + dy_px: float, + ) -> None: + """ + Apply a display-space offset to a text or annotation artist. + """ + self._reset_stagger_text_artist(artist) + if not dx_px and not dy_px: + return + figure = self.figure + if isinstance(artist, mtext.Annotation): + base_position = artist._stagger_base_position + transform = artist._get_xy_transform(renderer, artist.anncoords) + base_display = transform.transform(base_position) + staggered = transform.inverted().transform( + base_display + np.array([dx_px, dy_px]) + ) + artist.set_position(tuple(map(float, staggered))) + else: + offset = mtransforms.ScaledTranslation( + dx_px / figure.dpi, + dy_px / figure.dpi, + figure.dpi_scale_trans, + ) + artist.set_transform(artist._stagger_base_transform + offset) + artist.stale = True + + @docstring._concatenate_inherited + def stagger_text( + self, + artists: Optional[Sequence[mtext.Text]] = None, + *, + direction: str = "y", + step: Union[str, float] = "0.8em", + pad: Union[str, float] = "0.15em", + max_steps: int = 20, + renderer: Any = None, + ) -> list[mtext.Text]: + """ + Stagger text and annotation artists to reduce overlapping boxes. + + Parameters + ---------- + artists : sequence of `~matplotlib.text.Text`, optional + The artists to stagger. Defaults to visible axes text and annotation + artists added with `text` or `annotate`. + direction : {'x', 'y', 'horizontal', 'vertical'}, default: 'y' + The staggering direction. + step : float or unit-spec, default: '0.8em' + The offset increment applied while searching for a non-overlapping + position. + pad : float or unit-spec, default: '0.15em' + Extra bbox padding used when deciding whether two labels overlap. + max_steps : int, default: 20 + The maximum number of stagger steps searched on each side of the + original position. + renderer : optional + The renderer used for bbox calculations. If omitted, UltraPlot uses + the figure renderer. + + Returns + ------- + list of `~matplotlib.text.Text` + The staggered artists. + + Notes + ----- + This operates on existing `text` and `annotate` artists. It is + intentionally explicit instead of automatic, so UltraPlot does not + silently change text placement during later draws or layout updates. + """ + artists = self._iter_stagger_text_artists(artists) + if not artists: + return [] + match direction.lower(): + case "x" | "horizontal": + axis = "x" + case "y" | "vertical": + axis = "y" + case _: + raise ValueError(f"Invalid stagger direction {direction!r}.") + if max_steps < 0: + raise ValueError("max_steps must be non-negative.") + + figure = self.figure + renderer = _not_none(renderer, figure._get_renderer()) + step_px = float( + units(step, "pt", "px", figure=figure, axes=self, width=axis == "x") + ) + pad_px = float( + units(pad, "pt", "px", figure=figure, axes=self, width=axis == "x") + ) + deltas = [0.0] + for level in range(1, max_steps + 1): + delta = level * step_px + deltas.extend((delta, -delta)) + + placed_bboxes: list[mtransforms.Bbox] = [] + for artist in artists: + bbox = None + for delta in deltas: + dx_px = delta if axis == "x" else 0.0 + dy_px = delta if axis == "y" else 0.0 + self._apply_stagger_text_offset(artist, renderer, dx_px, dy_px) + bbox = self._pad_stagger_bbox( + self._get_stagger_text_bbox(artist, renderer), + pad_px, + ) + if bbox is None or not any( + bbox.overlaps(prev) for prev in placed_bboxes + ): + break + if bbox is not None: + placed_bboxes.append(bbox) + artist.stale = True + self.stale = True + if figure is not None: + figure.stale = True + return artists + 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 e19e81e80..f00f0d9e0 100644 --- a/ultraplot/tests/test_axes.py +++ b/ultraplot/tests/test_axes.py @@ -252,6 +252,88 @@ def test_annotate_curve_xy_uses_rc_defaults(): assert np.isclose(obj._min_advance, 1.5) +def _text_bbox(artist, renderer): + patch = artist.get_bbox_patch() + if patch is not None and patch.get_visible(): + return patch.get_window_extent(renderer) + if isinstance(artist, mtext.Annotation): + return mtext.Text.get_window_extent(artist, renderer) + return artist.get_window_extent(renderer) + + +def test_stagger_text_vertical_avoids_overlap(): + fig, ax = uplt.subplots() + artists = [ + ax.text(0.5, 0.5, "alpha", ha="center", va="center", bbox={"facecolor": "w"}), + ax.text(0.5, 0.5, "beta", ha="center", va="center", bbox={"facecolor": "w"}), + ] + fig.canvas.draw() + renderer = fig._get_renderer() + before = [_text_bbox(artist, renderer) for artist in artists] + assert before[0].overlaps(before[1]) + + ax.stagger_text(direction="y", step="12pt") + fig.canvas.draw() + renderer = fig._get_renderer() + after = [_text_bbox(artist, renderer) for artist in artists] + assert not after[0].overlaps(after[1]) + + +def test_stagger_text_horizontal_works_for_annotations(): + fig, ax = uplt.subplots() + artists = [ + ax.annotate( + "left", + xy=(0.25, 0.3), + xytext=(0.5, 0.5), + textcoords="axes fraction", + ha="center", + va="center", + bbox={"facecolor": "w"}, + ), + ax.annotate( + "right", + xy=(0.75, 0.7), + xytext=(0.5, 0.5), + textcoords="axes fraction", + ha="center", + va="center", + bbox={"facecolor": "w"}, + ), + ] + fig.canvas.draw() + renderer = fig._get_renderer() + before = [_text_bbox(artist, renderer) for artist in artists] + assert before[0].overlaps(before[1]) + + ax.stagger_text(direction="x", step="12pt") + fig.canvas.draw() + renderer = fig._get_renderer() + after = [_text_bbox(artist, renderer) for artist in artists] + assert not after[0].overlaps(after[1]) + + +def test_stagger_text_is_idempotent(): + fig, ax = uplt.subplots() + artists = [ + ax.text(0.5, 0.5, "alpha", ha="center", va="center", bbox={"facecolor": "w"}), + ax.text(0.5, 0.5, "beta", ha="center", va="center", bbox={"facecolor": "w"}), + ] + + fig.canvas.draw() + ax.stagger_text(direction="y", step="12pt") + fig.canvas.draw() + renderer = fig._get_renderer() + first = [_text_bbox(artist, renderer).bounds for artist in artists] + + ax.stagger_text(direction="y", step="12pt") + fig.canvas.draw() + renderer = fig._get_renderer() + second = [_text_bbox(artist, renderer).bounds for artist in artists] + + assert np.allclose(first, second) + + def _get_text_stroke_joinstyle(text): for effect in text.get_path_effects(): if isinstance(effect, mpatheffects.Stroke): From b42cee075b4566e634e1b4f46dd71398c53df2ab Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 17 Mar 2026 13:15:09 +1000 Subject: [PATCH 2/6] Support 2D text staggering --- ultraplot/axes/base.py | 73 +++++++++++++++------- ultraplot/figure.py | 67 ++++++++++++++++++++ ultraplot/tests/test_axes.py | 27 ++++++++ ultraplot/tests/test_figure.py | 110 +++++++++++++++++++++++++++++++++ 4 files changed, 255 insertions(+), 22 deletions(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 651e37699..1d78b73c5 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -4051,20 +4051,51 @@ def _get_stagger_text_bbox( @staticmethod def _pad_stagger_bbox( bbox: Optional[mtransforms.Bbox], - pad_px: float, + pad_x_px: float, + pad_y_px: float, ) -> Optional[mtransforms.Bbox]: """ Expand a bbox by the requested display-space padding. """ - if bbox is None or pad_px <= 0: + if bbox is None or (pad_x_px <= 0 and pad_y_px <= 0): return bbox return mtransforms.Bbox.from_extents( - bbox.x0 - pad_px, - bbox.y0 - pad_px, - bbox.x1 + pad_px, - bbox.y1 + pad_px, + bbox.x0 - pad_x_px, + bbox.y0 - pad_y_px, + bbox.x1 + pad_x_px, + bbox.y1 + pad_y_px, ) + @staticmethod + def _iter_stagger_offsets( + mode: str, + step_x_px: float, + step_y_px: float, + max_steps: int, + ) -> list[tuple[float, float]]: + """ + Build a deterministic list of display-space stagger offsets. + """ + offsets = [(0.0, 0.0)] + if mode == "x": + for level in range(1, max_steps + 1): + delta = level * step_x_px + offsets.extend(((delta, 0.0), (-delta, 0.0))) + return offsets + if mode == "y": + for level in range(1, max_steps + 1): + delta = level * step_y_px + offsets.extend(((0.0, delta), (0.0, -delta))) + return offsets + + for level in range(1, max_steps + 1): + for ix in range(-level, level + 1): + for iy in range(-level, level + 1): + if max(abs(ix), abs(iy)) != level: + continue + offsets.append((ix * step_x_px, iy * step_y_px)) + return offsets + def _reset_stagger_text_artist(self, artist: mtext.Text) -> None: """ Reset a staggered artist back to its stored base position. @@ -4131,7 +4162,7 @@ def stagger_text( artists : sequence of `~matplotlib.text.Text`, optional The artists to stagger. Defaults to visible axes text and annotation artists added with `text` or `annotate`. - direction : {'x', 'y', 'horizontal', 'vertical'}, default: 'y' + direction : {'x', 'y', 'both', 'horizontal', 'vertical'}, default: 'y' The staggering direction. step : float or unit-spec, default: '0.8em' The offset increment applied while searching for a non-overlapping @@ -4161,9 +4192,11 @@ def stagger_text( return [] match direction.lower(): case "x" | "horizontal": - axis = "x" + mode = "x" case "y" | "vertical": - axis = "y" + mode = "y" + case "both" | "xy" | "2d": + mode = "both" case _: raise ValueError(f"Invalid stagger direction {direction!r}.") if max_steps < 0: @@ -4171,27 +4204,23 @@ def stagger_text( figure = self.figure renderer = _not_none(renderer, figure._get_renderer()) - step_px = float( - units(step, "pt", "px", figure=figure, axes=self, width=axis == "x") - ) - pad_px = float( - units(pad, "pt", "px", figure=figure, axes=self, width=axis == "x") + step_x_px = float(units(step, "pt", "px", figure=figure, axes=self, width=True)) + step_y_px = float( + units(step, "pt", "px", figure=figure, axes=self, width=False) ) - deltas = [0.0] - for level in range(1, max_steps + 1): - delta = level * step_px - deltas.extend((delta, -delta)) + pad_x_px = float(units(pad, "pt", "px", figure=figure, axes=self, width=True)) + pad_y_px = float(units(pad, "pt", "px", figure=figure, axes=self, width=False)) + offsets = self._iter_stagger_offsets(mode, step_x_px, step_y_px, max_steps) placed_bboxes: list[mtransforms.Bbox] = [] for artist in artists: bbox = None - for delta in deltas: - dx_px = delta if axis == "x" else 0.0 - dy_px = delta if axis == "y" else 0.0 + for dx_px, dy_px in offsets: self._apply_stagger_text_offset(artist, renderer, dx_px, dy_px) bbox = self._pad_stagger_bbox( self._get_stagger_text_bbox(artist, renderer), - pad_px, + pad_x_px, + pad_y_px, ) if bbox is None or not any( bbox.overlaps(prev) for prev in placed_bboxes diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 4edab717d..c96a96304 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -2959,6 +2959,73 @@ def _axis_has_label_text(ax, axis): f"Ignoring unused projection-specific format() keyword argument(s): {kw}" # noqa: E501 ) + def _iter_selected_axes(self, axs=None, *, panels=False): + """ + Yield axes from a nested selection or default to the figure subplots. + """ + if axs is None: + yield from self._iter_axes(hidden=False, children=False, panels=panels) + return + if isinstance(axs, maxes.Axes): + if getattr(axs, "figure", None) is not self: + raise ValueError("Expected axes belonging to this figure.") + yield axs + return + if isinstance(axs, (str, bytes)): + raise TypeError("Expected an axes or iterable of axes, not a string.") + try: + iterator = iter(axs) + except TypeError as exc: + raise TypeError("Expected an axes or iterable of axes.") from exc + for obj in iterator: + yield from self._iter_selected_axes(obj, panels=panels) + + def stagger_text( + self, + axs=None, + *, + direction="y", + step="0.8em", + pad="0.15em", + max_steps=20, + renderer=None, + ): + """ + Call `stagger_text` on selected axes. + + Parameters + ---------- + axs : axes or sequence of axes, optional + The axes whose text artists should be staggered. Defaults to the + numbered subplots in the figure. + direction, step, pad, max_steps, renderer + Passed to `~ultraplot.axes.Axes.stagger_text`. + + Returns + ------- + list of `~matplotlib.text.Text` + The staggered artists from all selected axes. + + Notes + ----- + Staggering is applied independently within each selected axes. Pass + `axs` to target a single subplot or subplot selection. + """ + artists = [] + for ax in self._iter_selected_axes(axs, panels=False): + if not hasattr(ax, "stagger_text"): + raise TypeError("Expected UltraPlot axes with stagger_text().") + artists.extend( + ax.stagger_text( + direction=direction, + step=step, + pad=pad, + max_steps=max_steps, + renderer=renderer, + ) + ) + return artists + @docstring._concatenate_inherited @docstring._snippet_manager def colorbar( diff --git a/ultraplot/tests/test_axes.py b/ultraplot/tests/test_axes.py index f00f0d9e0..db6314174 100644 --- a/ultraplot/tests/test_axes.py +++ b/ultraplot/tests/test_axes.py @@ -313,6 +313,33 @@ def test_stagger_text_horizontal_works_for_annotations(): assert not after[0].overlaps(after[1]) +def test_stagger_text_both_direction_is_supported(): + fig, ax = uplt.subplots() + artists = [ + ax.text(0.5, 0.5, "alpha", ha="center", va="center", bbox={"facecolor": "w"}), + ax.text(0.5, 0.5, "beta", ha="center", va="center", bbox={"facecolor": "w"}), + ax.text(0.5, 0.5, "gamma", ha="center", va="center", bbox={"facecolor": "w"}), + ] + fig.canvas.draw() + renderer = fig._get_renderer() + before = [_text_bbox(artist, renderer) for artist in artists] + assert any( + bbox1.overlaps(bbox2) + for i, bbox1 in enumerate(before) + for bbox2 in before[i + 1 :] + ) + + ax.stagger_text(direction="both", step="12pt") + fig.canvas.draw() + renderer = fig._get_renderer() + after = [_text_bbox(artist, renderer) for artist in artists] + assert not any( + bbox1.overlaps(bbox2) + for i, bbox1 in enumerate(after) + for bbox2 in after[i + 1 :] + ) + + def test_stagger_text_is_idempotent(): fig, ax = uplt.subplots() artists = [ diff --git a/ultraplot/tests/test_figure.py b/ultraplot/tests/test_figure.py index 066f3dd2a..1677f3429 100644 --- a/ultraplot/tests/test_figure.py +++ b/ultraplot/tests/test_figure.py @@ -3,6 +3,7 @@ import warnings from datetime import datetime, timedelta +import matplotlib.text as mtext import numpy as np import pytest import ultraplot as uplt @@ -78,6 +79,115 @@ def test_get_renderer_basic(): assert hasattr(renderer, "draw_path") +def _text_bbox(artist, renderer): + patch = artist.get_bbox_patch() + if patch is not None and patch.get_visible(): + return patch.get_window_extent(renderer) + if isinstance(artist, mtext.Annotation): + return mtext.Text.get_window_extent(artist, renderer) + return artist.get_window_extent(renderer) + + +def test_figure_stagger_text_defaults_to_subplots(): + fig, axs = uplt.subplots(ncols=2) + groups = [] + for ax in axs: + groups.append( + [ + ax.text( + 0.5, 0.5, "alpha", ha="center", va="center", bbox={"facecolor": "w"} + ), + ax.text( + 0.5, 0.5, "beta", ha="center", va="center", bbox={"facecolor": "w"} + ), + ] + ) + + fig.canvas.draw() + renderer = fig._get_renderer() + assert all( + _text_bbox(a, renderer).overlaps(_text_bbox(b, renderer)) for a, b in groups + ) + + artists = fig.stagger_text(direction="y", step="12pt") + assert len(artists) == 4 + + fig.canvas.draw() + renderer = fig._get_renderer() + assert all( + not _text_bbox(a, renderer).overlaps(_text_bbox(b, renderer)) for a, b in groups + ) + + +def test_figure_stagger_text_accepts_single_subplot(): + fig, axs = uplt.subplots(ncols=2) + left = [ + axs[0].text( + 0.5, 0.5, "alpha", ha="center", va="center", bbox={"facecolor": "w"} + ), + axs[0].text( + 0.5, 0.5, "beta", ha="center", va="center", bbox={"facecolor": "w"} + ), + ] + right = [ + axs[1].text( + 0.5, 0.5, "alpha", ha="center", va="center", bbox={"facecolor": "w"} + ), + axs[1].text( + 0.5, 0.5, "beta", ha="center", va="center", bbox={"facecolor": "w"} + ), + ] + + fig.canvas.draw() + renderer = fig._get_renderer() + assert _text_bbox(left[0], renderer).overlaps(_text_bbox(left[1], renderer)) + assert _text_bbox(right[0], renderer).overlaps(_text_bbox(right[1], renderer)) + + artists = fig.stagger_text(axs=axs[0], direction="y", step="12pt") + assert len(artists) == 2 + + fig.canvas.draw() + renderer = fig._get_renderer() + assert not _text_bbox(left[0], renderer).overlaps(_text_bbox(left[1], renderer)) + assert _text_bbox(right[0], renderer).overlaps(_text_bbox(right[1], renderer)) + + +def test_figure_stagger_text_passes_both_direction(): + fig, axs = uplt.subplots(ncols=2) + artists = [ + axs[1].text( + 0.5, 0.5, "alpha", ha="center", va="center", bbox={"facecolor": "w"} + ), + axs[1].text( + 0.5, 0.5, "beta", ha="center", va="center", bbox={"facecolor": "w"} + ), + axs[1].text( + 0.5, 0.5, "gamma", ha="center", va="center", bbox={"facecolor": "w"} + ), + ] + + fig.canvas.draw() + renderer = fig._get_renderer() + before = [_text_bbox(artist, renderer) for artist in artists] + assert any( + bbox1.overlaps(bbox2) + for i, bbox1 in enumerate(before) + for bbox2 in before[i + 1 :] + ) + + out = fig.stagger_text(axs=axs[1], direction="both", step="12pt") + assert len(out) == 3 + + fig.canvas.draw() + renderer = fig._get_renderer() + bboxes = [_text_bbox(artist, renderer) for artist in artists] + assert not any( + bbox1.overlaps(bbox2) + for i, bbox1 in enumerate(bboxes) + for bbox2 in bboxes[i + 1 :] + ) + + def test_draw_without_rendering_preserves_dpi(): """ draw_without_rendering should not mutate figure dpi/bbox. From 4e1199e1f42f2b9243805d21596020a12232f459 Mon Sep 17 00:00:00 2001 From: Casper van Elteren Date: Tue, 17 Mar 2026 17:36:00 +1000 Subject: [PATCH 3/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- ultraplot/tests/test_figure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/tests/test_figure.py b/ultraplot/tests/test_figure.py index 1677f3429..dd17cdfc1 100644 --- a/ultraplot/tests/test_figure.py +++ b/ultraplot/tests/test_figure.py @@ -152,7 +152,7 @@ def test_figure_stagger_text_accepts_single_subplot(): assert _text_bbox(right[0], renderer).overlaps(_text_bbox(right[1], renderer)) -def test_figure_stagger_text_passes_both_direction(): +def test_figure_stagger_text_supports_both_directions(): fig, axs = uplt.subplots(ncols=2) artists = [ axs[1].text( From 3cab9ae8a894eacb58ddcd9cbf75c957dc97673f Mon Sep 17 00:00:00 2001 From: Casper van Elteren Date: Tue, 17 Mar 2026 17:36:08 +1000 Subject: [PATCH 4/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- ultraplot/axes/base.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 1d78b73c5..d6dba740a 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -4101,14 +4101,20 @@ def _reset_stagger_text_artist(self, artist: mtext.Text) -> None: Reset a staggered artist back to its stored base position. """ if isinstance(artist, mtext.Annotation): - base_position = getattr(artist, "_stagger_base_position", artist.xyann) - artist._stagger_base_position = tuple(base_position) + base_position = getattr( + artist, + "_ultraplot_stagger_base_position", + artist.xyann, + ) + artist._ultraplot_stagger_base_position = tuple(base_position) artist.set_position(base_position) else: base_transform = getattr( - artist, "_stagger_base_transform", artist.get_transform() + artist, + "_ultraplot_stagger_base_transform", + artist.get_transform(), ) - artist._stagger_base_transform = base_transform + artist._ultraplot_stagger_base_transform = base_transform artist.set_transform(base_transform) artist.stale = True From 49b32a488912946f876d1be25f98be815f651858 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 17 Mar 2026 17:37:26 +1000 Subject: [PATCH 5/6] Document stagger_text direction aliases --- ultraplot/axes/base.py | 6 ++++-- ultraplot/figure.py | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index d6dba740a..435a78c50 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -4168,8 +4168,10 @@ def stagger_text( artists : sequence of `~matplotlib.text.Text`, optional The artists to stagger. Defaults to visible axes text and annotation artists added with `text` or `annotate`. - direction : {'x', 'y', 'both', 'horizontal', 'vertical'}, default: 'y' - The staggering direction. + direction : {'x', 'y', 'both', 'xy', '2d', 'horizontal', 'vertical'}, \ +default: 'y' + The staggering direction. Use ``'both'``, ``'xy'``, or ``'2d'`` + to search in both horizontal and vertical directions. step : float or unit-spec, default: '0.8em' The offset increment applied while searching for a non-overlapping position. diff --git a/ultraplot/figure.py b/ultraplot/figure.py index c96a96304..699165f7d 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -2999,7 +2999,9 @@ def stagger_text( The axes whose text artists should be staggered. Defaults to the numbered subplots in the figure. direction, step, pad, max_steps, renderer - Passed to `~ultraplot.axes.Axes.stagger_text`. + Passed to `~ultraplot.axes.Axes.stagger_text`. This includes the + ``'both'``, ``'xy'``, and ``'2d'`` direction aliases for + two-dimensional staggering. Returns ------- From 4b795eb979ed3f3f4205e9c9bd82755f5f78a7b9 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 17 Mar 2026 19:21:53 +1000 Subject: [PATCH 6/6] Fix stagger_text cached state names --- ultraplot/axes/base.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 435a78c50..048c4be87 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -4133,7 +4133,11 @@ def _apply_stagger_text_offset( return figure = self.figure if isinstance(artist, mtext.Annotation): - base_position = artist._stagger_base_position + base_position = getattr( + artist, + "_ultraplot_stagger_base_position", + artist.xyann, + ) transform = artist._get_xy_transform(renderer, artist.anncoords) base_display = transform.transform(base_position) staggered = transform.inverted().transform( @@ -4141,12 +4145,17 @@ def _apply_stagger_text_offset( ) artist.set_position(tuple(map(float, staggered))) else: + base_transform = getattr( + artist, + "_ultraplot_stagger_base_transform", + artist.get_transform(), + ) offset = mtransforms.ScaledTranslation( dx_px / figure.dpi, dy_px / figure.dpi, figure.dpi_scale_trans, ) - artist.set_transform(artist._stagger_base_transform + offset) + artist.set_transform(base_transform + offset) artist.stale = True @docstring._concatenate_inherited