-
-
Notifications
You must be signed in to change notification settings - Fork 7.9k
axes collage #16603
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
axes collage #16603
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
Add API for composing semantic axes layouts from text or nested lists | ||
--------------------------------------------------------------------- | ||
|
||
The `.Figure` class has a provisional method to generate complex grids | ||
of named `.axes.Axes` based on nested list input or ASCII art: | ||
|
||
.. plot:: | ||
:include-source: True | ||
|
||
axd = plt.figure(constrained_layout=True).subplot_mosaic( | ||
[["Top", "Top", "Edge"], | ||
["Left", ".", "Edge"]] | ||
) | ||
for k, ax in axd.items(): | ||
ax.text(0.5, 0.5, k, | ||
ha='center', va='center', fontsize=36, | ||
color='darkgrey') | ||
|
||
or as a string (with single-character Axes labels): | ||
|
||
.. plot:: | ||
:include-source: True | ||
|
||
axd = plt.figure(constrained_layout=True).subplot_mosaic( | ||
""" | ||
tacaswell marked this conversation as resolved.
Show resolved
Hide resolved
|
||
TTE | ||
L.E | ||
""") | ||
for k, ax in axd.items(): | ||
ax.text(0.5, 0.5, k, | ||
ha='center', va='center', fontsize=36, | ||
color='darkgrey') | ||
|
||
|
||
|
||
See :ref:`sphx_glr_tutorials_provisional_mosaic.py` for more | ||
details and examples. |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -166,6 +166,23 @@ def _make_twin_axes(self, *args, **kwargs): | |
self._twinned_axes.join(self, twin) | ||
return twin | ||
|
||
def __repr__(self): | ||
fields = [] | ||
if self.get_label(): | ||
fields += [f"label={self.get_label()!r}"] | ||
titles = [] | ||
for k in ["left", "center", "right"]: | ||
title = self.get_title(loc=k) | ||
if title: | ||
titles.append(f"{k!r}:{title!r}") | ||
if titles: | ||
fields += ["title={" + ",".join(titles) + "}"] | ||
if self.get_xlabel(): | ||
fields += [f"xlabel={self.get_xlabel()!r}"] | ||
if self.get_ylabel(): | ||
fields += [f"ylabel={self.get_ylabel()!r}"] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Probably should update Axes3D, too? Or just do a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it would be better to have a custom repr on the Axes 3D than to inject knowledge of it here. The subtle but pervasive interconnection is one of the things that makes maintaining Matplotlib hard. |
||
return f"<{self.__class__.__name__}:" + ", ".join(fields) + ">" | ||
|
||
|
||
# this here to support cartopy which was using a private part of the | ||
# API to register their Axes subclasses. | ||
|
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -8,6 +8,7 @@ | |||||
Control the default spacing between subplots. | ||||||
""" | ||||||
|
||||||
import inspect | ||||||
import logging | ||||||
from numbers import Integral | ||||||
|
||||||
|
@@ -1522,6 +1523,204 @@ def subplots(self, nrows=1, ncols=1, sharex=False, sharey=False, | |||||
.subplots(sharex=sharex, sharey=sharey, squeeze=squeeze, | ||||||
subplot_kw=subplot_kw)) | ||||||
|
||||||
@staticmethod | ||||||
def _normalize_grid_string(layout): | ||||||
layout = inspect.cleandoc(layout) | ||||||
return [list(ln) for ln in layout.strip('\n').split('\n')] | ||||||
|
||||||
def subplot_mosaic(self, layout, *, subplot_kw=None, gridspec_kw=None, | ||||||
empty_sentinel='.'): | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm 👍 on empty_sentinel kwarg |
||||||
""" | ||||||
Build a layout of Axes based on ASCII art or nested lists. | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It has type There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think art implies well art and that's kind of idiomatic/metaphorical when it's literally a string of characters. |
||||||
|
||||||
This is a helper function to build complex GridSpec layouts visually. | ||||||
|
||||||
.. note :: | ||||||
|
||||||
This API is provisional and may be revised in the future based on | ||||||
early user feedback. | ||||||
|
||||||
|
||||||
Parameters | ||||||
---------- | ||||||
layout : list of list of {hashable or nested} or str | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Various places in this PR imply these are strings, e.g. the Return section below:
I would formally only specify str for now even though more may work out of the box. One can always expand later.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Arrays of Axes work and integers work (at @ImportanceOfBeingErnest 's request) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. On thing we should decide is if tuple should be treated like strings/scalars (they are hashable) or like lists (they are iterables) |
||||||
|
||||||
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 must be of the form :: | ||||||
|
||||||
''' | ||||||
AAE | ||||||
C.E | ||||||
''' | ||||||
efiring marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
|
||||||
where each character is a column and each line is a row. | ||||||
This only allows only single character Axes labels and does | ||||||
not allow nesting but is very terse. | ||||||
|
||||||
subplot_kw : dict, optional | ||||||
Dictionary with keywords passed to the `.Figure.add_subplot` call | ||||||
used to create each subplot. | ||||||
|
||||||
gridspec_kw : dict, optional | ||||||
Dictionary with keywords passed to the `.GridSpec` constructor used | ||||||
to create the grid the subplots are placed on. | ||||||
|
||||||
empty_sentinel : object, optional | ||||||
efiring marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
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. | ||||||
Comment on lines
+1591
to
+1592
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can this be [name, Axes] - worried label will imply that that's the label we're going to apply to the axes. Unless is that the plan - these keys end up being the defaults for a latex like subfigure label? |
||||||
|
||||||
""" | ||||||
subplot_kw = subplot_kw or {} | ||||||
gridspec_kw = gridspec_kw or {} | ||||||
# special-case string input | ||||||
Comment on lines
+1595
to
+1597
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The internet says this is an antipattern... There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is far from our only type-normalization on parsing input.... There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I choked on that, too. It harks back to my Perl days, thankfully long gone. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The choking episode was not fatal... |
||||||
if isinstance(layout, str): | ||||||
layout = self._normalize_grid_string(layout) | ||||||
|
||||||
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 | ||||||
for j, r in enumerate(rest, start=1): | ||||||
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(layout): | ||||||
""" | ||||||
Given a 2D object array, identify unique IDs and nested layouts | ||||||
|
||||||
Parameters | ||||||
---------- | ||||||
layout : 2D numpy object array | ||||||
|
||||||
Returns | ||||||
------- | ||||||
unique_ids : Set[object] | ||||||
The unique non-sub layout entries in this layout | ||||||
nested : Dict[Tuple[int, int]], 2D object array | ||||||
""" | ||||||
unique_ids = set() | ||||||
nested = {} | ||||||
for j, row in enumerate(layout): | ||||||
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 unique_ids, nested | ||||||
|
||||||
def _do_layout(gs, layout, unique_ids, nested): | ||||||
""" | ||||||
Recursively do the layout. | ||||||
|
||||||
Parameters | ||||||
---------- | ||||||
gs : GridSpec | ||||||
|
||||||
layout : 2D object array | ||||||
The input converted to a 2D numpy array for this level. | ||||||
|
||||||
tacaswell marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
unique_ids : Set[object] | ||||||
The identified scalar labels at this level of nesting. | ||||||
|
||||||
nested : Dict[Tuple[int, int]], 2D object array | ||||||
The identified nested layouts if any. | ||||||
|
||||||
Returns | ||||||
------- | ||||||
Dict[label, Axes] | ||||||
A flat dict of all of the Axes created. | ||||||
""" | ||||||
rows, cols = layout.shape | ||||||
output = dict() | ||||||
|
||||||
# create the Axes at this level of nesting | ||||||
for name in unique_ids: | ||||||
indx = np.argwhere(layout == name) | ||||||
start_row, start_col = np.min(indx, axis=0) | ||||||
end_row, end_col = np.max(indx, axis=0) + 1 | ||||||
slc = (slice(start_row, end_row), slice(start_col, end_col)) | ||||||
|
||||||
if (layout[slc] != name).any(): | ||||||
raise ValueError( | ||||||
f"While trying to layout\n{layout!r}\n" | ||||||
f"we found that the label {name!r} specifies a " | ||||||
"non-rectangular or non-contiguous area.") | ||||||
|
||||||
ax = self.add_subplot( | ||||||
gs[slc], **{'label': str(name), **subplot_kw} | ||||||
) | ||||||
output[name] = ax | ||||||
|
||||||
# do any sub-layouts | ||||||
for (j, k), nested_layout in nested.items(): | ||||||
rows, cols = nested_layout.shape | ||||||
nested_output = _do_layout( | ||||||
gs[j, k].subgridspec(rows, cols, **gridspec_kw), | ||||||
nested_layout, | ||||||
*_identify_keys_and_nested(nested_layout) | ||||||
) | ||||||
overlap = set(output) & set(nested_output) | ||||||
if overlap: | ||||||
raise ValueError(f"There are duplicate keys {overlap} " | ||||||
f"between the outer layout\n{layout!r}\n" | ||||||
f"and the nested layout\n{nested_layout}") | ||||||
output.update(nested_output) | ||||||
return output | ||||||
|
||||||
layout = _make_array(layout) | ||||||
rows, cols = layout.shape | ||||||
gs = self.add_gridspec(rows, cols, **gridspec_kw) | ||||||
ret = _do_layout(gs, layout, *_identify_keys_and_nested(layout)) | ||||||
tacaswell marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
for k, ax in ret.items(): | ||||||
if isinstance(k, str): | ||||||
ax.set_label(k) | ||||||
return ret | ||||||
|
||||||
def delaxes(self, ax): | ||||||
""" | ||||||
Remove the `~.axes.Axes` *ax* from the figure; update the current axes. | ||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -1274,6 +1274,87 @@ def subplots(nrows=1, ncols=1, sharex=False, sharey=False, squeeze=True, | |
return fig, axs | ||
|
||
|
||
def subplot_mosaic(layout, *, subplot_kw=None, gridspec_kw=None, | ||
empty_sentinel='.', **fig_kw): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not thrilled that all the docs need to be repeated here, is it so help? works There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is also a slightly different API because we also create the figure. Having the duplication is annoying, but I don't think there is a mechanical way to do the re-writing that is simpler in the long run that just having the duplicate text. |
||
""" | ||
Build a layout of Axes based on ASCII art or nested lists. | ||
|
||
This is a helper function to build complex GridSpec layouts visually. | ||
|
||
.. note :: | ||
|
||
This API is provisional and may be revised in the future based on | ||
early user feedback. | ||
|
||
|
||
Parameters | ||
---------- | ||
layout : 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 must be of the form :: | ||
|
||
''' | ||
AAE | ||
C.E | ||
''' | ||
|
||
where each character is a column and each line is a row. | ||
This only allows only single character Axes labels and does | ||
not allow nesting but is very terse. | ||
|
||
subplot_kw : dict, optional | ||
Dictionary with keywords passed to the `.Figure.add_subplot` call | ||
used to create each subplot. | ||
|
||
gridspec_kw : dict, optional | ||
Dictionary with keywords passed to the `.GridSpec` constructor used | ||
to create the grid the subplots are placed on. | ||
|
||
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. | ||
|
||
**fig_kw | ||
All additional keyword arguments are passed to the | ||
`.pyplot.figure` call. | ||
|
||
Returns | ||
------- | ||
fig : `~.figure.Figure` | ||
The new figure | ||
|
||
dict[label, Axes] | ||
A dictionary mapping the labels to the Axes objects. | ||
|
||
""" | ||
fig = figure(**fig_kw) | ||
ax_dict = fig.subplot_mosaic( | ||
layout, | ||
subplot_kw=subplot_kw, | ||
gridspec_kw=gridspec_kw, | ||
empty_sentinel=empty_sentinel | ||
) | ||
return fig, ax_dict | ||
|
||
|
||
def subplot2grid(shape, loc, rowspan=1, colspan=1, fig=None, **kwargs): | ||
""" | ||
Create a subplot at a specific location inside a regular grid. | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please clearly mark as Experimental
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
and also make explicit that this is Semantic/named axes layouting (basically we want even non-R folks to grok this immediately)