diff --git a/doc/users/next_whats_new/title_will_not_overlap_xaxis.rst b/doc/users/next_whats_new/title_will_not_overlap_xaxis.rst new file mode 100644 index 000000000000..74db251a4a5f --- /dev/null +++ b/doc/users/next_whats_new/title_will_not_overlap_xaxis.rst @@ -0,0 +1,15 @@ +Axes title will no longer overlap xaxis +--------------------------------------- + +Previously an axes title had to be moved manually if an xaxis overlapped +(usually when the xaxis was put on the top of the axes). Now, the title +will be automatically moved above the xaxis and its decorators (including +the xlabel) if they are at the top. + +If desired, the title can still be placed manually. There is a slight kludge; +the algorithm checks if the y-position of the title is 1.0 (the default), +and moves if it is. If the user places the title in the default location +(i.e. ``ax.title.set_position(0.5, 1.0)``), the title will still be moved +above the xaxis. If the user wants to avoid this, they can +specify a number that is close (i.e. ``ax.title.set_position(0.5, 1.01)``) +and the title will not be moved via this algorithm. diff --git a/examples/ticks_and_spines/tick_xlabel_top.py b/examples/ticks_and_spines/tick_xlabel_top.py index db992e529b92..a437810b06a3 100644 --- a/examples/ticks_and_spines/tick_xlabel_top.py +++ b/examples/ticks_and_spines/tick_xlabel_top.py @@ -23,6 +23,6 @@ fig, ax = plt.subplots() ax.plot(x) -ax.set_title('xlabel top', pad=24) # increase padding to make room for labels +ax.set_title('xlabel top') # Note title moves to make room for ticks plt.show() diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index b771474c585b..e49c33fe0c3e 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -1,5 +1,6 @@ from collections import OrderedDict import itertools +import logging import math from operator import attrgetter import types @@ -32,6 +33,8 @@ from matplotlib.rcsetup import cycler from matplotlib.rcsetup import validate_axisbelow +_log = logging.getLogger(__name__) + rcParams = matplotlib.rcParams @@ -1077,6 +1080,8 @@ def cla(self): # refactor this out so it can be called in ax.set_title if # pad argument used... self._set_title_offset_trans(title_offset_points) + # determine if the title position has been set manually: + self._autotitlepos = None for _title in (self.title, self._left_title, self._right_title): self._set_artist_props(_title) @@ -2446,6 +2451,50 @@ def handle_single_axis(scale, autoscaleon, shared_axes, interval, def _get_axis_list(self): return (self.xaxis, self.yaxis) + def _update_title_position(self, renderer): + """ + Update the title position based on the bounding box enclosing + all the ticklabels and x-axis spine and xlabel... + """ + _log.debug('update_title_pos') + + if self._autotitlepos is not None and not self._autotitlepos: + _log.debug('title position was updated manually, not adjusting') + return + + titles = (self.title, self._left_title, self._right_title) + + if self._autotitlepos is None: + for title in titles: + x, y = title.get_position() + if not np.isclose(y, 1.0): + self._autotitlepos = False + _log.debug('not adjusting title pos because title was' + ' already placed manually: %f', y) + return + self._autotitlepos = True + + for title in titles: + x, y0 = title.get_position() + y = 1.0 + # need to check all our twins too... + axs = self._twinned_axes.get_siblings(self) + + for ax in axs: + try: + if (ax.xaxis.get_label_position() == 'top' + or ax.xaxis.get_ticks_position() == 'top'): + bb = ax.xaxis.get_tightbbox(renderer) + top = bb.ymax + # we don't need to pad because the padding is already + # in __init__: titleOffsetTrans + yn = self.transAxes.inverted().transform((0., top))[1] + y = max(y, yn) + except AttributeError: + pass + + title.set_position((x, y)) + # Drawing @allow_rasterization @@ -2459,6 +2508,7 @@ def draw(self, renderer=None, inframe=False): if not self.get_visible(): return renderer.open_group('axes') + # prevent triggering call backs during the draw process self._stale = True locator = self.get_axes_locator() @@ -2479,6 +2529,8 @@ def draw(self, renderer=None, inframe=False): for spine in self.spines.values(): artists.remove(spine) + self._update_title_position(renderer) + if self.axison and not inframe: if self._axisbelow is True: self.xaxis.set_zorder(0.5) @@ -2507,6 +2559,7 @@ def draw(self, renderer=None, inframe=False): # rasterize artists with negative zorder # if the minimum zorder is negative, start rasterization rasterization_zorder = self._rasterization_zorder + if (rasterization_zorder is not None and artists and artists[0].zorder < rasterization_zorder): renderer.start_rasterizing() @@ -4051,6 +4104,12 @@ def get_tightbbox(self, renderer, call_axes_locator=True): else: self.apply_aspect() + bb_xaxis = self.xaxis.get_tightbbox(renderer) + if bb_xaxis: + bb.append(bb_xaxis) + + self._update_title_position(renderer) + bb.append(self.get_window_extent(renderer)) if self.title.get_visible(): @@ -4060,10 +4119,6 @@ def get_tightbbox(self, renderer, call_axes_locator=True): if self._right_title.get_visible(): bb.append(self._right_title.get_window_extent(renderer)) - bb_xaxis = self.xaxis.get_tightbbox(renderer) - if bb_xaxis: - bb.append(bb_xaxis) - bb_yaxis = self.yaxis.get_tightbbox(renderer) if bb_yaxis: bb.append(bb_yaxis) diff --git a/lib/matplotlib/tests/baseline_images/test_axes/titletwiny.png b/lib/matplotlib/tests/baseline_images/test_axes/titletwiny.png new file mode 100644 index 000000000000..670a4bebbd65 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/titletwiny.png differ diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 5a3a3e5d4253..6eae5a7227d5 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -5411,6 +5411,42 @@ def test_axisbelow(): ax.set_axisbelow(setting) +@image_comparison(baseline_images=['titletwiny'], style='mpl20', + extensions=['png']) +def test_titletwiny(): + # Test that title is put above xlabel if xlabel at top + fig, ax = plt.subplots() + fig.subplots_adjust(top=0.8) + ax2 = ax.twiny() + ax.set_xlabel('Xlabel') + ax2.set_xlabel('Xlabel2') + ax.set_title('Title') + + +def test_titlesetpos(): + # Test that title stays put if we set it manually + fig, ax = plt.subplots() + fig.subplots_adjust(top=0.8) + ax2 = ax.twiny() + ax.set_xlabel('Xlabel') + ax2.set_xlabel('Xlabel2') + ax.set_title('Title') + pos = (0.5, 1.11) + ax.title.set_position(pos) + renderer = fig.canvas.get_renderer() + ax._update_title_position(renderer) + assert ax.title.get_position() == pos + + +def test_title_xticks_top(): + # Test that title moves if xticks on top of axes. + fig, ax = plt.subplots() + ax.xaxis.set_ticks_position('top') + ax.set_title('xlabel top') + fig.canvas.draw() + assert ax.title.get_position()[1] > 1.04 + + def test_offset_label_color(): # Tests issue 6440 fig = plt.figure()