diff --git a/doc/api/api_changes/2017-09-29_JMK_constrained_layout_api.rst b/doc/api/api_changes/2017-09-29_JMK_constrained_layout_api.rst new file mode 100644 index 000000000000..05e54d67f4c0 --- /dev/null +++ b/doc/api/api_changes/2017-09-29_JMK_constrained_layout_api.rst @@ -0,0 +1,16 @@ +API changes for ``constrained_layout`` +---------------------------------------- + +The new constrained_layout functionality has some minor (largely backwards- +compatible) API changes. See +:ref:`sphx_glr_tutorials_intermediate_constrainedlayout_guide.py` for +more details on this functionality. + +This requires a new dependency on kiwisolver_. + +_https://github.com/nucleic/kiwi + +kwarg ``fig`` deprectated in `.GridSpec.get_subplot_params` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Use ``figure`` instead of ``fig``, which is now deprecated. diff --git a/doc/users/next_whats_new/constrained_layout.rst b/doc/users/next_whats_new/constrained_layout.rst new file mode 100644 index 000000000000..a1af56601549 --- /dev/null +++ b/doc/users/next_whats_new/constrained_layout.rst @@ -0,0 +1,59 @@ +Constrained Layout Manager +--------------------------- + +.. warning:: + + Constrained Layout is **experimental**. The + behaviour and API are subject to change, or the whole functionality + may be removed without a deprecation period. + + +A new method to automatically decide spacing between subplots and their +organizing ``GridSpec`` instances has been added. It is meant to +replace the venerable ``tight_layout`` method. It is invoked via +a new ``constrained_layout=True`` kwarg to +`~.figure.Figure` or `~.figure.subplots`. + +There are new ``rcParams`` for this package, and spacing can be +more finely tuned with the new `~.set_constrained_layout_pads`. + +Features include: + + - Automatic spacing for subplots with a fixed-size padding in inches around + subplots and all their decorators, and space between as a fraction + of subplot size between subplots. + - Spacing for `~.figure.suptitle`, and colorbars that are attached to + more than one axes. + - Nested `~.GridSpec` layouts using `~.GridSpecFromSubplotSpec`. + + For more details and capabilities please see the new tutorial: + :doc:`/tutorials/intermediate/constrainedlayout_guide` + +Note the new API to access this: + +New ``plt.figure`` and ``plt.subplots`` kwarg: ``constrained_layout`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:meth:`~matplotlib.pyplot.figure` and :meth:`~matplotlib.pyplot.subplots` +can now be called with ``constrained_layout=True`` kwarg to enable +constrained_layout. + +New ``ax.set_position`` behaviour +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:meth:`~matplotlib.axes.set_position` now makes the specified axis no +longer responsive to ``constrained_layout``, consistent with the idea that the +user wants to place an axis manually. + +Internally, this means that old ``ax.set_position`` calls *inside* the library +are changed to private ``ax._set_position`` calls so that +``constrained_layout`` will still work with these axes. + +New ``figure`` kwarg for ``GridSpec`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In order to facilitate ``constrained_layout``, ``GridSpec`` now accepts a +``figure`` keyword. This is backwards compatible, in that not supplying this +will simply cause ``constrained_layout`` to not operate on the subplots +orgainzed by this ``GridSpec`` instance. Routines that use ``GridSpec`` (e.g. +``fig.subplots``) have been modified to pass the figure to ``GridSpec``. diff --git a/lib/matplotlib/_constrained_layout.py b/lib/matplotlib/_constrained_layout.py new file mode 100644 index 000000000000..3561fbfef2b0 --- /dev/null +++ b/lib/matplotlib/_constrained_layout.py @@ -0,0 +1,653 @@ +""" +This module provides the routine to adjust subplot layouts so that there are +no overlapping axes or axes decorations. All axes decorations are dealt with +(labels, ticks, titles, ticklabels) and some dependent artists are also dealt +with (colorbar, suptitle, legend). + +Layout is done via :meth:`~matplotlib.gridspec`, with one constraint per +gridspec, so it is possible to have overlapping axes if the gridspecs +overlap (i.e. using :meth:`~matplotlib.gridspec.GridSpecFromSubplotSpec`). +Axes placed using ``figure.subplots()`` or ``figure.add_subplots()`` will +participate in the layout. Axes manually placed via ``figure.add_axes()`` +will not. + +See Tutorial: :doc:`/tutorials/intermediate/constrainedlayout_guide` + +""" + +# Development Notes: + +# What gets a layoutbox: +# - figure +# - gridspec +# - subplotspec +# EITHER: +# - axes + pos for the axes (i.e. the total area taken by axis and +# the actual "position" argument that needs to be sent to +# ax.set_position.) +# - The axes layout box will also encomapss the legend, and that is +# how legends get included (axes legeneds, not figure legends) +# - colorbars are sibblings of the axes if they are single-axes +# colorbars +# OR: +# - a gridspec can be inside a subplotspec. +# - subplotspec +# EITHER: +# - axes... +# OR: +# - gridspec... with arbitrary nesting... +# - colorbars are siblings of the subplotspecs if they are multi-axes +# colorbars. +# - suptitle: +# - right now suptitles are just stacked atop everything else in figure. +# Could imagine suptitles being gridspec suptitles, but not implimented +# +# Todo: AnchoredOffsetbox connected to gridspecs or axes. This would +# be more general way to add extra-axes annotations. + +from __future__ import (absolute_import, division, print_function, + unicode_literals) + +import numpy as np +import logging +import warnings + +from matplotlib.legend import Legend +import matplotlib.transforms as transforms +import matplotlib._layoutbox as layoutbox + +_log = logging.getLogger(__name__) + + +def get_axall_tightbbox(ax, renderer): + ''' + Get the tight_bbox of the axis ax, and any dependent decorations, like + a `Legend` instance. + ''' + + # main bbox of the axis.... + bbox = ax.get_tightbbox(renderer=renderer) + # now add the possibility of the legend... + for child in ax.get_children(): + if isinstance(child, Legend): + bboxn = child._legend_box.get_window_extent(renderer) + bbox = transforms.Bbox.union([bbox, bboxn]) + # add other children here.... + return bbox + + +def in_same_column(ss0, ssc): + nrows, ncols = ss0.get_gridspec().get_geometry() + + if ss0.num2 is None: + ss0.num2 = ss0.num1 + rownum0min, colnum0min = divmod(ss0.num1, ncols) + rownum0max, colnum0max = divmod(ss0.num2, ncols) + if ssc.num2 is None: + ssc.num2 = ssc.num1 + rownumCmin, colnumCmin = divmod(ssc.num1, ncols) + rownumCmax, colnumCmax = divmod(ssc.num2, ncols) + if colnum0min >= colnumCmin and colnum0min <= colnumCmax: + return True + if colnum0max >= colnumCmin and colnum0max <= colnumCmax: + return True + return False + + +def in_same_row(ss0, ssc): + nrows, ncols = ss0.get_gridspec().get_geometry() + + if ss0.num2 is None: + ss0.num2 = ss0.num1 + rownum0min, colnum0min = divmod(ss0.num1, ncols) + rownum0max, colnum0max = divmod(ss0.num2, ncols) + if ssc.num2 is None: + ssc.num2 = ssc.num1 + rownumCmin, colnumCmin = divmod(ssc.num1, ncols) + rownumCmax, colnumCmax = divmod(ssc.num2, ncols) + if rownum0min >= rownumCmin and rownum0min <= rownumCmax: + return True + if rownum0max >= rownumCmin and rownum0max <= rownumCmax: + return True + return False + + +###################################################### +def do_constrained_layout(fig, renderer, h_pad, w_pad, + hspace=None, wspace=None): + + """ + Do the constrained_layout. Called at draw time in + ``figure.constrained_layout()`` + + Parameters + ---------- + + + fig: Figure + is the ``figure`` instance to do the layout in. + + renderer: Renderer + the renderer to use. + + h_pad, w_pad : float + are in figure-normalized units, and are a padding around the axes + elements. + + hspace, wspace : float + are in fractions of the subplot sizes. + + """ + + ''' Steps: + + 1. get a list of unique gridspecs in this figure. Each gridspec will be + constrained separately. + 2. Check for gaps in the gridspecs. i.e. if not every axes slot in the + gridspec has been filled. If empty, add a ghost axis that is made so + that it cannot be seen (though visible=True). This is needed to make + a blank spot in the layout. + 3. Compare the tight_bbox of each axes to its `position`, and assume that + the difference is the space needed by the elements around the edge of + the axes (decorations) like the title, ticklabels, x-labels, etc. This + can include legends who overspill the axes boundaries. + 4. Constrain gridspec elements to line up: + a) if colnum0 neq colnumC, the two subplotspecs are stacked next to + each other, with the appropriate order. + b) if colnum0 == columnC line up the left or right side of the + _poslayoutbox (depending if it is the min or max num that is equal). + c) do the same for rows... + 5. The above doesn't constrain relative sizes of the _poslayoutboxes at + all, and indeed zero-size is a solution that the solver often finds more + convenient than expanding the sizes. Right now the solution is to compare + subplotspec sizes (i.e. drowsC and drows0) and constrain the larger + _poslayoutbox to be larger than the ratio of the sizes. i.e. if drows0 > + drowsC, then ax._poslayoutbox > axc._poslayoutbox * drowsC / drows0. This + works fine *if* the decorations are similar between the axes. If the + larger subplotspec has much larger axes decorations, then the constraint + above is incorrect. + + We need the greater than in the above, in general, rather than an equals + sign. Consider the case of the left column having 2 rows, and the right + column having 1 row. We want the top and bottom of the _poslayoutboxes to + line up. So that means if there are decorations on the left column axes + they will be smaller than half as large as the right hand axis. + + This can break down if the decoration size for the right hand axis (the + margins) is very large. There must be a math way to check for this case. + + ''' + invTransFig = fig.transFigure.inverted().transform_bbox + + # list of unique gridspecs that contain child axes: + gss = set([]) + for ax in fig.axes: + if hasattr(ax, 'get_subplotspec'): + gs = ax.get_subplotspec().get_gridspec() + if gs._layoutbox is not None: + gss.add(gs) + if len(gss) == 0: + warnings.warn('There are no gridspecs with layoutboxes. ' + 'Possibly did not call parent GridSpec with the figure= ' + 'keyword') + + # check for unoccupied gridspec slots and make ghost axes for these + # slots... Do for each gs separately. This is a pretty big kludge + # but shoudn't have too much ill effect. The worst is that + # someone querrying the figure will wonder why there are more + # axes than they thought. + if fig._layoutbox.constrained_layout_called < 1: + for gs in gss: + nrows, ncols = gs.get_geometry() + hassubplotspec = np.zeros(nrows * ncols, dtype=bool) + axs = [] + for ax in fig.axes: + if (hasattr(ax, 'get_subplotspec') + and ax._layoutbox is not None + and ax.get_subplotspec().get_gridspec() == gs): + axs += [ax] + for ax in axs: + ss0 = ax.get_subplotspec() + if ss0.num2 is None: + ss0.num2 = ss0.num1 + hassubplotspec[ss0.num1:(ss0.num2 + 1)] = True + for nn, hss in enumerate(hassubplotspec): + if not hss: + # this gridspec slot doesn't have an axis so we + # make a "ghost". + ax = fig.add_subplot(gs[nn]) + ax.set_frame_on(False) + ax.set_xticks([]) + ax.set_yticks([]) + ax.set_facecolor((1, 0, 0, 0)) + + # for each axes, make a margin between the *pos* layoutbox and the + # *axes* layoutbox be a minimum size that can accomodate the + # decorations on the axis. + for ax in fig.axes: + _log.debug(ax._layoutbox) + if ax._layoutbox is not None: + pos = ax.get_position(original=True) + tightbbox = get_axall_tightbbox(ax, renderer) + bbox = invTransFig(tightbbox) + # use stored h_pad if it exists + h_padt = ax._poslayoutbox.h_pad + if h_padt is None: + h_padt = h_pad + w_padt = ax._poslayoutbox.w_pad + if w_padt is None: + w_padt = w_pad + ax._poslayoutbox.edit_left_margin_min(-bbox.x0 + + pos.x0 + w_padt) + ax._poslayoutbox.edit_right_margin_min(bbox.x1 - + pos.x1 + w_padt) + ax._poslayoutbox.edit_bottom_margin_min( + -bbox.y0 + pos.y0 + h_padt) + ax._poslayoutbox.edit_top_margin_min(bbox.y1-pos.y1+h_padt) + _log.debug('left %f', (-bbox.x0 + pos.x0 + w_pad)) + _log.debug('right %f', (bbox.x1 - pos.x1 + w_pad)) + _log.debug('bottom %f', (-bbox.y0 + pos.y0 + h_padt)) + # Sometimes its possible for the solver to collapse + # rather than expand axes, so they all have zero height + # or width. This stops that... It *should* have been + # taken into account w/ pref_width... + if fig._layoutbox.constrained_layout_called < 1: + ax._poslayoutbox.constrain_height_min(20, strength='weak') + ax._poslayoutbox.constrain_width_min(20, strength='weak') + ax._layoutbox.constrain_height_min(20, strength='weak') + ax._layoutbox.constrain_width_min(20, strength='weak') + ax._poslayoutbox.constrain_top_margin(0, strength='weak') + ax._poslayoutbox.constrain_bottom_margin(0, + strength='weak') + ax._poslayoutbox.constrain_right_margin(0, strength='weak') + ax._poslayoutbox.constrain_left_margin(0, strength='weak') + + # do layout for suptitle. + if fig._suptitle is not None: + sup = fig._suptitle + bbox = invTransFig(sup.get_window_extent(renderer=renderer)) + height = bbox.y1 - bbox.y0 + sup._layoutbox.edit_height(height+h_pad) + + # OK, the above lines up ax._poslayoutbox with ax._layoutbox + # now we need to + # 1) arrange the subplotspecs. We do it at this level because + # the subplotspecs are meant to contain other dependent axes + # like colorbars or legends. + # 2) line up the right and left side of the ax._poslayoutbox + # that have the same subplotspec maxes. + + if fig._layoutbox.constrained_layout_called < 1: + + # arrange the subplotspecs... This is all done relative to each + # other. Some subplotspecs conatain axes, and others contain gridspecs + # the ones that contain gridspecs are a set proportion of their + # parent gridspec. The ones that contain axes are not so constrained. + figlb = fig._layoutbox + for child in figlb.children: + if child._is_gridspec_layoutbox(): + # farm the gridspec layout out. + # + # This routine makes all the subplot spec containers + # have the correct arrangement. It just stacks the + # subplot layoutboxes in the correct order... + arange_subplotspecs(child, hspace=hspace, wspace=wspace) + + # - Align right/left and bottom/top spines of appropriate subplots. + # - Compare size of subplotspec including height and width ratios + # and make sure that the axes spines are at least as large + # as they should be. + for gs in gss: + # for each gridspec... + nrows, ncols = gs.get_geometry() + width_ratios = gs.get_width_ratios() + height_ratios = gs.get_height_ratios() + if width_ratios is None: + width_ratios = np.ones(ncols) + if height_ratios is None: + height_ratios = np.ones(nrows) + + # get axes in this gridspec.... + axs = [] + for ax in fig.axes: + if (hasattr(ax, 'get_subplotspec') + and ax._layoutbox is not None): + if ax.get_subplotspec().get_gridspec() == gs: + axs += [ax] + for ax in axs: + axs = axs[1:] + # now compare ax to all the axs: + # + # If the subplotspecs have the same colnumXmax, then line + # up their right sides. If they have the same min, then + # line up their left sides (and vertical equivalents). + ss0 = ax.get_subplotspec() + if ss0.num2 is None: + ss0.num2 = ss0.num1 + rownum0min, colnum0min = divmod(ss0.num1, ncols) + rownum0max, colnum0max = divmod(ss0.num2, ncols) + for axc in axs: + ssc = axc.get_subplotspec() + # get the rownums and colnums + rownumCmin, colnumCmin = divmod(ssc.num1, ncols) + if ssc.num2 is None: + ssc.num2 = ssc.num1 + rownumCmax, colnumCmax = divmod(ssc.num2, ncols) + + # Horizontally align axes spines if they have the + # same min or max: + if colnum0min == colnumCmin: + # we want the _poslayoutboxes to line up on left + # side of the axes spines... + layoutbox.align([ax._poslayoutbox, + axc._poslayoutbox], + 'left') + if colnum0max == colnumCmax: + # line up right sides of _poslayoutbox + layoutbox.align([ax._poslayoutbox, + axc._poslayoutbox], + 'right') + # Vertically align axes spines if they have the + # same min or max: + if rownum0min == rownumCmin: + # line up top of _poslayoutbox + _log.debug('rownum0min == rownumCmin') + layoutbox.align([ax._poslayoutbox, axc._poslayoutbox], + 'top') + if rownum0max == rownumCmax: + # line up bottom of _poslayoutbox + _log.debug('rownum0max == rownumCmax') + layoutbox.align([ax._poslayoutbox, axc._poslayoutbox], + 'bottom') + + ########### + # Now we make the widths and heights similar. + # This allows vertically stacked subplots to have + # different sizes if they occupy different ammounts + # of the gridspec: i.e. + # gs = gridspec.GridSpec(3,1) + # ax1 = gs[0,:] + # ax2 = gs[1:,:] + # then drows0 = 1, and drowsC = 2, and ax2 + # should be at least twice as large as ax1. + # For height, this only needs to be done if the + # subplots share a column. For width if they + # share a row. + widthC = np.sum( + width_ratios[colnumCmin:(colnumCmax + 1)]) + width0 = np.sum( + width_ratios[colnum0min:(colnum0max + 1)]) + heightC = np.sum( + height_ratios[rownumCmin:(rownumCmax + 1)]) + height0 = np.sum( + height_ratios[rownum0min:(rownum0max + 1)]) + + drowsC = (rownumCmax - rownumCmin + 1) + drows0 = (rownum0max - rownum0min + 1) + dcolsC = (colnumCmax - colnumCmin + 1) + dcols0 = (colnum0max - colnum0min + 1) + + if drowsC > drows0: + if in_same_column(ss0, ssc): + ax._poslayoutbox.constrain_height_min( + axc._poslayoutbox.height * drows0 * height0 + / drowsC / heightC) + elif drowsC < drows0: + if in_same_column(ss0, ssc): + axc._poslayoutbox.constrain_height_min( + ax._poslayoutbox.height * drowsC * heightC + / drows0 / drowsC) + else: + ax._poslayoutbox.constrain_height( + axc._poslayoutbox.height * height0 / heightC) + # widths... + if dcolsC > dcols0: + if in_same_row(ss0, ssc): + ax._poslayoutbox.constrain_width_min( + axc._poslayoutbox.width * dcols0 * width0 + / dcolsC / widthC) + elif dcolsC < dcols0: + if in_same_row(ss0, ssc): + axc._poslayoutbox.constrain_width_min( + ax._poslayoutbox.width * dcolsC * widthC + / dcols0 / width0) + else: + ax._poslayoutbox.constrain_width( + axc._poslayoutbox.width * width0 / widthC) + + fig._layoutbox.constrained_layout_called += 1 + fig._layoutbox.update_variables() + # Now set the position of the axes... + for ax in fig.axes: + if ax._layoutbox is not None: + newpos = ax._poslayoutbox.get_rect() + _log.debug('newpos %r', newpos) + # Now set the new position. + # ax.set_position will zero out the layout for + # this axis, allowing users to hard-code the position, + # so this does the same w/o zeroing layout. + ax._set_position(newpos, which='original') + + +def arange_subplotspecs(gs, hspace=0, wspace=0): + """ + arange the subplotspec children of this gridspec, and then recursively + do the same of any gridspec children of those gridspecs... + """ + sschildren = [] + for child in gs.children: + if child._is_subplotspec_layoutbox(): + for child2 in child.children: + # check for gridspec children... + name = (child2.name).split('.')[-1][:-3] + if name == 'gridspec': + arange_subplotspecs(child2, hspace=hspace, wspace=wspace) + sschildren += [child] + # now arrange the subplots... + for child0 in sschildren: + ss0 = child0.artist + nrows, ncols = ss0.get_gridspec().get_geometry() + if ss0.num2 is None: + ss0.num2 = ss0.num1 + rowNum0min, colNum0min = divmod(ss0.num1, ncols) + rowNum0max, colNum0max = divmod(ss0.num2, ncols) + sschildren = sschildren[1:] + for childc in sschildren: + ssc = childc.artist + rowNumCmin, colNumCmin = divmod(ssc.num1, ncols) + if ssc.num2 is None: + ssc.num2 = ssc.num1 + rowNumCmax, colNumCmax = divmod(ssc.num2, ncols) + # OK, this tells us the relative layout of ax + # with axc + thepad = wspace / ncols + if colNum0max < colNumCmin: + layoutbox.hstack([ss0._layoutbox, ssc._layoutbox], + padding=thepad) + if colNumCmax < colNum0min: + layoutbox.hstack([ssc._layoutbox, ss0._layoutbox], + padding=thepad) + + #### + # vertical alignment + thepad = hspace / nrows + if rowNum0max < rowNumCmin: + layoutbox.vstack([ss0._layoutbox, + ssc._layoutbox], + padding=thepad) + if rowNumCmax < rowNum0min: + layoutbox.vstack([ssc._layoutbox, + ss0._layoutbox], + padding=thepad) + + +def layoutcolorbarsingle(ax, cax, shrink, aspect, location, pad=0.05): + """ + Do the layout for a colorbar, to not oeverly pollute colorbar.py + + `pad` is in fraction of the original axis size. + """ + axlb = ax._layoutbox + axpos = ax._poslayoutbox + axsslb = ax.get_subplotspec()._layoutbox + lb = layoutbox.LayoutBox( + parent=axsslb, + name=axsslb.name + '.cbar', + artist=cax) + + if location in ('left', 'right'): + lbpos = layoutbox.LayoutBox( + parent=lb, + name=lb.name + '.pos', + tightwidth=False, + pos=True, + subplot=False, + artist=cax) + + if location == 'right': + # arrange to right of parent axis + layoutbox.hstack([axlb, lb], padding=pad * axlb.width, + strength='strong') + else: + layoutbox.hstack([lb, axlb], padding=pad * axlb.width) + # constrain the height and center... + layoutbox.match_heights([axpos, lbpos], [1, shrink]) + layoutbox.align([axpos, lbpos], 'v_center') + # set the width of the pos box + lbpos.constrain_width(shrink * axpos.height * (1/aspect), + strength='strong') + elif location in ('bottom', 'top'): + lbpos = layoutbox.LayoutBox( + parent=lb, + name=lb.name + '.pos', + tightheight=True, + pos=True, + subplot=False, + artist=cax) + + if location == 'bottom': + layoutbox.vstack([axlb, lb], padding=pad * axlb.height) + else: + layoutbox.vstack([lb, axlb], padding=pad * axlb.height) + # constrain the height and center... + layoutbox.match_widths([axpos, lbpos], + [1, shrink], strength='strong') + layoutbox.align([axpos, lbpos], 'h_center') + # set the height of the pos box + lbpos.constrain_height(axpos.width * aspect * shrink, + strength='medium') + + return lb, lbpos + + +def layoutcolorbargridspec(parents, cax, shrink, aspect, location, pad=0.05): + """ + Do the layout for a colorbar, to not oeverly pollute colorbar.py + + `pad` is in fraction of the original axis size. + """ + + gs = parents[0].get_subplotspec().get_gridspec() + # parent layout box.... + gslb = gs._layoutbox + + lb = layoutbox.LayoutBox(parent=gslb.parent, + name=gslb.parent.name + '.cbar', + artist=cax) + if location in ('left', 'right'): + lbpos = layoutbox.LayoutBox( + parent=lb, + name=lb.name + '.pos', + tightwidth=False, + pos=True, + subplot=False, + artist=cax) + + if location == 'right': + # arrange to right of the gridpec sibbling + layoutbox.hstack([gslb, lb], padding=pad * gslb.width, + strength='strong') + else: + layoutbox.hstack([lb, gslb], padding=pad * gslb.width) + # constrain the height and center... + # This isn't quite right. We'd like the colorbar + # pos to line up w/ the axes poss, not the size of the + # gs. + maxrow = -100000 + minrow = 1000000 + maxax = None + minax = None + + for ax in parents: + subspec = ax.get_subplotspec() + nrows, ncols = subspec.get_gridspec().get_geometry() + for num in [subspec.num1, subspec.num2]: + rownum1, colnum1 = divmod(subspec.num1, ncols) + if rownum1 > maxrow: + maxrow = rownum1 + maxax = ax + if rownum1 < minrow: + minrow = rownum1 + minax = ax + # invert the order so these are bottom to top: + maxposlb = minax._poslayoutbox + minposlb = maxax._poslayoutbox + # now we want the height of the colorbar pos to be + # set by the top and bottom of these poss + # bottom top + # b t + # h = (top-bottom)*shrink + # b = bottom + (top-bottom - h) / 2. + lbpos.constrain_height( + (maxposlb.top - minposlb.bottom) * + shrink, strength='strong') + lbpos.constrain_bottom( + (maxposlb.top - minposlb.bottom) * + (1 - shrink)/2 + minposlb.bottom, + strength='strong') + + # set the width of the pos box + lbpos.constrain_width(lbpos.height * (shrink / aspect), + strength='strong') + elif location in ('bottom', 'top'): + lbpos = layoutbox.LayoutBox( + parent=lb, + name=lb.name + '.pos', + tightheight=True, + pos=True, + subplot=False, + artist=cax) + + if location == 'bottom': + layoutbox.vstack([gslb, lb], padding=pad * gslb.width) + else: + layoutbox.vstack([lb, gslb], padding=pad * gslb.width) + + maxcol = -100000 + mincol = 1000000 + maxax = None + minax = None + + for ax in parents: + subspec = ax.get_subplotspec() + nrows, ncols = subspec.get_gridspec().get_geometry() + for num in [subspec.num1, subspec.num2]: + rownum1, colnum1 = divmod(subspec.num1, ncols) + if colnum1 > maxcol: + maxcol = colnum1 + maxax = ax + if rownum1 < mincol: + mincol = colnum1 + minax = ax + maxposlb = maxax._poslayoutbox + minposlb = minax._poslayoutbox + lbpos.constrain_width((maxposlb.right - minposlb.left) * + shrink) + lbpos.constrain_left( + (maxposlb.right - minposlb.left) * + (1-shrink)/2 + minposlb.left) + # set the height of the pos box + lbpos.constrain_height(lbpos.width * shrink * aspect, + strength='medium') + + return lb, lbpos diff --git a/lib/matplotlib/_layoutbox.py b/lib/matplotlib/_layoutbox.py new file mode 100644 index 000000000000..ad7c199de092 --- /dev/null +++ b/lib/matplotlib/_layoutbox.py @@ -0,0 +1,743 @@ +# -*- coding: utf-8 -*- +""" + +Conventions: + +"constrain_x" means to constrain the variable with either +another kiwisolver variable, or a float. i.e. `constrain_width(0.2)` +will set a constraint that the width has to be 0.2 and this constraint is +permanent - i.e. it will not be removed if it becomes obsolete. + +"edit_x" means to set x to a value (just a float), and that this value can +change. So `edit_width(0.2)` will set width to be 0.2, but `edit_width(0.3)` +will allow it to change to 0.3 later. Note that these values are still just +"suggestions" in `kiwisolver` parlance, and could be over-ridden by +other constrains. + +""" + +from __future__ import (absolute_import, division, print_function, + unicode_literals) + +import itertools +import kiwisolver as kiwi +import logging +import numpy as np +import warnings + +import matplotlib + +_log = logging.getLogger(__name__) + + +# renderers can be complicated +def get_renderer(fig): + if fig._cachedRenderer: + renderer = fig._cachedRenderer + else: + canvas = fig.canvas + if canvas and hasattr(canvas, "get_renderer"): + renderer = canvas.get_renderer() + else: + # not sure if this can happen + # seems to with PDF... + _log.info("constrained_layout : falling back to Agg renderer") + from matplotlib.backends.backend_agg import FigureCanvasAgg + canvas = FigureCanvasAgg(fig) + renderer = canvas.get_renderer() + + return renderer + + +class LayoutBox(object): + """ + Basic rectangle representation using kiwi solver variables + """ + + def __init__(self, parent=None, name='', tightwidth=False, + tightheight=False, artist=None, + lower_left=(0, 0), upper_right=(1, 1), pos=False, + subplot=False, h_pad=None, w_pad=None): + Variable = kiwi.Variable + self.parent = parent + self.name = name + sn = self.name + '_' + if parent is None: + self.solver = kiwi.Solver() + self.constrained_layout_called = 0 + else: + self.solver = parent.solver + self.constrained_layout_called = None + # parent wants to know about this child! + parent.add_child(self) + # keep track of artist associated w/ this layout. Can be none + self.artist = artist + # keep track if this box is supposed to be a pos that is constrained + # by the parent. + self.pos = pos + # keep track of whether we need to match this subplot up with others. + self.subplot = subplot + + # we need the str below for Py 2 which complains the string is unicode + self.top = Variable(str(sn + 'top')) + self.bottom = Variable(str(sn + 'bottom')) + self.left = Variable(str(sn + 'left')) + self.right = Variable(str(sn + 'right')) + + self.width = Variable(str(sn + 'width')) + self.height = Variable(str(sn + 'height')) + self.h_center = Variable(str(sn + 'h_center')) + self.v_center = Variable(str(sn + 'v_center')) + + self.min_width = Variable(str(sn + 'min_width')) + self.min_height = Variable(str(sn + 'min_height')) + self.pref_width = Variable(str(sn + 'pref_width')) + self.pref_height = Variable(str(sn + 'pref_height')) + # margis are only used for axes-position layout boxes. maybe should + # be a separate subclass: + self.left_margin = Variable(str(sn + 'left_margin')) + self.right_margin = Variable(str(sn + 'right_margin')) + self.bottom_margin = Variable(str(sn + 'bottom_margin')) + self.top_margin = Variable(str(sn + 'top_margin')) + # mins + self.left_margin_min = Variable(str(sn + 'left_margin_min')) + self.right_margin_min = Variable(str(sn + 'right_margin_min')) + self.bottom_margin_min = Variable(str(sn + 'bottom_margin_min')) + self.top_margin_min = Variable(str(sn + 'top_margin_min')) + + right, top = upper_right + left, bottom = lower_left + self.tightheight = tightheight + self.tightwidth = tightwidth + self.add_constraints() + self.children = [] + self.subplotspec = None + if self.pos: + self.constrain_margins() + self.h_pad = h_pad + self.w_pad = w_pad + + def constrain_margins(self): + """ + Only do this for pos. This sets a variable distance + margin between the position of the axes and the outer edge of + the axes. + + Margins are variable because they change with the fogure size. + + Margin minimums are set to make room for axes decorations. However, + the margins can be larger if we are mathicng the position size to + otehr axes. + """ + sol = self.solver + + # left + if not sol.hasEditVariable(self.left_margin_min): + sol.addEditVariable(self.left_margin_min, 'strong') + sol.suggestValue(self.left_margin_min, 0.0001) + c = (self.left_margin == self.left - self.parent.left) + self.solver.addConstraint(c | 'required') + c = (self.left_margin >= self.left_margin_min) + self.solver.addConstraint(c | 'strong') + + # right + if not sol.hasEditVariable(self.right_margin_min): + sol.addEditVariable(self.right_margin_min, 'strong') + sol.suggestValue(self.right_margin_min, 0.0001) + c = (self.right_margin == self.parent.right - self.right) + self.solver.addConstraint(c | 'required') + c = (self.right_margin >= self.right_margin_min) + self.solver.addConstraint(c | 'required') + # bottom + if not sol.hasEditVariable(self.bottom_margin_min): + sol.addEditVariable(self.bottom_margin_min, 'strong') + sol.suggestValue(self.bottom_margin_min, 0.0001) + c = (self.bottom_margin == self.bottom - self.parent.bottom) + self.solver.addConstraint(c | 'required') + c = (self.bottom_margin >= self.bottom_margin_min) + self.solver.addConstraint(c | 'required') + # top + if not sol.hasEditVariable(self.top_margin_min): + sol.addEditVariable(self.top_margin_min, 'strong') + sol.suggestValue(self.top_margin_min, 0.0001) + c = (self.top_margin == self.parent.top - self.top) + self.solver.addConstraint(c | 'required') + c = (self.top_margin >= self.top_margin_min) + self.solver.addConstraint(c | 'required') + + def add_child(self, child): + self.children += [child] + + def remove_child(self, child): + try: + self.children.remove(child) + except ValueError: + _log.info("Tried to remove child that doesn't belong to parent") + + def add_constraints(self): + sol = self.solver + # never let width and height go negative. + for i in [self.min_width, self.min_height]: + sol.addEditVariable(i, 1e9) + sol.suggestValue(i, 0.0) + # define relation ships between things thing width and right and left + self.hard_constraints() + # self.soft_constraints() + if self.parent: + self.parent_constrain() + # sol.updateVariables() + + def parent_constrain(self): + parent = self.parent + hc = [self.left >= parent.left, + self.bottom >= parent.bottom, + self.top <= parent.top, + self.right <= parent.right] + for c in hc: + self.solver.addConstraint(c | 'required') + + def hard_constraints(self): + hc = [self.width == self.right - self.left, + self.height == self.top - self.bottom, + self.h_center == (self.left + self.right) * 0.5, + self.v_center == (self.top + self.bottom) * 0.5, + self.width >= self.min_width, + self.height >= self.min_height] + for c in hc: + self.solver.addConstraint(c | 'required') + + def soft_constraints(self): + sol = self.solver + if self.tightwidth: + suggest = 0. + else: + suggest = 20. + c = (self.pref_width == suggest) + for i in c: + sol.addConstraint(i | 'required') + if self.tightheight: + suggest = 0. + else: + suggest = 20. + c = (self.pref_height == suggest) + for i in c: + sol.addConstraint(i | 'required') + + c = [(self.width >= suggest), + (self.height >= suggest)] + for i in c: + sol.addConstraint(i | 150000) + + def set_parent(self, parent): + ''' replace the parent of this with the new parent + ''' + self.parent = parent + self.parent_constrain() + + def constrain_geometry(self, left, bottom, right, top, strength='strong'): + hc = [self.left == left, + self.right == right, + self.bottom == bottom, + self.top == top] + for c in hc: + self.solver.addConstraint((c | strength)) + # self.solver.updateVariables() + + def constrain_same(self, other, strength='strong'): + """ + Make the layoutbox have same position as other layoutbox + """ + hc = [self.left == other.left, + self.right == other.right, + self.bottom == other.bottom, + self.top == other.top] + for c in hc: + self.solver.addConstraint((c | strength)) + + def constrain_left_margin(self, margin, strength='strong'): + c = (self.left == self.parent.left + margin) + self.solver.addConstraint(c | strength) + + def edit_left_margin_min(self, margin): + self.solver.suggestValue(self.left_margin_min, margin) + + def constrain_right_margin(self, margin, strength='strong'): + c = (self.right == self.parent.right - margin) + self.solver.addConstraint(c | strength) + + def edit_right_margin_min(self, margin): + self.solver.suggestValue(self.right_margin_min, margin) + + def constrain_bottom_margin(self, margin, strength='strong'): + c = (self.bottom == self.parent.bottom + margin) + self.solver.addConstraint(c | strength) + + def edit_bottom_margin_min(self, margin): + self.solver.suggestValue(self.bottom_margin_min, margin) + + def constrain_top_margin(self, margin, strength='strong'): + c = (self.top == self.parent.top - margin) + self.solver.addConstraint(c | strength) + + def edit_top_margin_min(self, margin): + self.solver.suggestValue(self.top_margin_min, margin) + + def get_rect(self): + return (self.left.value(), self.bottom.value(), + self.width.value(), self.height.value()) + + def update_variables(self): + ''' + Update *all* the variables that are part of the solver this LayoutBox + is created with + ''' + self.solver.updateVariables() + + def edit_height(self, height, strength='strong'): + ''' + Set the height of the layout box. + + This is done as an editable variable so that the value can change + due to resizing. + ''' + sol = self.solver + for i in [self.height]: + if not sol.hasEditVariable(i): + sol.addEditVariable(i, strength) + sol.suggestValue(self.height, height) + + def constrain_height(self, height, strength='strong'): + ''' + Constrain the height of the layout box. height is + either a float or a layoutbox.height. + ''' + c = (self.height == height) + self.solver.addConstraint(c | strength) + + def constrain_height_min(self, height, strength='strong'): + c = (self.height >= height) + self.solver.addConstraint(c | strength) + + def edit_width(self, width, strength='strong'): + sol = self.solver + for i in [self.width]: + if not sol.hasEditVariable(i): + sol.addEditVariable(i, strength) + sol.suggestValue(self.width, width) + + def constrain_width(self, width, strength='strong'): + ''' + Constrain the width of the layout box. `width` is + either a float or a layoutbox.width. + ''' + c = (self.width == width) + self.solver.addConstraint(c | strength) + + def constrain_width_min(self, width, strength='strong'): + c = (self.width >= width) + self.solver.addConstraint(c | strength) + + def constrain_left(self, left, strength='strong'): + c = (self.left == left) + self.solver.addConstraint(c | strength) + + def constrain_bottom(self, bottom, strength='strong'): + c = (self.bottom == bottom) + self.solver.addConstraint(c | strength) + + def constrain_right(self, right, strength='strong'): + c = (self.right == right) + self.solver.addConstraint(c | strength) + + def constrain_top(self, top, strength='strong'): + c = (self.top == top) + self.solver.addConstraint(c | strength) + + def _is_subplotspec_layoutbox(self): + ''' + Helper to check if this layoutbox is the layoutbox of a + subplotspec + ''' + name = (self.name).split('.')[-1][:-3] + if name == 'ss': + return True + return False + + def _is_gridspec_layoutbox(self): + ''' + Helper to check if this layoutbox is the layoutbox of a + gridspec + ''' + name = (self.name).split('.')[-1][:-3] + if name == 'gridspec': + return True + return False + + def find_child_subplots(self): + ''' + Find children of this layout box that are subplots. We want to line + poss up, and this is an easy way to find them all. + ''' + if self.subplot: + subplots = [self] + else: + subplots = [] + for child in self.children: + subplots += child.find_child_subplots() + return subplots + + def layout_from_subplotspec(self, subspec, + name='', artist=None, pos=False): + ''' Make a layout box from a subplotspec. The layout box is + constrained to be a fraction of the width/height of the parent, + and be a fraction of the parent width/height from the left/bottom + of the parent. Therefore the parent can move around and the + layout for the subplot spec should move with it. + + The parent is *usually* the gridspec that made the subplotspec.?? + ''' + lb = LayoutBox(parent=self, name=name, artist=artist, pos=pos) + gs = subspec.get_gridspec() + nrows, ncols = gs.get_geometry() + parent = self.parent + + # OK, now, we want to set the position of this subplotspec + # based on its subplotspec parameters. The new gridspec will inherit. + + # from gridspec. prob should be new method in gridspec + left = 0.0 + right = 1.0 + bottom = 0.0 + top = 1.0 + totWidth = right-left + totHeight = top-bottom + hspace = 0. + wspace = 0. + + # calculate accumulated heights of columns + cellH = totHeight / (nrows + hspace * (nrows - 1)) + sepH = hspace*cellH + + if gs._row_height_ratios is not None: + netHeight = cellH * nrows + tr = float(sum(gs._row_height_ratios)) + cellHeights = [netHeight*r/tr for r in gs._row_height_ratios] + else: + cellHeights = [cellH] * nrows + + sepHeights = [0] + ([sepH] * (nrows - 1)) + cellHs = np.add.accumulate(np.ravel( + list(zip(sepHeights, cellHeights)))) + + # calculate accumulated widths of rows + cellW = totWidth/(ncols + wspace * (ncols - 1)) + sepW = wspace*cellW + + if gs._col_width_ratios is not None: + netWidth = cellW * ncols + tr = float(sum(gs._col_width_ratios)) + cellWidths = [netWidth * r / tr for r in gs._col_width_ratios] + else: + cellWidths = [cellW] * ncols + + sepWidths = [0] + ([sepW] * (ncols - 1)) + cellWs = np.add.accumulate(np.ravel(list(zip(sepWidths, cellWidths)))) + + figTops = [top - cellHs[2 * rowNum] for rowNum in range(nrows)] + figBottoms = [top - cellHs[2 * rowNum + 1] for rowNum in range(nrows)] + figLefts = [left + cellWs[2 * colNum] for colNum in range(ncols)] + figRights = [left + cellWs[2 * colNum + 1] for colNum in range(ncols)] + + rowNum, colNum = divmod(subspec.num1, ncols) + figBottom = figBottoms[rowNum] + figTop = figTops[rowNum] + figLeft = figLefts[colNum] + figRight = figRights[colNum] + + if subspec.num2 is not None: + + rowNum2, colNum2 = divmod(subspec.num2, ncols) + figBottom2 = figBottoms[rowNum2] + figTop2 = figTops[rowNum2] + figLeft2 = figLefts[colNum2] + figRight2 = figRights[colNum2] + + figBottom = min(figBottom, figBottom2) + figLeft = min(figLeft, figLeft2) + figTop = max(figTop, figTop2) + figRight = max(figRight, figRight2) + # These are numbers relative to 0,0,1,1. Need to constrain + # relative to parent. + + width = figRight - figLeft + height = figTop - figBottom + parent = self.parent + cs = [self.left == parent.left + parent.width * figLeft, + self.bottom == parent.bottom + parent.height * figBottom, + self.width == parent.width * width, + self.height == parent.height * height] + for c in cs: + self.solver.addConstraint((c | 'required')) + + return lb + + def __repr__(self): + args = (self.name, self.left.value(), self.bottom.value(), + self.right.value(), self.top.value()) + return ('LayoutBox: %25s, (left: %1.3f) (bot: %1.3f) ' + '(right: %1.3f) (top: %1.3f) ') % args + + +# Utility functions that act on layoutboxes... +def hstack(boxes, padding=0, strength='strong'): + ''' + Stack LayoutBox instances from left to right. + `padding` is in figure-relative units. + ''' + + for i in range(1, len(boxes)): + c = (boxes[i-1].right + padding <= boxes[i].left) + boxes[i].solver.addConstraint(c | strength) + + +def hpack(boxes, padding=0, strength='strong'): + ''' + Stack LayoutBox instances from left to right. + ''' + + for i in range(1, len(boxes)): + c = (boxes[i-1].right + padding == boxes[i].left) + boxes[i].solver.addConstraint(c | strength) + + +def vstack(boxes, padding=0, strength='strong'): + ''' + Stack LayoutBox instances from top to bottom + ''' + + for i in range(1, len(boxes)): + c = (boxes[i-1].bottom - padding >= boxes[i].top) + boxes[i].solver.addConstraint(c | strength) + + +def vpack(boxes, padding=0, strength='strong'): + ''' + Stack LayoutBox instances from top to bottom + ''' + + for i in range(1, len(boxes)): + c = (boxes[i-1].bottom - padding >= boxes[i].top) + boxes[i].solver.addConstraint(c | strength) + + +def match_heights(boxes, height_ratios=None, strength='medium'): + ''' + Stack LayoutBox instances from top to bottom + ''' + + if height_ratios is None: + height_ratios = np.ones(len(boxes)) + for i in range(1, len(boxes)): + c = (boxes[i-1].height == + boxes[i].height*height_ratios[i-1]/height_ratios[i]) + boxes[i].solver.addConstraint(c | strength) + + +def match_widths(boxes, width_ratios=None, strength='medium'): + ''' + Stack LayoutBox instances from top to bottom + ''' + + if width_ratios is None: + width_ratios = np.ones(len(boxes)) + for i in range(1, len(boxes)): + c = (boxes[i-1].width == + boxes[i].width*width_ratios[i-1]/width_ratios[i]) + boxes[i].solver.addConstraint(c | strength) + + +def vstackeq(boxes, padding=0, height_ratios=None): + vstack(boxes, padding=padding) + match_heights(boxes, height_ratios=height_ratios) + + +def hstackeq(boxes, padding=0, width_ratios=None): + hstack(boxes, padding=padding) + match_widths(boxes, width_ratios=width_ratios) + + +def align(boxes, attr, strength='strong'): + cons = [] + for box in boxes[1:]: + cons = (getattr(boxes[0], attr) == getattr(box, attr)) + boxes[0].solver.addConstraint(cons | strength) + + +def match_top_margins(boxes, levels=1): + box0 = boxes[0] + top0 = box0 + for n in range(levels): + top0 = top0.parent + for box in boxes[1:]: + topb = box + for n in range(levels): + topb = topb.parent + c = (box0.top-top0.top == box.top-topb.top) + box0.solver.addConstraint(c | 'strong') + + +def match_bottom_margins(boxes, levels=1): + box0 = boxes[0] + top0 = box0 + for n in range(levels): + top0 = top0.parent + for box in boxes[1:]: + topb = box + for n in range(levels): + topb = topb.parent + c = (box0.bottom-top0.bottom == box.bottom-topb.bottom) + box0.solver.addConstraint(c | 'strong') + + +def match_left_margins(boxes, levels=1): + box0 = boxes[0] + top0 = box0 + for n in range(levels): + top0 = top0.parent + for box in boxes[1:]: + topb = box + for n in range(levels): + topb = topb.parent + c = (box0.left-top0.left == box.left-topb.left) + box0.solver.addConstraint(c | 'strong') + + +def match_right_margins(boxes, levels=1): + box0 = boxes[0] + top0 = box0 + for n in range(levels): + top0 = top0.parent + for box in boxes[1:]: + topb = box + for n in range(levels): + topb = topb.parent + c = (box0.right-top0.right == box.right-topb.right) + box0.solver.addConstraint(c | 'strong') + + +def match_width_margins(boxes, levels=1): + match_left_margins(boxes, levels=levels) + match_right_margins(boxes, levels=levels) + + +def match_height_margins(boxes, levels=1): + match_top_margins(boxes, levels=levels) + match_bottom_margins(boxes, levels=levels) + + +def match_margins(boxes, levels=1): + match_width_margins(boxes, levels=levels) + match_height_margins(boxes, levels=levels) + + +_layoutboxobjnum = itertools.count() + + +def seq_id(): + ''' + Generate a short sequential id for layoutbox objects... + ''' + + global _layoutboxobjnum + + return ('%03d' % (next(_layoutboxobjnum))) + + +def print_children(lb): + ''' + Print the children of the layoutbox + ''' + print(lb) + for child in lb.children: + print_children(child) + + +def nonetree(lb): + ''' + Make all elements in this tree none... This signals not to do any more + layout. + ''' + if lb is not None: + if lb.parent is None: + # Clear the solver. Hopefully this garbage collects. + lb.solver.reset() + nonechildren(lb) + else: + nonetree(lb.parent) + + +def nonechildren(lb): + for child in lb.children: + nonechildren(child) + lb.artist._layoutbox = None + lb = None + + +def print_tree(lb): + ''' + Print the tree of layoutboxes + ''' + + if lb.parent is None: + print('LayoutBox Tree\n') + print('==============\n') + print_children(lb) + print('\n') + else: + print_tree(lb.parent) + + +def plot_children(fig, box, level=0, printit=True): + ''' + Simple plotting to show where boxes are + ''' + import matplotlib + import matplotlib.pyplot as plt + + if isinstance(fig, matplotlib.figure.Figure): + ax = fig.add_axes([0., 0., 1., 1.]) + ax.set_facecolor([1., 1., 1., 0.7]) + ax.set_alpha(0.3) + fig.draw(fig.canvas.get_renderer()) + else: + ax = fig + + import matplotlib.patches as patches + colors = plt.rcParams["axes.prop_cycle"].by_key()["color"] + if printit: + print("Level:", level) + for child in box.children: + rect = child.get_rect() + if printit: + print(child) + ax.add_patch( + patches.Rectangle( + (child.left.value(), child.bottom.value()), # (x,y) + child.width.value(), # width + child.height.value(), # height + fc='none', + alpha=0.8, + ec=colors[level] + ) + ) + if level > 0: + name = child.name.split('.')[-1] + if level % 2 == 0: + ax.text(child.left.value(), child.bottom.value(), name, + size=12-level, color=colors[level]) + else: + ax.text(child.right.value(), child.top.value(), name, + ha='right', va='top', size=12-level, + color=colors[level]) + + plot_children(ax, child, level=level+1, printit=printit) diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index d61f6dfe809f..76e1798b78f4 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -1312,8 +1312,8 @@ def plot(self, *args, **kwargs): The following two calls yield identical results: >>> plot(x, y, 'go--', linewidth=2, markersize=12) - >>> plot(x, y, color='green', marker='o', linestyle='dashed', \ -linewidth=2, markersize=12) + >>> plot(x, y, color='green', marker='o', linestyle='dashed', + linewidth=2, markersize=12) When conflicting with *fmt*, keyword arguments take precedence. @@ -1990,7 +1990,7 @@ def bar(self, *args, **kwargs): bar(x, height, width, *, align='center', **kwargs) bar(x, height, width, bottom, *, align='center', **kwargs) - The bars are positioned at *x* with the given *align*\ ment. Their + The bars are positioned at *x* with the given *align* ment. Their dimensions are given by *width* and *height*. The vertical baseline is *bottom* (default 0). @@ -2307,7 +2307,7 @@ def barh(self, *args, **kwargs): bar(y, width, height, *, align='center', **kwargs) bar(y, width, height, left, *, align='center', **kwargs) - The bars are positioned at *y* with the given *align*\ ment. Their + The bars are positioned at *y* with the given *align*. Their dimensions are given by *width* and *height*. The horizontal baseline is *left* (default 0). diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index 291a8b6c1eaf..bb70972b6c51 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -560,11 +560,17 @@ def __init__(self, fig, rect, right=rcParams['ytick.right'] and rcParams['ytick.major.right'], which='major') + self._layoutbox = None + self._poslayoutbox = None + def __getstate__(self): # The renderer should be re-created by the figure, and then cached at # that point. state = super(_AxesBase, self).__getstate__() state['_cachedRenderer'] = None + state.pop('_layoutbox') + state.pop('_poslayoutbox') + return state def __setstate__(self, state): @@ -576,6 +582,8 @@ def __setstate__(self, state): for artist in container: artist._remove_method = container.remove self._stale = True + self._layoutbox = None + self._poslayoutbox = None def get_window_extent(self, *args, **kwargs): """ @@ -869,6 +877,19 @@ def set_position(self, pos, which='both'): which : ['both' | 'active' | 'original'], optional Determines which position variables to change. + """ + self._set_position(pos, which='both') + # because this is being called externally to the library we + # zero the constrained layout parts. + self._layoutbox = None + self._poslayoutbox = None + + def _set_position(self, pos, which='both'): + """ + private version of set_position. Call this internally + to get the same functionality of `get_position`, but not + to take the axis out of the constrained_layout + hierarchy. """ if not isinstance(pos, mtransforms.BboxBase): pos = mtransforms.Bbox.from_bounds(*pos) @@ -1519,7 +1540,7 @@ def apply_aspect(self, position=None): aspect_scale_mode = "linear" if aspect == 'auto': - self.set_position(position, which='active') + self._set_position(position, which='active') return if aspect == 'equal': @@ -1539,12 +1560,12 @@ def apply_aspect(self, position=None): box_aspect = A * self.get_data_ratio() pb = position.frozen() pb1 = pb.shrunk_to_aspect(box_aspect, pb, fig_aspect) - self.set_position(pb1.anchored(self.get_anchor(), pb), 'active') + self._set_position(pb1.anchored(self.get_anchor(), pb), 'active') return # reset active to original in case it had been changed # by prior use of 'box' - self.set_position(position, which='active') + self._set_position(position, which='active') xmin, xmax = self.get_xbound() ymin, ymax = self.get_ybound() @@ -4148,10 +4169,25 @@ def _make_twin_axes(self, *kl, **kwargs): if 'sharex' in kwargs and 'sharey' in kwargs: raise ValueError("Twinned Axes may share only one axis.") ax2 = self.figure.add_axes(self.get_position(True), *kl, **kwargs) - ## do not touch every thing shared, just this and it's twin. + self.set_adjustable('datalim') ax2.set_adjustable('datalim') self._twinned_axes.join(self, ax2) + # check if we have a layoutbox. If so, then set this axis + # gets the same poslayoutbox and layoutbox... + if self._layoutbox is not None: + name = self._layoutbox.name + 'twin' + layoutbox.seq_id() + ax2._layoutbox = layoutbox.LayoutBox( + parent=self._subplotspec._layoutbox, + name=name, + artist=ax2) + ax2._poslayoutbox = layoutbox.LayoutBox( + parent=ax2._layoutbox, + name=ax2._layoutbox.name+'.pos', + pos=True, subplot=True, artist=ax2) + # make the layout boxes be the same + ax2._layoutbox.constrain_same(self._layoutbox) + ax2._poslayoutbox.constrain_same(self._poslayoutbox) return ax2 def twinx(self): diff --git a/lib/matplotlib/axes/_subplots.py b/lib/matplotlib/axes/_subplots.py index 9c033c728889..f2774cb70e78 100644 --- a/lib/matplotlib/axes/_subplots.py +++ b/lib/matplotlib/axes/_subplots.py @@ -9,6 +9,11 @@ import matplotlib.artist as martist from matplotlib.axes._axes import Axes +import matplotlib._layoutbox as layoutbox + +import warnings +from matplotlib.cbook import mplDeprecation + class SubplotBase(object): """ @@ -27,7 +32,6 @@ def __init__(self, fig, *args, **kwargs): being created. *plotNum* starts at 1 in the upper left corner and increases to the right. - If *numRows* <= *numCols* <= *plotNum* < 10, *args* can be the decimal integer *numRows* * 100 + *numCols* * 10 + *plotNum*. """ @@ -42,10 +46,10 @@ def __init__(self, fig, *args, **kwargs): s = str(int(args[0])) rows, cols, num = map(int, s) except ValueError: - raise ValueError( - 'Single argument to subplot must be a 3-digit ' - 'integer') - self._subplotspec = GridSpec(rows, cols)[num - 1] + raise ValueError('Single argument to subplot must be ' + 'a 3-digit integer') + self._subplotspec = GridSpec(rows, cols, + figure=self.figure)[num - 1] # num - 1 for converting from MATLAB to python indexing elif len(args) == 3: rows, cols, num = args @@ -53,13 +57,16 @@ def __init__(self, fig, *args, **kwargs): cols = int(cols) if isinstance(num, tuple) and len(num) == 2: num = [int(n) for n in num] - self._subplotspec = GridSpec(rows, cols)[num[0] - 1:num[1]] + self._subplotspec = GridSpec( + rows, cols, + figure=self.figure)[(num[0] - 1):num[1]] else: if num < 1 or num > rows*cols: raise ValueError( - "num must be 1 <= num <= {maxn}, not {num}".format( - maxn=rows*cols, num=num)) - self._subplotspec = GridSpec(rows, cols)[int(num) - 1] + ("num must be 1 <= num <= {maxn}, not {num}" + ).format(maxn=rows*cols, num=num)) + self._subplotspec = GridSpec( + rows, cols, figure=self.figure)[int(num) - 1] # num - 1 for converting from MATLAB to python indexing else: raise ValueError('Illegal argument(s) to subplot: %s' % (args,)) @@ -68,6 +75,23 @@ def __init__(self, fig, *args, **kwargs): # _axes_class is set in the subplot_class_factory self._axes_class.__init__(self, fig, self.figbox, **kwargs) + # add a layout box to this, for both the full axis, and the poss + # of the axis. We need both because the axes may become smaller + # due to parasitic axes and hence no longer fill the subplotspec. + if self._subplotspec._layoutbox is None: + self._layoutbox = None + self._poslayoutbox = None + else: + name = self._subplotspec._layoutbox.name + '.ax' + name = name + layoutbox.seq_id() + self._layoutbox = layoutbox.LayoutBox( + parent=self._subplotspec._layoutbox, + name=name, + artist=self) + self._poslayoutbox = layoutbox.LayoutBox( + parent=self._layoutbox, + name=self._layoutbox.name+'.pos', + pos=True, subplot=True, artist=self) def __reduce__(self): # get the first axes class which does not @@ -91,7 +115,8 @@ def get_geometry(self): # COVERAGE NOTE: Never used internally or from examples def change_geometry(self, numrows, numcols, num): """change subplot geometry, e.g., from 1,1,1 to 2,2,3""" - self._subplotspec = GridSpec(numrows, numcols)[num - 1] + self._subplotspec = GridSpec(numrows, numcols, + figure=self.figure)[num - 1] self.update_params() self.set_position(self.figbox) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 6bd359511ea7..ebe5920df68d 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -3159,8 +3159,8 @@ def _update_view(self): for ax, (view, (pos_orig, pos_active)) in items: ax._set_view(view) # Restore both the original and modified positions - ax.set_position(pos_orig, 'original') - ax.set_position(pos_active, 'active') + ax._set_position(pos_orig, 'original') + ax._set_position(pos_active, 'active') self.canvas.draw_idle() def save_figure(self, *args): diff --git a/lib/matplotlib/backend_tools.py b/lib/matplotlib/backend_tools.py index 26a561b7e63f..6639763e417d 100644 --- a/lib/matplotlib/backend_tools.py +++ b/lib/matplotlib/backend_tools.py @@ -622,8 +622,8 @@ def update_view(self): if set(all_axes).issubset(pos): for a in all_axes: # Restore both the original and modified positions - a.set_position(pos[a][0], 'original') - a.set_position(pos[a][1], 'active') + a._set_position(pos[a][0], 'original') + a._set_position(pos[a][1], 'active') self.figure.canvas.draw_idle() diff --git a/lib/matplotlib/colorbar.py b/lib/matplotlib/colorbar.py index 0ae5771a80fc..8dfd5dd532ba 100644 --- a/lib/matplotlib/colorbar.py +++ b/lib/matplotlib/colorbar.py @@ -40,7 +40,8 @@ import matplotlib.path as mpath import matplotlib.ticker as ticker import matplotlib.transforms as mtransforms - +import matplotlib._layoutbox as layoutbox +import matplotlib._constrained_layout as constrained_layout from matplotlib import docstring make_axes_kw_doc = ''' @@ -532,9 +533,10 @@ def _add_solids(self, X, Y, C): self.dividers = None if self.drawedges: linewidths = (0.5 * mpl.rcParams['axes.linewidth'],) - self.dividers = collections.LineCollection(self._edges(X, Y), - colors=(mpl.rcParams['axes.edgecolor'],), - linewidths=linewidths) + self.dividers = collections.LineCollection( + self._edges(X, Y), + colors=(mpl.rcParams['axes.edgecolor'],), + linewidths=linewidths) self.ax.add_collection(self.dividers) elif len(self._y) >= self.n_rasterize: self.solids.set_rasterized(True) @@ -1049,7 +1051,7 @@ def remove(self): except AttributeError: # use_gridspec was False pos = ax.get_position(original=True) - ax.set_position(pos) + ax._set_position(pos) else: # use_gridspec was True ax.set_subplotspec(subplotspec) @@ -1124,13 +1126,21 @@ def make_axes(parents, location=None, orientation=None, fraction=0.15, anchor = kw.pop('anchor', loc_settings['anchor']) parent_anchor = kw.pop('panchor', loc_settings['panchor']) - pad = kw.pop('pad', loc_settings['pad']) # turn parents into a list if it is not already. We do this w/ np # because `plt.subplots` can return an ndarray and is natural to # pass to `colorbar`. parents = np.atleast_1d(parents).ravel() + # check if using constrained_layout: + gs = parents[0].get_subplotspec().get_gridspec() + using_constrained_layout = (gs._layoutbox is not None) + # defaults are not appropriate for constrained_layout: + pad0 = loc_settings['pad'] + if using_constrained_layout: + pad0 = 0.02 + pad = kw.pop('pad', pad0) + fig = parents[0].get_figure() if not all(fig is ax.get_figure() for ax in parents): raise ValueError('Unable to create a colorbar axes as not all ' @@ -1165,12 +1175,37 @@ def make_axes(parents, location=None, orientation=None, fraction=0.15, for ax in parents: new_posn = shrinking_trans.transform(ax.get_position()) new_posn = mtransforms.Bbox(new_posn) - ax.set_position(new_posn) + ax._set_position(new_posn) if parent_anchor is not False: ax.set_anchor(parent_anchor) cax = fig.add_axes(pbcb) - cax.set_aspect(aspect, anchor=anchor, adjustable='box') + + # OK, now make a layoutbox for the cb axis. Later, we will use this + # to make the colorbar fit nicely. + if not using_constrained_layout: + # no layout boxes: + lb = None + lbpos = None + # and we need to set the aspect ratio by hand... + cax.set_aspect(aspect, anchor=anchor, adjustable='box') + else: + if len(parents) == 1: + # this is a single axis... + ax = parents[0] + lb, lbpos = constrained_layout.layoutcolorbarsingle( + ax, cax, shrink, aspect, location, pad=pad) + else: # there is more than one parent, so lets use gridspec + # the colorbar will be a sibling of this gridspec, so the + # parent is the same parent as the gridspec. Either the figure, + # or a subplotspec. + + lb, lbpos = constrained_layout.layoutcolorbargridspec( + parents, cax, shrink, aspect, location, pad) + + cax._layoutbox = lb + cax._poslayoutbox = lbpos + return cax, kw @@ -1222,6 +1257,11 @@ def make_axes_gridspec(parent, **kw): pad_s = (1 - shrink) * 0.5 wh_ratios = [pad_s, shrink, pad_s] + # we need to none the tree of layoutboxes because + # constrained_layout can't remove and replace the tree + # hierarchy w/o a seg fault. + gs = parent.get_subplotspec().get_gridspec() + layoutbox.nonetree(gs._layoutbox) gs_from_subplotspec = gridspec.GridSpecFromSubplotSpec if orientation == 'vertical': pad = kw.pop('pad', 0.05) @@ -1253,7 +1293,7 @@ def make_axes_gridspec(parent, **kw): parent.set_subplotspec(gs[0]) parent.update_params() - parent.set_position(parent.figbox) + parent._set_position(parent.figbox) parent.set_anchor(panchor) fig = parent.get_figure() @@ -1329,7 +1369,8 @@ def _add_solids(self, X, Y, C): self.dividers = None if self.drawedges: - self.dividers = collections.LineCollection(self._edges(X, Y), + self.dividers = collections.LineCollection( + self._edges(X, Y), colors=(mpl.rcParams['axes.edgecolor'],), linewidths=(0.5 * mpl.rcParams['axes.linewidth'],)) self.ax.add_collection(self.dividers) diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index 216353a0c71a..7e21f3ef8742 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -47,6 +47,7 @@ from matplotlib.text import Text, _process_text_args from matplotlib.transforms import (Affine2D, Bbox, BboxTransformTo, TransformedBbox) +import matplotlib._layoutbox as layoutbox from matplotlib.backend_bases import NonGuiException _log = logging.getLogger(__name__) @@ -291,6 +292,8 @@ def __init__(self, frameon=None, # whether or not to draw the figure frame subplotpars=None, # default to rc tight_layout=None, # default to rc figure.autolayout + constrained_layout=None, # default to rc + #figure.constrained_layout.use ): """ Parameters @@ -323,6 +326,15 @@ def __init__(self, ``pad``, ``w_pad``, ``h_pad``, and ``rect``, the default `~.tight_layout` paddings will be overridden. Defaults to rc ``figure.autolayout``. + + constrained_layout : bool + If ``True`` use constrained layout to adjust positioning of plot + elements. Like ``tight_layout``, but designed to be more + flexible. See + :doc:`/tutorials/intermediate/constrainedlayout_guide` + for examples. (Note: does not work with :meth:`.subplot` or + :meth:`.subplot2grid`.) + Defaults to rc ``figure.constrained_layout.use``. """ Artist.__init__(self) # remove the non-figure artist _axes property @@ -374,6 +386,11 @@ def __init__(self, subplotpars = SubplotParams() self.subplotpars = subplotpars + # constrained_layout: + self._layoutbox = None + # set in set_constrained_layout_pads() + self.set_constrained_layout(constrained_layout) + self.set_tight_layout(tight_layout) self._axstack = AxesStack() # track all figure axes and current axes @@ -496,6 +513,106 @@ def set_tight_layout(self, tight): self._tight_parameters = tight if isinstance(tight, dict) else {} self.stale = True + def get_constrained_layout(self): + """ + Return a boolean: True means constrained layout is being used. + + See :doc:`/tutorials/intermediate/constrainedlayout_guide` + """ + return self._constrained + + def set_constrained_layout(self, constrained): + """ + Set whether ``constrained_layout`` is used upon drawing. If None, + the rcParams['figure.constrained_layout.use'] value will be used. + + When providing a dict containing the keys `w_pad`, `h_pad` + the default ``constrained_layout`` paddings will be + overridden. These pads are in inches and default to 3.0/72.0. + ``w_pad`` is the width padding and ``h_pad`` is the height padding. + + ACCEPTS: [True | False | dict | None ] + + See :doc:`/tutorials/intermediate/constrainedlayout_guide` + """ + self._constrained_layout_pads = dict() + self._constrained_layout_pads['w_pad'] = None + self._constrained_layout_pads['h_pad'] = None + self._constrained_layout_pads['wspace'] = None + self._constrained_layout_pads['hspace'] = None + if constrained is None: + constrained = rcParams['figure.constrained_layout.use'] + self._constrained = bool(constrained) + if isinstance(constrained, dict): + self.set_constrained_layout_pads(**constrained) + else: + self.set_constrained_layout_pads() + + self.stale = True + + def set_constrained_layout_pads(self, **kwargs): + """ + Set padding for ``constrained_layout``. Note the kwargs can be passed + as a dictionary ``fig.set_constrained_layout(**paddict)``. + + Parameters: + ----------- + + w_pad : scalar + Width padding in inches. This is the pad around axes + and is meant to make sure there is enough room for fonts to + look good. Defaults to 3 pts = 0.04167 inches + + h_pad : scalar + Height padding in inches. Defaults to 3 pts. + + wspace: scalar + Width padding between subplots, expressed as a fraction of the + subplot width. The total padding ends up being w_pad + wspace. + + hspace: scalar + Height padding between subplots, expressed as a fraction of the + subplot width. The total padding ends up being h_pad + hspace. + + See :doc:`/tutorials/intermediate/constrainedlayout_guide` + """ + + todo = ['w_pad', 'h_pad', 'wspace', 'hspace'] + for td in todo: + if td in kwargs and kwargs[td] is not None: + self._constrained_layout_pads[td] = kwargs[td] + else: + self._constrained_layout_pads[td] = ( + rcParams['figure.constrained_layout.' + td]) + + def get_constrained_layout_pads(self, relative=False): + """ + Get padding for ``constrained_layout``. + + Returns a list of `w_pad, h_pad` in inches and + `wspace` and `hspace` as fractions of the subplot. + + Parameter: + ----------- + + relative : boolean + If `True`, then convert from inches to figure relative. + + See: :doc:`/tutorials/intermediate/constrainedlayout_guide` + """ + w_pad = self._constrained_layout_pads['w_pad'] + h_pad = self._constrained_layout_pads['h_pad'] + wspace = self._constrained_layout_pads['wspace'] + hspace = self._constrained_layout_pads['hspace'] + + if relative and ((w_pad is not None) or (h_pad is not None)): + renderer0 = layoutbox.get_renderer(self) + dpi = renderer0.dpi + w_pad = w_pad * dpi / renderer0.width + h_pad = h_pad * dpi / renderer0.height + + return w_pad, h_pad, wspace, hspace + def autofmt_xdate(self, bottom=0.2, rotation=30, ha='right', which=None): """ Date ticklabels often overlap, so it is useful to rotate them @@ -623,7 +740,19 @@ def suptitle(self, t, **kwargs): sup.remove() else: self._suptitle = sup - + if self._layoutbox is not None: + # assign a layout box to the suptitle... + figlb = self._layoutbox + self._suptitle._layoutbox = layoutbox.LayoutBox( + parent=figlb, + name=figlb.name+'.suptitle') + for child in figlb.children: + if not (child == self._suptitle._layoutbox): + w_pad, h_pad, wspace, hspace = \ + self.get_constrained_layout_pads( + relative=True) + layoutbox.vstack([self._suptitle._layoutbox, child], + padding=h_pad*2., strength='required') self.stale = True return self._suptitle @@ -886,7 +1015,7 @@ def _make_key(self, *args, **kwargs): 'make a hashable key out of args and kwargs' def fixitems(items): - #items may have arrays and lists in them, so convert them + # items may have arrays and lists in them, so convert them # to tuples for the key ret = [] for k, v in items: @@ -1110,7 +1239,6 @@ def add_subplot(self, *args, **kwargs): self._axstack.remove(ax) a = subplot_class_factory(projection_class)(self, *args, **kwargs) - self._axstack.add(key, a) self.sca(a) a._remove_method = self.__remove_ax @@ -1208,7 +1336,11 @@ def subplots(self, nrows=1, ncols=1, sharex=False, sharey=False, if gridspec_kw is None: gridspec_kw = {} - gs = GridSpec(nrows, ncols, **gridspec_kw) + if self.get_constrained_layout(): + gs = GridSpec(nrows, ncols, figure=self, **gridspec_kw) + else: + # this should turn constrained_layout off if we don't want it + gs = GridSpec(nrows, ncols, figure=None, **gridspec_kw) # Create array to hold all axes. axarr = np.empty((nrows, ncols), dtype=object) @@ -1323,9 +1455,15 @@ def draw(self, renderer): try: renderer.open_group('figure') + if self.get_constrained_layout() and self.axes: + if True: + self.execute_constrained_layout(renderer) + else: + pass if self.get_tight_layout() and self.axes: try: - self.tight_layout(renderer, **self._tight_parameters) + self.tight_layout(renderer, + **self._tight_parameters) except ValueError: pass # ValueError can occur when resizing a window. @@ -1711,6 +1849,8 @@ def _gci(self): def __getstate__(self): state = super(Figure, self).__getstate__() + + # print('\n\n\nStarting pickle') # the axobservers cannot currently be pickled. # Additionally, the canvas cannot currently be pickled, but this has # the benefit of meaning that a figure can be detached from one canvas, @@ -1731,6 +1871,14 @@ def __getstate__(self): matplotlib._pylab_helpers.Gcf.figs)): state['_restore_to_pylab'] = True + # set all the layoutbox information to None. kiwisolver + # objects can't be pickeled, so we lose the layout options + # at this point. + state.pop('_layoutbox', None) + # suptitle: + if self._suptitle is not None: + self._suptitle._layoutbox = None + return state def __setstate__(self, state): @@ -1748,6 +1896,7 @@ def __setstate__(self, state): # re-initialise some of the unstored state information self._axobservers = [] self.canvas = None + self._layoutbox = None if restore_to_pylab: # lazy import to avoid circularity @@ -1911,7 +2060,8 @@ def colorbar(self, mappable, cax=None, ax=None, use_gridspec=True, **kw): current_ax = self.gca() if cax is None: - if use_gridspec and isinstance(ax, SubplotBase): + if use_gridspec and isinstance(ax, SubplotBase) \ + and (not self.get_constrained_layout()): cax, kw = cbar.make_axes_gridspec(ax, **kw) else: cax, kw = cbar.make_axes(ax, **kw) @@ -2052,6 +2202,45 @@ def get_tightbbox(self, renderer): return bbox_inches + def init_layoutbox(self): + """ + initilaize the layoutbox for use in constrained_layout. + """ + if self._layoutbox is None: + self._layoutbox = layoutbox.LayoutBox(parent=None, + name='figlb', + artist=self) + self._layoutbox.constrain_geometry(0., 0., 1., 1.) + + def execute_constrained_layout(self, renderer=None): + """ + Use ``layoutbox`` to determine pos positions within axes. + + See also set_constrained_layout_pads + """ + + from matplotlib._constrained_layout import (do_constrained_layout) + + _log.debug('Executing constrainedlayout') + if self._layoutbox is None: + warnings.warn("Calling figure.constrained_layout, but figure " + "not setup to do constrained layout. " + " You either called GridSpec without the " + "fig keyword, you are using plt.subplot, " + "or you need to call figure or subplots" + "with the constrained_layout=True kwarg.") + return + w_pad, h_pad, wspace, hspace = self.get_constrained_layout_pads() + # convert to unit-relative lengths + + fig = self + width, height = fig.get_size_inches() + w_pad = w_pad / width + h_pad = h_pad / height + if renderer is None: + renderer = layoutbox.get_renderer(fig) + do_constrained_layout(fig, renderer, h_pad, w_pad, hspace, wspace) + def tight_layout(self, renderer=None, pad=1.08, h_pad=None, w_pad=None, rect=None): """ diff --git a/lib/matplotlib/gridspec.py b/lib/matplotlib/gridspec.py index 8bd46c1a7d5f..910d66e526df 100644 --- a/lib/matplotlib/gridspec.py +++ b/lib/matplotlib/gridspec.py @@ -18,6 +18,7 @@ import six import copy +import logging import warnings import numpy as np @@ -25,6 +26,10 @@ import matplotlib as mpl from matplotlib import _pylab_helpers, tight_layout, rcParams from matplotlib.transforms import Bbox +import matplotlib._layoutbox as layoutbox +from matplotlib.cbook import mplDeprecation + +_log = logging.getLogger(__name__) class GridSpecBase(object): @@ -47,7 +52,7 @@ def get_geometry(self): 'get the geometry of the grid, e.g., 2,3' return self._nrows, self._ncols - def get_subplot_params(self, fig=None): + def get_subplot_params(self, figure=None, fig=None): pass def new_subplotspec(self, loc, rowspan=1, colspan=1): @@ -76,20 +81,31 @@ def set_height_ratios(self, height_ratios): def get_height_ratios(self): return self._row_height_ratios - def get_grid_positions(self, fig): + def get_grid_positions(self, fig, raw=False): """ return lists of bottom and top position of rows, left and right positions of columns. + + If raw=True, then these are all in units relative to the container + with no margins. (used for constrained_layout). """ nrows, ncols = self.get_geometry() - subplot_params = self.get_subplot_params(fig) - left = subplot_params.left - right = subplot_params.right - bottom = subplot_params.bottom - top = subplot_params.top - wspace = subplot_params.wspace - hspace = subplot_params.hspace + if raw: + left = 0. + right = 1. + bottom = 0. + top = 1. + wspace = 0. + hspace = 0. + else: + subplot_params = self.get_subplot_params(fig) + left = subplot_params.left + right = subplot_params.right + bottom = subplot_params.bottom + top = subplot_params.top + wspace = subplot_params.wspace + hspace = subplot_params.hspace tot_width = right - left tot_height = top - bottom @@ -156,7 +172,7 @@ class GridSpec(GridSpecBase): as the SubplotParams. """ - def __init__(self, nrows, ncols, + def __init__(self, nrows, ncols, figure=None, left=None, bottom=None, right=None, top=None, wspace=None, hspace=None, width_ratios=None, height_ratios=None): @@ -183,12 +199,41 @@ def __init__(self, nrows, ncols, self.top = top self.wspace = wspace self.hspace = hspace + self.figure = figure + GridSpecBase.__init__(self, nrows, ncols, width_ratios=width_ratios, height_ratios=height_ratios) + if (self.figure is None) or not self.figure.get_constrained_layout(): + _log.info("GridSpec must be called with the fig keyword if " + "constrained_layout is used") + self._layoutbox = None + else: + self.figure.init_layoutbox() + self._layoutbox = layoutbox.LayoutBox( + parent=self.figure._layoutbox, + name='gridspec' + layoutbox.seq_id(), + artist=self) + # by default the layoutbox for a gridsepc will fill a figure. + # but this can change below if the gridspec is created from a + # subplotspec. (GridSpecFromSubplotSpec) + _AllowedKeys = ["left", "bottom", "right", "top", "wspace", "hspace"] + def __getstate__(self): + state = self.__dict__ + try: + state.pop('_layoutbox') + except KeyError: + pass + return state + + def __setstate__(self, state): + self.__dict__ = state + # layoutboxes don't survive pickling... + self._layoutbox = None + def update(self, **kwargs): """ Update the current values. If any kwarg is None, default to @@ -209,27 +254,33 @@ def update(self, **kwargs): if isinstance(ax._sharex, mpl.axes.SubplotBase): if ax._sharex.get_subplotspec().get_gridspec() == self: ax._sharex.update_params() - ax.set_position(ax._sharex.figbox) + ax._set_position(ax._sharex.figbox) elif isinstance(ax._sharey, mpl.axes.SubplotBase): if ax._sharey.get_subplotspec().get_gridspec() == self: ax._sharey.update_params() - ax.set_position(ax._sharey.figbox) + ax._set_position(ax._sharey.figbox) else: ss = ax.get_subplotspec().get_topmost_subplotspec() if ss.get_gridspec() == self: ax.update_params() - ax.set_position(ax.figbox) + ax._set_position(ax.figbox) - def get_subplot_params(self, fig=None): + def get_subplot_params(self, figure=None, fig=None): """ Return a dictionary of subplot layout parameters. The default parameters are from rcParams unless a figure attribute is set. """ - if fig is None: + if fig is not None: + warnings.warn("the 'fig' kwarg is deprecated " + "use 'figure' instead", mplDeprecation) + if figure is None: + figure = fig + + if figure is None: kw = {k: rcParams["figure.subplot."+k] for k in self._AllowedKeys} subplotpars = mpl.figure.SubplotParams(**kw) else: - subplotpars = copy.copy(fig.subplotpars) + subplotpars = copy.copy(figure.subplotpars) update_kw = {k: getattr(self, k) for k in self._AllowedKeys} subplotpars.update(**update_kw) @@ -239,7 +290,7 @@ def get_subplot_params(self, fig=None): def locally_modified_subplot_params(self): return [k for k in self._AllowedKeys if getattr(self, k)] - def tight_layout(self, fig, renderer=None, + def tight_layout(self, figure, renderer=None, pad=1.08, h_pad=None, w_pad=None, rect=None): """ Adjust subplot parameters to give specified padding. @@ -260,16 +311,16 @@ def tight_layout(self, fig, renderer=None, """ subplotspec_list = tight_layout.get_subplotspec_list( - fig.axes, grid_spec=self) + figure.axes, grid_spec=self) if None in subplotspec_list: warnings.warn("This figure includes Axes that are not compatible " "with tight_layout, so results might be incorrect.") if renderer is None: - renderer = tight_layout.get_renderer(fig) + renderer = tight_layout.get_renderer(figure) kwargs = tight_layout.get_tight_layout_figure( - fig, fig.axes, subplotspec_list, renderer, + figure, figure.axes, subplotspec_list, renderer, pad=pad, h_pad=h_pad, w_pad=w_pad, rect=rect) self.update(**kwargs) @@ -296,19 +347,34 @@ def __init__(self, nrows, ncols, GridSpecBase.__init__(self, nrows, ncols, width_ratios=width_ratios, height_ratios=height_ratios) + # do the layoutboxes + subspeclb = subplot_spec._layoutbox + if subspeclb is None: + self._layoutbox = None + else: + # OK, this is needed to divide the figure. + self._layoutbox = subspeclb.layout_from_subplotspec( + subplot_spec, + name=subspeclb.name + '.gridspec' + layoutbox.seq_id(), + artist=self) - def get_subplot_params(self, fig=None): + def get_subplot_params(self, figure=None, fig=None): """Return a dictionary of subplot layout parameters. """ + if fig is not None: + warnings.warn("the 'fig' kwarg is deprecated " + "use 'figure' instead", mplDeprecation) + if figure is None: + figure = fig hspace = (self._hspace if self._hspace is not None - else fig.subplotpars.hspace if fig is not None + else figure.subplotpars.hspace if figure is not None else rcParams["figure.subplot.hspace"]) wspace = (self._wspace if self._wspace is not None - else fig.subplotpars.wspace if fig is not None + else figure.subplotpars.wspace if figure is not None else rcParams["figure.subplot.wspace"]) - figbox = self._subplot_spec.get_position(fig) + figbox = self._subplot_spec.get_position(figure) left, bottom, right, top = figbox.extents return mpl.figure.SubplotParams(left=left, right=right, @@ -335,6 +401,31 @@ def __init__(self, gridspec, num1, num2=None): self._gridspec = gridspec self.num1 = num1 self.num2 = num2 + if gridspec._layoutbox is not None: + glb = gridspec._layoutbox + # So note that here we don't assign any layout yet, + # just make the layoutbox that will conatin all items + # associated w/ this axis. This can include other axes like + # a colorbar or a legend. + self._layoutbox = layoutbox.LayoutBox( + parent=glb, + name=glb.name + '.ss' + layoutbox.seq_id(), + artist=self) + else: + self._layoutbox = None + + def __getstate__(self): + state = self.__dict__ + try: + state.pop('_layoutbox') + except KeyError: + pass + return state + + def __setstate__(self, state): + self.__dict__ = state + # layoutboxes don't survive pickling... + self._layoutbox = None def get_gridspec(self): return self._gridspec @@ -364,8 +455,8 @@ def get_rows_columns(self): col_stop = col_start return nrows, ncols, row_start, row_stop, col_start, col_stop - def get_position(self, fig, return_all=False): - """Update the subplot position from ``fig.subplotpars``. + def get_position(self, figure, return_all=False): + """Update the subplot position from ``figure.subplotpars``. """ gridspec = self.get_gridspec() nrows, ncols = gridspec.get_geometry() @@ -373,7 +464,7 @@ def get_position(self, fig, return_all=False): [self.num1] if self.num2 is None else [self.num1, self.num2], (nrows, ncols)) fig_bottoms, fig_tops, fig_lefts, fig_rights = \ - gridspec.get_grid_positions(fig) + gridspec.get_grid_positions(figure) fig_bottom = fig_bottoms[rows].min() fig_top = fig_tops[rows].max() diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index df4f0fe7db37..23e4e14e8715 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -1286,6 +1286,19 @@ def _validate_linestyle(ls): 'figure.subplot.hspace': [0.2, ValidateInterval(0, 1, closedmin=True, closedmax=False)], + # do constrained_layout. + 'figure.constrained_layout.use': [False, validate_bool], + # wspace and hspace are fraction of adjacent subplots to use + # for space. Much smaller than above because we don't need + # room for the text. + 'figure.constrained_layout.hspace': [0.02, ValidateInterval( + 0, 1, closedmin=True, closedmax=False)], + 'figure.constrained_layout.wspace': [0.02, ValidateInterval( + 0, 1, closedmin=True, closedmax=False)], + # This is a buffer around the axes in inches. This is 3pts. + 'figure.constrained_layout.h_pad': [0.04167, validate_float], + 'figure.constrained_layout.w_pad': [0.04167, validate_float], + ## Saving figure's properties 'savefig.dpi': ['figure', validate_dpi], # DPI 'savefig.facecolor': ['w', validate_color], # facecolor; white diff --git a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout1.png b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout1.png new file mode 100644 index 000000000000..a12759614401 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout1.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout10.png b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout10.png new file mode 100644 index 000000000000..97cf2dca98a0 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout10.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout11.png b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout11.png new file mode 100644 index 000000000000..e0bb47296b2c Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout11.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout11rat.png b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout11rat.png new file mode 100644 index 000000000000..403a3acc64ca Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout11rat.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout12.png b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout12.png new file mode 100644 index 000000000000..3e24209e18b0 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout12.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout13.png b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout13.png new file mode 100644 index 000000000000..92c18947f9e6 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout13.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout14.png b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout14.png new file mode 100644 index 000000000000..82fba5397e36 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout14.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout15.png b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout15.png new file mode 100644 index 000000000000..fb3915574226 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout15.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout16.png b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout16.png new file mode 100644 index 000000000000..2c20f43dcf5f Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout16.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout2.png b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout2.png new file mode 100644 index 000000000000..7a72a8117335 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout2.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout3.png b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout3.png new file mode 100644 index 000000000000..61337c0eeb21 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout3.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout4.pdf b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout4.pdf new file mode 100644 index 000000000000..0cf2edcae0be Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout4.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout4.png b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout4.png new file mode 100644 index 000000000000..a1e26c37c1a2 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout4.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout4.svg b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout4.svg new file mode 100644 index 000000000000..1f194eb09342 --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout4.svg @@ -0,0 +1,1933 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout5.png b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout5.png new file mode 100644 index 000000000000..2bc15ba74e29 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout5.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout6.png b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout6.png new file mode 100644 index 000000000000..ed300a445c56 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout6.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout7.png b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout7.png new file mode 100644 index 000000000000..492bf311a4aa Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout7.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout8.png b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout8.png new file mode 100644 index 000000000000..78c49877ffd7 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout8.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout9.png b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout9.png new file mode 100644 index 000000000000..b0cc59893d15 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout9.png differ diff --git a/lib/matplotlib/tests/test_constrainedlayout.py b/lib/matplotlib/tests/test_constrainedlayout.py new file mode 100644 index 000000000000..5333ba8c85b5 --- /dev/null +++ b/lib/matplotlib/tests/test_constrainedlayout.py @@ -0,0 +1,347 @@ +from __future__ import (absolute_import, division, print_function, + unicode_literals) + +import six +import warnings + +import numpy as np + +from matplotlib.testing.decorators import image_comparison +import matplotlib.pyplot as plt +from matplotlib.offsetbox import AnchoredOffsetbox, DrawingArea +from matplotlib.patches import Rectangle +import matplotlib.gridspec as gridspec +from matplotlib import ticker, rcParams + + +def example_plot(ax, fontsize=12, nodec=False): + ax.plot([1, 2]) + ax.locator_params(nbins=3) + if not nodec: + ax.set_xlabel('x-label', fontsize=fontsize) + ax.set_ylabel('y-label', fontsize=fontsize) + ax.set_title('Title', fontsize=fontsize) + else: + ax.set_xticklabels('') + ax.set_yticklabels('') + + +def example_pcolor(ax, fontsize=12): + dx, dy = 0.6, 0.6 + y, x = np.mgrid[slice(-3, 3 + dy, dy), + slice(-3, 3 + dx, dx)] + z = (1 - x / 2. + x ** 5 + y ** 3) * np.exp(-x ** 2 - y ** 2) + pcm = ax.pcolormesh(x, y, z, cmap='RdBu_r', vmin=-1., vmax=1., + rasterized=True) + # ax.locator_params(nbins=3) + ax.set_xlabel('x-label', fontsize=fontsize) + ax.set_ylabel('y-label', fontsize=fontsize) + ax.set_title('Title', fontsize=fontsize) + return pcm + + +@image_comparison(baseline_images=['constrained_layout1'], + extensions=['png']) +def test_constrained_layout1(): + 'Test constrained_layout for a single subplot' + fig = plt.figure(constrained_layout=True) + ax = fig.add_subplot(111) + example_plot(ax, fontsize=24) + + +@image_comparison(baseline_images=['constrained_layout2'], + extensions=['png']) +def test_constrained_layout2(): + 'Test constrained_layout for 2x2 subplots' + fig, axs = plt.subplots(2, 2, constrained_layout=True) + for ax in axs.flatten(): + example_plot(ax, fontsize=24) + + +@image_comparison(baseline_images=['constrained_layout3'], + extensions=['png']) +def test_constrained_layout3(): + 'Test constrained_layout for colorbars with subplots' + fig, axs = plt.subplots(2, 2, constrained_layout=True) + for nn, ax in enumerate(axs.flatten()): + pcm = example_pcolor(ax, fontsize=24) + if nn == 3: + pad = 0.08 + else: + pad = 0.02 # default + fig.colorbar(pcm, ax=ax, pad=pad) + + +@image_comparison(baseline_images=['constrained_layout4']) +def test_constrained_layout4(): + 'Test constrained_layout for a single colorbar with subplots' + fig, axs = plt.subplots(2, 2, constrained_layout=True) + for ax in axs.flatten(): + pcm = example_pcolor(ax, fontsize=24) + fig.colorbar(pcm, ax=axs, pad=0.01, shrink=0.6) + + +@image_comparison(baseline_images=['constrained_layout5'], + tol=5.e-2, extensions=['png']) +def test_constrained_layout5(): + ''' + Test constrained_layout for a single colorbar with subplots, + colorbar bottom + ''' + fig, axs = plt.subplots(2, 2, constrained_layout=True) + for ax in axs.flatten(): + pcm = example_pcolor(ax, fontsize=24) + fig.colorbar(pcm, ax=axs, + use_gridspec=False, pad=0.01, shrink=0.6, + location='bottom') + + +@image_comparison(baseline_images=['constrained_layout6'], + extensions=['png']) +def test_constrained_layout6(): + 'Test constrained_layout for nested gridspecs' + fig = plt.figure(constrained_layout=True) + gs = gridspec.GridSpec(1, 2, figure=fig) + gsl = gridspec.GridSpecFromSubplotSpec(2, 2, gs[0]) + gsr = gridspec.GridSpecFromSubplotSpec(1, 2, gs[1]) + axsl = [] + for gs in gsl: + ax = fig.add_subplot(gs) + axsl += [ax] + example_plot(ax, fontsize=12) + ax.set_xlabel('x-label\nMultiLine') + axsr = [] + for gs in gsr: + ax = fig.add_subplot(gs) + axsr += [ax] + pcm = example_pcolor(ax, fontsize=12) + + fig.colorbar(pcm, ax=axsr, + pad=0.01, shrink=0.99, location='bottom', + ticks=ticker.MaxNLocator(nbins=5)) + + +@image_comparison(baseline_images=['constrained_layout8'], + extensions=['png']) +def test_constrained_layout8(): + 'Test for gridspecs that are not completely full' + fig = plt.figure(figsize=(7, 4), constrained_layout=True) + gs = gridspec.GridSpec(3, 5, figure=fig) + axs = [] + j = 1 + for i in [0, 1]: + ax = fig.add_subplot(gs[j, i]) + axs += [ax] + pcm = example_pcolor(ax, fontsize=10) + if i > 0: + ax.set_ylabel('') + if j < 1: + ax.set_xlabel('') + ax.set_title('') + j = 0 + for i in [2, 4]: + ax = fig.add_subplot(gs[j, i]) + axs += [ax] + pcm = example_pcolor(ax, fontsize=10) + if i > 0: + ax.set_ylabel('') + if j < 1: + ax.set_xlabel('') + ax.set_title('') + ax = fig.add_subplot(gs[2, :]) + axs += [ax] + pcm = example_pcolor(ax, fontsize=10) + + fig.colorbar(pcm, ax=axs, pad=0.01, shrink=0.6) + + +@image_comparison(baseline_images=['constrained_layout7'], + extensions=['png']) +def test_constrained_layout7(): + 'Test for proper warning if fig not set in GridSpec' + fig = plt.figure(tight_layout=True) + gs = gridspec.GridSpec(1, 2) + gsl = gridspec.GridSpecFromSubplotSpec(2, 2, gs[0]) + gsr = gridspec.GridSpecFromSubplotSpec(1, 2, gs[1]) + axsl = [] + for gs in gsl: + ax = fig.add_subplot(gs) + axsl += [ax] + example_plot(ax, fontsize=12) + ax.set_xlabel('x-label\nMultiLine') + axsr = [] + for gs in gsr: + ax = fig.add_subplot(gs) + axsr += [ax] + pcm = example_pcolor(ax, fontsize=12) + + fig.colorbar(pcm, ax=axsr, pad=0.01, + shrink=0.99, location='bottom', + ticks=ticker.MaxNLocator(nbins=5)) + + +@image_comparison(baseline_images=['constrained_layout8'], + extensions=['png']) +def test_constrained_layout8(): + 'Test for gridspecs that are not completely full' + fig = plt.figure(figsize=(10, 5), constrained_layout=True) + gs = gridspec.GridSpec(3, 5, figure=fig) + axs = [] + j = 1 + for i in [0, 4]: + ax = fig.add_subplot(gs[j, i]) + axs += [ax] + pcm = example_pcolor(ax, fontsize=9) + if i > 0: + ax.set_ylabel('') + if j < 1: + ax.set_xlabel('') + ax.set_title('') + j = 0 + for i in [1]: + ax = fig.add_subplot(gs[j, i]) + axs += [ax] + pcm = example_pcolor(ax, fontsize=9) + if i > 0: + ax.set_ylabel('') + if j < 1: + ax.set_xlabel('') + ax.set_title('') + ax = fig.add_subplot(gs[2, :]) + axs += [ax] + pcm = example_pcolor(ax, fontsize=9) + + fig.colorbar(pcm, ax=axs, pad=0.01, shrink=0.6) + + +@image_comparison(baseline_images=['constrained_layout9'], + extensions=['png']) +def test_constrained_layout9(): + 'Test for handling suptitle and for sharex and sharey' + fig, axs = plt.subplots(2, 2, constrained_layout=True, + sharex=False, sharey=False) + # ax = fig.add_subplot(111) + for ax in axs.flatten(): + pcm = example_pcolor(ax, fontsize=24) + ax.set_xlabel('') + ax.set_ylabel('') + ax.set_aspect(2.) + fig.colorbar(pcm, ax=axs, pad=0.01, shrink=0.6) + fig.suptitle('Test Suptitle', fontsize=28) + + +@image_comparison(baseline_images=['constrained_layout10'], + extensions=['png']) +def test_constrained_layout10(): + 'Test for handling legend outside axis' + fig, axs = plt.subplots(2, 2, constrained_layout=True) + for ax in axs.flatten(): + ax.plot(np.arange(12), label='This is a label') + ax.legend(loc='center left', bbox_to_anchor=(0.8, 0.5)) + + +@image_comparison(baseline_images=['constrained_layout11'], + extensions=['png']) +def test_constrained_layout11(): + 'Test for multiple nested gridspecs ' + fig = plt.figure(constrained_layout=True, figsize=(10, 3)) + gs0 = gridspec.GridSpec(1, 2, figure=fig) + gsl = gridspec.GridSpecFromSubplotSpec(1, 2, gs0[0]) + gsl0 = gridspec.GridSpecFromSubplotSpec(2, 2, gsl[1]) + ax = fig.add_subplot(gs0[1]) + example_plot(ax, fontsize=9) + axs = [] + for gs in gsl0: + ax = fig.add_subplot(gs) + axs += [ax] + pcm = example_pcolor(ax, fontsize=9) + fig.colorbar(pcm, ax=axs, shrink=0.6, aspect=70.) + ax = fig.add_subplot(gsl[0]) + example_plot(ax, fontsize=9) + + +@image_comparison(baseline_images=['constrained_layout11rat'], + extensions=['png']) +def test_constrained_layout11rat(): + 'Test for multiple nested gridspecs with width_ratios' + fig = plt.figure(constrained_layout=True, figsize=(10, 3)) + gs0 = gridspec.GridSpec(1, 2, figure=fig, width_ratios=[6., 1.]) + gsl = gridspec.GridSpecFromSubplotSpec(1, 2, gs0[0]) + gsl0 = gridspec.GridSpecFromSubplotSpec(2, 2, gsl[1], + height_ratios=[2., 1.]) + ax = fig.add_subplot(gs0[1]) + example_plot(ax, fontsize=9) + axs = [] + for gs in gsl0: + ax = fig.add_subplot(gs) + axs += [ax] + pcm = example_pcolor(ax, fontsize=9) + fig.colorbar(pcm, ax=axs, shrink=0.6, aspect=70.) + ax = fig.add_subplot(gsl[0]) + example_plot(ax, fontsize=9) + + +@image_comparison(baseline_images=['constrained_layout12'], + extensions=['png']) +def test_constrained_layout12(): + 'Test that very unbalanced labeling still works.' + fig = plt.figure(constrained_layout=True) + + gs0 = gridspec.GridSpec(6, 2, figure=fig) + + ax1 = fig.add_subplot(gs0[:3, 1]) + ax2 = fig.add_subplot(gs0[3:, 1]) + + example_plot(ax1, fontsize=24) + example_plot(ax2, fontsize=24) + + ax = fig.add_subplot(gs0[0:2, 0]) + example_plot(ax, nodec=True) + ax = fig.add_subplot(gs0[2:4, 0]) + example_plot(ax, nodec=True) + ax = fig.add_subplot(gs0[4:, 0]) + example_plot(ax, nodec=True) + ax.set_xlabel('x-label') + + +@image_comparison(baseline_images=['constrained_layout13'], tol=2.e-2, + extensions=['png']) +def test_constrained_layout13(): + 'Test that padding works.' + fig, axs = plt.subplots(2, 2, constrained_layout=True) + for ax in axs.flatten(): + pcm = example_pcolor(ax, fontsize=12) + fig.colorbar(pcm, ax=ax, shrink=0.6, aspect=20., pad=0.02) + fig.set_constrained_layout_pads(w_pad=24./72., h_pad=24./72.) + + +@image_comparison(baseline_images=['constrained_layout14'], + extensions=['png']) +def test_constrained_layout14(): + 'Test that padding works.' + fig, axs = plt.subplots(2, 2, constrained_layout=True) + for ax in axs.flatten(): + pcm = example_pcolor(ax, fontsize=12) + fig.colorbar(pcm, ax=ax, shrink=0.6, aspect=20., pad=0.02) + fig.set_constrained_layout_pads( + w_pad=3./72., h_pad=3./72., + hspace=0.2, wspace=0.2) + + +@image_comparison(baseline_images=['constrained_layout15'], + extensions=['png']) +def test_constrained_layout15(): + 'Test that rcparams work.' + rcParams['figure.constrained_layout.use'] = True + fig, axs = plt.subplots(2, 2) + for ax in axs.flatten(): + example_plot(ax, fontsize=12) + + +@image_comparison(baseline_images=['constrained_layout16'], + extensions=['png']) +def test_constrained_layout16(): + 'Test ax.set_position.' + fig, ax = plt.subplots(constrained_layout=True) + example_plot(ax, fontsize=12) + ax2 = fig.add_axes([0.2, 0.2, 0.4, 0.4]) diff --git a/matplotlibrc.template b/matplotlibrc.template index 7ac32f71cf8d..8660d0e1c8a6 100644 --- a/matplotlibrc.template +++ b/matplotlibrc.template @@ -433,6 +433,10 @@ backend : $TEMPLATE_BACKEND #figure.edgecolor : white # figure edgecolor #figure.autolayout : False # When True, automatically adjust subplot # parameters to make the plot fit the figure + # using `tight_layout` +#figure.constrained_layout.use: False # When True, automatically make plot + # elements fit on the figure. (Not compatible + # with `autolayout`, above). #figure.max_open_warning : 20 # The maximum number of figures to open through # the pyplot interface before emitting a warning. # If less than one this feature is disabled. diff --git a/setupext.py b/setupext.py index 0872bc0bc5c2..99c30128e5f4 100644 --- a/setupext.py +++ b/setupext.py @@ -1435,6 +1435,7 @@ def get_install_requires(self): "python-dateutil>=2.1", "pytz", "six>=1.10", + "kiwisolver>=1.0.1", ] if sys.version_info < (3,): install_requires += ["backports.functools_lru_cache"] diff --git a/tutorials/intermediate/constrainedlayout_guide.py b/tutorials/intermediate/constrainedlayout_guide.py new file mode 100644 index 000000000000..ac55c6da1fcd --- /dev/null +++ b/tutorials/intermediate/constrainedlayout_guide.py @@ -0,0 +1,700 @@ +""" +================================ +Constrained Layout Guide +================================ + +How to use constrained-layout to fit plots within your figure cleanly. + +*constrained_layout* automatically adjusts subplots and decorations like +legends and colorbars so that they fit in the figure window wihile still +preserving, as best they can, the logical layout requested by the user. + +*constrained_layout* is similar to *tight_layout*, but uses a constraint +solver to determine the size of axes that allows them to fit. + +.. warning:: + + As of Matplotlib 2.2, Constrained Layout is **experimental**. The + behaviour and API are subject to change, or the whole functionality + may be removed without a deprecation period. If you *require* your + plots to be absolutely reproducible, get the Axes positions after + running Constrained Layout and use ``ax.set_position()`` in your code + with ``constrained_layout=False``. + +Simple Example +============== + +In matplotlib, the location of axes (including subplots) are specified in +normalized figure coordinates. It can happen that your axis labels or +titles (or sometimes even ticklabels) go outside the figure area, and are thus +clipped. + +""" + +# sphinx_gallery_thumbnail_number = 18 + +#import matplotlib +#matplotlib.use('Qt5Agg') + +import warnings + +import matplotlib.pyplot as plt +import numpy as np +import matplotlib.gridspec as gridspec + +import matplotlib._layoutbox as layoutbox + +plt.rcParams['savefig.facecolor'] = "0.8" +plt.rcParams['figure.figsize'] = 4.5, 4. + + +def example_plot(ax, fontsize=12, nodec=False): + ax.plot([1, 2]) + + ax.locator_params(nbins=3) + if not nodec: + ax.set_xlabel('x-label', fontsize=fontsize) + ax.set_ylabel('y-label', fontsize=fontsize) + ax.set_title('Title', fontsize=fontsize) + else: + ax.set_xticklabels('') + ax.set_yticklabels('') + + +fig, ax = plt.subplots() +example_plot(ax, fontsize=24) + +############################################################################### +# To prevent this, the location of axes needs to be adjusted. For +# subplots, this can be done by adjusting the subplot params +# (:ref:`howto-subplots-adjust`). However, specifying your figure with the +# ``constrained_layout=True`` kwarg will do the adjusting automatically. + +fig, ax = plt.subplots(constrained_layout=True) +example_plot(ax, fontsize=24) + +############################################################################### +# When you have multiple subplots, often you see labels of different +# axes overlapping each other. + +fig, axs = plt.subplots(2, 2, constrained_layout=False) +for ax in axs.flatten(): + example_plot(ax) + +############################################################################### +# Specifying `constrained_layout=True` in the call to `plt.subplots` +# causes the layout to be properly constrained. + +fig, axs = plt.subplots(2, 2, constrained_layout=True) +for ax in axs.flatten(): + example_plot(ax) + +############################################################################### +# Colorbars +# ========= +# +# If you create a colorbar with the :func:`~matplotlib.pyplot.colorbar` +# command you need to make room for it. ``constrained_layout`` does this +# automatically. Note that if you specify ``use_gridspec=True`` it will be +# ignored because this option is made for improving the layout via +# ``tight_layout``. + +arr = np.arange(100).reshape((10, 10)) +fig, ax = plt.subplots(figsize=(4, 4), constrained_layout=True) +im = ax.pcolormesh(arr, rasterized=True) +fig.colorbar(im, ax=ax, shrink=0.6) + +############################################################################ +# If you specify multiple axes to the ``ax`` argument of ``colorbar``, +# constrained_layout will take space from all axes that share the same +# gridspec. + +fig, axs = plt.subplots(2, 2, figsize=(4, 4), constrained_layout=True) +for ax in axs.flatten(): + im = ax.pcolormesh(arr, rasterized=True) +fig.colorbar(im, ax=axs, shrink=0.6) + +#################################################### +# Suptitle +# ========= +# +# ``constrained_layout`` can also make room for ``suptitle``. + +fig, axs = plt.subplots(2, 2, figsize=(4, 4), constrained_layout=True) +for ax in axs.flatten(): + im = ax.pcolormesh(arr, rasterized=True) +fig.colorbar(im, ax=axs, shrink=0.6) +fig.suptitle('Big Suptitle') + +#################################################### +# Legends +# ======= +# +# Legends can be placed outside +# of their parent axis. Constrained-layout is designed to handle this. +# However, constrained-layout does *not* handle legends being created via +# ``fig.legend()`` (yet). + +fig, ax = plt.subplots(constrained_layout=True) +ax.plot(np.arange(10), label='This is a plot') +ax.legend(loc='center left', bbox_to_anchor=(0.9, 0.5)) + +############################################# +# However, this will steal space from a subplot layout: + +fig, axs = plt.subplots(2, 2, constrained_layout=True) +for ax in axs.flatten()[:-1]: + ax.plot(np.arange(10)) +axs[1, 1].plot(np.arange(10), label='This is a plot') +axs[1, 1].legend(loc='center left', bbox_to_anchor=(0.9, 0.5)) + +############################################################################### +# Padding and Spacing +# =================== +# +# For constrained_layout, we have implimented a padding around the edge of +# each axes. This padding sets the distance from the edge of the plot, +# and the minimum distance between adjacent plots. It is specified in +# inches by the keyword arguments `w_pad` and `h_pad` to the function +# `fig.set_constrained_layout_pads`: + +fig, axs = plt.subplots(2, 2, constrained_layout=True) +for ax in axs.flatten(): + example_plot(ax, nodec=True) + ax.set_xticklabels('') + ax.set_yticklabels('') +fig.set_constrained_layout_pads(w_pad=4./72., h_pad=4./72., + hspace=0., wspace=0.) + +fig, axs = plt.subplots(2, 2, constrained_layout=True) +for ax in axs.flatten(): + example_plot(ax, nodec=True) + ax.set_xticklabels('') + ax.set_yticklabels('') +fig.set_constrained_layout_pads(w_pad=2./72., h_pad=2./72., + hspace=0., wspace=0.) + +########################################## +# Spacing between subplots is set by `wspace` and `hspace`. There are +# specified as a fraction of the size of the subplot group as a whole. +# If the size of the figure is changed, then these spaces change in +# proportion. Note in the blow how the space at the edges doesn't change from +# the above, but the space between subplots does. + +fig, axs = plt.subplots(2, 2, constrained_layout=True) +for ax in axs.flatten(): + example_plot(ax, nodec=True) + ax.set_xticklabels('') + ax.set_yticklabels('') +fig.set_constrained_layout_pads(w_pad=2./72., h_pad=2./72., + hspace=0.2, wspace=0.2) + + +########################################## +# Spacing with colorbars +# ----------------------- +# +# Colorbars still respect the `w_pad` and `h_pad` values. However they will +# be `wspace` and `hsapce` apart from other subplots. Note the use of a `pad` +# kwarg here in the `colorbar` call. It defaults to 0.02 of the size of the +# axis it is attached to. + +fig, axs = plt.subplots(2, 2, constrained_layout=True) +for ax in axs.flatten(): + pc = ax.pcolormesh(arr, rasterized=True) + fig.colorbar(im, ax=ax, shrink=0.6, pad=0) + ax.set_xticklabels('') + ax.set_yticklabels('') +fig.set_constrained_layout_pads(w_pad=2./72., h_pad=2./72., + hspace=0.2, wspace=0.2) + +########################################## +# In the above example, the colorbar will not ever be closer than 2 pts to +# the plot, but if we want it a bit further away, we can specify its value +# for `pad` to be non-zero. + +fig, axs = plt.subplots(2, 2, constrained_layout=True) +for ax in axs.flatten(): + pc = ax.pcolormesh(arr, rasterized=True) + fig.colorbar(im, ax=ax, shrink=0.6, pad=0.05) + ax.set_xticklabels('') + ax.set_yticklabels('') +fig.set_constrained_layout_pads(w_pad=2./72., h_pad=2./72., + hspace=0.2, wspace=0.2) + +########################################## +# rcParams: +# ----------- +# +# There are four `rcParams` that can be set, either in a script +# or in the `matplotlibrc` file. They all have the prefix +# `figure.constrained_layout`: +# +# - `do`: Whether to do constrained_layout. Default is False +# - `w_pad`, `h_pad` Padding around axes objects. +# Float representing inches. Default is 3./72. inches (3 pts) +# - `wspace`, `hspace` Space between subplot groups. +# Float representing a fraction of the suplot widths being separated. +# Default is 0.02. + +plt.rcParams['figure.constrained_layout.use'] = True +fig, axs = plt.subplots(2, 2, figsize=(3, 3)) +for ax in axs.flatten(): + example_plot(ax) + +############################# +# Use with GridSpec +# ================= +# +# constrained_layout is meant to be used +# with :func:`~matplotlib.figure.Figure.subplots` or +# :func:`~matplotlib.gridspec.GridSpec` and +# :func:`~matplotlib.figure.Figure.add_subplot`. + +fig = plt.figure(constrained_layout=True) + +gs1 = gridspec.GridSpec(2, 1, figure=fig) +ax1 = fig.add_subplot(gs1[0]) +ax2 = fig.add_subplot(gs1[1]) + +example_plot(ax1) +example_plot(ax2) + +############################################################################### +# More complicated gridspec layouts are possible... + +fig = plt.figure(constrained_layout=True) + +gs0 = gridspec.GridSpec(1, 2, figure=fig) + +gs1 = gridspec.GridSpecFromSubplotSpec(2, 1, gs0[0]) +ax1 = fig.add_subplot(gs1[0]) +ax2 = fig.add_subplot(gs1[1]) + +example_plot(ax1) +example_plot(ax2) + +gs2 = gridspec.GridSpecFromSubplotSpec(3, 1, gs0[1]) + +for ss in gs2: + ax = fig.add_subplot(ss) + example_plot(ax) + ax.set_title("") + ax.set_xlabel("") + +ax.set_xlabel("x-label", fontsize=12) + +############################################################################ +# Note that in the above the left and columns don't have the same vertical +# extent. If we want the top and bottom of the two grids to line up then +# they need to be in the same gridspec: + +fig = plt.figure(constrained_layout=True) + +gs0 = gridspec.GridSpec(6, 2, figure=fig) + +ax1 = fig.add_subplot(gs0[:3, 0]) +ax2 = fig.add_subplot(gs0[3:, 0]) + +example_plot(ax1) +example_plot(ax2) + +ax = fig.add_subplot(gs0[0:2, 1]) +example_plot(ax) +ax = fig.add_subplot(gs0[2:4, 1]) +example_plot(ax) +ax = fig.add_subplot(gs0[4:, 1]) +example_plot(ax) + +############################################################################ +# This example uses two gridspecs to have the colorbar only pertain to +# one set of pcolors. Note how the left column is wider than the +# two right-hand columns because of this. Of course, if you wanted the +# subplots to be the same size you only needed one gridspec. + + +def docomplicated(suptitle=None): + fig = plt.figure(constrained_layout=True) + gs0 = gridspec.GridSpec(1, 2, figure=fig, width_ratios=[1., 2.]) + gsl = gridspec.GridSpecFromSubplotSpec(2, 1, gs0[0]) + gsr = gridspec.GridSpecFromSubplotSpec(2, 2, gs0[1]) + + for gs in gsl: + ax = fig.add_subplot(gs) + example_plot(ax) + axs = [] + for gs in gsr: + ax = fig.add_subplot(gs) + pcm = ax.pcolormesh(arr, rasterized=True) + ax.set_xlabel('x-label') + ax.set_ylabel('y-label') + ax.set_title('title') + + axs += [ax] + fig.colorbar(pcm, ax=axs) + if suptitle is not None: + fig.suptitle(suptitle) + +docomplicated() + +############################################################################### +# Manually setting axes positions +# ================================ +# +# There can be good reasons to manually set an axes position. A manual call +# to `ax.set_position()` will set the axes so constrained_layout has no +# effect on it anymore. (Note that constrained_layout still leaves the space +# for the axes that is moved). + +fig, axs = plt.subplots(1, 2, constrained_layout=True) +example_plot(axs[0], fontsize=12) +axs[1].set_position([0.2, 0.2, 0.4, 0.4]) + +############################################################################### +# If you want an inset axes in data-space, you need to manually execute the +# layout using `fig.execute_constrained_layout()` call. The inset figure +# will then be properly positioned. However, it will not be properly +# positioned if the size of the figure is subsequently changed. Similarly, +# if the figure is printed to another backend, there may be slight changes +# of location due to small differences in how the backends render fonts. + +from matplotlib.transforms import Bbox + +fig, axs = plt.subplots(1, 2, constrained_layout=True) +example_plot(axs[0], fontsize=12) +fig.execute_constrained_layout() +# put into data-space: +bb_data_ax2 = Bbox.from_bounds(0.5, 1., 0.2, 0.4) +disp_coords = axs[0].transData.transform(bb_data_ax2) +fig_coords_ax2 = fig.transFigure.inverted().transform(disp_coords) +bb_ax2 = Bbox(fig_coords_ax2) +ax2 = fig.add_axes(bb_ax2) + +############################################################################### +# Limitations +# ======================== +# +# Incompatible functions +# ---------------------- +# +# ``constrained_layout`` will not work on subplots +# created via the `subplot` command. The reason is that each of these +# commands creates a separate `GridSpec` instance and `constrained_layout` +# uses (nested) gridspecs to carry out the layout. So the following fails +# to yield a nice layout: + + +fig = plt.figure(constrained_layout=True) + +ax1 = plt.subplot(221) +ax2 = plt.subplot(223) +ax3 = plt.subplot(122) + +example_plot(ax1) +example_plot(ax2) +example_plot(ax3) + +############################################################################### +# Of course that layout is possible using a gridspec: + +fig = plt.figure(constrained_layout=True) +gs = gridspec.GridSpec(2, 2, figure=fig) + +ax1 = fig.add_subplot(gs[0, 0]) +ax2 = fig.add_subplot(gs[1, 0]) +ax3 = fig.add_subplot(gs[:, 1]) + +example_plot(ax1) +example_plot(ax2) +example_plot(ax3) + +############################################################################### +# Similarly, +# :func:`~matplotlib.pyplot.subplot2grid` doesn't work for the same reason: +# each call creates a different parent gridspec. + +fig = plt.figure(constrained_layout=True) + +ax1 = plt.subplot2grid((3, 3), (0, 0)) +ax2 = plt.subplot2grid((3, 3), (0, 1), colspan=2) +ax3 = plt.subplot2grid((3, 3), (1, 0), colspan=2, rowspan=2) +ax4 = plt.subplot2grid((3, 3), (1, 2), rowspan=2) + +example_plot(ax1) +example_plot(ax2) +example_plot(ax3) +example_plot(ax4) + +############################################################################### +# The way to make this plot compatible with ``constrained_layout`` is again +# to use ``gridspec`` directly + +fig = plt.figure(constrained_layout=True) +gs = gridspec.GridSpec(3, 3, figure=fig) + +ax1 = fig.add_subplot(gs[0, 0]) +ax2 = fig.add_subplot(gs[0, 1:]) +ax3 = fig.add_subplot(gs[1:, 0:2]) +ax4 = fig.add_subplot(gs[1:, -1]) + +example_plot(ax1) +example_plot(ax2) +example_plot(ax3) +example_plot(ax4) + +############################################################################### +# Other Caveats +# ------------- +# +# * ``constrained_layout`` only considers ticklabels, +# axis labels, titles, and legends. Thus, other artists may be clipped +# and also may overlap. +# +# * It assumes that the extra space needed for ticklabels, axis labels, +# and titles is independent of original location of axes. This is +# often true, but there are rare cases where it is not. +# +# * There are small differences in how the backends handle rendering fonts, +# so the results will not be pixel-identical. + +########################################################### +# Debugging +# ========= +# +# Constrained-layout can fail in somewhat unexpected ways. Because it uses +# a constraint solver the solver can find solutions that are mathematically +# correct, but that aren't at all what the user wants. The usual failure +# mode is for all sizes to collapse to their smallest allowable value. If +# this happens, it is for one of two reasons: +# +# 1. There was not enough room for the elements you were requesting to draw +# 2. There is a bug - in which case open an issue at +# https://github.com/matplotlib/matplotlib/issues. +# +# If there is a bug, please report with a self-contained example that does +# not require outside data or dependencies (other than perhaps numpy). + +########################################################### +# Notes on the algorithm +# ====================== +# +# The algorithm for the constraint is relatively straightforward, but +# has some complexity due to the complex ways we can layout a figure. +# +# Figure layout +# ------------- +# +# Figures are laid out in a hierarchy: +# +# 1. Figure: `fig = plt.figure()` +# +# a. Gridspec `gs0 = gridspec.GridSpec(1, 2, figure=fig)` +# +# i. Subplotspec: `ss = gs[0, 0]` +# +# 1. Axes: `ax0 = fig.add_subplot(ss)` +# +# ii. Subplotspec: `ss = gs[0, 1]` +# +# 1. Gridspec: `gsR = gridspec.GridSpecFromSubplotSpec(2, 1, ss)` +# +# - Subplotspec: ss = gsR[0, 0] +# +# - Axes: `axR0 = fig.add_subplot(ss)` +# +# - Subplotspec: ss = gsR[1, 0] +# +# - Axes: `axR1 = fig.add_subplot(ss)` +# +# Each item has a layoutbox associated with it. The nesting of gridspecs +# created with `~.GridSpecFromSubplotSpec` can be arbitrarily deep. +# +# Each `~.Axes` has *two* layoutboxes. The first one ``ax._layoutbox`` +# represents the outside of the Axes and all its decorations (i.e. ticklabels, +# axis labels, etc.). The second layoutbox corresponds to the Axes' +# `ax.position`, which sets where in the figure the spines are placed. +# +# Why so many stacked containers? Ideally, all that would be needed are the +# Axes layotu boxes. For the Gridspec case, a container is +# needed if the Gridspec is nested via `~.GridSpecFromSubplotSpec`. At the +# top level, it is desirable for symmetry, but it also makes room for +# `~.Figure.suptitle`. +# +# For the Subplotspec/Axes case, Axes often have colorbars or other +# annotations that need to be packaged inside the Subplotspec, hence the +# need for the outer layer. +# +# +# Simple case: one Axes +# --------------------- +# +# For a single Axes the layout is straight forward. The Figure and +# outer Gridspec layoutboxes co-incide. The Subplotspec and Axes +# boxes also co-incide because the Axes has no colorbar. Note +# the difference between the red ``pos`` box and the green ``ax`` box +# is set by the size of the decorations around the Axes. +# +# In the code, this is accomplished by the entries in `~.do_constrained_layout` +# like:: +# +# ax._poslayoutbox.edit_left_margin_min(-bbox.x0 + pos.x0 + w_padt) +# + +from matplotlib._layoutbox import plot_children + +fig, ax = plt.subplots(constrained_layout=True) +example_plot(ax, fontsize=24) +plot_children(fig, fig._layoutbox, printit=False) + +####################################################################### +# Simple case: two Axes +# --------------------- +# For this case, the Axes layoutboxes and the Subplotspec boxes still +# co-incide. However, because the decorations in the right-hand plot are so +# much smaller than the left-hand, so the right-hand layoutboxes are smaller. +# +# The Subplotspec boxes are laid out in the code in the subroutine +# `~.arange_subplotspecs`, which simply checks the subplotspecs in the code +# against one another and stacks them appropriately. +# +# The two ``pos`` axes are lined up. Because they have the same +# minimum row, they are lined up at the top. Because +# they have the same maximum row they are lined up at the bottom. In the +# code this is accomplished via the calls to ``layoutbox.align``. If +# there was more than one row, then the same horizontal alignment would +# occur between the rows. +# +# The two ``pos`` axes are given the same width because the subplotspecs +# occupy the same number of columns. This is accomplished in the code where +# ``dcols0`` is compared to ``dcolsC``. If they are equal, then their widths +# are constrained to be equal. +# +# While it is a bit subtle in this case, note that the division between the +# Subplotspecs is *not* centered, but has been moved to the right to make +# space for the larger labels on the left-hand plot. + +fig, ax = plt.subplots(1, 2, constrained_layout=True) +example_plot(ax[0], fontsize=32) +example_plot(ax[1], fontsize=8) +plot_children(fig, fig._layoutbox, printit=False) + +####################################################################### +# Two Axes and colorbar +# --------------------- +# +# Adding a colorbar makes it clear why the Subplotspec layoutboxes must +# be different from the axes layoutboxes. Here we see the left-hand +# subplotspec has more room to accomodate the `~.Figure.colorbar`, and +# that there are two green ``ax`` boxes inside the ``ss`` box. +# +# Note that the width of the ``pos`` boxes is still the same because of the +# constraint on their widths because their subplotspecs occupy the same +# number of columns (one in this example). +# +# The colorbar layout logic is contained in `~.colorbar.make_axes` which +# call `._constrained_layout.layoutcolorbarsingle` for cbars attached to +# a single axes, and `._constrained_layout.layoutcolorbargridspec` if the +# colorbar is associated wiht a gridspec. + +fig, ax = plt.subplots(1, 2, constrained_layout=True) +im = ax[0].pcolormesh(arr, rasterized=True) +fig.colorbar(im, ax=ax[0], shrink=0.6) +im = ax[1].pcolormesh(arr, rasterized=True) +plot_children(fig, fig._layoutbox, printit=False) + +####################################################################### +# Colorbar associated with a Gridspec +# ----------------------------------- +# +# This example shows the Subplotspec layoutboxes being made smaller by +# a colorbar layoutbox. The size of the colorbar layoutbox is +# set to be *shrink* smaller than the vertical extent of the ``pos`` +# layoutboxes in the gridspec, and it is made to be centered between +# those two points. + +fig, ax = plt.subplots(2, 2, constrained_layout=True) +for a in ax.flatten(): + im = a.pcolormesh(arr, rasterized=True) +fig.colorbar(im, ax=ax, shrink=0.6) +plot_children(fig, fig._layoutbox, printit=False) + +####################################################################### +# Uneven sized Axes +# ----------------- +# +# There are two ways to make axes have an uneven size in a +# Gridspec layout, either by specifying them to cross Gridspecs rows +# or columns, or by specifying width and height ratios. +# +# The first method is used here. The constraint that makes the heights +# be correct is in the code where ``drowsC < drows0`` which in +# this case would be 1 is less than 2. So we constrain the +# the height of the 1-row Axes to be less than half the height of the +# 2-row Axes. +# +# ..note:: +# +# This algorithm can be wrong if the decorations attached +# to the smaller axes are very large, so there is an unaccounted-for +# edge case. + + +fig = plt.figure(constrained_layout=True) +gs = gridspec.GridSpec(2, 2, figure=fig) +ax = fig.add_subplot(gs[:, 0]) +im = ax.pcolormesh(arr, rasterized=True) +ax = fig.add_subplot(gs[0, 1]) +im = ax.pcolormesh(arr, rasterized=True) +ax = fig.add_subplot(gs[1, 1]) +im = ax.pcolormesh(arr, rasterized=True) +plot_children(fig, fig._layoutbox, printit=False) + +####################################################################### +# Height and width ratios are accomodated with the same part of +# the code with the smaller axes always constrained to be less in size +# than the larger. + +fig = plt.figure(constrained_layout=True) +gs = gridspec.GridSpec(3, 2, figure=fig, + height_ratios=[1., 0.5, 1.5], + width_ratios=[1.2, 0.8]) +ax = fig.add_subplot(gs[:2, 0]) +im = ax.pcolormesh(arr, rasterized=True) +ax = fig.add_subplot(gs[2, 0]) +im = ax.pcolormesh(arr, rasterized=True) +ax = fig.add_subplot(gs[0, 1]) +im = ax.pcolormesh(arr, rasterized=True) +ax = fig.add_subplot(gs[1:, 1]) +im = ax.pcolormesh(arr, rasterized=True) +plot_children(fig, fig._layoutbox, printit=False) + +######################################################################## +# Empty gridspec slots +# -------------------- +# +# The final piece of the code that has not been explained is what happens if +# there is an empty gridspec opening. In that case a fake invisible axes is +# added and we proceed as before. The empty gridspec has no decorations, but +# the axes postion in made the same size as the occupied Axes positions. +# +# This is done at the start of +# `~._constrained_layout.do_constrained_layout` (``hassubplotspec``). + +fig = plt.figure(constrained_layout=True) +gs = gridspec.GridSpec(1, 3, figure=fig) +ax = fig.add_subplot(gs[0]) +im = ax.pcolormesh(arr, rasterized=True) +ax = fig.add_subplot(gs[-1]) +im = ax.pcolormesh(arr, rasterized=True) +plot_children(fig, fig._layoutbox, printit=False) +plt.show() + +######################################################################## +# Other notes +# ----------- +# +# The layout is called only once. This is OK if the original layout was +# pretty close (which it should be in most cases). However, if the layout +# changes a lot from the default layout then the decorators can change size. +# In particular the x and ytick labels can change. If this happens, then +# we should probably call the whole routine twice. diff --git a/tutorials/intermediate/tight_layout_guide.py b/tutorials/intermediate/tight_layout_guide.py index b3cbb0861113..6318032e6412 100644 --- a/tutorials/intermediate/tight_layout_guide.py +++ b/tutorials/intermediate/tight_layout_guide.py @@ -21,6 +21,8 @@ """ +# sphinx_gallery_thumbnail_number = 7 + import matplotlib.pyplot as plt import numpy as np