Skip to content

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

Merged
merged 1 commit into from
Jun 13, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions doc/users/next_whats_new/2020-05-tac.rst
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
---------------------------------------------------------------------

Copy link
Member

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

Copy link
Member

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)

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(
"""
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.
17 changes: 17 additions & 0 deletions lib/matplotlib/axes/_subplots.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably should update Axes3D, too? Or just do a if hasattr(self, 'get_zlabel()') or something?

Copy link
Member Author

Choose a reason for hiding this comment

The 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.
Expand Down
199 changes: 199 additions & 0 deletions lib/matplotlib/figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
Control the default spacing between subplots.
"""

import inspect
import logging
from numbers import Integral

Expand Down Expand Up @@ -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='.'):
Copy link
Member

Choose a reason for hiding this comment

The 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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ASCII art should be replaced with string

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It has type str, but it has extra meaning (it should look like the layout you want) which I think is captured by ASCII art. I don't like that it suggests only ASCII will work, but I don't think anyone would understand "unicode-art"?

Copy link
Member

@story645 story645 Jun 12, 2020

Choose a reason for hiding this comment

The 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hashable: Is there any benefit from using non-strings as keys?

Various places in this PR imply these are strings, e.g. the Return section below:

A dictionary mapping the string labels to the new Axes objects.

I would formally only specify str for now even though more may work out of the box. One can always expand later.

Suggested change
layout : list of list of {hashable or nested} or str
layout : list of list of {str or nested} or str

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arrays of Axes work and integers work (at @ImportanceOfBeingErnest 's request)

Copy link
Member Author

Choose a reason for hiding this comment

The 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
'''

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.

Returns
-------
dict[label, Axes]
A dictionary mapping the labels to the Axes objects.
Comment on lines +1591 to +1592
Copy link
Member

Choose a reason for hiding this comment

The 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The internet says this is an antipattern...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is far from our only type-normalization on parsing input....

Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Member

Choose a reason for hiding this comment

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

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))
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.
Expand Down
81 changes: 81 additions & 0 deletions lib/matplotlib/pyplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Copy link
Member

Choose a reason for hiding this comment

The 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

Copy link
Member Author

Choose a reason for hiding this comment

The 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.
Expand Down
Loading