diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 01cc96d5..aa32380d 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -354,7 +354,9 @@ inside the axes. This can help them stand out on top of artists plotted inside the axes. abcpad : float or unit-spec, default: :rc:`abc.pad` - The padding for the inner and outer titles and a-b-c labels. + Horizontal offset to shift the a-b-c label position. Positive values move + the label right, negative values move it left. This is separate from + `abctitlepad`, which controls spacing between abc and title when co-located. %(units.pt)s abc_kw, title_kw : dict-like, optional Additional settings used to update the a-b-c label and title @@ -846,8 +848,10 @@ def __init__(self, *args, **kwargs): self._auto_format = None # manipulated by wrapper functions self._abc_border_kwargs = {} self._abc_loc = None - self._abc_pad = 0 - self._abc_title_pad = rc["abc.titlepad"] + self._abc_pad = 0 # User's horizontal offset for abc label (in points) + self._abc_title_pad = rc[ + "abc.titlepad" + ] # Spacing between abc and title when co-located self._title_above = rc["title.above"] self._title_border_kwargs = {} # title border properties self._title_loc = None @@ -2986,6 +2990,8 @@ def _update_title(self, loc, title=None, **kwargs): kw["text"] = title[self.number - 1] else: raise ValueError(f"Invalid title {title!r}. Must be string(s).") + if any(key in kwargs for key in ("size", "fontsize")): + self._title_dict[loc]._ultraplot_manual_size = True kw.update(kwargs) self._title_dict[loc].update(kw) @@ -2998,6 +3004,8 @@ def _update_title_position(self, renderer): # NOTE: Critical to do this every time in case padding changes or # we added or removed an a-b-c label in the same position as a title width, height = self._get_size_inches() + if width <= 0 or height <= 0: + return x_pad = self._title_pad / (72 * width) y_pad = self._title_pad / (72 * height) for loc, obj in self._title_dict.items(): @@ -3010,7 +3018,8 @@ def _update_title_position(self, renderer): # This is known matplotlib problem but especially annoying with top panels. # NOTE: See axis.get_ticks_position for inspiration pad = self._title_pad - abcpad = self._abc_title_pad + # Horizontal separation between abc label and title when co-located (in points) + abc_title_sep_pts = self._abc_title_pad if self.xaxis.get_visible() and any( tick.tick2line.get_visible() and not tick.label2.get_visible() for tick in self.xaxis.majorTicks @@ -3038,11 +3047,19 @@ def _update_title_position(self, renderer): # Offset title away from a-b-c label # NOTE: Title texts all use axes transform in x-direction - - # Offset title away from a-b-c label + # We need to convert padding values from points to axes coordinates (0-1 normalized) atext, ttext = aobj.get_text(), tobj.get_text() awidth = twidth = 0 - pad = (abcpad / 72) / self._get_size_inches()[0] + width_inches = self._get_size_inches()[0] + + # Convert abc-title separation from points to axes coordinates + # This is the spacing BETWEEN abc and title when they share the same location + abc_title_sep = (abc_title_sep_pts / 72) / width_inches + + # Convert user's horizontal offset from points to axes coordinates + # This is the user-specified shift for the abc label position (via abcpad parameter) + abc_offset = (self._abc_pad / 72) / width_inches + ha = aobj.get_ha() # Get dimensions of non-empty elements @@ -3059,27 +3076,96 @@ def _update_title_position(self, renderer): .width ) + # Shrink the title font if both texts share a location and would overflow + if ( + atext + and ttext + and self._abc_loc == self._title_loc + and twidth > 0 + and not getattr(tobj, "_ultraplot_manual_size", False) + ): + scale = 1 + base_x = tobj.get_position()[0] + if ha == "left": + available = 1 - (base_x + awidth + abc_title_sep) + if available < twidth and available > 0: + scale = available / twidth + elif ha == "right": + available = base_x + abc_offset - abc_title_sep - awidth + if available < twidth and available > 0: + scale = available / twidth + elif ha == "center": + # Conservative fit for centered titles sharing the abc location + left_room = base_x - 0.5 * (awidth + abc_title_sep) + right_room = 1 - (base_x + 0.5 * (awidth + abc_title_sep)) + max_room = min(left_room, right_room) + if max_room < twidth / 2 and max_room > 0: + scale = (2 * max_room) / twidth + + if scale < 1: + tobj.set_fontsize(tobj.get_fontsize() * scale) + twidth *= scale + # Calculate offsets based on alignment and content aoffset = toffset = 0 if atext and ttext: if ha == "left": - toffset = awidth + pad + toffset = awidth + abc_title_sep elif ha == "right": - aoffset = -(twidth + pad) + aoffset = -(twidth + abc_title_sep) elif ha == "center": - toffset = 0.5 * (awidth + pad) - aoffset = -0.5 * (twidth + pad) + toffset = 0.5 * (awidth + abc_title_sep) + aoffset = -0.5 * (twidth + abc_title_sep) # Apply positioning adjustments + # For abc label: apply offset from co-located title + user's horizontal offset if atext: aobj.set_x( aobj.get_position()[0] + aoffset - + (self._abc_pad / 72) / (self._get_size_inches()[0]) + + abc_offset # User's horizontal shift (from abcpad parameter) ) if ttext: tobj.set_x(tobj.get_position()[0] + toffset) + # Shrink title if it overlaps the abc label at a different location + if ( + atext + and self._abc_loc != self._title_loc + and not getattr( + self._title_dict[self._title_loc], "_ultraplot_manual_size", False + ) + ): + title_obj = self._title_dict[self._title_loc] + title_text = title_obj.get_text() + if title_text: + abc_bbox = aobj.get_window_extent(renderer).transformed( + self.transAxes.inverted() + ) + title_bbox = title_obj.get_window_extent(renderer).transformed( + self.transAxes.inverted() + ) + ax0, ax1 = abc_bbox.x0, abc_bbox.x1 + tx0, tx1 = title_bbox.x0, title_bbox.x1 + if tx0 < ax1 + abc_title_sep and tx1 > ax0 - abc_title_sep: + base_x = title_obj.get_position()[0] + ha = title_obj.get_ha() + max_width = 0 + if ha == "left": + if base_x <= ax0 - abc_title_sep: + max_width = (ax0 - abc_title_sep) - base_x + elif ha == "right": + if base_x >= ax1 + abc_title_sep: + max_width = base_x - (ax1 + abc_title_sep) + elif ha == "center": + if base_x >= ax1 + abc_title_sep: + max_width = 2 * (base_x - (ax1 + abc_title_sep)) + elif base_x <= ax0 - abc_title_sep: + max_width = 2 * ((ax0 - abc_title_sep) - base_x) + if 0 < max_width < title_bbox.width: + scale = max_width / title_bbox.width + title_obj.set_fontsize(title_obj.get_fontsize() * scale) + def _update_super_title(self, suptitle=None, **kwargs): """ Update the figure super title. diff --git a/ultraplot/tests/test_axes.py b/ultraplot/tests/test_axes.py index 27b621c9..e59d5ac9 100644 --- a/ultraplot/tests/test_axes.py +++ b/ultraplot/tests/test_axes.py @@ -148,6 +148,117 @@ def test_dualx_log_transform_is_finite(): assert np.isfinite(transformed).all() +def test_title_manual_size_ignores_auto_shrink(): + """ + Ensure explicit title sizes bypass auto-scaling. + """ + fig, axs = uplt.subplots(figsize=(2, 2)) + axs.format( + abc=True, + title="X" * 200, + titleloc="left", + abcloc="left", + title_kw={"size": 20}, + ) + title_obj = axs[0]._title_dict["left"] + fig.canvas.draw() + assert title_obj.get_fontsize() == 20 + + +def test_title_shrinks_when_abc_overlaps_different_loc(): + """ + Ensure long titles shrink when overlapping abc at a different location. + """ + fig, axs = uplt.subplots(figsize=(3, 2)) + axs.format(abc=True, title="X" * 200, titleloc="center", abcloc="left") + title_obj = axs[0]._title_dict["center"] + original_size = title_obj.get_fontsize() + fig.canvas.draw() + assert title_obj.get_fontsize() < original_size + + +def test_title_shrinks_right_aligned_same_location(): + """ + Test that right-aligned titles shrink when they would overflow with abc label. + """ + fig, axs = uplt.subplots(figsize=(2, 2)) + axs.format(abc=True, title="X" * 100, titleloc="right", abcloc="right") + title_obj = axs[0]._title_dict["right"] + original_size = title_obj.get_fontsize() + fig.canvas.draw() + assert title_obj.get_fontsize() < original_size + + +def test_title_shrinks_centered_same_location(): + """ + Test that centered titles shrink when they would overflow with abc label. + """ + fig, axs = uplt.subplots(figsize=(2, 2)) + axs.format(abc=True, title="X" * 150, titleloc="center", abcloc="center") + title_obj = axs[0]._title_dict["center"] + original_size = title_obj.get_fontsize() + fig.canvas.draw() + assert title_obj.get_fontsize() < original_size + + +def test_title_shrinks_right_aligned_different_location(): + """ + Test that right-aligned titles shrink when overlapping abc at different location. + """ + fig, axs = uplt.subplots(figsize=(3, 2)) + axs.format(abc=True, title="X" * 100, titleloc="right", abcloc="left") + title_obj = axs[0]._title_dict["right"] + original_size = title_obj.get_fontsize() + fig.canvas.draw() + assert title_obj.get_fontsize() < original_size + + +def test_title_shrinks_left_aligned_different_location(): + """ + Test that left-aligned titles shrink when overlapping abc at different location. + """ + fig, axs = uplt.subplots(figsize=(3, 2)) + axs.format(abc=True, title="X" * 100, titleloc="left", abcloc="right") + title_obj = axs[0]._title_dict["left"] + original_size = title_obj.get_fontsize() + fig.canvas.draw() + assert title_obj.get_fontsize() < original_size + + +def test_title_no_shrink_when_no_overlap(): + """ + Test that titles don't shrink when there's no overlap with abc label. + """ + fig, axs = uplt.subplots(figsize=(4, 2)) + axs.format(abc=True, title="Short Title", titleloc="left", abcloc="right") + title_obj = axs[0]._title_dict["left"] + original_size = title_obj.get_fontsize() + fig, ax = uplt.subplots() + ax.set_xscale("log") + ax.set_xlim(0.1, 10) + sec = ax.dualx(lambda x: 1 / x) + fig.canvas.draw() + assert title_obj.get_fontsize() == original_size + + +def test_title_shrinks_centered_left_of_abc(): + """ + Test that centered titles shrink when they are to the left of abc label. + This covers the specific case where base_x <= ax0 - pad for centered titles. + """ + fig, axs = uplt.subplots(figsize=(3, 2)) + axs.format(abc=True, title="X" * 100, titleloc="center", abcloc="right") + title_obj = axs[0]._title_dict["center"] + original_size = title_obj.get_fontsize() + fig.canvas.draw() + assert title_obj.get_fontsize() < original_size + ticks = axs[0].get_xticks() + assert ticks.size > 0 + xy = np.column_stack([ticks, np.zeros_like(ticks)]) + transformed = axs[0].transData.transform(xy) + assert np.isfinite(transformed).all() + + def test_axis_access(): # attempt to access the ax object 2d and linearly fig, ax = uplt.subplots(ncols=2, nrows=2)