diff --git a/doc/api/next_api_changes/behavior/17494-JMK.rst b/doc/api/next_api_changes/behavior/17494-JMK.rst new file mode 100644 index 000000000000..2833ff3dcdbf --- /dev/null +++ b/doc/api/next_api_changes/behavior/17494-JMK.rst @@ -0,0 +1,25 @@ +Constrained layout rewrite +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The layout manager ``constrained_layout`` was re-written with different +outer constraints that should be more robust to complicated subplot layouts. +User-facing changes are: + +- some poorly constrained layouts will have different width/height plots than + before. +- colorbars now respect the ``anchor`` keyword argument of + `matplotlib.colorbar.make_axes` +- colorbars are wider. +- colorbars in different rows or columns line up more robustly. +- *hspace* and *wspace* options to `.Figure.set_constrained_layout_pads` + were twice as wide as the docs said they should be. So these now follow + the docs. + +This feature will remain "experimental" until the new changes have been +used enough by users, so we anticipate version 3.5 or 3.6. On the other hand, +``constrained_layout`` is extensively tested and used in examples in the +library, so using it should be safe, but layouts may not be exactly the same +as more development takes place. + +Details of using ``constrained_layout``, and its algorithm are available at +:doc:`/tutorials/intermediate/constrainedlayout_guide` diff --git a/lib/matplotlib/_constrained_layout.py b/lib/matplotlib/_constrained_layout.py index dc00b6c50e05..fedb69c3d91c 100644 --- a/lib/matplotlib/_constrained_layout.py +++ b/lib/matplotlib/_constrained_layout.py @@ -1,8 +1,8 @@ """ 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). +ticklabels) and some dependent artists are also dealt with (colorbar, +suptitle). Layout is done via `~matplotlib.gridspec`, with one constraint per gridspec, so it is possible to have overlapping axes if the gridspecs overlap (i.e. @@ -13,58 +13,49 @@ 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 encompass the legend, and that is -# how legends get included (axes legends, not figure legends) -# - colorbars are siblings 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 implemented -# -# Todo: AnchoredOffsetbox connected to gridspecs or axes. This would -# be more general way to add extra-axes annotations. - import logging import numpy as np import matplotlib.cbook as cbook -import matplotlib._layoutbox as layoutbox _log = logging.getLogger(__name__) - -def _spans_overlap(span0, span1): - return span0.start in span1 or span1.start in span0 - - -def _axes_all_finite_sized(fig): - """Return whether all axes in the figure have a finite width and height.""" - for ax in fig.axes: - if ax._layoutbox is not None: - newpos = ax._poslayoutbox.get_rect() - if newpos[2] <= 0 or newpos[3] <= 0: - return False - return True +""" +General idea: +------------- + +First, a figure has a gridspec that divides the figure into nrows and ncols, +with heights and widths set by ``height_ratios`` and ``width_ratios``, +often just set to 1 for an equal grid. + +Subplotspecs that are derived from this gridspec can contain either a +``SubPanel``, a ``GridSpecFromSubplotSpec``, or an axes. The ``SubPanel`` and +``GridSpecFromSubplotSpec`` are dealt with recursively and each contain an +analagous layout. + +Each ``GridSpec`` has a ``_layoutgrid`` attached to it. The ``_layoutgrid`` +has the same logical layout as the ``GridSpec``. Each row of the grid spec +has a top and bottom "margin" and each column has a left and right "margin". +The "inner" height of each row is constrained to be the same (or as modified +by ``height_ratio``), and the "inner" width of each column is +constrained to be the same (as modified by ``width_ratio``), where "inner" +is the width or height of each column/row minus the size of the margins. + +Then the size of the margins for each row and column are determined as the +max width of the decorators on each axes that has decorators in that margin. +For instance, a normal axes would have a left margin that includes the +left ticklabels, and the ylabel if it exists. The right margin may include a +colorbar, the bottom margin the xaxis decorations, and the top margin the +title. + +With these constraints, the solver then finds appropriate bounds for the +columns and rows. It's possible that the margins take up the whole figure, +in which case the algorithm is not applied and a warning is raised. + +See the tutorial doc:`/tutorials/intermediate/constrainedlayout_guide` +for more discussion of the algorithm with examples. +""" ###################################################### @@ -82,181 +73,362 @@ def do_constrained_layout(fig, renderer, h_pad, w_pad, renderer : Renderer Renderer to use. - h_pad, w_pad : float - Padding around the axes elements in figure-normalized units. - - hspace, wspace : float - Spacing in fractions of the subplot sizes. + h_pad, w_pad : float + Padding around the axes elements in figure-normalized units. + hspace, wspace : float + Fraction of the figure to dedicate to space between the + axes. These are evenly spread between the gaps between the axes. + A value of 0.2 for a three-column layout would have a space + of 0.1 of the figure width between each column. + If h/wspace < h/w_pad, then the pads are used instead. """ - # 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 != colnumC, the two subplotspecs are stacked next to - # each other, with the appropriate order. - # b) if colnum0 == colnumC, 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: + if gs._layoutgrid is not None: gss.add(gs) + gss = list(gss) if len(gss) == 0: - cbook._warn_external('There are no gridspecs with layoutboxes. ' + cbook._warn_external('There are no gridspecs with layoutgrids. ' 'Possibly did not call parent GridSpec with the' - ' figure= keyword') - - if fig._layoutbox.constrained_layout_called < 1: - for gs in gss: - # fill in any empty gridspec slots w/ ghost axes... - _make_ghost_gridspec_slots(fig, gs) + ' "figure" keyword') for _ in range(2): - # do the algorithm twice. This has to be done because decorators + # do the algorithm twice. This has to be done because decorations # change size after the first re-position (i.e. x/yticklabels get # larger/smaller). This second reposition tends to be much milder, # so doing twice makes things work OK. - for ax in fig.axes: - _log.debug(ax._layoutbox) - if ax._layoutbox is not None: - # make margins for each layout box based on the size of - # the decorators. - _make_layout_margins(ax, renderer, h_pad, w_pad) - - # do layout for suptitle. - suptitle = fig._suptitle - do_suptitle = (suptitle is not None and - suptitle._layoutbox is not None and - suptitle.get_in_layout()) - if do_suptitle: - bbox = invTransFig( - suptitle.get_window_extent(renderer=renderer)) - height = bbox.height - if np.isfinite(height): - # reserve at top of figure include an h_pad above and below - suptitle._layoutbox.edit_height(height + h_pad * 2) - - # 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 contain 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(): - # This routine makes all the subplot spec containers - # have the correct arrangement. It just stacks the - # subplot layoutboxes in the correct order... - _arrange_subplotspecs(child, hspace=hspace, wspace=wspace) - - for gs in gss: - _align_spines(fig, gs) - - fig._layoutbox.constrained_layout_called += 1 - fig._layoutbox.update_variables() - - # check if any axes collapsed to zero. If not, don't change positions: - if _axes_all_finite_sized(fig): - # Now set the position of the axes... - for ax in fig.axes: - if ax._layoutbox is not None: - newpos = ax._poslayoutbox.get_rect() - # 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') - if do_suptitle: - newpos = suptitle._layoutbox.get_rect() - suptitle.set_y(1.0 - h_pad) - else: - if suptitle is not None and suptitle._layoutbox is not None: - suptitle._layoutbox.edit_height(0) + + # make margins for all the axes and subpanels in the + # figure. Add margins for colorbars... + _make_layout_margins(fig, renderer, h_pad=h_pad, w_pad=w_pad, + hspace=hspace, wspace=wspace) + + _make_margin_suptitles(fig, renderer, h_pad=h_pad, w_pad=w_pad) + + # if a layout is such that a columns (or rows) margin has no + # constraints, we need to make all such instances in the grid + # match in margin size. + _match_submerged_margins(fig) + + # update all the variables in the layout. + fig._layoutgrid.update_variables() + + if _check_no_collapsed_axes(fig): + _reposition_axes(fig, renderer, h_pad=h_pad, w_pad=w_pad, + hspace=hspace, wspace=wspace) else: - cbook._warn_external('constrained_layout not applied. At least ' - 'one axes collapsed to zero width or height.') + cbook._warn_external('constrained_layout not applied because ' + 'axes sizes collapsed to zero. Try making ' + 'figure larger or axes decorations smaller.') + _reset_margins(fig) -def _make_ghost_gridspec_slots(fig, gs): +def _check_no_collapsed_axes(fig): """ - Check for unoccupied gridspec slots and make ghost axes for these - slots... Do for each gs separately. This is a pretty big kludge - but shouldn't have too much ill effect. The worst is that - someone querying the figure will wonder why there are more - axes than they thought. + Check that no axes have collapsed to zero size. """ - nrows, ncols = gs.get_geometry() - hassubplotspec = np.zeros(nrows * ncols, dtype=bool) - axs = [] + for panel in fig.panels: + ok = _check_no_collapsed_axes(panel) + if not ok: + return False + 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() - 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_visible(False) - - -def _make_layout_margins(ax, renderer, h_pad, w_pad): + if hasattr(ax, 'get_subplotspec'): + gs = ax.get_subplotspec().get_gridspec() + lg = gs._layoutgrid + if lg is not None: + for i in range(gs.nrows): + for j in range(gs.ncols): + bb = lg.get_inner_bbox(i, j) + if bb.width <= 0 or bb.height <= 0: + return False + return True + + +def _get_margin_from_padding(object, *, w_pad=0, h_pad=0, + hspace=0, wspace=0): + + ss = object._subplotspec + gs = ss.get_gridspec() + lg = gs._layoutgrid + + if hasattr(gs, 'hspace'): + _hspace = (gs.hspace if gs.hspace is not None else hspace) + _wspace = (gs.wspace if gs.wspace is not None else wspace) + else: + _hspace = (gs._hspace if gs._hspace is not None else hspace) + _wspace = (gs._wspace if gs._wspace is not None else wspace) + + _wspace = _wspace / 2 + _hspace = _hspace / 2 + + nrows, ncols = gs.get_geometry() + # there are two margins for each direction. The "cb" + # margins are for pads and colorbars, the non-"cb" are + # for the axes decorations (labels etc). + margin = {'leftcb': w_pad, 'rightcb': w_pad, + 'bottomcb': h_pad, 'topcb': h_pad, + 'left': 0, 'right': 0, + 'top': 0, 'bottom': 0} + if _wspace / ncols > w_pad: + if ss.colspan.start > 0: + margin['leftcb'] = _wspace / ncols + if ss.colspan.stop < ncols: + margin['rightcb'] = _wspace / ncols + if _hspace / nrows > h_pad: + if ss.rowspan.stop < nrows: + margin['bottomcb'] = _hspace / nrows + if ss.rowspan.start > 0: + margin['topcb'] = _hspace / nrows + + return margin + + +def _make_layout_margins(fig, renderer, *, w_pad=0, h_pad=0, + hspace=0, wspace=0): """ For each axes, make a margin between the *pos* layoutbox and the *axes* layoutbox be a minimum size that can accommodate the decorations on the axis. + + Then make room for colorbars. + """ + for panel in fig.panels: # recursively make child panel margins + ss = panel._subplotspec + _make_layout_margins(panel, renderer, w_pad=w_pad, h_pad=h_pad, + hspace=hspace, wspace=wspace) + + margins = _get_margin_from_padding(panel, w_pad=0, h_pad=0, + hspace=hspace, wspace=wspace) + panel._layoutgrid.parent.edit_outer_margin_mins(margins, ss) + + # for ax in [a for a in fig._localaxes if hasattr(a, 'get_subplotspec')]: + for ax in fig.get_axes(): + if not hasattr(ax, 'get_subplotspec'): + continue + + ss = ax.get_subplotspec() + gs = ss.get_gridspec() + nrows, ncols = gs.get_geometry() + + if gs._layoutgrid is None: + return + + margin = _get_margin_from_padding(ax, w_pad=w_pad, h_pad=h_pad, + hspace=hspace, wspace=wspace) + margin0 = margin.copy() + pos, bbox = _get_pos_and_bbox(ax, renderer) + + # the margin is the distance between the bounding box of the axes + # and its position (plus the padding from above) + margin['left'] += pos.x0 - bbox.x0 + margin['right'] += bbox.x1 - pos.x1 + # remember that rows are ordered from top: + margin['bottom'] += pos.y0 - bbox.y0 + margin['top'] += bbox.y1 - pos.y1 + + # make margin for colorbars. These margins go in the + # padding margin, versus the margin for axes decorators. + for cbax in ax._colorbars: + # note pad is a fraction of the parent width... + pad = _colorbar_get_pad(cbax) + # colorbars can be child of more than one subplot spec: + cbp_rspan, cbp_cspan = _get_cb_parent_spans(cbax) + loc = cbax._colorbar_info['location'] + cbpos, cbbbox = _get_pos_and_bbox(cbax, renderer) + if loc == 'right': + if cbp_cspan.stop == ss.colspan.stop: + # only increase if the colorbar is on the right edge + margin['rightcb'] += cbbbox.width + pad + elif loc == 'left': + if cbp_cspan.start == ss.colspan.start: + # only increase if the colorbar is on the left edge + margin['leftcb'] += cbbbox.width + pad + elif loc == 'top': + if cbp_rspan.start == ss.rowspan.start: + margin['topcb'] += cbbbox.height + pad + else: + if cbp_rspan.stop == ss.rowspan.stop: + margin['bottomcb'] += cbbbox.height + pad + # If the colorbars are wider than the parent box in the + # cross direction + if loc in ['top', 'bottom']: + if (cbp_cspan.start == ss.colspan.start and + cbbbox.x0 < bbox.x0): + margin['left'] += bbox.x0 - cbbbox.x0 + if (cbp_cspan.stop == ss.colspan.stop and + cbbbox.x1 > bbox.x1): + margin['right'] += cbbbox.x1 - bbox.x1 + # or taller: + if loc in ['left', 'right']: + if (cbp_rspan.stop == ss.rowspan.stop and + cbbbox.y0 < bbox.y0): + margin['bottom'] += bbox.y0 - cbbbox.y0 + if (cbp_rspan.start == ss.rowspan.start and + cbbbox.y1 > bbox.y1): + margin['top'] += cbbbox.y1 - bbox.y1 + # pass the new margins down to the layout grid for the solution... + gs._layoutgrid.edit_outer_margin_mins(margin, ss) + + +def _make_margin_suptitles(fig, renderer, *, w_pad=0, h_pad=0): + # Figure out how large the suptitle is and make the + # top level figure margin larger. + for panel in fig.panels: + _make_margin_suptitles(panel, renderer, w_pad=w_pad, h_pad=h_pad) + invTransFig = fig.transPanel.inverted().transform_bbox + + w_pad, h_pad = (fig.transPanel - fig.transFigure).transform((w_pad, h_pad)) + + if fig._suptitle is not None and fig._suptitle.get_in_layout(): + bbox = invTransFig(fig._suptitle.get_tightbbox(renderer)) + p = fig._suptitle.get_position() + fig._suptitle.set_position((p[0], 1-h_pad)) + fig._layoutgrid.edit_margin_min('top', bbox.height + 2 * h_pad) + + +def _match_submerged_margins(fig): + """ + Make the margins that are submerged inside an Axes the same size. + + This allows axes that span two columns (or rows) that are offset + from one another to have the same size. + + This gives the proper layout for something like:: + fig = plt.figure(constrained_layout=True) + axs = fig.subplot_mosaic("AAAB\nCCDD") + + Without this routine, the axes D will be wider than C, because the + margin width between the two columns in C has no width by default, + whereas the margins between the two columns of D are set by the + width of the margin between A and B. However, obviously the user would + like C and D to be the same size, so we need to add constraints to these + "submerged" margins. + + This routine makes all the interior margins the same, and the spacing + between the three columns in A and the two column in C are all set to the + margins between the two columns of D. + + See test_constrained_layout::test_constrained_layout12 for an example. + """ + + for panel in fig.panels: + _match_submerged_margins(panel) + + axs = [a for a in fig.get_axes() if hasattr(a, 'get_subplotspec')] + + for ax1 in axs: + ss1 = ax1.get_subplotspec() + lg1 = ss1.get_gridspec()._layoutgrid + if lg1 is None: + axs.remove(ax1) + continue + + # interior columns: + if len(ss1.colspan) > 1: + maxsubl = np.max( + lg1.margin_vals['left'][ss1.colspan[1:]] + + lg1.margin_vals['leftcb'][ss1.colspan[1:]] + ) + maxsubr = np.max( + lg1.margin_vals['right'][ss1.colspan[:-1]] + + lg1.margin_vals['rightcb'][ss1.colspan[:-1]] + ) + for ax2 in axs: + ss2 = ax2.get_subplotspec() + lg2 = ss2.get_gridspec()._layoutgrid + if lg2 is not None and len(ss2.colspan) > 1: + maxsubl2 = np.max( + lg2.margin_vals['left'][ss2.colspan[1:]] + + lg2.margin_vals['leftcb'][ss2.colspan[1:]]) + if maxsubl2 > maxsubl: + maxsubl = maxsubl2 + maxsubr2 = np.max( + lg2.margin_vals['right'][ss2.colspan[:-1]] + + lg2.margin_vals['rightcb'][ss2.colspan[:-1]]) + if maxsubr2 > maxsubr: + maxsubr = maxsubr2 + for i in ss1.colspan[1:]: + lg1.edit_margin_min('left', maxsubl, cell=i) + for i in ss1.colspan[:-1]: + lg1.edit_margin_min('right', maxsubr, cell=i) + + # interior rows: + if len(ss1.rowspan) > 1: + maxsubt = np.max( + lg1.margin_vals['top'][ss1.rowspan[1:]] + + lg1.margin_vals['topcb'][ss1.rowspan[1:]] + ) + maxsubb = np.max( + lg1.margin_vals['bottom'][ss1.rowspan[:-1]] + + lg1.margin_vals['bottomcb'][ss1.rowspan[:-1]] + ) + + for ax2 in axs: + ss2 = ax2.get_subplotspec() + lg2 = ss2.get_gridspec()._layoutgrid + if lg2 is not None: + if len(ss2.rowspan) > 1: + maxsubt = np.max([np.max( + lg2.margin_vals['top'][ss2.rowspan[1:]] + + lg2.margin_vals['topcb'][ss2.rowspan[1:]] + ), maxsubt]) + maxsubb = np.max([np.max( + lg2.margin_vals['bottom'][ss2.rowspan[:-1]] + + lg2.margin_vals['bottomcb'][ss2.rowspan[:-1]] + ), maxsubb]) + for i in ss1.rowspan[1:]: + lg1.edit_margin_min('top', maxsubt, cell=i) + for i in ss1.rowspan[:-1]: + lg1.edit_margin_min('bottom', maxsubb, cell=i) + + +def _get_cb_parent_spans(cbax): + """ + Figure out which subplotspecs this colorbar belongs to: + """ + rowstart = np.inf + rowstop = -np.inf + colstart = np.inf + colstop = -np.inf + for parent in cbax._colorbar_info['parents']: + ss = parent.get_subplotspec() + rowstart = min(ss.rowspan.start, rowstart) + rowstop = max(ss.rowspan.stop, rowstop) + colstart = min(ss.colspan.start, colstart) + colstop = max(ss.colspan.stop, colstop) + + rowspan = range(rowstart, rowstop) + colspan = range(colstart, colstop) + return rowspan, colspan + + +def _get_pos_and_bbox(ax, renderer): + """ + Get the position and the bbox for the axes. + + Parameters + ---------- + ax + renderer + + Returns + ------- + pos : Bbox + Position in figure coordinates. + bbox : Bbox + Tight bounding box in figure coordinates. + """ fig = ax.figure - invTransFig = fig.transFigure.inverted().transform_bbox pos = ax.get_position(original=True) + # pos is in panel co-ords, but we need in figure for the layout + pos = pos.transformed(fig.transPanel - fig.transFigure) try: tightbbox = ax.get_tightbbox(renderer=renderer, for_layout_only=True) except TypeError: @@ -265,393 +437,165 @@ def _make_layout_margins(ax, renderer, h_pad, w_pad): if tightbbox is None: bbox = pos else: - bbox = invTransFig(tightbbox) - - # this can go wrong: - if not (np.isfinite(bbox.width) and np.isfinite(bbox.height)): - # just abort, this is likely a bad set of coordinates that - # is transitory... - return - # 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)) - _log.debug('bbox.y0 %f', bbox.y0) - _log.debug('pos.y0 %f', pos.y0) - # 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') - - -def _align_spines(fig, gs): + bbox = tightbbox.transformed(fig.transFigure.inverted()) + return pos, bbox + + +def _reposition_axes(fig, renderer, *, w_pad=0, h_pad=0, hspace=0, wspace=0): """ - - 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. + Reposition all the axes based on the new inner bounding box. """ - # for each gridspec... - nrows, ncols = gs.get_geometry() - width_ratios = gs.get_width_ratios() - height_ratios = gs.get_height_ratios() - - # get axes in this gridspec.... - axs = [ax for ax in fig.axes - if (hasattr(ax, 'get_subplotspec') - and ax._layoutbox is not None - and ax.get_subplotspec().get_gridspec() == gs)] - rowspans = [] - colspans = [] - heights = [] - widths = [] - - for ax in axs: - ss0 = ax.get_subplotspec() - rowspan = ss0.rowspan - colspan = ss0.colspan - rowspans.append(rowspan) - colspans.append(colspan) - heights.append(sum(height_ratios[rowspan.start:rowspan.stop])) - widths.append(sum(width_ratios[colspan.start:colspan.stop])) - - for idx0, ax0 in enumerate(axs): - # Compare ax to all other axs: If the subplotspecs start (/stop) at - # the same column, then line up their left (/right) sides; likewise - # for rows/top/bottom. - rowspan0 = rowspans[idx0] - colspan0 = colspans[idx0] - height0 = heights[idx0] - width0 = widths[idx0] - alignleft = False - alignright = False - alignbot = False - aligntop = False - alignheight = False - alignwidth = False - for idx1 in range(idx0 + 1, len(axs)): - ax1 = axs[idx1] - rowspan1 = rowspans[idx1] - colspan1 = colspans[idx1] - width1 = widths[idx1] - height1 = heights[idx1] - # Horizontally align axes spines if they have the same min or max: - if not alignleft and colspan0.start == colspan1.start: - _log.debug('same start columns; line up layoutbox lefts') - layoutbox.align([ax0._poslayoutbox, ax1._poslayoutbox], - 'left') - alignleft = True - if not alignright and colspan0.stop == colspan1.stop: - _log.debug('same stop columns; line up layoutbox rights') - layoutbox.align([ax0._poslayoutbox, ax1._poslayoutbox], - 'right') - alignright = True - # Vertically align axes spines if they have the same min or max: - if not aligntop and rowspan0.start == rowspan1.start: - _log.debug('same start rows; line up layoutbox tops') - layoutbox.align([ax0._poslayoutbox, ax1._poslayoutbox], - 'top') - aligntop = True - if not alignbot and rowspan0.stop == rowspan1.stop: - _log.debug('same stop rows; line up layoutbox bottoms') - layoutbox.align([ax0._poslayoutbox, ax1._poslayoutbox], - 'bottom') - alignbot = True - - # Now we make the widths and heights of position boxes - # similar. (i.e the spine locations) - # This allows vertically stacked subplots to have different sizes - # if they occupy different amounts of the gridspec, e.g. if - # gs = gridspec.GridSpec(3, 1) - # ax0 = gs[0, :] - # ax1 = gs[1:, :] - # then len(rowspan0) = 1, and len(rowspan1) = 2, - # and ax1 should be at least twice as large as ax0. - # But it can be more than twice as large because - # it needs less room for the labeling. - - # For heights, do it if the subplots share a column. - if not alignheight and len(rowspan0) == len(rowspan1): - ax0._poslayoutbox.constrain_height( - ax1._poslayoutbox.height * height0 / height1) - alignheight = True - elif _spans_overlap(colspan0, colspan1): - if height0 > height1: - ax0._poslayoutbox.constrain_height_min( - ax1._poslayoutbox.height * height0 / height1) - elif height0 < height1: - ax1._poslayoutbox.constrain_height_min( - ax0._poslayoutbox.height * height1 / height0) - # For widths, do it if the subplots share a row. - if not alignwidth and len(colspan0) == len(colspan1): - ax0._poslayoutbox.constrain_width( - ax1._poslayoutbox.width * width0 / width1) - alignwidth = True - elif _spans_overlap(rowspan0, rowspan1): - if width0 > width1: - ax0._poslayoutbox.constrain_width_min( - ax1._poslayoutbox.width * width0 / width1) - elif width0 < width1: - ax1._poslayoutbox.constrain_width_min( - ax0._poslayoutbox.width * width1 / width0) - - -def _arrange_subplotspecs(gs, hspace=0, wspace=0): - """Recursively arrange the subplotspec children of the given gridspec.""" - sschildren = [] - for child in gs.children: - if child._is_subplotspec_layoutbox(): - for child2 in child.children: - # check for gridspec children... - if child2._is_gridspec_layoutbox(): - _arrange_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() - rowspan0 = ss0.rowspan - colspan0 = ss0.colspan - sschildren = sschildren[1:] - for child1 in sschildren: - ss1 = child1.artist - rowspan1 = ss1.rowspan - colspan1 = ss1.colspan - # OK, this tells us the relative layout of child0 with child1. - pad = wspace / ncols - if colspan0.stop <= colspan1.start: - layoutbox.hstack([ss0._layoutbox, ss1._layoutbox], padding=pad) - if colspan1.stop <= colspan0.start: - layoutbox.hstack([ss1._layoutbox, ss0._layoutbox], padding=pad) - # vertical alignment - pad = hspace / nrows - if rowspan0.stop <= rowspan1.start: - layoutbox.vstack([ss0._layoutbox, ss1._layoutbox], padding=pad) - if rowspan1.stop <= rowspan0.start: - layoutbox.vstack([ss1._layoutbox, ss0._layoutbox], padding=pad) - - -def layoutcolorbarsingle(ax, cax, shrink, aspect, location, pad=0.05): + trans_fig_to_panel = fig.transFigure - fig.transPanel + for panel in fig.panels: + bbox = panel._layoutgrid.get_outer_bbox() + panel._redo_transform_rel_fig( + bbox=bbox.transformed(trans_fig_to_panel)) + _reposition_axes(panel, renderer, + w_pad=w_pad, h_pad=h_pad, + wspace=wspace, hspace=hspace) + + # for ax in fig._localaxes: + # if not hasattr(a, 'get_subplotspec'): + for ax in fig.get_axes(): + if not hasattr(ax, 'get_subplotspec'): + continue + + # grid bbox is in Figure co-ordinates, but we specify in panel + # co-ordinates... + ss = ax.get_subplotspec() + gs = ss.get_gridspec() + nrows, ncols = gs.get_geometry() + if gs._layoutgrid is None: + return + + bbox = gs._layoutgrid.get_inner_bbox(rows=ss.rowspan, cols=ss.colspan) + + bboxouter = gs._layoutgrid.get_outer_bbox(rows=ss.rowspan, + cols=ss.colspan) + + # transform from figure to panel for set_position: + newbbox = trans_fig_to_panel.transform_bbox(bbox) + ax._set_position(newbbox) + + # move the colorbars: + # we need to keep track of oldw and oldh if there is more than + # one colorbar: + offset = {'left': 0, 'right': 0, 'bottom': 0, 'top': 0} + for nn, cbax in enumerate(ax._colorbars[::-1]): + if ax == cbax._colorbar_info['parents'][0]: + margin = _reposition_colorbar( + cbax, renderer, offset=offset) + + +def _reposition_colorbar(cbax, renderer, *, offset=None): """ - Do the layout for a colorbar, to not overly pollute colorbar.py + Place the colorbar in its new place. + + Parameters + ---------- + cbax : Axes + Axes for the colorbar - *pad* is in fraction of the original axis size. + renderer : + w_pad, h_pad : float + width and height padding (in fraction of figure) + hspace, wspace : float + width and height padding as fraction of figure size divided by + number of columns or rows + margin : array-like + offset the colorbar needs to be pushed to in order to + account for multiple colorbars """ - 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) + parents = cbax._colorbar_info['parents'] + gs = parents[0].get_gridspec() + ncols, nrows = gs.ncols, gs.nrows + fig = cbax.figure + trans_fig_to_panel = fig.transFigure - fig.transPanel + + cb_rspans, cb_cspans = _get_cb_parent_spans(cbax) + bboxparent = gs._layoutgrid.get_bbox_for_cb(rows=cb_rspans, cols=cb_cspans) + pb = gs._layoutgrid.get_inner_bbox(rows=cb_rspans, cols=cb_cspans) + location = cbax._colorbar_info['location'] + anchor = cbax._colorbar_info['anchor'] + fraction = cbax._colorbar_info['fraction'] + aspect = cbax._colorbar_info['aspect'] + shrink = cbax._colorbar_info['shrink'] + + cbpos, cbbbox = _get_pos_and_bbox(cbax, renderer) + + # Colorbar gets put at extreme edge of outer bbox of the subplotspec + # It needs to be moved in by: 1) a pad 2) its "margin" 3) by + # any colorbars already added at this location: + cbpad = _colorbar_get_pad(cbax) + if location in ('left', 'right'): + # fraction and shrink are fractions of parent + pbcb = pb.shrunk(fraction, shrink).anchored(anchor, pb) + # The colorbar is at the left side of the parent. Need + # to translate to right (or left) if location == 'right': - # arrange to right of parent axis - layoutbox.hstack([axlb, lb], padding=pad * axlb.width, - strength='strong') + lmargin = cbpos.x0 - cbbbox.x0 + dx = bboxparent.x1 - pbcb.x0 + offset['right'] + dx += cbpad + lmargin + offset['right'] += cbbbox.width + cbpad + pbcb = pbcb.translated(dx, 0) 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) + lmargin = cbpos.x0 - cbbbox.x0 + dx = bboxparent.x0 - pbcb.x0 # edge of parent + dx += -cbbbox.width - cbpad + lmargin - offset['left'] + offset['left'] += cbbbox.width + cbpad + pbcb = pbcb.translated(dx, 0) + else: # horizontal axes: + pbcb = pb.shrunk(shrink, fraction).anchored(anchor, pb) + if location == 'top': + bmargin = cbpos.y0 - cbbbox.y0 + dy = bboxparent.y1 - pbcb.y0 + offset['top'] + dy += cbpad + bmargin + offset['top'] += cbbbox.height + cbpad + pbcb = pbcb.translated(0, dy) 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') + bmargin = cbpos.y0 - cbbbox.y0 + dy = bboxparent.y0 - pbcb.y0 + dy += -cbbbox.height - cbpad + bmargin - offset['bottom'] + offset['bottom'] += cbbbox.height + cbpad + pbcb = pbcb.translated(0, dy) - return lb, lbpos + pbcb = trans_fig_to_panel.transform_bbox(pbcb) + cbax.set_transform(fig.transPanel) + cbax._set_position(pbcb) + cbax.set_aspect(aspect, anchor=anchor, adjustable='box') + return offset -def _getmaxminrowcolumn(axs): - """ - Find axes covering the first and last rows and columns of a list of axes. +def _reset_margins(fig): """ - startrow = startcol = np.inf - stoprow = stopcol = -np.inf - startax_row = startax_col = stopax_row = stopax_col = None - for ax in axs: - subspec = ax.get_subplotspec() - if subspec.rowspan.start < startrow: - startrow = subspec.rowspan.start - startax_row = ax - if subspec.rowspan.stop > stoprow: - stoprow = subspec.rowspan.stop - stopax_row = ax - if subspec.colspan.start < startcol: - startcol = subspec.colspan.start - startax_col = ax - if subspec.colspan.stop > stopcol: - stopcol = subspec.colspan.stop - stopax_col = ax - return (startrow, stoprow - 1, startax_row, stopax_row, - startcol, stopcol - 1, startax_col, stopax_col) - - -def layoutcolorbargridspec(parents, cax, shrink, aspect, location, pad=0.05): - """ - Do the layout for a colorbar, to not overly pollute colorbar.py + Reset the margins in the layoutboxes of fig. - *pad* is in fraction of the original axis size. + Margins are usually set as a minimum, so if the figure gets smaller + the minimum needs to be zero in order for it to grow again. """ + for span in fig.panels: + _reset_margins(span) + for ax in fig.axes: + if hasattr(ax, 'get_subplotspec'): + ss = ax.get_subplotspec() + gs = ss.get_gridspec() + if gs._layoutgrid is not None: + gs._layoutgrid.reset_margins() + fig._layoutgrid.reset_margins() - 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) - # figure out the row and column extent of the parents. - (minrow, maxrow, minax_row, maxax_row, - mincol, maxcol, minax_col, maxax_col) = _getmaxminrowcolumn(parents) +def _colorbar_get_pad(cax): + parents = cax._colorbar_info['parents'] + gs = parents[0].get_gridspec() - if location in ('left', 'right'): - lbpos = layoutbox.LayoutBox( - parent=lb, - name=lb.name + '.pos', - tightwidth=False, - pos=True, - subplot=False, - artist=cax) - for ax in parents: - if location == 'right': - order = [ax._layoutbox, lb] - else: - order = [lb, ax._layoutbox] - layoutbox.hstack(order, padding=pad * gslb.width, - strength='strong') - # 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. - - # Horizontal Layout: need to check all the axes in this gridspec - for ch in gslb.children: - subspec = ch.artist - if location == 'right': - if subspec.colspan.stop - 1 <= maxcol: - order = [subspec._layoutbox, lb] - # arrange to right of the parents - elif subspec.colspan.start > maxcol: - order = [lb, subspec._layoutbox] - elif location == 'left': - if subspec.colspan.start >= mincol: - order = [lb, subspec._layoutbox] - elif subspec.colspan.stop - 1 < mincol: - order = [subspec._layoutbox, lb] - layoutbox.hstack(order, padding=pad * gslb.width, - strength='strong') - - # Vertical layout: - maxposlb = minax_row._poslayoutbox - minposlb = maxax_row._poslayoutbox - # now we want the height of the colorbar pos to be - # set by the top and bottom of the min/max axes... - # 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) - - for ax in parents: - if location == 'bottom': - order = [ax._layoutbox, lb] - else: - order = [lb, ax._layoutbox] - layoutbox.vstack(order, padding=pad * gslb.width, - strength='strong') - - # Vertical Layout: need to check all the axes in this gridspec - for ch in gslb.children: - subspec = ch.artist - if location == 'bottom': - if subspec.rowspan.stop - 1 <= minrow: - order = [subspec._layoutbox, lb] - elif subspec.rowspan.start > maxrow: - order = [lb, subspec._layoutbox] - elif location == 'top': - if subspec.rowspan.stop - 1 < minrow: - order = [subspec._layoutbox, lb] - elif subspec.rowspan.start >= maxrow: - order = [lb, subspec._layoutbox] - layoutbox.vstack(order, padding=pad * gslb.width, - strength='strong') - - # Do horizontal layout... - maxposlb = maxax_col._poslayoutbox - minposlb = minax_col._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 + cb_rspans, cb_cspans = _get_cb_parent_spans(cax) + bboxouter = gs._layoutgrid.get_inner_bbox(rows=cb_rspans, cols=cb_cspans) + + if cax._colorbar_info['location'] in ['right', 'left']: + size = bboxouter.width + else: + size = bboxouter.height + + return cax._colorbar_info['pad'] * size diff --git a/lib/matplotlib/_layoutbox.py b/lib/matplotlib/_layoutbox.py deleted file mode 100644 index 2c61890e6bce..000000000000 --- a/lib/matplotlib/_layoutbox.py +++ /dev/null @@ -1,679 +0,0 @@ -""" - -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. - -""" - -import itertools -import kiwisolver as kiwi -import logging -import numpy as np - - -_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: - """ - 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 - - self.top = Variable(sn + 'top') - self.bottom = Variable(sn + 'bottom') - self.left = Variable(sn + 'left') - self.right = Variable(sn + 'right') - - self.width = Variable(sn + 'width') - self.height = Variable(sn + 'height') - self.h_center = Variable(sn + 'h_center') - self.v_center = Variable(sn + 'v_center') - - self.min_width = Variable(sn + 'min_width') - self.min_height = Variable(sn + 'min_height') - self.pref_width = Variable(sn + 'pref_width') - self.pref_height = Variable(sn + 'pref_height') - # margins are only used for axes-position layout boxes. maybe should - # be a separate subclass: - self.left_margin = Variable(sn + 'left_margin') - self.right_margin = Variable(sn + 'right_margin') - self.bottom_margin = Variable(sn + 'bottom_margin') - self.top_margin = Variable(sn + 'top_margin') - # mins - self.left_margin_min = Variable(sn + 'left_margin_min') - self.right_margin_min = Variable(sn + 'right_margin_min') - self.bottom_margin_min = Variable(sn + 'bottom_margin_min') - self.top_margin_min = Variable(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 figure 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 - other 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] - return name[:2] == 'ss' - - def _is_gridspec_layoutbox(self): - """ - Helper to check if this layoutbox is the layoutbox of a gridspec. - """ - name = self.name.split('.')[-1] - return name[:8] == 'gridspec' - - 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 = 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.cumsum(np.column_stack([sepHeights, cellHeights]).flat) - - # 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 = 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.cumsum(np.column_stack([sepWidths, cellWidths]).flat) - - 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)] - - rowNum1, colNum1 = divmod(subspec.num1, ncols) - rowNum2, colNum2 = divmod(subspec.num2, ncols) - figBottom = min(figBottoms[rowNum1], figBottoms[rowNum2]) - figTop = max(figTops[rowNum1], figTops[rowNum2]) - figLeft = min(figLefts[colNum1], figLefts[colNum2]) - figRight = max(figRights[colNum1], figRights[colNum2]) - - # 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): - return (f'LayoutBox: {self.name:25s}, ' - f'(left: {self.left.value():1.3f}) ' - f'(bot: {self.bottom.value():1.3f}) ' - f'(right: {self.right.value():1.3f}) ' - f'(top: {self.top.value():1.3f})') - - -# 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, strength='medium'): - """Stack LayoutBox instances from top to bottom.""" - 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, strength='medium'): - """Stack LayoutBox instances from top to bottom.""" - 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 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.""" - return '%06d' % 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, signalling 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: - 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/_layoutgrid.py b/lib/matplotlib/_layoutgrid.py new file mode 100644 index 000000000000..4cf7b9d886c3 --- /dev/null +++ b/lib/matplotlib/_layoutgrid.py @@ -0,0 +1,560 @@ +""" +A layoutgrid is a nrows by ncols set of boxes, meant to be used by +`._constrained_layout`, each box is analagous to a subplotspec element of +a gridspec. + +Each box is defined by left[ncols], right[ncols], bottom[nrows] and top[nrows], +and by two editable margins for each side. The main margin gets its value +set by the size of ticklabels, titles, etc on each axes that is in the figure. +The outer margin is the padding around the axes, and space for any +colorbars. + +The "inner" widths and heights of these boxes are then constrained to be the +same (relative the values of `width_ratios[ncols]` and `height_ratios[nrows]`). + +The layoutgrid is then constrained to be contained within a parent layoutgrid, +its column(s) and row(s) specified when it is created. +""" + +import itertools +import kiwisolver as kiwi +import logging +import numpy as np +from matplotlib.transforms import Bbox + + +_log = logging.getLogger(__name__) + + +class LayoutGrid: + """ + Analagous to a gridspec, and contained in another LayoutGrid. + """ + + def __init__(self, parent=None, parent_pos=(0, 0), + parent_inner=False, name='', ncols=1, nrows=1, + h_pad=None, w_pad=None, width_ratios=None, + height_ratios=None): + Variable = kiwi.Variable + self.parent = parent + self.parent_pos = parent_pos + self.parent_inner = parent_inner + self.name = name + self.nrows = nrows + self.ncols = ncols + self.height_ratios = np.atleast_1d(height_ratios) + if height_ratios is None: + self.height_ratios = np.ones(nrows) + self.width_ratios = np.atleast_1d(width_ratios) + if width_ratios is None: + self.width_ratios = np.ones(ncols) + + sn = self.name + '_' + if parent is None: + self.parent = None + self.solver = kiwi.Solver() + else: + self.parent = parent + parent.add_child(self, *parent_pos) + self.solver = self.parent.solver + # keep track of artist associated w/ this layout. Can be none + self.artists = np.empty((nrows, ncols), dtype=object) + self.children = np.empty((nrows, ncols), dtype=object) + + self.margins = {} + self.margin_vals = {} + # all the boxes in each column share the same left/right margins: + for todo in ['left', 'right', 'leftcb', 'rightcb']: + # track the value so we can change only if a margin is larger + # than the current value + self.margin_vals[todo] = np.zeros(ncols) + + sol = self.solver + + # These are redundant, but make life easier if + # we define them all. All that is really + # needed is left/right, margin['left'], and margin['right'] + self.widths = [Variable(f'{sn}widths[{i}]') for i in range(ncols)] + self.lefts = [Variable(f'{sn}lefts[{i}]') for i in range(ncols)] + self.rights = [Variable(f'{sn}rights[{i}]') for i in range(ncols)] + self.inner_widths = [Variable(f'{sn}inner_widths[{i}]') + for i in range(ncols)] + for todo in ['left', 'right', 'leftcb', 'rightcb']: + self.margins[todo] = [Variable(f'{sn}margins[{todo}][{i}]') + for i in range(ncols)] + for i in range(ncols): + sol.addEditVariable(self.margins[todo][i], 'strong') + + for todo in ['bottom', 'top', 'bottomcb', 'topcb']: + self.margins[todo] = np.empty((nrows), dtype=object) + self.margin_vals[todo] = np.zeros(nrows) + + self.heights = [Variable(f'{sn}heights[{i}]') for i in range(nrows)] + self.inner_heights = [Variable(f'{sn}inner_heights[{i}]') + for i in range(nrows)] + self.bottoms = [Variable(f'{sn}bottoms[{i}]') for i in range(nrows)] + self.tops = [Variable(f'{sn}tops[{i}]') for i in range(nrows)] + for todo in ['bottom', 'top', 'bottomcb', 'topcb']: + self.margins[todo] = [Variable(f'{sn}margins[{todo}][{i}]') + for i in range(nrows)] + for i in range(nrows): + sol.addEditVariable(self.margins[todo][i], 'strong') + + # set these margins to zero by default. They will be edited as + # children are filled. + self.reset_margins() + self.add_constraints() + + self.h_pad = h_pad + self.w_pad = w_pad + + def __repr__(self): + str = f'LayoutBox: {self.name:25s} {self.nrows}x{self.ncols},\n' + for i in range(self.nrows): + for j in range(self.ncols): + str += f'{i}, {j}: '\ + f'L({self.lefts[j].value():1.3f}, ' \ + f'B{self.bottoms[i].value():1.3f}, ' \ + f'W{self.widths[j].value():1.3f}, ' \ + f'H{self.heights[i].value():1.3f}, ' \ + f'innerW{self.inner_widths[j].value():1.3f}, ' \ + f'innerH{self.inner_heights[i].value():1.3f}, ' \ + f'ML{self.margins["left"][j].value():1.3f}, ' \ + f'MR{self.margins["right"][j].value():1.3f}, \n' + return str + + def reset_margins(self): + """ + Reset all the margins to zero. Must do this after changing + figure size, for instance, because the relative size of the + axes labels etc changes. + """ + for todo in ['left', 'right', 'bottom', 'top', + 'leftcb', 'rightcb', 'bottomcb', 'topcb']: + self.edit_margins(todo, 0.0) + + def add_constraints(self): + # define self-consistent constraints + self.hard_constraints() + # define relationship with parent layoutgrid: + self.parent_constraints() + # define relative widths of the grid cells to each other + # and stack horizontally and vertically. + self.grid_constraints() + + def hard_constraints(self): + """ + These are the redundant constraints, plus ones that make the + rest of the code easier. + """ + for i in range(self.ncols): + hc = [self.rights[i] >= self.lefts[i], + (self.rights[i] - self.margins['right'][i] - + self.margins['rightcb'][i] >= + self.lefts[i] - self.margins['left'][i] - + self.margins['leftcb'][i]) + ] + for c in hc: + self.solver.addConstraint(c | 'required') + + for i in range(self.nrows): + hc = [self.tops[i] >= self.bottoms[i], + (self.tops[i] - self.margins['top'][i] - + self.margins['topcb'][i] >= + self.bottoms[i] - self.margins['bottom'][i] - + self.margins['bottomcb'][i]) + ] + for c in hc: + self.solver.addConstraint(c | 'required') + + def add_child(self, child, i=0, j=0): + self.children[i, j] = child + + def parent_constraints(self): + # constraints that are due to the parent... + # i.e. the first column's left is equal to the + # parent's left, the last column right equal to the + # parent's right... + parent = self.parent + if parent is None: + hc = [self.lefts[0] == 0, + self.rights[-1] == 1, + # top and bottom reversed order... + self.tops[0] == 1, + self.bottoms[-1] == 0] + else: + rows, cols = self.parent_pos + rows = np.atleast_1d(rows) + cols = np.atleast_1d(cols) + + left = parent.lefts[cols[0]] + right = parent.rights[cols[-1]] + top = parent.tops[rows[0]] + bottom = parent.bottoms[rows[-1]] + if self.parent_inner: + # the layout grid is contained inside the inner + # grid of the parent. + left += parent.margins['left'][cols[0]] + left += parent.margins['leftcb'][cols[0]] + right -= parent.margins['right'][cols[-1]] + right -= parent.margins['rightcb'][cols[-1]] + top -= parent.margins['top'][rows[0]] + top -= parent.margins['topcb'][rows[0]] + bottom += parent.margins['bottom'][rows[-1]] + bottom += parent.margins['bottomcb'][rows[-1]] + hc = [self.lefts[0] == left, + self.rights[-1] == right, + # from top to bottom + self.tops[0] == top, + self.bottoms[-1] == bottom] + for c in hc: + self.solver.addConstraint(c | 'required') + + def grid_constraints(self): + # constrain the ratio of the inner part of the grids + # to be the same (relative to width_ratios) + + # constrain widths: + w = (self.rights[0] - self.margins['right'][0] - + self.margins['rightcb'][0]) + w = (w - self.lefts[0] - self.margins['left'][0] - + self.margins['leftcb'][0]) + w0 = w / self.width_ratios[0] + # from left to right + for i in range(1, self.ncols): + w = (self.rights[i] - self.margins['right'][i] - + self.margins['rightcb'][i]) + w = (w - self.lefts[i] - self.margins['left'][i] - + self.margins['leftcb'][i]) + c = (w == w0 * self.width_ratios[i]) + self.solver.addConstraint(c | 'strong') + # constrain the grid cells to be directly next to each other. + c = (self.rights[i - 1] == self.lefts[i]) + self.solver.addConstraint(c | 'strong') + + # constrain heights: + h = self.tops[0] - self.margins['top'][0] - self.margins['topcb'][0] + h = (h - self.bottoms[0] - self.margins['bottom'][0] - + self.margins['bottomcb'][0]) + h0 = h / self.height_ratios[0] + # from top to bottom: + for i in range(1, self.nrows): + h = (self.tops[i] - self.margins['top'][i] - + self.margins['topcb'][i]) + h = (h - self.bottoms[i] - self.margins['bottom'][i] - + self.margins['bottomcb'][i]) + c = (h == h0 * self.height_ratios[i]) + self.solver.addConstraint(c | 'strong') + # constrain the grid cells to be directly above each other. + c = (self.bottoms[i - 1] == self.tops[i]) + self.solver.addConstraint(c | 'strong') + + # Margin editing: The margins are variable and meant to + # contain things of a fixed size like axes labels, tick labels, titles + # etc + def edit_margin(self, todo, size, cell): + """ + Change the size of the margin for one cell. + + Parameters + ---------- + todo : string (one of 'left', 'right', 'bottom', 'top') + margin to alter. + + size : float + Size of the margin. If it is larger than the existing minimum it + updates the margin size. Fraction of figure size. + + cell : int + Cell column or row to edit. + """ + self.solver.suggestValue(self.margins[todo][cell], size) + self.margin_vals[todo][cell] = size + + def edit_margin_min(self, todo, size, cell=0): + """ + Change the minimum size of the margin for one cell. + + Parameters + ---------- + todo : string (one of 'left', 'right', 'bottom', 'top') + margin to alter. + + size : float + Minimum size of the margin . If it is larger than the + existing minimum it updates the margin size. Fraction of + figure size. + + cell: int + Cell column or row to edit. + """ + + if size > self.margin_vals[todo][cell]: + self.edit_margin(todo, size, cell) + + def edit_margins(self, todo, size): + """ + Change the size of all the margin of all the cells in the layout grid. + + Parameters + ---------- + todo : string (one of 'left', 'right', 'bottom', 'top') + margin to alter. + + size : float + Size to set the margins. Fraction of figure size. + """ + + for i in range(len(self.margin_vals[todo])): + self.edit_margin(todo, size, i) + + def edit_all_margins_min(self, todo, size): + """ + Change the minimum size of all the margin of all + the cells in the layout grid. + + Parameters + ---------- + todo: string (one of 'left', 'right', 'bottom', 'top') + margin to alter. + + size: float + Minimum size of the margin . If it is larger than the + existing minimum it updates the margin size. Fraction of + figure size. + """ + + for i in range(len(self.margin_vals[todo])): + self.edit_margin_min(todo, size, i) + + def edit_outer_margin_mins(self, margin, ss): + """ + Edit all four margin minimums in one statement. + + Parameters + ---------- + margin: dict + size of margins in a dict with keys 'left', 'right', 'bottom', + 'top' + + ss: SubplotSpec + defines the subplotspec these margins should be applied to + """ + + self.edit_margin_min('left', margin['left'], ss.colspan.start) + self.edit_margin_min('leftcb', margin['leftcb'], ss.colspan.start) + self.edit_margin_min('right', margin['right'], ss.colspan.stop - 1) + self.edit_margin_min('rightcb', margin['rightcb'], ss.colspan.stop - 1) + # rows are from the top down: + self.edit_margin_min('top', margin['top'], ss.rowspan.start) + self.edit_margin_min('topcb', margin['topcb'], ss.rowspan.start) + self.edit_margin_min('bottom', margin['bottom'], ss.rowspan.stop - 1) + self.edit_margin_min('bottomcb', margin['bottomcb'], + ss.rowspan.stop - 1) + + def get_margins(self, todo, col): + """Return the margin at this position""" + return self.margin_vals[todo][col] + + def get_outer_bbox(self, rows=0, cols=0): + """ + Return the outer bounding box of the subplot specs + given by rows and cols. rows and cols can be spans. + """ + rows = np.atleast_1d(rows) + cols = np.atleast_1d(cols) + + bbox = Bbox.from_extents( + self.lefts[cols[0]].value(), + self.bottoms[rows[-1]].value(), + self.rights[cols[-1]].value(), + self.tops[rows[0]].value()) + return bbox + + def get_inner_bbox(self, rows=0, cols=0): + """ + Return the inner bounding box of the subplot specs + given by rows and cols. rows and cols can be spans. + """ + rows = np.atleast_1d(rows) + cols = np.atleast_1d(cols) + + bbox = Bbox.from_extents( + (self.lefts[cols[0]].value() + + self.margins['left'][cols[0]].value() + + self.margins['leftcb'][cols[0]].value()), + (self.bottoms[rows[-1]].value() + + self.margins['bottom'][rows[-1]].value() + + self.margins['bottomcb'][rows[-1]].value()), + (self.rights[cols[-1]].value() - + self.margins['right'][cols[-1]].value() - + self.margins['rightcb'][cols[-1]].value()), + (self.tops[rows[0]].value() - + self.margins['top'][rows[0]].value() - + self.margins['topcb'][rows[0]].value()) + ) + return bbox + + def get_bbox_for_cb(self, rows=0, cols=0): + """ + Return the bounding box that includes the + decorations but, *not* the colorbar... + """ + rows = np.atleast_1d(rows) + cols = np.atleast_1d(cols) + + bbox = Bbox.from_extents( + (self.lefts[cols[0]].value() + + self.margins['leftcb'][cols[0]].value()), + (self.bottoms[rows[-1]].value() + + self.margins['bottomcb'][rows[-1]].value()), + (self.rights[cols[-1]].value() - + self.margins['rightcb'][cols[-1]].value()), + (self.tops[rows[0]].value() - + self.margins['topcb'][rows[0]].value()) + ) + return bbox + + def get_left_margin_bbox(self, rows=0, cols=0): + """ + Return the left margin bounding box of the subplot specs + given by rows and cols. rows and cols can be spans. + """ + rows = np.atleast_1d(rows) + cols = np.atleast_1d(cols) + + bbox = Bbox.from_extents( + (self.lefts[cols[0]].value() + + self.margins['leftcb'][cols[0]].value()), + (self.bottoms[rows[-1]].value()), + (self.lefts[cols[0]].value() + + self.margins['leftcb'][cols[0]].value() + + self.margins['left'][cols[0]].value()), + (self.tops[rows[0]].value())) + return bbox + + def get_bottom_margin_bbox(self, rows=0, cols=0): + """ + Return the left margin bounding box of the subplot specs + given by rows and cols. rows and cols can be spans. + """ + rows = np.atleast_1d(rows) + cols = np.atleast_1d(cols) + + bbox = Bbox.from_extents( + (self.lefts[cols[0]].value()), + (self.bottoms[rows[-1]].value() + + self.margins['bottomcb'][rows[-1]].value()), + (self.rights[cols[-1]].value()), + (self.bottoms[rows[-1]].value() + + self.margins['bottom'][rows[-1]].value() + + self.margins['bottomcb'][rows[-1]].value() + )) + return bbox + + def get_right_margin_bbox(self, rows=0, cols=0): + """ + Return the left margin bounding box of the subplot specs + given by rows and cols. rows and cols can be spans. + """ + rows = np.atleast_1d(rows) + cols = np.atleast_1d(cols) + + bbox = Bbox.from_extents( + (self.rights[cols[-1]].value() - + self.margins['right'][cols[-1]].value() - + self.margins['rightcb'][cols[-1]].value()), + (self.bottoms[rows[-1]].value()), + (self.rights[cols[-1]].value() - + self.margins['rightcb'][cols[-1]].value()), + (self.tops[rows[0]].value())) + return bbox + + def get_top_margin_bbox(self, rows=0, cols=0): + """ + Return the left margin bounding box of the subplot specs + given by rows and cols. rows and cols can be spans. + """ + rows = np.atleast_1d(rows) + cols = np.atleast_1d(cols) + + bbox = Bbox.from_extents( + (self.lefts[cols[0]].value()), + (self.tops[rows[0]].value() - + self.margins['topcb'][rows[0]].value()), + (self.rights[cols[-1]].value()), + (self.tops[rows[0]].value() - + self.margins['topcb'][rows[0]].value() - + self.margins['top'][rows[0]].value())) + return bbox + + def update_variables(self): + """ + Update the variables for the solver attached to this layoutgrid. + """ + self.solver.updateVariables() + +_layoutboxobjnum = itertools.count() + + +def seq_id(): + """Generate a short sequential id for layoutbox objects.""" + return '%06d' % next(_layoutboxobjnum) + + +def print_children(lb): + """Print the children of the layoutbox.""" + for child in lb.children: + print_children(child) + + +def plot_children(fig, lg, level=0, printit=False): + """Simple plotting to show where boxes are.""" + import matplotlib.pyplot as plt + import matplotlib.patches as mpatches + + fig.canvas.draw() + + colors = plt.rcParams["axes.prop_cycle"].by_key()["color"] + col = colors[level] + for i in range(lg.nrows): + for j in range(lg.ncols): + bb = lg.get_outer_bbox(rows=i, cols=j) + fig.add_artist( + mpatches.Rectangle(bb.p0, bb.width, bb.height, linewidth=1, + edgecolor='0.7', facecolor='0.7', + alpha=0.2, transform=fig.transFigure, + zorder=-3)) + bbi = lg.get_inner_bbox(rows=i, cols=j) + fig.add_artist( + mpatches.Rectangle(bbi.p0, bbi.width, bbi.height, linewidth=2, + edgecolor=col, facecolor='none', + transform=fig.transFigure, zorder=-2)) + + bbi = lg.get_left_margin_bbox(rows=i, cols=j) + fig.add_artist( + mpatches.Rectangle(bbi.p0, bbi.width, bbi.height, linewidth=0, + edgecolor='none', alpha=0.2, + facecolor=[0.5, 0.7, 0.5], + transform=fig.transFigure, zorder=-2)) + bbi = lg.get_right_margin_bbox(rows=i, cols=j) + fig.add_artist( + mpatches.Rectangle(bbi.p0, bbi.width, bbi.height, linewidth=0, + edgecolor='none', alpha=0.2, + facecolor=[0.7, 0.5, 0.5], + transform=fig.transFigure, zorder=-2)) + bbi = lg.get_bottom_margin_bbox(rows=i, cols=j) + fig.add_artist( + mpatches.Rectangle(bbi.p0, bbi.width, bbi.height, linewidth=0, + edgecolor='none', alpha=0.2, + facecolor=[0.5, 0.5, 0.7], + transform=fig.transFigure, zorder=-2)) + bbi = lg.get_top_margin_bbox(rows=i, cols=j) + fig.add_artist( + mpatches.Rectangle(bbi.p0, bbi.width, bbi.height, linewidth=0, + edgecolor='none', alpha=0.2, + facecolor=[0.7, 0.2, 0.7], + transform=fig.transFigure, zorder=-2)) + for ch in lg.children.flat: + if ch is not None: + plot_children(fig, ch, level=level+1) diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index 282517cba817..33fbf1061227 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -496,7 +496,9 @@ def __init__(self, fig, rect, self.set_figure(fig) self.set_box_aspect(box_aspect) self._axes_locator = None # Optionally set via update(kwargs). - + # placeholder for any colorbars added that use this axes. + # (see colorbar.py): + self._colorbars = [] self.spines = self._gen_axes_spines() # this call may differ for non-sep axes, e.g., polar @@ -568,15 +570,10 @@ def __init__(self, fig, rect, 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().__getstate__() - for key in ['_layoutbox', '_poslayoutbox']: - state[key] = None # Prune the sharing & twinning info to only contain the current group. for grouper_name in [ '_shared_x_axes', '_shared_y_axes', '_twinned_axes']: @@ -914,8 +911,6 @@ def set_position(self, pos, which='both'): self._set_position(pos, which=which) # 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'): """ diff --git a/lib/matplotlib/axes/_secondary_axes.py b/lib/matplotlib/axes/_secondary_axes.py index 2cd0934191bb..5f9be67809b8 100644 --- a/lib/matplotlib/axes/_secondary_axes.py +++ b/lib/matplotlib/axes/_secondary_axes.py @@ -36,8 +36,6 @@ def __init__(self, parent, orientation, location, functions, **kwargs): self._otherstrings = ['top', 'bottom'] self._parentscale = None # this gets positioned w/o constrained_layout so exclude: - self._layoutbox = None - self._poslayoutbox = None self.set_location(location) self.set_functions(functions) diff --git a/lib/matplotlib/axes/_subplots.py b/lib/matplotlib/axes/_subplots.py index 1b5b181149c4..0273e51a1f79 100644 --- a/lib/matplotlib/axes/_subplots.py +++ b/lib/matplotlib/axes/_subplots.py @@ -5,7 +5,6 @@ import matplotlib.artist as martist from matplotlib.axes._axes import Axes from matplotlib.gridspec import GridSpec, SubplotSpec -import matplotlib._layoutbox as layoutbox class SubplotBase: @@ -40,23 +39,6 @@ def __init__(self, fig, *args, **kwargs): self.update_params() # _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 inherit from a subplotbase @@ -159,10 +141,6 @@ def _make_twin_axes(self, *args, **kwargs): twin.set_label(real_label) self.set_adjustable('datalim') twin.set_adjustable('datalim') - if self._layoutbox is not None and twin._layoutbox is not None: - # make the layout boxes be explicitly the same - twin._layoutbox.constrain_same(self._layoutbox) - twin._poslayoutbox.constrain_same(self._poslayoutbox) self._twinned_axes.join(self, twin) return twin diff --git a/lib/matplotlib/colorbar.py b/lib/matplotlib/colorbar.py index d479dc19b550..f39813c1ec56 100644 --- a/lib/matplotlib/colorbar.py +++ b/lib/matplotlib/colorbar.py @@ -47,8 +47,6 @@ 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 _log = logging.getLogger(__name__) @@ -1421,21 +1419,11 @@ def make_axes(parents, location=None, orientation=None, fraction=0.15, # because `plt.subplots` can return an ndarray and is natural to # pass to `colorbar`. parents = np.atleast_1d(parents).ravel() + fig = parents[0].get_figure() - # check if using constrained_layout: - try: - gs = parents[0].get_subplotspec().get_gridspec() - using_constrained_layout = (gs._layoutbox is not None) - except AttributeError: - using_constrained_layout = False - - # defaults are not appropriate for constrained_layout: - pad0 = loc_settings['pad'] - if using_constrained_layout: - pad0 = 0.02 + pad0 = 0.05 if fig.get_constrained_layout() else loc_settings['pad'] 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 ' 'parents share the same figure.') @@ -1474,31 +1462,20 @@ def make_axes(parents, location=None, orientation=None, fraction=0.15, ax.set_anchor(parent_anchor) cax = fig.add_axes(pbcb, label="") - - # 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 not parents_iterable: - # 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 + for a in parents: + # tell the parent it has a colorbar + a._colorbars += [cax] + cax._colorbar_info = dict( + location=location, + parents=parents, + shrink=shrink, + anchor=anchor, + panchor=parent_anchor, + fraction=fraction, + aspect=aspect, + pad=pad) + # and we need to set the aspect ratio by hand... + cax.set_aspect(aspect, anchor=anchor, adjustable='box') return cax, kw @@ -1555,9 +1532,6 @@ def make_axes_gridspec(parent, *, location=None, orientation=None, 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 segfault. - layoutbox.nonetree(parent.get_subplotspec().get_gridspec()._layoutbox) if location == "left": gs = parent.get_subplotspec().subgridspec( 1, 2, wspace=wh_space, width_ratios=[fraction, 1-fraction-pad]) diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index ce1dfd831721..4b9c7aeb4dd2 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -35,7 +35,7 @@ from matplotlib.text import Text from matplotlib.transforms import (Affine2D, Bbox, BboxTransformTo, TransformedBbox) -import matplotlib._layoutbox as layoutbox +import matplotlib._layoutgrid as layoutgrid _log = logging.getLogger(__name__) @@ -346,10 +346,10 @@ 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._layoutgrid = None + self._constrained = False self.set_tight_layout(tight_layout) @@ -357,6 +357,11 @@ def __init__(self, self.clf() self._cachedRenderer = None + self.set_constrained_layout(constrained_layout) + # stub for subpanels: + self.panels = [] + self.transPanel = self.transFigure + # groupers to keep track of x and y labels we want to align. # see self.align_xlabels and self.align_ylabels and # axis._get_tick_boxes_siblings @@ -515,6 +520,8 @@ def set_constrained_layout(self, constrained): else: self.set_constrained_layout_pads() + self.init_layoutgrid() + self.stale = True def set_constrained_layout_pads(self, **kwargs): @@ -572,7 +579,7 @@ def get_constrained_layout_pads(self, relative=False): 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) + renderer0 = layoutgrid.get_renderer(self) dpi = renderer0.dpi w_pad = w_pad * dpi / renderer0.width h_pad = h_pad * dpi / renderer0.height @@ -731,22 +738,8 @@ def suptitle(self, t, **kwargs): sup.remove() else: self._suptitle = sup - self._suptitle._layoutbox = None - if self._layoutbox is not None and not manual_position: - w_pad, h_pad, wspace, hspace = \ - self.get_constrained_layout_pads(relative=True) - figlb = self._layoutbox - self._suptitle._layoutbox = layoutbox.LayoutBox( - parent=figlb, artist=self._suptitle, - name=figlb.name+'.suptitle') - # stack the suptitle on top of all the children. - # Some day this should be on top of all the children in the - # gridspec only. - for child in figlb.children: - if child is not self._suptitle._layoutbox: - layoutbox.vstack([self._suptitle._layoutbox, - child], - padding=h_pad*2., strength='required') + if manual_position: + self._suptitle.set_in_layout(False) self.stale = True return self._suptitle @@ -1406,6 +1399,7 @@ def add_subplot(self, *args, **kwargs): def _add_axes_internal(self, key, ax): """Private helper for `add_axes` and `add_subplot`.""" + #self._localaxes += [ax] self._axstack.add(key, ax) self.sca(ax) ax._remove_method = self.delaxes @@ -1771,6 +1765,7 @@ def _break_share_link(ax, grouper): return None self._axstack.remove(ax) + # self._localaxes.remove(ax) self._axobservers.process("_axes_change_event", self) self.stale = True @@ -1810,7 +1805,7 @@ def clf(self, keep_observers=False): self._axobservers = cbook.CallbackRegistry() self._suptitle = None if self.get_constrained_layout(): - layoutbox.nonetree(self._layoutbox) + self.init_layoutgrid() self.stale = True def clear(self, keep_observers=False): @@ -2169,12 +2164,9 @@ def __getstate__(self): in _pylab_helpers.Gcf.figs.values(): state['_restore_to_pylab'] = True - # set all the layoutbox information to None. kiwisolver objects can't + # set all the layoutgrid information to None. kiwisolver objects can't # be pickled, so we lose the layout options at this point. - state.pop('_layoutbox', None) - # suptitle: - if self._suptitle is not None: - self._suptitle._layoutbox = None + state.pop('_layoutgrid', None) return state @@ -2191,7 +2183,7 @@ def __setstate__(self, state): # re-initialise some of the unstored state information FigureCanvasBase(self) # Set self.canvas. - self._layoutbox = None + self._layoutgrid = None if restore_to_pylab: # lazy import to avoid circularity @@ -2578,24 +2570,24 @@ def get_tightbbox(self, renderer, bbox_extra_artists=None): return bbox_inches - def init_layoutbox(self): - """Initialize 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 init_layoutgrid(self): + """Initialize the layoutgrid for use in constrained_layout.""" + del(self._layoutgrid) + self._layoutgrid = layoutgrid.LayoutGrid( + parent=None, name='figlb') def execute_constrained_layout(self, renderer=None): """ - Use ``layoutbox`` to determine pos positions within axes. + Use ``layoutgrid`` to determine pos positions within axes. See also `.set_constrained_layout_pads`. """ from matplotlib._constrained_layout import do_constrained_layout + from matplotlib.tight_layout import get_renderer _log.debug('Executing constrainedlayout') - if self._layoutbox is None: + if self._layoutgrid is None: cbook._warn_external("Calling figure.constrained_layout, but " "figure not setup to do constrained layout. " " You either called GridSpec without the " @@ -2610,7 +2602,7 @@ def execute_constrained_layout(self, renderer=None): w_pad = w_pad / width h_pad = h_pad / height if renderer is None: - renderer = layoutbox.get_renderer(fig) + renderer = get_renderer(fig) do_constrained_layout(fig, renderer, h_pad, w_pad, hspace, wspace) @cbook._delete_parameter("3.2", "renderer") diff --git a/lib/matplotlib/gridspec.py b/lib/matplotlib/gridspec.py index 3764d5af46ec..192d22173d5b 100644 --- a/lib/matplotlib/gridspec.py +++ b/lib/matplotlib/gridspec.py @@ -18,7 +18,8 @@ import matplotlib as mpl from matplotlib import _pylab_helpers, cbook, tight_layout, rcParams from matplotlib.transforms import Bbox -import matplotlib._layoutbox as layoutbox +import matplotlib._layoutgrid as layoutgrid + _log = logging.getLogger(__name__) @@ -397,7 +398,7 @@ def __init__(self, nrows, ncols, figure=None, The number of rows and columns of the grid. figure : `~.figure.Figure`, optional - Only used for constrained layout to create a proper layoutbox. + Only used for constrained layout to create a proper layoutgrid. left, right, top, bottom : float, optional Extent of the subplots as a fraction of figure width or height. @@ -440,22 +441,24 @@ def __init__(self, nrows, ncols, figure=None, width_ratios=width_ratios, height_ratios=height_ratios) + # set up layoutgrid for constrained_layout: + self._layoutgrid = None if self.figure is None or not self.figure.get_constrained_layout(): - self._layoutbox = None + self._layoutgrid = 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 gridspec will fill a figure. - # but this can change below if the gridspec is created from a - # subplotspec. (GridSpecFromSubplotSpec) + self._toplayoutbox = self.figure._layoutgrid + self._layoutgrid = layoutgrid.LayoutGrid( + parent=self.figure._layoutgrid, + parent_inner=True, + name=(self.figure._layoutgrid.name + '.gridspec' + + layoutgrid.seq_id()), + ncols=ncols, nrows=nrows, width_ratios=width_ratios, + height_ratios=height_ratios) _AllowedKeys = ["left", "bottom", "right", "top", "wspace", "hspace"] def __getstate__(self): - return {**self.__dict__, "_layoutbox": None} + return {**self.__dict__, "_layoutgrid": None} def update(self, **kwargs): """ @@ -584,16 +587,26 @@ def __init__(self, nrows, ncols, super().__init__(nrows, ncols, width_ratios=width_ratios, height_ratios=height_ratios) - # do the layoutboxes - subspeclb = subplot_spec._layoutbox + # do the layoutgrids for constrained_layout: + subspeclb = subplot_spec.get_gridspec()._layoutgrid if subspeclb is None: - self._layoutbox = None + self._layoutgrid = 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) + # this _toplayoutbox is a container that spans the cols and + # rows in the parent gridspec. Not yet implemented, + # but we do this so that it is possible to have subgridspec + # level artists. + self._toplayoutgrid = layoutgrid.LayoutGrid( + parent=subspeclb, + name=subspeclb.name + '.top' + layoutgrid.seq_id(), + nrows=1, ncols=1, + parent_pos=(subplot_spec.rowspan, subplot_spec.colspan)) + self._layoutgrid = layoutgrid.LayoutGrid( + parent=self._toplayoutgrid, + name=(self._toplayoutgrid.name + '.gridspec' + + layoutgrid.seq_id()), + nrows=nrows, ncols=ncols, + width_ratios=width_ratios, height_ratios=height_ratios) def get_subplot_params(self, figure=None): """Return a dictionary of subplot layout parameters.""" @@ -642,18 +655,6 @@ 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 contain 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 __repr__(self): return (f"{self.get_gridspec()}[" @@ -727,7 +728,7 @@ def num2(self, value): self._num2 = value def __getstate__(self): - return {**self.__dict__, "_layoutbox": None} + return {**self.__dict__} def get_gridspec(self): return self._gridspec diff --git a/lib/matplotlib/tests/baseline_images/test_axes/secondary_xy.png b/lib/matplotlib/tests/baseline_images/test_axes/secondary_xy.png index b9c79c1b2a9b..bbf9f9e13211 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_axes/secondary_xy.png and b/lib/matplotlib/tests/baseline_images/test_axes/secondary_xy.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout1.png b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout1.png index a12759614401..25eade2b6297 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout1.png 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 index 32c5089c6404..1d23c1db38de 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout10.png 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 index e8974806d993..514bf02ce13c 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout11.png 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 index 403a3acc64ca..b674803ba2e8 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout11rat.png 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 index 3e24209e18b0..07e35e9d800a 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout12.png 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 index 92c18947f9e6..5889b0583432 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout13.png 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 index 82fba5397e36..e030c3c9f6c1 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout14.png 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 index fb3915574226..6790ac835838 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout15.png 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 index 2c20f43dcf5f..841cf77b7e08 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout16.png and b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout16.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout17.png b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout17.png index 41ceab8f38d2..8af582f00926 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout17.png and b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout17.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 index 7a72a8117335..575cd9a45b7e 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout2.png 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 index 61337c0eeb21..c679609be54e 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout3.png 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 deleted file mode 100644 index 0cf2edcae0be..000000000000 Binary files a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout4.pdf and /dev/null 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 index a1e26c37c1a2..2a6e55c08f64 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout4.png and b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout4.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout5.png b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout5.png index 2bc15ba74e29..af691c44867d 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout5.png 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 index ed300a445c56..6ba96e41a34d 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout6.png and b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout6.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 index 78c49877ffd7..757230e25363 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout8.png 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 index c262adc048fb..59fd2c76c5bc 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout9.png and b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/constrained_layout9.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/test_colorbar_location.png b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/test_colorbar_location.png index 4cc1ee216fd2..2ca5a5c29eb1 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/test_colorbar_location.png and b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/test_colorbar_location.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/test_colorbars_no_overlapH.png b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/test_colorbars_no_overlapH.png new file mode 100644 index 000000000000..d91bcdf22b7a Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/test_colorbars_no_overlapH.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/test_colorbars_no_overlapV.png b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/test_colorbars_no_overlapV.png new file mode 100644 index 000000000000..1768fc2fdc35 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/test_colorbars_no_overlapV.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_text/large_subscript_title.png b/lib/matplotlib/tests/baseline_images/test_text/large_subscript_title.png index 36c0eeb5f88e..20eeea59d121 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_text/large_subscript_title.png and b/lib/matplotlib/tests/baseline_images/test_text/large_subscript_title.png differ diff --git a/lib/matplotlib/tests/test_constrainedlayout.py b/lib/matplotlib/tests/test_constrainedlayout.py index dbd8a3552ad0..13a3192e516c 100644 --- a/lib/matplotlib/tests/test_constrainedlayout.py +++ b/lib/matplotlib/tests/test_constrainedlayout.py @@ -64,7 +64,7 @@ def test_constrained_layout3(): fig.colorbar(pcm, ax=ax, pad=pad) -@image_comparison(['constrained_layout4']) +@image_comparison(['constrained_layout4.png']) def test_constrained_layout4(): """Test constrained_layout for a single colorbar with subplots""" # Remove this line when this test image is regenerated. @@ -76,7 +76,7 @@ def test_constrained_layout4(): fig.colorbar(pcm, ax=axs, pad=0.01, shrink=0.6) -@image_comparison(['constrained_layout5.png'], tol=5.e-2) +@image_comparison(['constrained_layout5.png']) def test_constrained_layout5(): """ Test constrained_layout for a single colorbar with subplots, @@ -123,8 +123,9 @@ def test_constrained_layout6(): def test_constrained_layout7(): """Test for proper warning if fig not set in GridSpec""" with pytest.warns( - UserWarning, match=('Calling figure.constrained_layout, but figure ' - 'not setup to do constrained layout')): + UserWarning, match=('There are no gridspecs with layoutgrids. ' + 'Possibly did not call parent GridSpec with ' + 'the "figure" keyword')): fig = plt.figure(constrained_layout=True) gs = gridspec.GridSpec(1, 2) gsl = gridspec.GridSpecFromSubplotSpec(2, 2, gs[0]) @@ -238,15 +239,15 @@ def test_constrained_layout11rat(): @image_comparison(['constrained_layout12.png']) def test_constrained_layout12(): """Test that very unbalanced labeling still works.""" - fig = plt.figure(constrained_layout=True) + fig = plt.figure(constrained_layout=True, figsize=(6, 8)) 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) + example_plot(ax1, fontsize=18) + example_plot(ax2, fontsize=18) ax = fig.add_subplot(gs0[0:2, 0]) example_plot(ax, nodec=True) @@ -388,15 +389,12 @@ def test_constrained_layout23(): """ for i in range(2): - fig, ax = plt.subplots(num="123", constrained_layout=True, clear=True) + fig = plt.figure(constrained_layout=True, clear=True, num="123") + gs = fig.add_gridspec(1, 2) + sub = gs[0].subgridspec(2, 2) fig.suptitle("Suptitle{}".format(i)) -# This test occasionally fails the image comparison tests, so we mark as -# flaky. Apparently the constraint solver occasionally doesn't fully -# optimize. Would be nice if this were more deterministic... -@pytest.mark.timeout(30) -@pytest.mark.flaky(reruns=3) @image_comparison(['test_colorbar_location.png'], remove_text=True, style='mpl20') def test_colorbar_location(): @@ -414,7 +412,7 @@ def test_colorbar_location(): ax.set_ylabel('') fig.colorbar(pcm, ax=axs[:, 1], shrink=0.4) fig.colorbar(pcm, ax=axs[-1, :2], shrink=0.5, location='bottom') - fig.colorbar(pcm, ax=axs[0, 2:], shrink=0.5, location='bottom') + fig.colorbar(pcm, ax=axs[0, 2:], shrink=0.5, location='bottom', pad=0.05) fig.colorbar(pcm, ax=axs[-2, 3:], shrink=0.5, location='top') fig.colorbar(pcm, ax=axs[0, 0], shrink=0.5, location='left') fig.colorbar(pcm, ax=axs[1:3, 2], shrink=0.5, location='right') @@ -430,4 +428,62 @@ def test_hidden_axes(): extents1 = np.copy(axs[0, 0].get_position().extents) np.testing.assert_allclose( - extents1, [0.045552, 0.548288, 0.47319, 0.982638], rtol=1e-5) + extents1, [0.045552, 0.543288, 0.47819, 0.982638], rtol=1e-5) + + +def test_colorbar_align(): + for location in ['right', 'left', 'top', 'bottom']: + fig, axs = plt.subplots(2, 2, constrained_layout=True) + cbs = [] + for nn, ax in enumerate(axs.flat): + ax.tick_params(direction='in') + pc = example_pcolor(ax) + cb = fig.colorbar(pc, ax=ax, location=location, shrink=0.6, + pad=0.04) + cbs += [cb] + cb.ax.tick_params(direction='in') + if nn != 1: + cb.ax.xaxis.set_ticks([]) + cb.ax.yaxis.set_ticks([]) + ax.set_xticklabels('') + ax.set_yticklabels('') + fig.set_constrained_layout_pads(w_pad=4 / 72, h_pad=4 / 72, hspace=0.1, + wspace=0.1) + + fig.canvas.draw() + if location in ['left', 'right']: + np.testing.assert_allclose(cbs[0].ax.get_position().x0, + cbs[2].ax.get_position().x0) + np.testing.assert_allclose(cbs[1].ax.get_position().x0, + cbs[3].ax.get_position().x0) + else: + np.testing.assert_allclose(cbs[0].ax.get_position().y0, + cbs[1].ax.get_position().y0) + np.testing.assert_allclose(cbs[2].ax.get_position().y0, + cbs[3].ax.get_position().y0) + + +@image_comparison(['test_colorbars_no_overlapV.png'], + remove_text=False, style='mpl20') +def test_colorbars_no_overlapV(): + fig = plt.figure(figsize=(2, 4), constrained_layout=True) + axs = fig.subplots(2, 1, sharex=True, sharey=True) + for ax in axs: + ax.yaxis.set_major_formatter(ticker.NullFormatter()) + ax.tick_params(axis='both', direction='in') + im = ax.imshow([[1, 2], [3, 4]]) + fig.colorbar(im, ax=ax, orientation="vertical") + fig.suptitle("foo") + + +@image_comparison(['test_colorbars_no_overlapH.png'], + remove_text=False, style='mpl20') +def test_colorbars_no_overlapH(): + fig = plt.figure(figsize=(4, 2), constrained_layout=True) + fig.suptitle("foo") + axs = fig.subplots(1, 2, sharex=True, sharey=True) + for ax in axs: + ax.yaxis.set_major_formatter(ticker.NullFormatter()) + ax.tick_params(axis='both', direction='in') + im = ax.imshow([[1, 2], [3, 4]]) + fig.colorbar(im, ax=ax, orientation="horizontal") diff --git a/lib/mpl_toolkits/axes_grid1/colorbar.py b/lib/mpl_toolkits/axes_grid1/colorbar.py index 94b5ac8867b8..ce28ff98b3c7 100644 --- a/lib/mpl_toolkits/axes_grid1/colorbar.py +++ b/lib/mpl_toolkits/axes_grid1/colorbar.py @@ -43,8 +43,10 @@ ============= ==================================================== *orientation* vertical or horizontal *fraction* 0.15; fraction of original axes to use for colorbar - *pad* 0.05 if vertical, 0.15 if horizontal; fraction - of original axes between colorbar and new image axes + *pad* Defaults to 0.05 if vertical, 0.15 if horizontal; fraction + of original axes between colorbar and new image axes. + Defaults to 0.05 for both if `.get_constrained_layout` + is *True*. *shrink* 1.0; fraction by which to shrink the colorbar *aspect* 20; ratio of long to short dimensions ============= ==================================================== diff --git a/tutorials/intermediate/constrainedlayout_guide.py b/tutorials/intermediate/constrainedlayout_guide.py index c31d603dcc98..a28229396241 100644 --- a/tutorials/intermediate/constrainedlayout_guide.py +++ b/tutorials/intermediate/constrainedlayout_guide.py @@ -54,7 +54,6 @@ import matplotlib.gridspec as gridspec import numpy as np - plt.rcParams['savefig.facecolor'] = "0.8" plt.rcParams['figure.figsize'] = 4.5, 4. plt.rcParams['figure.max_open_warning'] = 50 @@ -147,31 +146,6 @@ def example_plot(ax, fontsize=12, hide_labels=False): fig.colorbar(im, ax=axs[1:, ][:, 1], shrink=0.8) fig.colorbar(im, ax=axs[:, -1], shrink=0.6) -############################################################################ -# Note that there is a bit of a subtlety when specifying a single axes -# as the parent. In the following, it might be desirable and expected -# for the colorbars to line up, but they don't because the colorbar paired -# with the bottom axes is tied to the subplotspec of the axes, and hence -# shrinks when the gridspec-level colorbar is added. - -fig, axs = plt.subplots(3, 1, figsize=(4, 4), constrained_layout=True) -for ax in axs[:2]: - im = ax.pcolormesh(arr, **pc_kwargs) -fig.colorbar(im, ax=axs[:2], shrink=0.6) -im = axs[2].pcolormesh(arr, **pc_kwargs) -fig.colorbar(im, ax=axs[2], shrink=0.6) - -############################################################################ -# The API to make a single-axes behave like a list of axes is to specify -# it as a list (or other iterable container), as below: - -fig, axs = plt.subplots(3, 1, figsize=(4, 4), constrained_layout=True) -for ax in axs[:2]: - im = ax.pcolormesh(arr, **pc_kwargs) -fig.colorbar(im, ax=axs[:2], shrink=0.6) -im = axs[2].pcolormesh(arr, **pc_kwargs) -fig.colorbar(im, ax=[axs[2]], shrink=0.6) - #################################################### # Suptitle # ========= @@ -260,66 +234,72 @@ def example_plot(ax, fontsize=12, hide_labels=False): # Padding and Spacing # =================== # -# For constrained_layout, we have implemented 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 -# `~.figure.Figure.set_constrained_layout_pads`: +# Padding between axes is controlled in the horizontal by *w_pad* and +# *wspace*, and vertical by *h_pad* and *hspace*. These can be edited +# via `~.figure.Figure.set_constrained_layout_pads`. *w/h_pad* are +# the minimum space around the axes in units of inches: fig, axs = plt.subplots(2, 2, constrained_layout=True) for ax in axs.flat: example_plot(ax, hide_labels=True) -fig.set_constrained_layout_pads(w_pad=4/72, h_pad=4/72, hspace=0, wspace=0) +fig.set_constrained_layout_pads(w_pad=4 / 72, h_pad=4 / 72, hspace=0, wspace=0) + +########################################## +# Spacing between subplots is further set by *wspace* and *hspace*. These +# are specified as a fraction of the size of the subplot group as a whole. +# If these values are smaller than *w_pad* or *h_pad*, then the fixed pads are +# used instead. Note in the below 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.flat: example_plot(ax, hide_labels=True) -fig.set_constrained_layout_pads(w_pad=2/72, h_pad=2/72, hspace=0, wspace=0) +fig.set_constrained_layout_pads(w_pad=4 / 72, h_pad=4 / 72, hspace=0.2, + wspace=0.2) ########################################## -# 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 below how the space at the edges doesn't change from -# the above, but the space between subplots does. +# If there are more than two columns, the *wspace* is shared between them, +# so here the wspace is divided in 2, with a *wspace* of 0.1 between each +# column: -fig, axs = plt.subplots(2, 2, constrained_layout=True) +fig, axs = plt.subplots(2, 3, constrained_layout=True) for ax in axs.flat: example_plot(ax, hide_labels=True) -fig.set_constrained_layout_pads(w_pad=2/72, h_pad=2/72, hspace=0.2, wspace=0.2) - +fig.set_constrained_layout_pads(w_pad=4 / 72, h_pad=4 / 72, hspace=0.2, + wspace=0.2) ########################################## -# Spacing with colorbars -# ----------------------- -# -# Colorbars will be placed ``wspace`` and ``hsapce`` apart from other -# subplots. The padding between the colorbar and the axis it is -# attached to will never be less than ``w_pad`` (for a vertical colorbar) -# or ``h_pad`` (for a horizontal colorbar). Note the use of the ``pad`` kwarg -# here in the ``colorbar`` call. It defaults to 0.02 of the size -# of the axis it is attached to. +# GridSpecs also have optional *hspace* and *wspace* keyword arguments, +# that will be used instead of the pads set by ``constrained_layout``: -fig, axs = plt.subplots(2, 2, constrained_layout=True) +fig, axs = plt.subplots(2, 2, constrained_layout=True, + gridspec_kw={'wspace': 0.3, 'hspace': 0.2}) for ax in axs.flat: - pc = ax.pcolormesh(arr, **pc_kwargs) - fig.colorbar(pc, 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) + example_plot(ax, hide_labels=True) +# this has no effect because the space set in the gridspec trumps the +# space set in constrained_layout. +fig.set_constrained_layout_pads(w_pad=4 / 72, h_pad=4 / 72, hspace=0.0, + wspace=0.0) +plt.show() ########################################## -# 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. +# Spacing with colorbars +# ----------------------- +# +# Colorbars are placed a distance *pad* from their parent, where *pad* +# is a fraction of the width of the parent(s). The spacing to the +# next subplot is then given by *w/hspace*. fig, axs = plt.subplots(2, 2, constrained_layout=True) -for ax in axs.flat: +pads = [0, 0.05, 0.1, 0.2] +for pad, ax in zip(pads, axs.flat): pc = ax.pcolormesh(arr, **pc_kwargs) - fig.colorbar(im, ax=ax, shrink=0.6, pad=0.05) + fig.colorbar(pc, ax=ax, shrink=0.6, pad=pad) 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) + ax.set_title(f'pad: {pad}') +fig.set_constrained_layout_pads(w_pad=2 / 72, h_pad=2 / 72, hspace=0.2, + wspace=0.2) ########################################## # rcParams @@ -329,12 +309,12 @@ def example_plot(ax, fontsize=12, hide_labels=False): # either in a script or in the :file:`matplotlibrc` file. # They all have the prefix ``figure.constrained_layout``: # -# - ``use``: Whether to use 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 subplot widths being separated. -# Default is 0.02. +# - *use*: Whether to use 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 subplot widths being separated. +# Default is 0.02. plt.rcParams['figure.constrained_layout.use'] = True fig, axs = plt.subplots(2, 2, figsize=(3, 3)) @@ -363,7 +343,8 @@ def example_plot(ax, fontsize=12, hide_labels=False): ############################################################################### # More complicated gridspec layouts are possible. Note here we use the -# convenience functions ``add_gridspec`` and ``subgridspec``. +# convenience functions `~.Figure.add_gridspec` and +# `~.SubplotSpec.subgridspec`. fig = plt.figure() @@ -387,11 +368,12 @@ def example_plot(ax, fontsize=12, hide_labels=False): 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: +# Note that in the above the left and right 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. We need to make this figure +# larger as well in order for the axes not to collapse to zero height: -fig = plt.figure() +fig = plt.figure(figsize=(4, 6)) gs0 = fig.add_gridspec(6, 2) @@ -402,11 +384,13 @@ def example_plot(ax, fontsize=12, hide_labels=False): example_plot(ax2) ax = fig.add_subplot(gs0[0:2, 1]) -example_plot(ax) +example_plot(ax, hide_labels=True) ax = fig.add_subplot(gs0[2:4, 1]) -example_plot(ax) +example_plot(ax, hide_labels=True) ax = fig.add_subplot(gs0[4:, 1]) -example_plot(ax) +example_plot(ax, hide_labels=True) +fig.suptitle('Overlapping Gridspecs') + ############################################################################ # This example uses two gridspecs to have the colorbar only pertain to @@ -437,6 +421,7 @@ def docomplicated(suptitle=None): if suptitle is not None: fig.suptitle(suptitle) + docomplicated() ############################################################################### @@ -445,33 +430,13 @@ def docomplicated(suptitle=None): # # There can be good reasons to manually set an axes position. A manual call # to `~.axes.Axes.set_position` will set the axes so constrained_layout has -# no effect on it anymore. (Note that constrained_layout still leaves the +# 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) 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) -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) - ############################################################################### # Manually turning off ``constrained_layout`` # =========================================== @@ -500,7 +465,6 @@ def docomplicated(suptitle=None): # `.GridSpec` instance if the geometry is not the same, and # ``constrained_layout``. So the following works fine: - fig = plt.figure() ax1 = plt.subplot(2, 2, 1) @@ -529,7 +493,7 @@ def docomplicated(suptitle=None): ############################################################################### # Similarly, -# :func:`~matplotlib.pyplot.subplot2grid` works with the same limitation +# `~matplotlib.pyplot.subplot2grid` works with the same limitation # that nrows and ncols cannot change for the layout to look good. fig = plt.figure() @@ -590,144 +554,82 @@ def docomplicated(suptitle=None): # 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)`` +# Layout in Matplotlib is carried out with gridspecs +# via the `~.GridSpec` class. A gridspec is a logical division of the figure +# into rows and columns, with the relative width of the Axes in those +# rows and columns set by *width_ratios* and *height_ratios*. # -# - 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 `~matplotlib.axes.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 layout 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. +# In constrained_layout, each gridspec gets a *layoutgrid* associated with +# it. The *layoutgrid* has a series of ``left`` and ``right`` variables +# for each column, and ``bottom`` and ``top`` variables for each row, and +# further it has a margin for each of left, right, bottom and top. In each +# row, the bottom/top margins are widened until all the decorators +# in that row are accomodated. Similarly for columns and the left/right +# margins. # # # Simple case: one Axes # --------------------- # -# For a single Axes the layout is straight forward. The Figure and -# outer Gridspec layoutboxes coincide. The Subplotspec and Axes -# boxes also coincide 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 +# For a single Axes the layout is straight forward. There is one parent +# layoutgrid for the figure consisting of one column and row, and +# a child layoutgrid for the gridspec that contains the axes, again +# consisting of one row and column. Space is made for the "decorations" on +# each side of 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) +# gridspec._layoutgrid[0, 0].edit_margin_min('left', +# -bbox.x0 + pos.x0 + w_pad) # +# where ``bbox`` is the tight bounding box of the axes, and ``pos`` its +# position. Note how the four margins encompass the axes decorations. -from matplotlib._layoutbox import plot_children +from matplotlib._layoutgrid import plot_children fig, ax = plt.subplots(constrained_layout=True) example_plot(ax, fontsize=24) -plot_children(fig, fig._layoutbox, printit=False) +plot_children(fig, fig._layoutgrid) ####################################################################### # 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. +# When there are multiple axes they have their layouts bound in +# simple ways. In this example the left axes has much larger decorations +# than the right, but they share a bottom margin, which is made large +# enough to accommodate the larger xlabel. Same with the shared top +# margin. The left and right margins are not shared, and hence are +# allowed to be different. 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) +plot_children(fig, fig._layoutgrid, 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 accommodate 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 `~matplotlib.colorbar.make_axes` -# which calls ``_constrained_layout.layoutcolorbarsingle()`` -# for cbars attached to a single axes, and -# ``_constrained_layout.layoutcolorbargridspec()`` if the colorbar is -# associated with a gridspec. +# A colorbar is simply another item that expands the margin of the parent +# layoutgrid cell: fig, ax = plt.subplots(1, 2, constrained_layout=True) im = ax[0].pcolormesh(arr, **pc_kwargs) fig.colorbar(im, ax=ax[0], shrink=0.6) im = ax[1].pcolormesh(arr, **pc_kwargs) -plot_children(fig, fig._layoutbox, printit=False) +plot_children(fig, fig._layoutgrid) ####################################################################### # 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. +# If a colorbar belongs to more than one cell of the grid, then +# it makes a larger margin for each: fig, axs = plt.subplots(2, 2, constrained_layout=True) for ax in axs.flat: im = ax.pcolormesh(arr, **pc_kwargs) fig.colorbar(im, ax=axs, shrink=0.6) -plot_children(fig, fig._layoutbox, printit=False) +plot_children(fig, fig._layoutgrid, printit=False) ####################################################################### # Uneven sized Axes @@ -737,17 +639,12 @@ def docomplicated(suptitle=None): # 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 -# 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. - +# The first method is used here. Note that the middle ``top`` and +# ``bottom`` margins are not affected by the left-hand column. This +# is a conscious decision of the algorithm, and leads to the case where +# the two right-hand axes have the same height, but it is not 1/2 the height +# of the left-hand axes. This is consietent with how ``gridspec`` works +# without constrained layout. fig = plt.figure(constrained_layout=True) gs = gridspec.GridSpec(2, 2, figure=fig) @@ -757,53 +654,19 @@ def docomplicated(suptitle=None): im = ax.pcolormesh(arr, **pc_kwargs) ax = fig.add_subplot(gs[1, 1]) im = ax.pcolormesh(arr, **pc_kwargs) -plot_children(fig, fig._layoutbox, printit=False) +plot_children(fig, fig._layoutgrid, printit=False) ####################################################################### -# Height and width ratios are accommodated 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, **pc_kwargs) -ax = fig.add_subplot(gs[2, 0]) -im = ax.pcolormesh(arr, **pc_kwargs) -ax = fig.add_subplot(gs[0, 1]) -im = ax.pcolormesh(arr, **pc_kwargs) -ax = fig.add_subplot(gs[1:, 1]) -im = ax.pcolormesh(arr, **pc_kwargs) -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 position in made the same size as the occupied Axes positions. -# -# This is done at the start of -# ``_constrained_layout.do_constrained_layout()`` (``hassubplotspec``). +# One case that requires finessing is if margins do not have any artists +# constraining their width. In the case below, the right margin for column 0 +# and the left margin for column 3 have no margin artists to set their width, +# so we take the maximum width of the margin widths that do have artists. +# This makes all the axes have the same size: fig = plt.figure(constrained_layout=True) -gs = gridspec.GridSpec(1, 3, figure=fig) -ax = fig.add_subplot(gs[0]) -im = ax.pcolormesh(arr, **pc_kwargs) -ax = fig.add_subplot(gs[-1]) -im = ax.pcolormesh(arr, **pc_kwargs) -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. +gs = fig.add_gridspec(2, 4) +ax00 = fig.add_subplot(gs[0, 0:2]) +ax01 = fig.add_subplot(gs[0, 2:]) +ax10 = fig.add_subplot(gs[1, 1:3]) +example_plot(ax10, fontsize=14) +plot_children(fig, fig._layoutgrid)