diff --git a/doc/api/pyplot_summary.rst b/doc/api/pyplot_summary.rst index d0def34c4995..c57b08ab459e 100644 --- a/doc/api/pyplot_summary.rst +++ b/doc/api/pyplot_summary.rst @@ -31,6 +31,7 @@ Managing Figure and Axes sca subplot subplot2grid + subfigure_mosaic subplot_mosaic subplots twinx diff --git a/galleries/examples/subplots_axes_and_figures/subfig_mosaic.py b/galleries/examples/subplots_axes_and_figures/subfig_mosaic.py new file mode 100644 index 000000000000..bdbee9a341ec --- /dev/null +++ b/galleries/examples/subplots_axes_and_figures/subfig_mosaic.py @@ -0,0 +1,399 @@ +""" +================= +Subfigure mosaic +================= + +This example is inspired by the `subplot_mosaic() +`__ +and `subfigures() +`__ +examples. It especially aims to mimic the former. Most of the API +that is going to be described below is analogous to the `.Figure.subplot_mosaic` API. + +`.Figure.subfigure_mosaic` provides a simple way of constructing +complex layouts, such as SubFigures that span multiple columns / rows +of the layout or leave some areas of the Figure blank. The layouts are +constructed either through ASCII art or nested lists. + +This interface naturally supports naming your SubFigures. `.Figure.subfigure_mosaic` +returns a dictionary keyed on the labels used to lay out the Figure. +""" +import matplotlib.pyplot as plt +import numpy as np + + +# Let's define a function to help visualize +def identify_subfigs(subfig_dict, fontsize=36): + """ + Helper to identify the SubFigures in the examples below. + + Draws the label in a large font in the center of the SubFigure. + + Parameters + ---------- + subfig_dict : dict[str, SubFigure] + Mapping between the title / label and the SubFigures. + fontsize : int, optional + How big the label should be. + """ + kw = dict(ha="center", va="center", fontsize=fontsize, color="darkgrey") + for k, subfig in subfig_dict.items(): + subfig.text(0.5, 0.5, k, **kw) + + +# %% +# If we want a 2x2 grid we can use `.Figure.subfigures` which returns a 2D array +# of `.figure.SubFigure` which we can index. + +fig = plt.figure() +subfigs = fig.subfigures(2, 2) + +subfigs[0, 0].set_edgecolor('black') +subfigs[0, 0].set_linewidth(2.1) +subfigs[1, 1].set_edgecolor('yellow') +subfigs[1, 1].set_linewidth(2.1) +subfigs[0, 1].set_facecolor('green') + +identify_subfigs( + {(j, k): a for j, r in enumerate(subfigs) for k, a in enumerate(r)}, +) + +# %% +# Using `.Figure.subfigure_mosaic` we can produce the same mosaic but give the +# SubFigures names + +fig = plt.figure() +subfigs = fig.subfigure_mosaic( + [ + ["First", "Second"], + ["Third", "Fourth"], + ], +) +subfigs["First"].set_edgecolor('black') +subfigs["First"].set_linewidth(2.1) +subfigs["Second"].set_facecolor('green') +subfigs["Fourth"].set_edgecolor('yellow') +subfigs["Fourth"].set_linewidth(2.1) + +identify_subfigs(subfigs) + +# %% +# A key difference between `.Figure.subfigures` and +# `.Figure.subfigure_mosaic` is the return value. While the former +# returns an array for index access, the latter returns a dictionary +# mapping the labels to the `.figure.SubFigure` instances created + +print(subfigs) + +# %% +# String short-hand +# ================= +# +# By restricting our labels to single characters we can +# "draw" the SubFigures we want as "ASCII art". The following + + +mosaic = """ + AB + CD + """ + +# %% +# will give us 4 SubFigures laid out in a 2x2 grid and generate the same +# subfigure mosaic as above (but now labeled with ``{"A", "B", "C", +# "D"}`` rather than ``{"First", "Second", "Third", "Fourth"}``). +# Bear in mind that subfigures do not come 'visible' the way subplots do. +# In case you want them to be clearly visible - you will need to set certain +# keyword arguments (such as edge/face color). This is discussed at length in the +# :ref:`controlling-creation` part of this example. + +fig = plt.figure() +subfigs = fig.subfigure_mosaic(mosaic) +identify_subfigs(subfigs) + +# %% +# Alternatively, you can use the more compact string notation: +mosaic = "AB;CD" + +# %% +# will give you the same composition, where the ``";"`` is used +# as the row separator instead of newline. + +fig = plt.figure() +subfigs = fig.subfigure_mosaic(mosaic) +identify_subfigs(subfigs) + +# %% +# SubFigures spanning multiple rows/columns +# ========================================= +# +# Something we can do with `.Figure.subfigure_mosaic`, that we cannot +# do with `.Figure.subfigures`, is to specify that a SubFigure should span +# several rows or columns. + + +# %% +# If we want to re-arrange our four SubFigures to have ``"C"`` be a horizontal +# span on the bottom and ``"D"`` be a vertical span on the right we would do: + +subfigs = plt.figure().subfigure_mosaic( + """ + ABD + CCD + """ +) + +# setting edges for clarity +for sf in subfigs.values(): + sf.set_edgecolor('black') + sf.set_linewidth(2.1) + +identify_subfigs(subfigs) + +# %% +# If we do not want to fill in all the spaces in the Figure with SubFigures, +# we can specify some spaces in the grid to be blank, like so: + +subfigs = plt.figure().subfigure_mosaic( + """ + A.C + BBB + .D. + """ +) + +# setting edges for clarity +for sf in subfigs.values(): + sf.set_edgecolor('black') + sf.set_linewidth(2.1) + +identify_subfigs(subfigs) + +# %% +# If we prefer to use another character (rather than a period ``"."``) +# to mark the empty space, we can use *empty_sentinel* to specify the +# character to use. + +subfigs = plt.figure().subfigure_mosaic( + """ + aX + Xb + """, + empty_sentinel="X", +) + +# setting edges for clarity +for sf in subfigs.values(): + sf.set_edgecolor('black') + sf.set_linewidth(2.1) +identify_subfigs(subfigs) + +# %% +# +# Internally there is no meaning attached to the letters we use, any +# Unicode code point is valid! + +subfigs = plt.figure().subfigure_mosaic( + """αя + ℝ☢""" +) + +# setting edges for clarity +for sf in subfigs.values(): + sf.set_edgecolor('black') + sf.set_linewidth(2.1) +identify_subfigs(subfigs) + +# %% +# It is not recommended to use white space as either a label or an +# empty sentinel with the string shorthand because it may be stripped +# while processing the input. +# +# Controlling mosaic creation +# =========================== +# +# This feature is built on top of `.gridspec` and you can pass the +# keyword arguments through to the underlying `.gridspec.GridSpec` +# (the same as `.Figure.subfigures`). +# +# In this case we want to use the input to specify the arrangement, +# but set the relative widths of the rows / columns. For convenience, +# `.gridspec.GridSpec`'s *height_ratios* and *width_ratios* are exposed in the +# `.Figure.subfigure_mosaic` calling sequence. + +subfigs = plt.figure().subfigure_mosaic( + """ + .a. + bAc + .d. + """, + # set the height ratios between the rows + height_ratios=[1, 3.5, 1], + # set the width ratios between the columns + width_ratios=[1, 3.5, 1], +) + +# setting edges for clarity +for sf in subfigs.values(): + sf.set_edgecolor('black') + sf.set_linewidth(2.1) +identify_subfigs(subfigs) + +# %% +# You can also use the `.Figure.subfigures` functionality to +# position the overall mosaic to put multiple versions of the same +# mosaic in a figure. + +mosaic = """AA + BC""" +fig = plt.figure() + +left, right = fig.subfigures(nrows=1, ncols=2) + +subfigs = left.subfigure_mosaic(mosaic) +for subfig in subfigs.values(): + subfig.set_edgecolor('black') + subfig.set_linewidth(2.1) +identify_subfigs(subfigs) + +subfigs = right.subfigure_mosaic(mosaic) +for subfig in subfigs.values(): + subfig.set_edgecolor('black') + subfig.set_linewidth(2.1) +identify_subfigs(subfigs) + +# %% +# .. _controlling-creation: +# Controlling subfigure creation +# ============================== +# +# We can also pass through arguments used to create the subfigures +# which will apply to all of the SubFigures created. So instead of iterating like so: + +for sf in subfigs.values(): + sf.set_edgecolor('black') + sf.set_linewidth(2.1) + +# %% +# we would write: + +subfigs = plt.figure().subfigure_mosaic( + "A.B;A.C", subfigure_kw={"edgecolor": "black", "linewidth": 2.1} +) +identify_subfigs(subfigs) + +# %% +# Per-SubFigure keyword arguments +# ---------------------------------- +# +# If you need to control the parameters passed to each subfigure individually use +# *per_subfigure_kw* to pass a mapping between the SubFigure identifiers (or +# tuples of SubFigure identifiers) to dictionaries of keywords to be passed. +# + +fig, subfigs = plt.subfigure_mosaic( + "AB;CD", + per_subfigure_kw={ + "A": {"facecolor": "green"}, + ("C", "D"): {"edgecolor": "black", "linewidth": 1.1, } + }, +) +identify_subfigs(subfigs) + +# %% +# If the layout is specified with the string short-hand, then we know the +# SubFigure labels will be one character and can unambiguously interpret longer +# strings in *per_subfigure_kw* to specify a set of SubFigures to apply the +# keywords to: + +fig, subfigs = plt.subfigure_mosaic( + "AB;CD", + per_subfigure_kw={ + "AD": {"facecolor": ".3"}, + "BC": {"edgecolor": "black", "linewidth": 2.1, } + }, +) +identify_subfigs(subfigs) + +# %% +# If *subfigure_kw* and *per_subfigure_kw* are used together, then they are +# merged with *per_subfigure_kw* taking priority: + +subfigs = plt.figure().subfigure_mosaic( + "AB;CD", + subfigure_kw={"facecolor": "xkcd:tangerine", "linewidth": 2}, + per_subfigure_kw={ + "B": {"facecolor": "xkcd:water blue"}, + "D": {"edgecolor": "yellow", "linewidth": 2.2, "facecolor": "g"}, + } +) +identify_subfigs(subfigs) + +# %% +# Nested list input +# ================= +# +# Everything we can do with the string shorthand we can also do when +# passing in a list (internally we convert the string shorthand to a nested +# list), for example using spans and blanks: + +subfigs = plt.figure().subfigure_mosaic( + [ + ["main", "zoom"], + ["main", "BLANK"], + ], + empty_sentinel="BLANK", + width_ratios=[2, 1], + subfigure_kw={"facecolor": "xkcd:sea green", "linewidth": 2}, +) +identify_subfigs(subfigs) + +# %% +# In addition, using the list input we can specify nested mosaics. Any element +# of the inner list can be another set of nested lists: + +inner = [ + ["inner A"], + ["inner B"], +] +inner_three = [ + ["inner Q"], + ["inner Z"], +] +inner_two = [ + ["inner C"], + [inner_three], +] + +layout = [["A", [[inner_two, "C"], + ["D", "E"]] + ], + ["F", "G"], + [".", [["H", [["I"], + ["."] + ] + ] + ] + ] + ] +fig, subfigs = plt.subfigure_mosaic(layout, subfigure_kw={'edgecolor': 'black', + 'linewidth': 1.5}, + per_subfigure_kw={"E": {'edgecolor': 'xkcd:red'}, + "G": {'facecolor': 'yellow'}, + "H": {'edgecolor': 'blue', + 'facecolor': 'xkcd:azure'}} + ) + +identify_subfigs(subfigs, fontsize=12) + +# %% +# We can also pass in a 2D NumPy array to do things like: +mosaic = np.zeros((4, 4), dtype=int) +for j in range(4): + mosaic[j, j] = j + 1 +subfigs = plt.figure().subfigure_mosaic( + mosaic, + subfigure_kw={'edgecolor': 'black', 'linewidth': 1.5}, + empty_sentinel=0, +) +identify_subfigs(subfigs, fontsize=12) diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index 41d4b6078223..4ab225ed35d8 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -49,7 +49,7 @@ import matplotlib.image as mimage from matplotlib.axes import Axes -from matplotlib.gridspec import GridSpec, SubplotParams +from matplotlib.gridspec import GridSpec, SubplotSpec, SubplotParams from matplotlib.layout_engine import ( ConstrainedLayoutEngine, TightLayoutEngine, LayoutEngine, PlaceHolderLayoutEngine @@ -1875,9 +1875,9 @@ def get_tightbbox(self, renderer=None, bbox_extra_artists=None): return _bbox @staticmethod - def _norm_per_subplot_kw(per_subplot_kw): + def _check_duplication_and_flatten_kwargs(per_subthing_kw): expanded = {} - for k, v in per_subplot_kw.items(): + for k, v in per_subthing_kw.items(): if isinstance(k, tuple): for sub_key in k: if sub_key in expanded: @@ -1899,119 +1899,64 @@ def _normalize_grid_string(layout): layout = inspect.cleandoc(layout) return [list(ln) for ln in layout.strip('\n').split('\n')] - def subplot_mosaic(self, mosaic, *, sharex=False, sharey=False, + def _sub_prep(self, mosaic, parent, mode='axes', *, width_ratios=None, height_ratios=None, empty_sentinel='.', - subplot_kw=None, per_subplot_kw=None, gridspec_kw=None): + subthing_kw=None, per_subthing_kw=None, gridspec_kw=None): """ - Build a layout of Axes based on ASCII art or nested lists. - - This is a helper function to build complex GridSpec layouts visually. - - See :ref:`mosaic` - for an example and full API documentation + Helper function that implements the internal logic for `.Figure.subplot_mosaic` + and `.Figure.subfigure_mosaic`. Parameters ---------- mosaic : list of list of {hashable or nested} or str + A visual layout of how the user wants their mosaic to be arranged. - A visual layout of how you want your Axes to be arranged - labeled as strings. For example :: - - x = [['A panel', 'A panel', 'edge'], - ['C panel', '.', 'edge']] - - produces 4 Axes: + sub_add_func : `.Figure.add_subplot` for `.Figure.subplot_mosaic` and + `.Figure.add_subfigure` for `.Figure.subfigure_mosaic` + The function that gets called for the mosaic creation. - - 'A panel' which is 1 row high and spans the first two columns - - 'edge' which is 2 rows high and is on the right edge - - 'C panel' which in 1 row and 1 column wide in the bottom left - - a blank space 1 row and 1 column wide in the bottom center - - Any of the entries in the layout can be a list of lists - of the same form to create nested layouts. - - If input is a str, then it can either be a multi-line string of - the form :: - - ''' - AAE - C.E - ''' - - where each character is a column and each line is a row. Or it - can be a single-line string where rows are separated by ``;``:: - - 'AB;CC' - - The string notation allows only single character Axes labels and - does not support nesting but is very terse. - - The Axes identifiers may be `str` or a non-iterable hashable - object (e.g. `tuple` s may not be used). - - sharex, sharey : bool, default: False - If True, the x-axis (*sharex*) or y-axis (*sharey*) will be shared - among all subplots. In that case, tick label visibility and axis - units behave as for `subplots`. If False, each subplot's x- or - y-axis will be independent. + sharex, sharey : bool, default: False + Only applicable for .subplot_mosaic. If True, the x-axis (*sharex*) or + y-axis (*sharey*) will be shared among all subplots. width_ratios : array-like of length *ncols*, optional - Defines the relative widths of the columns. Each column gets a - relative width of ``width_ratios[i] / sum(width_ratios)``. - If not given, all columns will have the same width. Equivalent - to ``gridspec_kw={'width_ratios': [...]}``. In the case of nested - layouts, this argument applies only to the outer layout. + Defines the relative widths of the columns. height_ratios : array-like of length *nrows*, optional - Defines the relative heights of the rows. Each row gets a - relative height of ``height_ratios[i] / sum(height_ratios)``. - If not given, all rows will have the same height. Equivalent - to ``gridspec_kw={'height_ratios': [...]}``. In the case of nested - layouts, this argument applies only to the outer layout. + Defines the relative heights of the rows. - subplot_kw : dict, optional + empty_sentinel : object, optional + Entry in the layout to mean "leave this space empty". + + subthing_kw : dict, optional Dictionary with keywords passed to the `.Figure.add_subplot` call - used to create each subplot. These values may be overridden by - values in *per_subplot_kw*. + used to create each subplot or to the `.Figure.add_subfigure` call + used to create each subfigure. These values may be overridden by + values in *per_subthing_kw*. - per_subplot_kw : dict, optional - A dictionary mapping the Axes identifiers or tuples of identifiers + per_subthing_kw : dict, optional + A dictionary mapping the identifiers or tuples of identifiers to a dictionary of keyword arguments to be passed to the - `.Figure.add_subplot` call used to create each subplot. The values - in these dictionaries have precedence over the values in - *subplot_kw*. - - If *mosaic* is a string, and thus all keys are single characters, - it is possible to use a single string instead of a tuple as keys; - i.e. ``"AB"`` is equivalent to ``("A", "B")``. - - .. versionadded:: 3.7 + `.Figure.add_subplot` call used to create each subplot or to + the `.Figure.add_subfigure` call used to create each subfigure. + The values in these dictionaries have precedence over the values in + *subthing_kw*. gridspec_kw : dict, optional Dictionary with keywords passed to the `.GridSpec` constructor used - to create the grid the subplots are placed on. In the case of - nested layouts, this argument applies only to the outer layout. - For more complex layouts, users should use `.Figure.subfigures` - to create the nesting. - - empty_sentinel : object, optional - Entry in the layout to mean "leave this space empty". Defaults - to ``'.'``. Note, if *layout* is a string, it is processed via - `inspect.cleandoc` to remove leading white space, which may - interfere with using white-space as the empty sentinel. + to create the grid the subplots or subfigures are placed on. Returns ------- - dict[label, Axes] - A dictionary mapping the labels to the Axes objects. The order of - the Axes is left-to-right and top-to-bottom of their position in the - total layout. - + dict[label, Axes] or dict[label, SubFigure] + A dictionary mapping the labels to the Axes (for `.Figure.subplot_mosaic`) + or SubFigure objects (for `.Figure.subfigure_mosaic`). """ - subplot_kw = subplot_kw or {} + + subthing_kw = subthing_kw or {} gridspec_kw = dict(gridspec_kw or {}) - per_subplot_kw = per_subplot_kw or {} + per_subthing_kw = per_subthing_kw or {} if height_ratios is not None: if 'height_ratios' in gridspec_kw: @@ -2027,14 +1972,11 @@ def subplot_mosaic(self, mosaic, *, sharex=False, sharey=False, # special-case string input if isinstance(mosaic, str): mosaic = self._normalize_grid_string(mosaic) - per_subplot_kw = { - tuple(k): v for k, v in per_subplot_kw.items() + per_subthing_kw = { + tuple(k): v for k, v in per_subthing_kw.items() } - per_subplot_kw = self._norm_per_subplot_kw(per_subplot_kw) - - # Only accept strict bools to allow a possible future API expansion. - _api.check_isinstance(bool, sharex=sharex, sharey=sharey) + per_subthing_kw = self._check_duplication_and_flatten_kwargs(per_subthing_kw) def _make_array(inp): """ @@ -2066,6 +2008,17 @@ def _make_array(inp): out[j, k] = v return out + def _get_subfigs_from_nested(nested, parent_add_func, gridspec): + subfigs_dict = {} + for item in nested.keys(): + # Getting the correct cell number for SubplotSpec + cell = item[0]*gridspec.ncols + item[1] + subplotspec = SubplotSpec(gridspec, cell) + # Adding an 'intermediate' SubFigure + # where the nested ones will be placed. + subfigs_dict[item] = parent_add_func(subplotspec) + return subfigs_dict + def _identify_keys_and_nested(mosaic): """ Given a 2D object array, identify unique IDs and nested mosaics @@ -2094,7 +2047,7 @@ def _identify_keys_and_nested(mosaic): return tuple(unique_ids), nested - def _do_layout(gs, mosaic, unique_ids, nested): + def _do_layout(gs, mosaic, unique_ids, nested, parent, mode): """ Recursively do the mosaic. @@ -2107,6 +2060,9 @@ def _do_layout(gs, mosaic, unique_ids, nested): The identified scalar labels at this level of nesting. nested : dict[tuple[int, int]], 2D object array The identified nested mosaics, if any. + sub_add_func : `.Figure.add_subplot` for `.Figure.subplot_mosaic` and + `.Figure.add_subfigure` for `.Figure.subfigure_mosaic` + The function that gets called for the mosaic creation. Returns ------- @@ -2145,7 +2101,6 @@ def _do_layout(gs, mosaic, unique_ids, nested): # cannot be spans yet!) for (j, k), nested_mosaic in nested.items(): this_level[(j, k)] = (None, nested_mosaic, 'nested') - # now go through the things in this level and add them # in order left-to-right top-to-bottom for key in sorted(this_level): @@ -2159,23 +2114,36 @@ def _do_layout(gs, mosaic, unique_ids, nested): if name in output: raise ValueError(f"There are duplicate keys {name} " f"in the layout\n{mosaic!r}") - ax = self.add_subplot( - gs[slc], **{ - 'label': str(name), - **subplot_kw, - **per_subplot_kw.get(name, {}) - } - ) + if mode == 'axes': + sub_add_func = parent.add_subplot + elif mode == 'subfigures': + sub_add_func = parent.add_subfigure + ax = sub_add_func( + gs[slc], **{ + 'label': str(name), + **subthing_kw, + **per_subthing_kw.get(name, {}) + } + ) + output[name] = ax elif method == 'nested': nested_mosaic = arg j, k = key # recursively add the nested mosaic rows, cols = nested_mosaic.shape + if mode == 'subfigures': + local_parent = parent.add_subfigure(gs[j, k]) + elif mode == 'axes': + local_parent = parent + else: + raise ValueError nested_output = _do_layout( gs[j, k].subgridspec(rows, cols), nested_mosaic, - *_identify_keys_and_nested(nested_mosaic) + *_identify_keys_and_nested(nested_mosaic), + local_parent, + mode ) overlap = set(output) & set(nested_output) if overlap: @@ -2192,7 +2160,241 @@ def _do_layout(gs, mosaic, unique_ids, nested): mosaic = _make_array(mosaic) rows, cols = mosaic.shape gs = self.add_gridspec(rows, cols, **gridspec_kw) - ret = _do_layout(gs, mosaic, *_identify_keys_and_nested(mosaic)) + ret = _do_layout(gs, mosaic, *_identify_keys_and_nested(mosaic), parent, mode) + + if extra := set(per_subthing_kw) - set(ret): + raise ValueError( + f"The keys {extra} are in *per_subplot_kw* " + "but not in the mosaic." + ) + + return ret + + def subfigure_mosaic(self, mosaic, *, sharex=False, sharey=False, width_ratios=None, + height_ratios=None, empty_sentinel='.', + subfigure_kw=None, per_subfigure_kw=None, gridspec_kw=None): + """ + Build a layout of SubFigures based on ASCII art or nested lists. + + This is a helper function to build complex GridSpec layouts visually. + + See :doc:`/gallery/subplots_axes_and_figures/subfig_mosaic` + for an example and full API documentation + + Parameters + ---------- + mosaic : list of list of {hashable or nested} or str + + A visual layout of how you want your SubFigures to be arranged + labeled as strings. For example :: + + x = [['A panel', 'A panel', 'edge'], + ['C panel', '.', 'edge']] + + produces 4 SubFigures: + + - 'A panel' which is 1 row high and spans the first two columns + - 'edge' which is 2 rows high and is on the right edge + - 'C panel' which in 1 row and 1 column wide in the bottom left + - a blank space 1 row and 1 column wide in the bottom center + + Any of the entries in the layout can be a list of lists + of the same form to create nested layouts. + + If input is a str, then it can either be a multi-line string of + the form :: + + ''' + AAE + C.E + ''' + + where each character is a column and each line is a row. Or it + can be a single-line string where rows are separated by ``;``:: + + 'AB;CC' + + The string notation allows only single character SubFigure labels and + does not support nesting but is very terse. + + The SubFigure identifiers may be `str` or a non-iterable hashable + object (e.g. `tuple` s may not be used). + + width_ratios : array-like of length *ncols*, optional + Defines the relative widths of the columns. Each column gets a + relative width of ``width_ratios[i] / sum(width_ratios)``. + If not given, all columns will have the same width. Equivalent + to ``gridspec_kw={'width_ratios': [...]}``. In the case of nested + layouts, this argument applies only to the outer layout. + + height_ratios : array-like of length *nrows*, optional + Defines the relative heights of the rows. Each row gets a + relative height of ``height_ratios[i] / sum(height_ratios)``. + If not given, all rows will have the same height. Equivalent + to ``gridspec_kw={'height_ratios': [...]}``. In the case of nested + layouts, this argument applies only to the outer layout. + + subfigure_kw : dict, optional + Dictionary with keywords passed to the `.Figure.add_subfigure` call + used to create each subfigure. These values may be overridden by + values in *per_subfigure_kw*. + + per_subfigure_kw : dict, optional + A dictionary mapping the SubFigure identifiers or tuples of identifiers + to a dictionary of keyword arguments to be passed to the + `.Figure.add_subfigure` call used to create each subfigure. The values + in these dictionaries have precedence over the values in + *subfigure_kw*. + + If *mosaic* is a string, and thus all keys are single characters, + it is possible to use a single string instead of a tuple as keys; + i.e. ``"AB"`` is equivalent to ``("A", "B")``. + + gridspec_kw : dict, optional + Dictionary with keywords passed to the `.GridSpec` constructor used + to create the grid the subfigures are placed on. In the case of + nested layouts, this argument applies only to the outer layout. + + empty_sentinel : object, optional + Entry in the layout to mean "leave this space empty". Defaults + to ``'.'``. Note, if *layout* is a string, it is processed via + `inspect.cleandoc` to remove leading white space, which may + interfere with using white-space as the empty sentinel. + + Returns + ------- + dict[label, SubFigure] + A dictionary mapping the labels to the SubFigure objects. The order of + the subfigures is left-to-right and top-to-bottom of their position in the + total layout. + + """ + + ret = self._sub_prep(mosaic, self, 'subfigures', width_ratios=width_ratios, + height_ratios=height_ratios, empty_sentinel=empty_sentinel, + subthing_kw=subfigure_kw, per_subthing_kw=per_subfigure_kw, + gridspec_kw=gridspec_kw) + return ret + + def subplot_mosaic(self, mosaic, *, sharex=False, sharey=False, + width_ratios=None, height_ratios=None, + empty_sentinel='.', + subplot_kw=None, per_subplot_kw=None, gridspec_kw=None): + """ + Build a layout of Axes based on ASCII art or nested lists. + + This is a helper function to build complex GridSpec layouts visually. + + See :ref:`mosaic` + for an example and full API documentation + + Parameters + ---------- + mosaic : list of list of {hashable or nested} or str + + A visual layout of how you want your Axes to be arranged + labeled as strings. For example :: + + x = [['A panel', 'A panel', 'edge'], + ['C panel', '.', 'edge']] + + produces 4 Axes: + + - 'A panel' which is 1 row high and spans the first two columns + - 'edge' which is 2 rows high and is on the right edge + - 'C panel' which in 1 row and 1 column wide in the bottom left + - a blank space 1 row and 1 column wide in the bottom center + + Any of the entries in the layout can be a list of lists + of the same form to create nested layouts. + + If input is a str, then it can either be a multi-line string of + the form :: + + ''' + AAE + C.E + ''' + + where each character is a column and each line is a row. Or it + can be a single-line string where rows are separated by ``;``:: + + 'AB;CC' + + The string notation allows only single character Axes labels and + does not support nesting but is very terse. + + The Axes identifiers may be `str` or a non-iterable hashable + object (e.g. `tuple` s may not be used). + + sharex, sharey : bool, default: False + If True, the x-axis (*sharex*) or y-axis (*sharey*) will be shared + among all subplots. In that case, tick label visibility and axis + units behave as for `subplots`. If False, each subplot's x- or + y-axis will be independent. + + width_ratios : array-like of length *ncols*, optional + Defines the relative widths of the columns. Each column gets a + relative width of ``width_ratios[i] / sum(width_ratios)``. + If not given, all columns will have the same width. Equivalent + to ``gridspec_kw={'width_ratios': [...]}``. In the case of nested + layouts, this argument applies only to the outer layout. + + height_ratios : array-like of length *nrows*, optional + Defines the relative heights of the rows. Each row gets a + relative height of ``height_ratios[i] / sum(height_ratios)``. + If not given, all rows will have the same height. Equivalent + to ``gridspec_kw={'height_ratios': [...]}``. In the case of nested + layouts, this argument applies only to the outer layout. + + subplot_kw : dict, optional + Dictionary with keywords passed to the `.Figure.add_subplot` call + used to create each subplot. These values may be overridden by + values in *per_subplot_kw*. + + per_subplot_kw : dict, optional + A dictionary mapping the Axes identifiers or tuples of identifiers + to a dictionary of keyword arguments to be passed to the + `.Figure.add_subplot` call used to create each subplot. The values + in these dictionaries have precedence over the values in + *subplot_kw*. + + If *mosaic* is a string, and thus all keys are single characters, + it is possible to use a single string instead of a tuple as keys; + i.e. ``"AB"`` is equivalent to ``("A", "B")``. + + .. versionadded:: 3.7 + + gridspec_kw : dict, optional + Dictionary with keywords passed to the `.GridSpec` constructor used + to create the grid the subplots are placed on. In the case of + nested layouts, this argument applies only to the outer layout. + For more complex layouts, users should use `.Figure.subfigures` + to create the nesting. + + empty_sentinel : object, optional + Entry in the layout to mean "leave this space empty". Defaults + to ``'.'``. Note, if *layout* is a string, it is processed via + `inspect.cleandoc` to remove leading white space, which may + interfere with using white-space as the empty sentinel. + + Returns + ------- + dict[label, Axes] + A dictionary mapping the labels to the Axes objects. The order of + the Axes is left-to-right and top-to-bottom of their position in the + total layout. + + """ + + # Only accept strict bools to allow a possible future API expansion. + _api.check_isinstance(bool, sharex=sharex, sharey=sharey) + + ret = self._sub_prep(mosaic, self, 'axes', + width_ratios=width_ratios, height_ratios=height_ratios, + empty_sentinel=empty_sentinel, subthing_kw=subplot_kw, + per_subthing_kw=per_subplot_kw, gridspec_kw=gridspec_kw) + ax0 = next(iter(ret.values())) for ax in ret.values(): if sharex: @@ -2201,11 +2403,7 @@ def _do_layout(gs, mosaic, unique_ids, nested): if sharey: ax.sharey(ax0) ax._label_outer_yaxis(skip_non_rectangular_axes=True) - if extra := set(per_subplot_kw) - set(ret): - raise ValueError( - f"The keys {extra} are in *per_subplot_kw* " - "but not in the mosaic." - ) + return ret def _set_artist_props(self, a): diff --git a/lib/matplotlib/figure.pyi b/lib/matplotlib/figure.pyi index 711f5b77783e..d0b0e69cdf16 100644 --- a/lib/matplotlib/figure.pyi +++ b/lib/matplotlib/figure.pyi @@ -216,6 +216,20 @@ class FigureBase(Artist): *, bbox_extra_artists: Iterable[Artist] | None = ..., ) -> Bbox: ... + + def subfigure_mosaic( + self, + mosaic: str | HashableList, + *, + width_ratios: ArrayLike | None = ..., + height_ratios: ArrayLike | None = ..., + empty_sentinel: Any = ..., + subfigure_kw: dict[str, Any] | None = ..., + per_subfigure_kw: dict[Any, dict[str, Any]] | None = ..., + gridspec_kw: dict[str, Any] | None = ... + ) -> dict[Any, SubFigure]: ... + + # Any in list of list is recursive list[list[Hashable | list[Hashable | ...]]] but that can't really be type checked @overload def subplot_mosaic( self, diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index e6242271d113..5bc233b07ab7 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -1776,6 +1776,130 @@ def subplots( return fig, axs +def subfigure_mosaic( + mosaic: str | HashableList, + *, + width_ratios: ArrayLike | None = None, + height_ratios: ArrayLike | None = None, + empty_sentinel: Any = '.', + subfigure_kw: dict[str, Any] | None = None, + gridspec_kw: dict[str, Any] | None = None, + per_subfigure_kw: dict[Hashable, dict[str, Any]] | None = None, + **fig_kw +) -> tuple[Figure, dict[Hashable, matplotlib.figure.SubFigure]]: + """ + Build a layout of SubFigures based on ASCII art or nested lists. + + This is a helper function to build complex GridSpec layouts visually. + + See :doc:`/gallery/subplots_axes_and_figures/subfig_mosaic` + for an example and full API documentation + + Parameters + ---------- + mosaic : list of list of {hashable or nested} or str + + A visual layout of how you want your SubFigures to be arranged + labeled as strings. For example :: + + x = [['A panel', 'A panel', 'edge'], + ['C panel', '.', 'edge']] + + produces 4 SubFigures: + + - 'A panel' which is 1 row high and spans the first two columns + - 'edge' which is 2 rows high and is on the right edge + - 'C panel' which in 1 row and 1 column wide in the bottom left + - a blank space 1 row and 1 column wide in the bottom center + + Any of the entries in the layout can be a list of lists + of the same form to create nested layouts. + + If input is a str, then it can either be a multi-line string of + the form :: + + ''' + AAE + C.E + ''' + + where each character is a column and each line is a row. Or it + can be a single-line string where rows are separated by ``;``:: + + 'AB;CC' + + The string notation allows only single character SubFigure labels and + does not support nesting but is very terse. + + The SubFigure identifiers may be `str` or a non-iterable hashable + object (e.g. `tuple` s may not be used). + + width_ratios : array-like of length *ncols*, optional + Defines the relative widths of the columns. Each column gets a + relative width of ``width_ratios[i] / sum(width_ratios)``. + If not given, all columns will have the same width. Equivalent + to ``gridspec_kw={'width_ratios': [...]}``. In the case of nested + layouts, this argument applies only to the outer layout. + + height_ratios : array-like of length *nrows*, optional + Defines the relative heights of the rows. Each row gets a + relative height of ``height_ratios[i] / sum(height_ratios)``. + If not given, all rows will have the same height. Equivalent + to ``gridspec_kw={'height_ratios': [...]}``. In the case of nested + layouts, this argument applies only to the outer layout. + + empty_sentinel : object, optional + Entry in the layout to mean "leave this space empty". Defaults + to ``'.'``. Note, if *layout* is a string, it is processed via + `inspect.cleandoc` to remove leading white space, which may + interfere with using white-space as the empty sentinel. + + subfigure_kw : dict, optional + Dictionary with keywords passed to the `.Figure.add_subfigure` call + used to create each subfigure. These values may be overridden by + values in *per_subfigure_kw*. + + per_subfigure_kw : dict, optional + A dictionary mapping the SubFigure identifiers or tuples of identifiers + to a dictionary of keyword arguments to be passed to the + `.Figure.add_subfigure` call used to create each subfigure. The values + in these dictionaries have precedence over the values in + *subfigure_kw*. + + If *mosaic* is a string, and thus all keys are single characters, + it is possible to use a single string instead of a tuple as keys; + i.e. ``"AB"`` is equivalent to ``("A", "B")``. + + gridspec_kw : dict, optional + Dictionary with keywords passed to the `.GridSpec` constructor used + to create the grid the subfigures are placed on. In the case of + nested layouts, this argument applies only to the outer layout. + + **fig_kw + All additional keyword arguments are passed to the + `.pyplot.figure` call. + + Returns + ------- + fig : `.Figure` + The new figure. + + dict[label, SubFigure] + A dictionary mapping the labels to the SubFigure objects. The order of + the subfigures is left-to-right and top-to-bottom of their position in the + total layout. + """ + fig = figure(**fig_kw) + subfigs_dict = fig.subfigure_mosaic( + mosaic, + height_ratios=height_ratios, width_ratios=width_ratios, + subfigure_kw=subfigure_kw, gridspec_kw=gridspec_kw, + empty_sentinel=empty_sentinel, + per_subfigure_kw=per_subfigure_kw, + ) + return fig, subfigs_dict + + @overload def subplot_mosaic( mosaic: str, diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py index 99045e773d02..ceb315f02671 100644 --- a/lib/matplotlib/tests/test_figure.py +++ b/lib/matplotlib/tests/test_figure.py @@ -966,6 +966,279 @@ def test_animated_with_canvas_change(fig_test, fig_ref): ax_test.plot(range(5), animated=True) +class TestSubfigureMosaic: + @check_figures_equal(extensions=["png"]) + @pytest.mark.parametrize( + "x", [ + [["A", "A", "B"], ["C", "D", "B"]], + [[1, 1, 2], [3, 4, 2]], + (("A", "A", "B"), ("C", "D", "B")), + ((1, 1, 2), (3, 4, 2)) + ] + ) + def test_basic(self, fig_test, fig_ref, x): + grid_axes = fig_test.subfigure_mosaic(x) + + for k, ax in grid_axes.items(): + ax.text(0.5, 0.5, k) + + labels = sorted(np.unique(x)) + + assert len(labels) == len(grid_axes) + + gs = fig_ref.add_gridspec(2, 3) + axA = fig_ref.add_subfigure(gs[:1, :2]) + axA.text(0.5, 0.5, labels[0]) + + axB = fig_ref.add_subfigure(gs[:, 2]) + axB.text(0.5, 0.5, labels[1]) + + axC = fig_ref.add_subfigure(gs[1, 0]) + axC.text(0.5, 0.5, labels[2]) + + axD = fig_ref.add_subfigure(gs[1, 1]) + axD.text(0.5, 0.5, labels[3]) + + @check_figures_equal(extensions=["png"]) + def test_all_nested(self, fig_test, fig_ref): + x = [["A", "B"], ["C", "D"]] + y = [["E", "F"], ["G", "H"]] + + fig_ref.set_layout_engine("constrained") + fig_test.set_layout_engine("constrained") + + grid_axes = fig_test.subfigure_mosaic([[x, y]]) + for ax in grid_axes.values(): + ax.text(0.5, 0.5, ax.get_label()) + + gs = fig_ref.add_gridspec(1, 2) + gs_left = gs[0, 0].subgridspec(2, 2) + subfig_left = fig_ref.add_subfigure(gs[0, 0]) + for j, r in enumerate(x): + for k, label in enumerate(r): + subfig_left.add_subfigure(gs_left[j, k]).text(0.5, 0.5, label) + + gs_right = gs[0, 1].subgridspec(2, 2) + subfig_right = fig_ref.add_subfigure(gs[0, 1]) + for j, r in enumerate(y): + for k, label in enumerate(r): + subfig_right.add_subfigure(gs_right[j, k]).text(0.5, 0.5, label) + + @check_figures_equal(extensions=["png"]) + def test_nested(self, fig_test, fig_ref): + + fig_ref.set_layout_engine("constrained") + fig_test.set_layout_engine("constrained") + + x = [["A", "B"], ["C", "D"]] + + y = [["F"], [x]] + + grid_axes = fig_test.subfigure_mosaic(y) + for k, ax in grid_axes.items(): + ax.text(0.5, 0.5, k) + + gs = fig_ref.add_gridspec(2, 1) + subfig_bottom = fig_ref.add_subfigure(gs[1, 0]) + gs_n = gs[1, 0].subgridspec(2, 2) + + axA = subfig_bottom.add_subfigure(gs_n[0, 0]) + axA.text(0.5, 0.5, "A") + + axB = subfig_bottom.add_subfigure(gs_n[0, 1]) + axB.text(0.5, 0.5, "B") + + axC = subfig_bottom.add_subfigure(gs_n[1, 0]) + axC.text(0.5, 0.5, "C") + + axD = subfig_bottom.add_subfigure(gs_n[1, 1]) + axD.text(0.5, 0.5, "D") + + axF = fig_ref.add_subfigure(gs[0, 0]) + axF.text(0.5, 0.5, "F") + + @check_figures_equal(extensions=["png"]) + def test_nested_tuple(self, fig_test, fig_ref): + x = [["A", "B", "B"], ["C", "C", "D"]] + xt = (("A", "B", "B"), ("C", "C", "D")) + + fig_ref.subfigure_mosaic([["F"], [x]]) + fig_test.subfigure_mosaic([["F"], [xt]]) + + def test_nested_width_ratios(self): + x = [["A", [["B"], + ["C"]]]] + width_ratios = [2, 1] + + fig, axd = plt.subfigure_mosaic(x, width_ratios=width_ratios) + + outer = list(axd.values())[0]._subplotspec.get_gridspec().get_width_ratios() + inner = list(axd.values())[1]._subplotspec.get_gridspec().get_width_ratios() + assert outer == width_ratios + assert inner != width_ratios + + def test_nested_height_ratios(self): + x = [["A", [["B"], + ["C"]]], ["D", "D"]] + height_ratios = [1, 2] + + fig, axd = plt.subfigure_mosaic(x, height_ratios=height_ratios) + + outer = list(axd.values())[0]._subplotspec.get_gridspec().get_height_ratios() + inner = list(axd.values())[1]._subplotspec.get_gridspec().get_height_ratios() + assert outer == height_ratios + assert inner != height_ratios + + @check_figures_equal(extensions=["png"]) + @pytest.mark.parametrize( + "x, empty_sentinel", + [ + ([["A", None], [None, "B"]], None), + ([["A", "."], [".", "B"]], "SKIP"), + ([["A", 0], [0, "B"]], 0), + ([[1, None], [None, 2]], None), + ([[1, "."], [".", 2]], "SKIP"), + ([[1, 0], [0, 2]], 0), + ], + ) + def test_empty(self, fig_test, fig_ref, x, empty_sentinel): + if empty_sentinel != "SKIP": + kwargs = {"empty_sentinel": empty_sentinel} + else: + kwargs = {} + grid_axes = fig_test.subfigure_mosaic(x, **kwargs) + + for k, ax in grid_axes.items(): + ax.text(0.5, 0.5, k) + + labels = sorted( + {name for row in x for name in row} - {empty_sentinel, "."} + ) + + assert len(labels) == len(grid_axes) + + gs = fig_ref.add_gridspec(2, 2) + axA = fig_ref.add_subfigure(gs[0, 0]) + axA.text(0.5, 0.5, labels[0]) + + axB = fig_ref.add_subfigure(gs[1, 1]) + axB.text(0.5, 0.5, labels[1]) + + def test_fail_list_of_str(self): + with pytest.raises(ValueError, match='must be 2D'): + plt.subfigure_mosaic(['foo', 'bar']) + with pytest.raises(ValueError, match='must be 2D'): + plt.subfigure_mosaic(['foo']) + with pytest.raises(ValueError, match='must be 2D'): + plt.subfigure_mosaic([['foo', ('bar',)]]) + with pytest.raises(ValueError, match='must be 2D'): + plt.subfigure_mosaic([['a', 'b'], [('a', 'b'), 'c']]) + + @check_figures_equal(extensions=["png"]) + @pytest.mark.parametrize("subfigure_kw", [{}, {"facecolor": "magenta"}]) + def test_subfigure_kw(self, fig_test, fig_ref, subfigure_kw): + x = [[1, 2]] + grid_axes = fig_test.subfigure_mosaic(x, subfigure_kw=subfigure_kw) + subplot_kw = subfigure_kw or {} + + gs = fig_ref.add_gridspec(1, 2) + axA = fig_ref.add_subfigure(gs[0, 0], **subfigure_kw) + axB = fig_ref.add_subfigure(gs[0, 1], **subfigure_kw) + + @check_figures_equal(extensions=["png"]) + @pytest.mark.parametrize("multi_value", ['BC', tuple('BC')]) + def test_per_subfigure_kw(self, fig_test, fig_ref, multi_value): + x = 'AB;CD' + grid_axes = fig_test.subfigure_mosaic( + x, + subfigure_kw={'facecolor': 'red'}, + per_subfigure_kw={ + 'D': {'facecolor': 'blue'}, + multi_value: {'facecolor': 'green'}, + } + ) + + gs = fig_ref.add_gridspec(2, 2) + for color, spec in zip(['red', 'green', 'green', 'blue'], gs): + fig_ref.add_subfigure(spec, facecolor=color) + + def test_extra_per_subfigure_kw(self): + with pytest.raises( + ValueError, match=f'The keys {set("B")!r} are in' + ): + Figure().subfigure_mosaic("A", per_subfigure_kw={"B": {}}) + + @check_figures_equal(extensions=["png"]) + @pytest.mark.parametrize("str_pattern", + ["AAA\nBBB", "\nAAA\nBBB\n", "ABC\nDEF"] + ) + def test_single_str_input(self, fig_test, fig_ref, str_pattern): + grid_axes = fig_test.subfigure_mosaic(str_pattern) + + grid_axes = fig_ref.subfigure_mosaic( + [list(ln) for ln in str_pattern.strip().split("\n")] + ) + + @pytest.mark.parametrize( + "x,match", + [ + ( + [["A", "."], [".", "A"]], + ( + "(?m)we found that the label .A. specifies a " + + "non-rectangular or non-contiguous area." + ), + ), + ( + [["A", "B"], [None, [["A", "B"], ["C", "D"]]]], + "There are duplicate keys .* between the outer layout", + ), + ("AAA\nc\nBBB", "All of the rows must be the same length"), + ( + [["A", [["B", "C"], ["D"]]], ["E", "E"]], + "All of the rows must be the same length", + ), + ], + ) + def test_fail(self, x, match): + fig = plt.figure() + with pytest.raises(ValueError, match=match): + fig.subfigure_mosaic(x) + + @check_figures_equal(extensions=["png"]) + def test_hashable_keys(self, fig_test, fig_ref): + fig_test.subfigure_mosaic([[object(), object()]]) + fig_ref.subfigure_mosaic([["A", "B"]]) + + @pytest.mark.parametrize('str_pattern', + ['abc', 'cab', 'bca', 'cba', 'acb', 'bac']) + def test_user_order(self, str_pattern): + fig, ax_dict = plt.subfigure_mosaic(str_pattern) + assert list(str_pattern) == list(ax_dict) + assert list(fig.subfigs) == list(ax_dict.values()) + + def test_nested_user_order(self): + layout = [ + ["A", [["B", "C"], + ["D", "E"]]], + ["F", "G"], + [".", [["H", [["I"], + ["."]]]]] + ] + fig, ax_dict = plt.subfigure_mosaic(layout) + # getting all of the subfigures of subfigures + def _get_subs_nested(fig, child_list=[]): + for subfig in fig.subfigs: + if len(subfig.subfigs): + _get_subs_nested(subfig, child_list=child_list) + else: + child_list.append(subfig) + return child_list + child_list = _get_subs_nested(fig) + assert list(ax_dict) == list("ABCDEFGHI") + assert set(child_list) == set(ax_dict.values()) + + class TestSubplotMosaic: @check_figures_equal(extensions=["png"]) @pytest.mark.parametrize( @@ -1177,8 +1450,8 @@ def test_string_parser(self): DE """) == [['A', 'B'], ['C', 'C'], ['D', 'E']] - def test_per_subplot_kw_expander(self): - normalize = Figure._norm_per_subplot_kw + def test_per_subthing_kw_expander(self): + normalize = Figure._check_duplication_and_flatten_kwargs assert normalize({"A": {}, "B": {}}) == {"A": {}, "B": {}} assert normalize({("A", "B"): {}}) == {"A": {}, "B": {}} with pytest.raises(