Skip to content

[ENH]: subfigure_mosaic #25949

Open

Description

@anntzer

Problem

Recently I had a case of layouting subfigures where I could have benefitted from a subfigure_mosaic function, similar to subplot_mosaic.

Proposed solution

Provide subfigure_mosaic.
Bonus points if the implementation shares as much as possible with subplot_mosaic (I suspect we can just factor out everything and pass the "child-adding method" (fig.add_subplot or fig.add_subfigure) as a parameter to the internal implementation?).

Activity

  1. added
    🌱 Good first issueOpen a pull request against these issues if there are no active ones!
    Difficulty: Mediumhttps://matplotlib.org/devdocs/devel/contribute.html#good-first-issues
    on May 23, 2023
  2. tacaswell commented on May 23, 2023

    @tacaswell
    Member

    I think this is a good first issue (limited API discussion as "match subplot_mosaic" is going to be the answer) and only affects a very limited part of the codebase.

    Labeling this as medium difficluty because the subplot_mosaic code is a bit complex (it supports infinite nesting of layouts via recursion) and some of the internal variables are not super clearly named (I named them).

    The source is:

    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.
    """
    subplot_kw = subplot_kw or {}
    gridspec_kw = dict(gridspec_kw or {})
    per_subplot_kw = per_subplot_kw or {}
    if height_ratios is not None:
    if 'height_ratios' in gridspec_kw:
    raise ValueError("'height_ratios' must not be defined both as "
    "parameter and as key in 'gridspec_kw'")
    gridspec_kw['height_ratios'] = height_ratios
    if width_ratios is not None:
    if 'width_ratios' in gridspec_kw:
    raise ValueError("'width_ratios' must not be defined both as "
    "parameter and as key in 'gridspec_kw'")
    gridspec_kw['width_ratios'] = width_ratios
    # 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_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)
    def _make_array(inp):
    """
    Convert input into 2D array
    We need to have this internal function rather than
    ``np.asarray(..., dtype=object)`` so that a list of lists
    of lists does not get converted to an array of dimension > 2.
    Returns
    -------
    2D object array
    """
    r0, *rest = inp
    if isinstance(r0, str):
    raise ValueError('List mosaic specification must be 2D')
    for j, r in enumerate(rest, start=1):
    if isinstance(r, str):
    raise ValueError('List mosaic specification must be 2D')
    if len(r0) != len(r):
    raise ValueError(
    "All of the rows must be the same length, however "
    f"the first row ({r0!r}) has length {len(r0)} "
    f"and row {j} ({r!r}) has length {len(r)}."
    )
    out = np.zeros((len(inp), len(r0)), dtype=object)
    for j, r in enumerate(inp):
    for k, v in enumerate(r):
    out[j, k] = v
    return out
    def _identify_keys_and_nested(mosaic):
    """
    Given a 2D object array, identify unique IDs and nested mosaics
    Parameters
    ----------
    mosaic : 2D object array
    Returns
    -------
    unique_ids : tuple
    The unique non-sub mosaic entries in this mosaic
    nested : dict[tuple[int, int], 2D object array]
    """
    # make sure we preserve the user supplied order
    unique_ids = cbook._OrderedSet()
    nested = {}
    for j, row in enumerate(mosaic):
    for k, v in enumerate(row):
    if v == empty_sentinel:
    continue
    elif not cbook.is_scalar_or_string(v):
    nested[(j, k)] = _make_array(v)
    else:
    unique_ids.add(v)
    return tuple(unique_ids), nested
    def _do_layout(gs, mosaic, unique_ids, nested):
    """
    Recursively do the mosaic.
    Parameters
    ----------
    gs : GridSpec
    mosaic : 2D object array
    The input converted to a 2D array for this level.
    unique_ids : tuple
    The identified scalar labels at this level of nesting.
    nested : dict[tuple[int, int]], 2D object array
    The identified nested mosaics, if any.
    Returns
    -------
    dict[label, Axes]
    A flat dict of all of the Axes created.
    """
    output = dict()
    # we need to merge together the Axes at this level and the axes
    # in the (recursively) nested sub-mosaics so that we can add
    # them to the figure in the "natural" order if you were to
    # ravel in c-order all of the Axes that will be created
    #
    # This will stash the upper left index of each object (axes or
    # nested mosaic) at this level
    this_level = dict()
    # go through the unique keys,
    for name in unique_ids:
    # sort out where each axes starts/ends
    indx = np.argwhere(mosaic == name)
    start_row, start_col = np.min(indx, axis=0)
    end_row, end_col = np.max(indx, axis=0) + 1
    # and construct the slice object
    slc = (slice(start_row, end_row), slice(start_col, end_col))
    # some light error checking
    if (mosaic[slc] != name).any():
    raise ValueError(
    f"While trying to layout\n{mosaic!r}\n"
    f"we found that the label {name!r} specifies a "
    "non-rectangular or non-contiguous area.")
    # and stash this slice for later
    this_level[(start_row, start_col)] = (name, slc, 'axes')
    # do the same thing for the nested mosaics (simpler because these
    # 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):
    name, arg, method = this_level[key]
    # we are doing some hokey function dispatch here based
    # on the 'method' string stashed above to sort out if this
    # element is an Axes or a nested mosaic.
    if method == 'axes':
    slc = arg
    # add a single axes
    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, {})
    }
    )
    output[name] = ax
    elif method == 'nested':
    nested_mosaic = arg
    j, k = key
    # recursively add the nested mosaic
    rows, cols = nested_mosaic.shape
    nested_output = _do_layout(
    gs[j, k].subgridspec(rows, cols),
    nested_mosaic,
    *_identify_keys_and_nested(nested_mosaic)
    )
    overlap = set(output) & set(nested_output)
    if overlap:
    raise ValueError(
    f"There are duplicate keys {overlap} "
    f"between the outer layout\n{mosaic!r}\n"
    f"and the nested layout\n{nested_mosaic}"
    )
    output.update(nested_output)
    else:
    raise RuntimeError("This should never happen")
    return output
    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))
    ax0 = next(iter(ret.values()))
    for ax in ret.values():
    if sharex:
    ax.sharex(ax0)
    ax._label_outer_xaxis(check_patch=True)
    if sharey:
    ax.sharey(ax0)
    ax._label_outer_yaxis(check_patch=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

    On a cursory skim, I think that

    ax = self.add_subplot(
    gs[slc], **{
    'label': str(name),
    **subplot_kw,
    **per_subplot_kw.get(name, {})
    }
    )
    is the only Axes-specific thing and parameterizin _do_layout to take make_the_sub_thing as an input would work.

    One maybe interesting question is that for nested layouts to we want to have sub-figures of sub-figures or flatten all of the sub-figures to be children of the Figure they are called on? I think the return has to be flat so I would lean towards the implementation also being flat (with the nesting in the spec being a details of the layout not an expression of actual nesting) and if the user does really want sub-figure of sub-figure they can call subfigure_moasic in a loop to build up exactly the tree they want.

    Tasks:

    • understand how subplot_mosaic currently works
    • refactor the layout logic into a private method
    • pass the "make the thing" method through _do_layout
    • add new FigureBase method subfigure_mosaic
    • tests (lots of tests....should be able to adapt the subplot_mosaic tests)
    • whats new entry (suspect that this is something people will be excited about so we want to highlight it)

    software estimate are notoriously hard, but I would guess 4-20 hours with a bulk of the time being in writing tests and docs.

  3. added this to the future releases milestone on May 23, 2023
  4. anntzer commented on May 23, 2023

    @anntzer
    ContributorAuthor

    One maybe interesting question is that for nested layouts to we want to have sub-figures of sub-figures or flatten all of the sub-figures to be children of the Figure they are called on? I think the return has to be flat

    Flat would seem normal to me.

  5. brandall25 commented on May 30, 2023

    @brandall25

    Could I have a go at this?

  6. melissawm commented on May 30, 2023

    @melissawm
    Member

    Hi @brandall25 - go ahead! Make sure you check our contributor guide and let us know if you have questions. Thanks!

  7. brandall25 commented on Jun 2, 2023

    @brandall25

    Hi @tacaswell, I have some questions about the tasks you laid out.

    When you say "refactor the layout logic into a private method", do you want that to be a private method of FigureBase that both subplot_mosaic and subfigure_mosaic can use?

    Also, I'm a bit confused by the make_the_sub_thing parameter. Would this be add_subplot for subplot_mosaic and maybe add_subfigure for subfigure_mosaic?

    Thanks!

  8. tacaswell commented on Jun 2, 2023

    @tacaswell
    Member

    When you say "refactor the layout logic into a private method", do you want that to be a private method of FigureBase that both subplot_mosaic and subfigure_mosaic can use?

    yes, or a class method, top level (private) function if it does not need any state. The main goal is to not have two copies of this somewhat long subtle code in the code base.

    Would this be add_subplot for subplot_mosaic and maybe add_subfigure for subfigure_mosaic?

    Yes, exactly that. The logic is "I need to add a sub-version of " which we can specify via dependency injection.

  9. turnipseason commented on Jun 2, 2023

    @turnipseason
    Contributor

    @brandall25 Hi! Not sure I'm qualified to answer you here, but I've been working on this since last week and did just what you described for both of your questions. It seems to work.
    The only problem now is writing proper docs and tests, because just passing a string with no kwargs for subfigure_mosaic doesn't create anything pretty like the plot function does. In fact, it looks like nothing at all. That's probably intended (since it does work if you add kwargs), but still.

    Edit: sorry, didn't see there was already an answer! @tacaswell should I make a PR for this? I didn't comment before because I wasn't sure I'd be able to do anything at all and then it was late.

  10. brandall25 commented on Jun 2, 2023

    @brandall25

    @turnipseason Hello, thanks for responding! Sounds like you have it covered, I think I'll move on to another issue.

  11. turnipseason commented on Jun 2, 2023

    @turnipseason
    Contributor

    @brandall25 Hi! Sorry, I really should've written a comment earlier. Good luck with the other issues!

  12. tacaswell commented on Jun 2, 2023

    @tacaswell
    Member

    @turnipseason Yes please to the PR.

    Adding to or mimicing https://github.com/matplotlib/matplotlib/blob/main/galleries/users_explain/axes/mosaic.py for sub-figures would probably make sense. You can use the same fig.text(.5, .5, LABEL, ha='center', va='center') (in fact if you pass a Figure object to identify_axes I think it will "just work"... 🦆 typing is fun when it works :)

    Alternatively looking https://matplotlib.org/devdocs/gallery/subplots_axes_and_figures/subfigures.html#figure-subfigures and using the "change the patch color" as a way to label the space (or both!) may be a good starting point for documentation inspiration.

    Similarly look at how subplot_mosaic and SubFigure are tested, I suspect they can be adapted.

  13. tacaswell commented on Jun 2, 2023

    @tacaswell
    Member

    also don't worry about the first draft being perfect, we expect iteration on PRs.

  14. 6 remaining items

  15. modified the milestones: v3.9.0, v3.10.0 on Apr 3, 2024
  16. modified the milestones: v3.10.0, v3.11.0 on Oct 22, 2024
  17. mgamzec commented on Jun 9, 2025

    @mgamzec

    Is the issue still open?

  18. melissawm commented on Jun 9, 2025

    @melissawm
    Member

    Hi @mgamzec - as mentioned above there is still a PR open for this although it hasn't seen movement in a while. In the meantime, please try and pick another issue to work on. Thanks!

  19. JoshTVR commented on Mar 30, 2026

    @JoshTVR

    Hi, I see this was already implemented by @turnipseason.
    Should this issue be closed?

    Thanks!

  20. timhoffm commented on Mar 30, 2026

    @timhoffm
    Member

    No. The PR (#26061) is not merged.

  21. p-sreesankar commented on May 30, 2026

    @p-sreesankar

    Hello, Can I have a go at this issue.

  22. timhoffm commented on May 31, 2026

    @timhoffm
    Member

    #26061 is still work in progress. Please do not open a concurrent pull request. If you want to contribute here, check the status of #26021 and try help clarifying the state / comment on what still needs to be done.

    Given there is already a PR, and the length of its discussion I'm removing "good first issue".

    On a side note, I don't think subfigure_mosaic is really necessary as the need for complex subfigure layouts should be very rare. I won't get involved here and leave it to the original proponents to decide the future of this topic.

  23. removed
    🌱 Good first issueOpen a pull request against these issues if there are no active ones!
    on May 31, 2026
  24. story645 commented on May 31, 2026

    @story645
    Member

    , I don't think subfigure_mosaic is really necessary as the need for complex subfigure layouts should be very rare

    So I've been really wanting it lately cause I use subfigures a lot for reusing panels across paper/poster/slide deck.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Difficulty: Mediumhttps://matplotlib.org/devdocs/devel/contribute.html#good-first-issuesNew feature

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions