Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/subplots.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 5 additions & 0 deletions ultraplot/axes/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
119 changes: 105 additions & 14 deletions ultraplot/figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -3308,8 +3394,10 @@ def format(

Important
---------
`leftlabelpad`, `toplabelpad`, `rightlabelpad`, and `bottomlabelpad`
keywords are actually :ref:`configuration settings <ug_config>`.
`leftlabelpad`, `leftlabelsharedpad`, `toplabelpad`,
`toplabelsharedpad`, `rightlabelpad`, `rightlabelsharedpad`,
`bottomlabelpad`, and `bottomlabelsharedpad` keywords are actually
:ref:`configuration settings <ug_config>`.
We explicitly document these arguments here because it is common to
change them for specific figures. But many :ref:`other configuration
settings <ug_format>` can be passed to ``format`` too.
Expand Down Expand Up @@ -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

Expand Down
24 changes: 24 additions & 0 deletions ultraplot/internals/rcsetup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions ultraplot/tests/test_figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)