diff --git a/docs/subplots.py b/docs/subplots.py index 8845ba5d1..5754f9f7d 100644 --- a/docs/subplots.py +++ b/docs/subplots.py @@ -395,6 +395,14 @@ # Explicit sharing levels still force sharing attempts and may warn when # incompatible axes are encountered. # +# When figure-level row or column labels and a shared spanning axis label use +# the same side, the row or column labels are placed nearer the axes and the +# spanning label is placed outside them. Control this gap with the +# :rc:`leftlabel.sharedpad`, :rc:`rightlabel.sharedpad`, +# :rc:`bottomlabel.sharedpad`, and :rc:`toplabel.sharedpad` settings. These +# can also be passed to ``format`` without periods; for example, +# ``fig.format(leftlabelsharedpad='2em')``. +# # The below examples demonstrate the effect of various axis and label sharing # settings on the appearance of several subplot grids. diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 0f4484505..fcd1b2384 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -382,6 +382,11 @@ : :rc:`leftlabel.pad`, :rc:`toplabel.pad`, :rc:`rightlabel.pad`, :rc:`bottomlabel.pad` The padding between the labels and the axes content. %(units.pt)s +leftlabelsharedpad, toplabelsharedpad, rightlabelsharedpad, bottomlabelsharedpad : float or unit-spec, default +: :rc:`leftlabel.sharedpad`, :rc:`toplabel.sharedpad`, :rc:`rightlabel.sharedpad`, :rc:`bottomlabel.sharedpad` + The padding between side labels and a shared spanning axis label on the + same side. The spanning label is placed outside the side labels. + %(units.pt)s leftlabels_kw, toplabels_kw, rightlabels_kw, bottomlabels_kw : dict-like, optional Additional settings used to update the labels with ``text.update()``. figtitle diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 5e3d994aa..2f404bdc4 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1059,6 +1059,11 @@ def _init_super_labels(self): d["right"] = rc["rightlabel.pad"] d["bottom"] = rc["bottomlabel.pad"] d["top"] = rc["toplabel.pad"] + d = self._suplabel_shared_pad = {} + d["left"] = rc["leftlabel.sharedpad"] + d["right"] = rc["rightlabel.sharedpad"] + d["bottom"] = rc["bottomlabel.sharedpad"] + d["top"] = rc["toplabel.sharedpad"] @_clear_border_cache def clear(self, keep_observers=False): @@ -1909,7 +1914,15 @@ def _get_align_coord(self, side, axs, align="center", includepanels=False): return pos, ax def _get_offset_coord( - self, side, axs, renderer, *, pad=None, extra=None, include_subset_titles=True + self, + side, + axs, + renderer, + *, + pad=None, + extra=None, + include_subset_titles=True, + exclude_spanning_axis_labels=False, ): """ Return the figure coordinate for offsetting super labels and super titles. @@ -1923,12 +1936,25 @@ def _get_offset_coord( ) # noqa: E501 objs = objs + (extra or ()) # e.g. top super labels for obj in objs: + # Spanning axis labels reserve space with whitespace placeholders. + # When aligning figure-level side labels, those placeholders must not + # count as content; otherwise the side labels are incorrectly placed + # outside the spanning label. + label = None + if exclude_spanning_axis_labels and isinstance(obj, paxes.Axes): + axis = obj.yaxis if side in ("left", "right") else obj.xaxis + label = axis.label + visible = label.get_visible() + if label.get_text() and not label.get_text().strip(): + label.set_visible(False) if isinstance(obj, paxes.Axes): bbox = obj.get_tightbbox( renderer, include_subset_titles=include_subset_titles ) else: bbox = obj.get_tightbbox(renderer) # cannot use cached bbox + if label is not None: + label.set_visible(visible) attr = s + "max" if side in ("top", "right") else s + "min" c = getattr(bbox, attr) c = (c, 0) if side in ("left", "right") else (0, c) @@ -2507,11 +2533,66 @@ def _align_super_labels(self, side, renderer): labs = self._suplabel_dict[side] axs = tuple(ax for ax, lab in labs.items() if lab.get_text()) if not axs: + self._align_spanning_axis_labels(side, renderer, ()) return - c = self._get_offset_coord(side, axs, renderer) + c = self._get_offset_coord( + side, axs, renderer, exclude_spanning_axis_labels=True + ) for lab in labs.values(): s = "x" if side in ("left", "right") else "y" lab.update({s: c}) + self._align_spanning_axis_labels(side, renderer, tuple(labs.values())) + + def _align_spanning_axis_labels(self, side, renderer, side_labels): + """ + Place spanning axis labels outside figure-level labels on the same side. + + Figure-level side labels describe individual rows or columns, while a + spanning axis label describes the whole group. The latter therefore has + lower visual priority and belongs farther from the axes. + """ + labels = ( + self._supylabel_dict if side in ("left", "right") else self._supxlabel_dict + ) + side_labels = tuple(label for label in side_labels if label.get_text()) + if not labels: + return + pad = self._suplabel_shared_pad[side] + for ax, label in labels.items(): + axis = ax.yaxis if side in ("left", "right") else ax.xaxis + if axis.get_label_position() != side: + continue + axis_label = axis.label + old_offset = getattr(axis_label, "_ultraplot_spanning_offset", 0) + size = self.bbox.width if side in ("left", "right") else self.bbox.height + old_offset_px = old_offset * size + if not side_labels or not label.get_text(): + if old_offset: + axis_label._ultraplot_spanning_offset = 0 + coord = axis_label.get_position() + getattr( + axis_label, "set_" + ("x" if side in ("left", "right") else "y") + )(coord[0 if side in ("left", "right") else 1] - old_offset_px) + continue + bbox = label.get_window_extent(renderer) + if side == "left": + edge = min(lab.get_window_extent(renderer).xmin for lab in side_labels) + delta = edge - pad - bbox.xmax + setter, coord = axis_label.set_x, axis_label.get_position()[0] + elif side == "right": + edge = max(lab.get_window_extent(renderer).xmax for lab in side_labels) + delta = edge + pad - bbox.xmin + setter, coord = axis_label.set_x, axis_label.get_position()[0] + elif side == "bottom": + edge = min(lab.get_window_extent(renderer).ymin for lab in side_labels) + delta = edge - pad - bbox.ymax + setter, coord = axis_label.set_y, axis_label.get_position()[1] + else: + edge = max(lab.get_window_extent(renderer).ymax for lab in side_labels) + delta = edge + pad - bbox.ymin + setter, coord = axis_label.set_y, axis_label.get_position()[1] + axis_label._ultraplot_spanning_offset = old_offset + delta / size + setter(coord - old_offset_px) def _align_super_title(self, renderer): """ @@ -2796,24 +2877,29 @@ def _update_axis_label(self, side, axs): axis = getattr(ax, x + "axis") axis.label.set_text(space) - # Update spanning label position then add simple monkey patch - # NOTE: Simply using axis._update_label_position() when this is - # called is not sufficient. Fails with e.g. inline backend. - t = mtransforms.IdentityTransform() # set in pixels + # Update spanning label position then add simple monkey patch. + # Axis-label positions are expressed in display pixels, but spanning + # labels are figure artists. Store their position in figure coordinates + # so it remains correct when saving at a DPI different from the canvas + # DPI (e.g. ``savefig(dpi=1000)``). cx, cy = axlab.get_position() if x == "x": - trans = mtransforms.blended_transform_factory(self.transFigure, t) - coord = (c, cy) + coord = (c, self.transFigure.inverted().transform((0, cy))[1]) else: - trans = mtransforms.blended_transform_factory(t, self.transFigure) - coord = (cx, c) - suplab.set_transform(trans) + coord = (self.transFigure.inverted().transform((cx, 0))[0], c) + suplab.set_transform(self.transFigure) suplab.set_position(coord) setpos = getattr(mtext.Text, "set_" + y) def _set_coord(self, *args, **kwargs): # noqa: E306 + offset = getattr(self, "_ultraplot_spanning_offset", 0) + size = self.figure.bbox.width if x == "y" else self.figure.bbox.height + args = (args[0] + offset * size, *args[1:]) setpos(self, *args, **kwargs) - setpos(suplab, *args, **kwargs) + coord = self.figure.transFigure.inverted().transform( + (args[0], 0) if x == "y" else (0, args[0]) + ) + setpos(suplab, coord[0 if x == "y" else 1], *args[1:], **kwargs) setattr(axlab, "set_" + y, _set_coord.__get__(axlab)) @@ -3308,8 +3394,10 @@ def format( Important --------- - `leftlabelpad`, `toplabelpad`, `rightlabelpad`, and `bottomlabelpad` - keywords are actually :ref:`configuration settings `. + `leftlabelpad`, `leftlabelsharedpad`, `toplabelpad`, + `toplabelsharedpad`, `rightlabelpad`, `rightlabelsharedpad`, + `bottomlabelpad`, and `bottomlabelsharedpad` keywords are actually + :ref:`configuration settings `. We explicitly document these arguments here because it is common to change them for specific figures. But many :ref:`other configuration settings ` can be passed to ``format`` too. @@ -3356,6 +3444,9 @@ def format( pad = rc.find(side + "label.pad", context=True) if pad is not None: self._suplabel_pad[side] = pad + pad = rc.find(side + "label.sharedpad", context=True) + if pad is not None: + self._suplabel_shared_pad[side] = pad if includepanels is not None: self._includepanels = includepanels diff --git a/ultraplot/internals/rcsetup.py b/ultraplot/internals/rcsetup.py index eedb4ae38..933ab8fd8 100644 --- a/ultraplot/internals/rcsetup.py +++ b/ultraplot/internals/rcsetup.py @@ -1251,6 +1251,12 @@ def _validator_accepts(validator, value): "Padding between axes content and column labels on the bottom of the figure." + _addendum_pt, ), + "bottomlabel.sharedpad": ( + 2 * TITLEPAD, + _validate_pt, + "Padding between column labels and a shared x label on the bottom of the figure." + + _addendum_pt, + ), "bottomlabel.rotation": ( "horizontal", _validate_rotation, @@ -1987,6 +1993,12 @@ def _validator_accepts(validator, value): "Padding between axes content and row labels on the left-hand side." + _addendum_pt, ), + "leftlabel.sharedpad": ( + 2 * TITLEPAD, + _validate_pt, + "Padding between row labels and a shared y label on the left-hand side." + + _addendum_pt, + ), "leftlabel.rotation": ( "vertical", _validate_rotation, @@ -2097,6 +2109,12 @@ def _validator_accepts(validator, value): "Padding between axes content and row labels on the right-hand side." + _addendum_pt, ), + "rightlabel.sharedpad": ( + 2 * TITLEPAD, + _validate_pt, + "Padding between row labels and a shared y label on the right-hand side." + + _addendum_pt, + ), "rightlabel.rotation": ( "vertical", _validate_rotation, @@ -2522,6 +2540,12 @@ def _validator_accepts(validator, value): "Padding between axes content and column labels on the top of the figure." + _addendum_pt, ), + "toplabel.sharedpad": ( + 2 * TITLEPAD, + _validate_pt, + "Padding between column labels and a shared x label on the top of the figure." + + _addendum_pt, + ), "toplabel.rotation": ( "horizontal", _validate_rotation, diff --git a/ultraplot/tests/test_figure.py b/ultraplot/tests/test_figure.py index c8a006cf7..e5e917cf5 100644 --- a/ultraplot/tests/test_figure.py +++ b/ultraplot/tests/test_figure.py @@ -997,3 +997,37 @@ def test_figure_is_reusable_after_clear(): assert len(fig.subplotgrid) == 3 assert fig.gridspec.get_geometry() == (1, 3) uplt.close(fig) + + +def test_spanning_ylabel_is_outside_leftlabels(): + """Row labels take precedence over the shared y label in side layout.""" + fig, axs = uplt.subplots(nrows=2, share=True, refwidth=2) + axs.format(ylabel="Shared y") + fig.format(leftlabels=("Row 1", "Row 2"), leftlabelsharedpad="20pt") + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + shared = next(iter(fig._supylabel_dict.values())).get_window_extent(renderer) + rows = [ + label.get_window_extent(renderer) + for label in fig._suplabel_dict["left"].values() + ] + gap = min(row.xmin for row in rows) - shared.xmax + assert gap >= fig._suplabel_shared_pad["left"] - 1e-6 + uplt.close(fig) + + +def test_spanning_ylabel_scales_with_output_dpi(tmp_path): + """Spanning labels remain inside the figure when the output DPI changes.""" + fig, axs = uplt.subplots(nrows=2, share=True, figsize=(3, 3)) + axs.format(ylabel="Shared y") + fig.format(leftlabels=("Row 1", "Row 2"), leftlabelsharedpad="5em") + path = tmp_path / "spanning-label.png" + fig.savefig(path, dpi=300) + from matplotlib import image + + pixels = image.imread(path) + dark = pixels[..., :3].min(axis=2) < 0.1 + # At high output DPI, the spanning y label must occupy the outer label lane. + # Previously its raw-pixel transform placed it outside the saved image. + assert dark[:, : pixels.shape[1] * 8 // 100].any() + uplt.close(fig)