Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[ENH]: subfigure_mosaic #25949

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
anntzer opened this issue May 22, 2023 · 13 comments · May be fixed by #26061
Open

[ENH]: subfigure_mosaic #25949

anntzer opened this issue May 22, 2023 · 13 comments · May be fixed by #26061
Labels
Difficulty: Medium https://matplotlib.org/devdocs/devel/contribute.html#good-first-issues Good first issue Open a pull request against these issues if there are no active ones! New feature
Milestone

Comments

@anntzer
Copy link
Contributor

anntzer commented May 22, 2023

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?).

@tacaswell tacaswell added Good first issue Open a pull request against these issues if there are no active ones! Difficulty: Medium https://matplotlib.org/devdocs/devel/contribute.html#good-first-issues labels May 23, 2023
@tacaswell
Copy link
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.

@tacaswell tacaswell added this to the future releases milestone May 23, 2023
@anntzer
Copy link
Contributor Author

anntzer commented May 23, 2023

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.

@brandall25
Copy link

Could I have a go at this?

@melissawm
Copy link
Member

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

@brandall25
Copy link

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!

@tacaswell
Copy link
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.

@turnipseason
Copy link
Contributor

turnipseason commented Jun 2, 2023

@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.

@brandall25
Copy link

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

@turnipseason
Copy link
Contributor

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

@tacaswell
Copy link
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.

@tacaswell
Copy link
Member

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

@turnipseason turnipseason linked a pull request Jun 3, 2023 that will close this issue
5 tasks
@klorine28
Copy link

Is the issue still open, or has the issue changed it would seem to me from the comment thread it has been resolved.

@tacaswell
Copy link
Member

@klorine28 There is an active PR addressing this issue (see cross reference right above your comment), I suggest you look for a different issue to work on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Difficulty: Medium https://matplotlib.org/devdocs/devel/contribute.html#good-first-issues Good first issue Open a pull request against these issues if there are no active ones! New feature
Projects
None yet
Development

Successfully merging a pull request may close this issue.

8 participants