diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 5ed3b5a7..f1e930a5 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -1160,6 +1160,11 @@ def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None: "y": self._ymargin, } self._margin_overrides: set[str] = set() + # Whether autoscaling should stop at the margin-expanded data limits + # instead of asking the locator for round-number limits. ``None`` has + # Matplotlib's initial false-like behavior and lets tight=None preserve + # the preceding explicit choice. + self._tight: bool | None = None # Edge annotations (notably bar_label) need more than the raw 5% data # margin to contain a 10 pt glyph plus point padding. Keep that # renderer-independent reservation separate so an explicit margins() @@ -1515,6 +1520,7 @@ def clear(self) -> None: "y": self._ymargin, } self._margin_overrides = set() + self._tight = None self._explicit_domains = set() self._grid = bool(rcParams["axes.grid"]) self._grid_axes = {"x": self._grid, "y": self._grid} @@ -4521,6 +4527,7 @@ def axis( elif arg in {"auto", "equal", "scaled", "image", "square"}: # All five Matplotlib modes begin with autoscale_view(tight=False), # whose limits include the configured x/y margins. + self._tight = False self._aspect_equal = False self._aspect_value = 1.0 self._aspect_adjustable = "box" @@ -4529,7 +4536,7 @@ def axis( # the placeholder (0, 1) view into explicit limits. Matplotlib # continues autoscaling when artists are added afterwards. if self._entries: - self._set_tight_domains() + self._set_tight_domains(tight=False) if arg in {"equal", "scaled", "image", "square"}: if self._entries: self._set_aspect_equal_from_current() @@ -4552,6 +4559,10 @@ def axis( self.set_ylim(y0, y0 + edge) self._aspect_bounds = (x0, x0 + edge, y0, y0 + edge) self._set_box_aspect_ratio(1.0) + if arg == "image": + # image follows the shared tight=False transition with its + # own autoscale_view(tight=True). + self._tight = True elif arg == "tight": self._aspect_equal = False self._aspect_value = 1.0 @@ -4699,27 +4710,37 @@ def get_subplotspec(self) -> Any: self.get_gridspec() return self._subplot_spec - def margins(self, *args: Any, **kwargs: Any) -> None: - """Set the autoscaling padding around the data, as axis fractions. + def margins(self, *args: Any, **kwargs: Any) -> tuple[float, float] | None: + """Set or return autoscaling padding around the data, as axis fractions. Call as ``margins(m)``, ``margins(x, y)``, or with ``x=``/``y=``; values must be finite and greater than -0.5 (negative margins clip). - ``tight`` is accepted and ignored; explicitly set limits are left alone. + With no values, return the current ``(xmargin, ymargin)``. ``tight`` + controls round-number locator expansion; ``None`` preserves its prior + setting. Explicitly set limits are left alone. """ - tight = kwargs.pop("tight", None) - del tight + tight = kwargs.pop("tight", True) x = kwargs.pop("x", None) y = kwargs.pop("y", None) if kwargs: raise TypeError(f"margins() got unsupported keyword argument {next(iter(kwargs))!r}") - if len(args) > 2: - raise TypeError("margins() takes at most two positional arguments") + if args and (x is not None or y is not None): + raise TypeError("Cannot pass both positional and keyword arguments for x and/or y.") if len(args) == 1: x = y = args[0] elif len(args) == 2: x, y = args + elif args: + raise TypeError( + "Must pass a single positional argument for all margins, " + "or one for each margin (x, y)." + ) if x is None and y is None: - return + if tight is not True: + warnings.warn(f"ignoring tight={tight!r} in get mode", stacklevel=2) + return self._xmargin, self._ymargin + if tight is not None: + self._tight = bool(tight) if x is not None: self._xmargin = _validate_margin(x, "x") self._margin_overrides.add("x") @@ -4753,11 +4774,11 @@ def get_ymargin(self) -> float: def set_xmargin(self, margin: float) -> None: """Set x-axis autoscale padding as a fraction of the data interval.""" - self.margins(x=margin) + self.margins(x=margin, tight=None) def set_ymargin(self, margin: float) -> None: """Set y-axis autoscale padding as a fraction of the data interval.""" - self.margins(y=margin) + self.margins(y=margin, tight=None) def relim(self, visible_only: bool = False) -> None: """Recompute the data limits from the plotted entries. @@ -4772,26 +4793,36 @@ def relim(self, visible_only: bool = False) -> None: self._invalidate() def autoscale( - self, enable: bool = True, axis: str = "both", tight: Optional[bool] = None + self, enable: bool | None = True, axis: str = "both", tight: Optional[bool] = None ) -> None: """Turn autoscaling on or off and re-fit the limits. ``axis`` restricts to ``"x"`` or ``"y"`` (default ``"both"``); - ``tight=True`` pins the limits to the raw data extent, and + ``tight=True`` removes margins from axes receiving the request, and ``enable=False`` freezes the current auto limits. """ if axis not in {"both", "x", "y"}: raise ValueError("autoscale() axis must be 'both', 'x', or 'y'") axes = ("x", "y") if axis == "both" else (axis,) - for item in axes: - if enable: - self._explicit_domains.discard(item) - if tight: - self._axis_props(item)["domain"] = self._entry_extent(item) - self._explicit_domains.add(item) + # Matplotlib forwards ``tight`` to both axes when enable=None, + # regardless of ``axis``. Their enabled/disabled state itself remains + # unchanged. + forwarded_axes = ("x", "y") if enable is None else axes if enable else () + if forwarded_axes and tight is not None: + self._tight = bool(tight) + for item in forwarded_axes: + if tight: + if item == "x": + self._xmargin = 0.0 else: - self._axis_props(item).pop("domain", None) - else: + self._ymargin = 0.0 + self._margin_overrides.add(item) + if enable is True: + self._explicit_domains.discard(item) + if item not in self._explicit_domains: + self._axis_props(item).pop("domain", None) + if enable is False: + for item in axes: self._axis_props(item)["domain"] = self._auto_domain(item) self._explicit_domains.add(item) self._invalidate() @@ -4801,13 +4832,16 @@ def autoscale_view( ) -> None: """Re-fit the axes limits to the data (see `autoscale`). - ``scalex``/``scaley`` select which axes to autoscale; ``tight`` - drops the data margins. + ``scalex``/``scaley`` select which currently enabled axes to + recompute. ``tight`` controls locator rounding while preserving the + configured margins. """ - if scalex: - self.autoscale(True, axis="x", tight=tight) - if scaley: - self.autoscale(True, axis="y", tight=tight) + if tight is not None: + self._tight = bool(tight) + for item, selected in (("x", scalex), ("y", scaley)): + if selected and item not in self._explicit_domains: + self._axis_props(item).pop("domain", None) + self._invalidate() def get_xbound(self) -> tuple[float, float]: """The x bounds as a ``(lower, upper)`` pair (see `get_xlim`).""" @@ -5117,11 +5151,11 @@ def secondary_yaxis( self._invalidate() return made - def _set_tight_domains(self) -> None: - # Matplotlib's axis("tight") disables further autoscaling after an - # autoscale_view(tight=True), but that view still includes the current - # axes.xmargin/axes.ymargin (5% by default). "Tight" suppresses tick - # locator expansion; it does not mean raw data extrema. + def _set_tight_domains(self, *, tight: bool = True) -> None: + # Axis modes materialize the margin-expanded view before applying + # their aspect/autoscale policy. "Tight" suppresses tick-locator + # expansion; it does not mean raw data extrema. + self._tight = bool(tight) for axis in ("x", "y"): self._axis_props(axis)["domain"] = self._auto_domain(axis) self._explicit_domains.update({"x", "y"}) @@ -5302,9 +5336,51 @@ def get_yaxis_transform(self, **kwargs: Any) -> str: del kwargs return "yaxis transform" - def label_outer(self, **kwargs: Any) -> None: - """Compat-noop: the engine lays out shared-axis tick labels itself.""" - del kwargs + def label_outer(self, remove_inner_ticks: bool = False) -> None: + """Keep axis labels and tick labels only on the GridSpec's outer edges.""" + spec = self.get_subplotspec() + if spec is None: + return + + x_label_side = self._axis_props("x").get("side", "bottom") + if spec.rows[0] != 0: + if x_label_side == "top": + self.set_xlabel("") + self.tick_params( + axis="x", + which="both", + labeltop=False, + **({"top": False} if remove_inner_ticks else {}), + ) + if spec.rows[1] != spec.nrows: + if x_label_side == "bottom": + self.set_xlabel("") + self.tick_params( + axis="x", + which="both", + labelbottom=False, + **({"bottom": False} if remove_inner_ticks else {}), + ) + + y_label_side = self._axis_props("y").get("side", "left") + if spec.cols[0] != 0: + if y_label_side == "left": + self.set_ylabel("") + self.tick_params( + axis="y", + which="both", + labelleft=False, + **({"left": False} if remove_inner_ticks else {}), + ) + if spec.cols[1] != spec.ncols: + if y_label_side == "right": + self.set_ylabel("") + self.tick_params( + axis="y", + which="both", + labelright=False, + **({"right": False} if remove_inner_ticks else {}), + ) def add_artist(self, artist: Any) -> Any: """Add a prebuilt artist to the axes. @@ -7978,7 +8054,11 @@ def _build_chart_uncached(self, width: int, height: int) -> Any: if aspect_domains is not None: x_props["domain"], y_props["domain"] = aspect_domains auto_tick_counts = self._auto_tick_counts(x_props, width, height) - if rcParams["axes.autolimit_mode"] == "round_numbers" and not adjusted_aspect: + if ( + rcParams["axes.autolimit_mode"] == "round_numbers" + and not adjusted_aspect + and not self._tight + ): self._apply_round_number_domains(x_props, y_props, auto_tick_counts) self._apply_tickers("x", x_props, auto_tick_counts["x"]) self._apply_tickers("y", y_props, auto_tick_counts["y"]) diff --git a/tests/pyplot/test_axes_outer_label_margin_parity.py b/tests/pyplot/test_axes_outer_label_margin_parity.py new file mode 100644 index 00000000..51283842 --- /dev/null +++ b/tests/pyplot/test_axes_outer_label_margin_parity.py @@ -0,0 +1,315 @@ +"""Focused Matplotlib parity for subplot edge labels and autoscale margins.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +import xy.pyplot as plt + + +@pytest.fixture(autouse=True) +def _clean() -> Iterator[None]: + plt.close("all") + yield + plt.close("all") + + +def _label_every_axis(axes) -> None: + for index, ax in enumerate(axes.flat): + ax.set_xlabel(f"x-{index}") + ax.set_ylabel(f"y-{index}") + + +def test_label_outer_keeps_only_last_row_and_first_column_labels() -> None: + _fig, axes = plt.subplots(2, 2) + _label_every_axis(axes) + + for ax in axes.flat: + ax.label_outer() + + assert [ax.get_xlabel() for ax in axes.flat] == ["", "", "x-2", "x-3"] + assert [ax.get_ylabel() for ax in axes.flat] == ["y-0", "", "y-2", ""] + assert [ax._tick_label_sides["x"] for ax in axes.flat] == [ + {"labelbottom": False, "labeltop": False}, + {"labelbottom": False, "labeltop": False}, + {"labelbottom": True, "labeltop": False}, + {"labelbottom": True, "labeltop": False}, + ] + assert [ax._tick_label_sides["y"] for ax in axes.flat] == [ + {"labelleft": True, "labelright": False}, + {"labelleft": False, "labelright": False}, + {"labelleft": True, "labelright": False}, + {"labelleft": False, "labelright": False}, + ] + + +def test_label_outer_respects_top_and_right_label_positions() -> None: + _fig, axes = plt.subplots(2, 2) + _label_every_axis(axes) + for ax in axes.flat: + ax.xaxis.set_label_position("top") + ax.yaxis.set_label_position("right") + ax.tick_params( + axis="x", + labelbottom=False, + labeltop=True, + bottom=False, + top=True, + ) + ax.tick_params( + axis="y", + labelleft=False, + labelright=True, + left=False, + right=True, + ) + ax.label_outer() + + assert [ax.get_xlabel() for ax in axes.flat] == ["x-0", "x-1", "", ""] + assert [ax.get_ylabel() for ax in axes.flat] == ["", "y-1", "", "y-3"] + assert [ax._tick_label_sides["x"]["labeltop"] for ax in axes.flat] == [ + True, + True, + False, + False, + ] + assert [ax._tick_label_sides["y"]["labelright"] for ax in axes.flat] == [ + False, + True, + False, + True, + ] + + +def test_label_outer_can_remove_inner_ticks_without_removing_outer_ticks() -> None: + _fig, axes = plt.subplots(2, 2) + + for ax in axes.flat: + ax.label_outer(remove_inner_ticks=True) + + assert [ax._tick_sides["x"]["bottom"] for ax in axes.flat] == [ + False, + False, + True, + True, + ] + assert [ax._tick_sides["y"]["left"] for ax in axes.flat] == [ + True, + False, + True, + False, + ] + + +def test_label_outer_uses_the_full_gridspec_span() -> None: + fig = plt.figure() + grid = fig.add_gridspec(3, 3) + upper_left = fig.add_subplot(grid[:2, :2]) + lower_right = fig.add_subplot(grid[1:, 1:]) + for index, ax in enumerate((upper_left, lower_right)): + ax.set_xlabel(f"x-{index}") + ax.set_ylabel(f"y-{index}") + ax.label_outer() + + assert upper_left.get_xlabel() == "" + assert upper_left.get_ylabel() == "y-0" + assert lower_right.get_xlabel() == "x-1" + assert lower_right.get_ylabel() == "" + + +def test_label_outer_is_a_noop_for_free_form_axes() -> None: + fig = plt.figure() + ax = fig.add_axes([0.2, 0.2, 0.6, 0.6]) + ax.set_xlabel("x") + ax.set_ylabel("y") + labels_before = {axis: dict(sides) for axis, sides in ax._tick_label_sides.items()} + ticks_before = {axis: dict(sides) for axis, sides in ax._tick_sides.items()} + + ax.label_outer(remove_inner_ticks=True) + + assert ax.get_xlabel() == "x" + assert ax.get_ylabel() == "y" + assert ax._tick_label_sides == labels_before + assert ax._tick_sides == ticks_before + + +def test_margins_getter_and_mixed_argument_error_are_matplotlib_compatible() -> None: + _fig, ax = plt.subplots() + + assert ax.margins() == pytest.approx((0.05, 0.05)) + ax.margins(x=0.2, y=-0.1) + assert ax.margins() == pytest.approx((0.2, -0.1)) + + before = ax.margins() + with pytest.raises( + TypeError, + match="Cannot pass both positional and keyword arguments for x and/or y", + ): + ax.margins(0.3, x=0.4) + assert ax.margins() == before + + +def test_margins_tight_controls_and_preserves_round_number_expansion() -> None: + with plt.rc_context({"axes.autolimit_mode": "round_numbers"}): + _fig, ax = plt.subplots() + ax.plot([0.3, 1.2], [0.3, 1.2]) + + ax.margins(0.05) + tight_default = ax._build_chart(640, 480).figure() + assert tight_default.x_range() == pytest.approx((0.255, 1.245)) + + ax.margins(0.05, tight=False) + loose = ax._build_chart(640, 480).figure() + assert loose.x_range() == pytest.approx((0.2, 1.4)) + + ax.margins(0.05, tight=None) + preserved = ax._build_chart(640, 480).figure() + assert preserved.x_range() == pytest.approx((0.2, 1.4)) + + ax.margins(0.05, tight=True) + tight_explicit = ax._build_chart(640, 480).figure() + assert tight_explicit.x_range() == pytest.approx((0.255, 1.245)) + + +def test_autoscale_transitions_update_and_preserve_tight_state() -> None: + with plt.rc_context({"axes.autolimit_mode": "round_numbers"}): + _fig, ax = plt.subplots() + ax.plot([0.3, 1.2], [0.3, 1.2]) + + ax.margins(0.05) + ax.autoscale_view(tight=False) + assert ax._tight is False + assert ax._build_chart(640, 480).figure().x_range() == pytest.approx((0.2, 1.4)) + + ax.margins(0.05, tight=False) + ax.autoscale_view(tight=True) + assert ax._tight is True + ax.autoscale_view(tight=None) + assert ax._tight is True + + ax.autoscale(tight=False) + assert ax._tight is False + ax.autoscale(tight=True) + assert ax._tight is True + + ax.margins(0.05, tight=False) + ax.autoscale(False, tight=True) + assert ax._tight is False + ax.autoscale(None, tight=True) + assert ax._tight is True + assert ax.margins() == (0.0, 0.0) + ax.autoscale(True, tight=None) + assert ax._build_chart(640, 480).figure().x_range() == pytest.approx((0.3, 1.2)) + + +def test_autoscale_tight_zeroes_enabled_margins_without_freezing_limits() -> None: + _fig, ax = plt.subplots() + ax.plot([0.0, 10.0], [0.0, 20.0]) + ax.margins(x=0.2, y=0.1) + + ax.autoscale(tight=True) + + assert ax.margins() == (0.0, 0.0) + assert ax._explicit_domains.isdisjoint({"x", "y"}) + assert ax.get_xlim() == pytest.approx((0.0, 10.0)) + assert ax.get_ylim() == pytest.approx((0.0, 20.0)) + + ax.plot([20.0], [40.0]) + assert ax.get_xlim() == pytest.approx((0.0, 20.0)) + assert ax.get_ylim() == pytest.approx((0.0, 40.0)) + + +def test_autoscale_view_tight_preserves_margins_and_disabled_axes() -> None: + _fig, ax = plt.subplots() + ax.plot([0.0, 10.0], [0.0, 20.0]) + ax.margins(x=0.2, y=0.1) + ax.autoscale(False, axis="y") + frozen_y = ax.get_ylim() + + ax.autoscale_view(tight=True) + + assert ax.margins() == pytest.approx((0.2, 0.1)) + assert "x" not in ax._explicit_domains + assert "y" in ax._explicit_domains + assert ax.get_xlim() == pytest.approx((-2.0, 12.0)) + assert ax.get_ylim() == frozen_y + + ax.plot([20.0], [40.0]) + assert ax.get_xlim() == pytest.approx((-4.0, 24.0)) + assert ax.get_ylim() == frozen_y + + +def test_autoscale_none_forwards_tight_to_both_axes_without_reenabling() -> None: + _fig, ax = plt.subplots() + ax.plot([0.0, 10.0], [0.0, 20.0]) + ax.margins(x=0.2, y=0.1, tight=False) + ax.autoscale(False, axis="x") + frozen_x = ax.get_xlim() + + ax.autoscale(None, axis="x", tight=True) + + assert ax._tight is True + assert ax.get_xmargin() == 0.0 + assert ax.get_ymargin() == 0.0 + assert "x" in ax._explicit_domains + assert "y" not in ax._explicit_domains + assert ax.get_xlim() == frozen_x + assert ax.get_ylim() == pytest.approx((0.0, 20.0)) + + ax.plot([20.0], [40.0]) + assert ax.get_xlim() == frozen_x + assert ax.get_ylim() == pytest.approx((0.0, 40.0)) + + +def test_autoscale_none_tight_updates_both_disabled_axes_without_reenabling() -> None: + _fig, ax = plt.subplots() + ax.plot([0.0, 10.0], [0.0, 20.0]) + ax.margins(x=0.2, y=0.1, tight=False) + ax.autoscale(False) + frozen_x = ax.get_xlim() + frozen_y = ax.get_ylim() + + ax.autoscale(None, axis="x", tight=True) + + assert ax._tight is True + assert ax.margins() == (0.0, 0.0) + assert {"x", "y"}.issubset(ax._explicit_domains) + assert ax.get_xlim() == frozen_x + assert ax.get_ylim() == frozen_y + + ax.plot([20.0], [40.0]) + assert ax.get_xlim() == frozen_x + assert ax.get_ylim() == frozen_y + + +def test_axis_modes_share_matplotlib_tight_state_transitions() -> None: + with plt.rc_context({"axes.autolimit_mode": "round_numbers"}): + _fig, ax = plt.subplots() + ax.plot([0.3, 1.2], [0.3, 1.2]) + + ax.axis("tight") + assert ax._tight is True + ax.autoscale(tight=None) + assert ax._tight is True + assert ax._build_chart(640, 480).figure().x_range() == pytest.approx((0.255, 1.245)) + + ax.axis("auto") + assert ax._tight is False + ax.autoscale(tight=None) + assert ax._build_chart(640, 480).figure().x_range() == pytest.approx((0.2, 1.4)) + + ax.axis("image") + assert ax._tight is True + + +def test_margins_preserve_explicit_limits_and_allow_negative_clipping() -> None: + _fig, ax = plt.subplots() + ax.plot([0.0, 10.0], [0.0, 20.0]) + ax.set_xlim(-2.0, 12.0) + + ax.margins(x=0.3, y=-0.1) + + assert ax.get_xlim() == (-2.0, 12.0) + assert ax.get_ylim() == pytest.approx((2.0, 18.0))