Skip to content

Commit 424de9a

Browse files
tacaswellQuLogicWeatherGodtimhoffm
committed
ENH: API to use ASCII art or nested lists to compose complex figures
See tutorials/provisional/mosaic.py for details. Co-authored-by: Elliott Sales de Andrade <quantum.analyst@gmail.com> Co-authored-by: Benjamin Root <ben.v.root@gmail.com> Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com>
1 parent 76e9e26 commit 424de9a

File tree

7 files changed

+833
-0
lines changed

7 files changed

+833
-0
lines changed
+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
Add API for composing semantic axes layouts from text or nested lists
2+
---------------------------------------------------------------------
3+
4+
The `.Figure` class has a provisional method to generate complex grids
5+
of named `.axes.Axes` based on nested list input or ASCII art:
6+
7+
.. plot::
8+
:include-source: True
9+
10+
axd = plt.figure(constrained_layout=True).subplot_mosaic(
11+
[["Top", "Top", "Edge"],
12+
["Left", ".", "Edge"]]
13+
)
14+
for k, ax in axd.items():
15+
ax.text(0.5, 0.5, k,
16+
ha='center', va='center', fontsize=36,
17+
color='darkgrey')
18+
19+
or as a string (with single-character Axes labels):
20+
21+
.. plot::
22+
:include-source: True
23+
24+
axd = plt.figure(constrained_layout=True).subplot_mosaic(
25+
"""
26+
TTE
27+
L.E
28+
""")
29+
for k, ax in axd.items():
30+
ax.text(0.5, 0.5, k,
31+
ha='center', va='center', fontsize=36,
32+
color='darkgrey')
33+
34+
35+
36+
See :ref:`sphx_glr_tutorials_provisional_mosaic.py` for more
37+
details and examples.

lib/matplotlib/axes/_subplots.py

+17
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,23 @@ def _make_twin_axes(self, *args, **kwargs):
166166
self._twinned_axes.join(self, twin)
167167
return twin
168168

169+
def __repr__(self):
170+
fields = []
171+
if self.get_label():
172+
fields += [f"label={self.get_label()!r}"]
173+
titles = []
174+
for k in ["left", "center", "right"]:
175+
title = self.get_title(loc=k)
176+
if title:
177+
titles.append(f"{k!r}:{title!r}")
178+
if titles:
179+
fields += ["title={" + ",".join(titles) + "}"]
180+
if self.get_xlabel():
181+
fields += [f"xlabel={self.get_xlabel()!r}"]
182+
if self.get_ylabel():
183+
fields += [f"ylabel={self.get_ylabel()!r}"]
184+
return f"<{self.__class__.__name__}:" + ", ".join(fields) + ">"
185+
169186

170187
# this here to support cartopy which was using a private part of the
171188
# API to register their Axes subclasses.

lib/matplotlib/figure.py

+199
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
Control the default spacing between subplots.
99
"""
1010

11+
import inspect
1112
import logging
1213
from numbers import Integral
1314

@@ -1522,6 +1523,204 @@ def subplots(self, nrows=1, ncols=1, sharex=False, sharey=False,
15221523
.subplots(sharex=sharex, sharey=sharey, squeeze=squeeze,
15231524
subplot_kw=subplot_kw))
15241525

1526+
@staticmethod
1527+
def _normalize_grid_string(layout):
1528+
layout = inspect.cleandoc(layout)
1529+
return [list(ln) for ln in layout.strip('\n').split('\n')]
1530+
1531+
def subplot_mosaic(self, layout, *, subplot_kw=None, gridspec_kw=None,
1532+
empty_sentinel='.'):
1533+
"""
1534+
Build a layout of Axes based on ASCII art or nested lists.
1535+
1536+
This is a helper function to build complex GridSpec layouts visually.
1537+
1538+
.. note ::
1539+
1540+
This API is provisional and may be revised in the future based on
1541+
early user feedback.
1542+
1543+
1544+
Parameters
1545+
----------
1546+
layout : list of list of {hashable or nested} or str
1547+
1548+
A visual layout of how you want your Axes to be arranged
1549+
labeled as strings. For example ::
1550+
1551+
x = [['A panel', 'A panel', 'edge'],
1552+
['C panel', '.', 'edge']]
1553+
1554+
Produces 4 axes:
1555+
1556+
- 'A panel' which is 1 row high and spans the first two columns
1557+
- 'edge' which is 2 rows high and is on the right edge
1558+
- 'C panel' which in 1 row and 1 column wide in the bottom left
1559+
- a blank space 1 row and 1 column wide in the bottom center
1560+
1561+
Any of the entries in the layout can be a list of lists
1562+
of the same form to create nested layouts.
1563+
1564+
If input is a str, then it must be of the form ::
1565+
1566+
'''
1567+
AAE
1568+
C.E
1569+
'''
1570+
1571+
where each character is a column and each line is a row.
1572+
This only allows only single character Axes labels and does
1573+
not allow nesting but is very terse.
1574+
1575+
subplot_kw : dict, optional
1576+
Dictionary with keywords passed to the `.Figure.add_subplot` call
1577+
used to create each subplot.
1578+
1579+
gridspec_kw : dict, optional
1580+
Dictionary with keywords passed to the `.GridSpec` constructor used
1581+
to create the grid the subplots are placed on.
1582+
1583+
empty_sentinel : object, optional
1584+
Entry in the layout to mean "leave this space empty". Defaults
1585+
to ``'.'``. Note, if *layout* is a string, it is processed via
1586+
`inspect.cleandoc` to remove leading white space, which may
1587+
interfere with using white-space as the empty sentinel.
1588+
1589+
Returns
1590+
-------
1591+
dict[label, Axes]
1592+
A dictionary mapping the labels to the Axes objects.
1593+
1594+
"""
1595+
subplot_kw = subplot_kw or {}
1596+
gridspec_kw = gridspec_kw or {}
1597+
# special-case string input
1598+
if isinstance(layout, str):
1599+
layout = self._normalize_grid_string(layout)
1600+
1601+
def _make_array(inp):
1602+
"""
1603+
Convert input into 2D array
1604+
1605+
We need to have this internal function rather than
1606+
``np.asarray(..., dtype=object)`` so that a list of lists
1607+
of lists does not get converted to an array of dimension >
1608+
2
1609+
1610+
Returns
1611+
-------
1612+
2D object array
1613+
1614+
"""
1615+
r0, *rest = inp
1616+
for j, r in enumerate(rest, start=1):
1617+
if len(r0) != len(r):
1618+
raise ValueError(
1619+
"All of the rows must be the same length, however "
1620+
f"the first row ({r0!r}) has length {len(r0)} "
1621+
f"and row {j} ({r!r}) has length {len(r)}."
1622+
)
1623+
out = np.zeros((len(inp), len(r0)), dtype=object)
1624+
for j, r in enumerate(inp):
1625+
for k, v in enumerate(r):
1626+
out[j, k] = v
1627+
return out
1628+
1629+
def _identify_keys_and_nested(layout):
1630+
"""
1631+
Given a 2D object array, identify unique IDs and nested layouts
1632+
1633+
Parameters
1634+
----------
1635+
layout : 2D numpy object array
1636+
1637+
Returns
1638+
-------
1639+
unique_ids : Set[object]
1640+
The unique non-sub layout entries in this layout
1641+
nested : Dict[Tuple[int, int]], 2D object array
1642+
"""
1643+
unique_ids = set()
1644+
nested = {}
1645+
for j, row in enumerate(layout):
1646+
for k, v in enumerate(row):
1647+
if v == empty_sentinel:
1648+
continue
1649+
elif not cbook.is_scalar_or_string(v):
1650+
nested[(j, k)] = _make_array(v)
1651+
else:
1652+
unique_ids.add(v)
1653+
1654+
return unique_ids, nested
1655+
1656+
def _do_layout(gs, layout, unique_ids, nested):
1657+
"""
1658+
Recursively do the layout.
1659+
1660+
Parameters
1661+
----------
1662+
gs : GridSpec
1663+
1664+
layout : 2D object array
1665+
The input converted to a 2D numpy array for this level.
1666+
1667+
unique_ids : Set[object]
1668+
The identified scalar labels at this level of nesting.
1669+
1670+
nested : Dict[Tuple[int, int]], 2D object array
1671+
The identified nested layouts if any.
1672+
1673+
Returns
1674+
-------
1675+
Dict[label, Axes]
1676+
A flat dict of all of the Axes created.
1677+
"""
1678+
rows, cols = layout.shape
1679+
output = dict()
1680+
1681+
# create the Axes at this level of nesting
1682+
for name in unique_ids:
1683+
indx = np.argwhere(layout == name)
1684+
start_row, start_col = np.min(indx, axis=0)
1685+
end_row, end_col = np.max(indx, axis=0) + 1
1686+
slc = (slice(start_row, end_row), slice(start_col, end_col))
1687+
1688+
if (layout[slc] != name).any():
1689+
raise ValueError(
1690+
f"While trying to layout\n{layout!r}\n"
1691+
f"we found that the label {name!r} specifies a "
1692+
"non-rectangular or non-contiguous area.")
1693+
1694+
ax = self.add_subplot(
1695+
gs[slc], **{'label': str(name), **subplot_kw}
1696+
)
1697+
output[name] = ax
1698+
1699+
# do any sub-layouts
1700+
for (j, k), nested_layout in nested.items():
1701+
rows, cols = nested_layout.shape
1702+
nested_output = _do_layout(
1703+
gs[j, k].subgridspec(rows, cols, **gridspec_kw),
1704+
nested_layout,
1705+
*_identify_keys_and_nested(nested_layout)
1706+
)
1707+
overlap = set(output) & set(nested_output)
1708+
if overlap:
1709+
raise ValueError(f"There are duplicate keys {overlap} "
1710+
f"between the outer layout\n{layout!r}\n"
1711+
f"and the nested layout\n{nested_layout}")
1712+
output.update(nested_output)
1713+
return output
1714+
1715+
layout = _make_array(layout)
1716+
rows, cols = layout.shape
1717+
gs = self.add_gridspec(rows, cols, **gridspec_kw)
1718+
ret = _do_layout(gs, layout, *_identify_keys_and_nested(layout))
1719+
for k, ax in ret.items():
1720+
if isinstance(k, str):
1721+
ax.set_label(k)
1722+
return ret
1723+
15251724
def delaxes(self, ax):
15261725
"""
15271726
Remove the `~.axes.Axes` *ax* from the figure; update the current axes.

lib/matplotlib/pyplot.py

+81
Original file line numberDiff line numberDiff line change
@@ -1274,6 +1274,87 @@ def subplots(nrows=1, ncols=1, sharex=False, sharey=False, squeeze=True,
12741274
return fig, axs
12751275

12761276

1277+
def subplot_mosaic(layout, *, subplot_kw=None, gridspec_kw=None,
1278+
empty_sentinel='.', **fig_kw):
1279+
"""
1280+
Build a layout of Axes based on ASCII art or nested lists.
1281+
1282+
This is a helper function to build complex GridSpec layouts visually.
1283+
1284+
.. note ::
1285+
1286+
This API is provisional and may be revised in the future based on
1287+
early user feedback.
1288+
1289+
1290+
Parameters
1291+
----------
1292+
layout : list of list of {hashable or nested} or str
1293+
1294+
A visual layout of how you want your Axes to be arranged
1295+
labeled as strings. For example ::
1296+
1297+
x = [['A panel', 'A panel', 'edge'],
1298+
['C panel', '.', 'edge']]
1299+
1300+
Produces 4 axes:
1301+
1302+
- 'A panel' which is 1 row high and spans the first two columns
1303+
- 'edge' which is 2 rows high and is on the right edge
1304+
- 'C panel' which in 1 row and 1 column wide in the bottom left
1305+
- a blank space 1 row and 1 column wide in the bottom center
1306+
1307+
Any of the entries in the layout can be a list of lists
1308+
of the same form to create nested layouts.
1309+
1310+
If input is a str, then it must be of the form ::
1311+
1312+
'''
1313+
AAE
1314+
C.E
1315+
'''
1316+
1317+
where each character is a column and each line is a row.
1318+
This only allows only single character Axes labels and does
1319+
not allow nesting but is very terse.
1320+
1321+
subplot_kw : dict, optional
1322+
Dictionary with keywords passed to the `.Figure.add_subplot` call
1323+
used to create each subplot.
1324+
1325+
gridspec_kw : dict, optional
1326+
Dictionary with keywords passed to the `.GridSpec` constructor used
1327+
to create the grid the subplots are placed on.
1328+
1329+
empty_sentinel : object, optional
1330+
Entry in the layout to mean "leave this space empty". Defaults
1331+
to ``'.'``. Note, if *layout* is a string, it is processed via
1332+
`inspect.cleandoc` to remove leading white space, which may
1333+
interfere with using white-space as the empty sentinel.
1334+
1335+
**fig_kw
1336+
All additional keyword arguments are passed to the
1337+
`.pyplot.figure` call.
1338+
1339+
Returns
1340+
-------
1341+
fig : `~.figure.Figure`
1342+
The new figure
1343+
1344+
dict[label, Axes]
1345+
A dictionary mapping the labels to the Axes objects.
1346+
1347+
"""
1348+
fig = figure(**fig_kw)
1349+
ax_dict = fig.subplot_mosaic(
1350+
layout,
1351+
subplot_kw=subplot_kw,
1352+
gridspec_kw=gridspec_kw,
1353+
empty_sentinel=empty_sentinel
1354+
)
1355+
return fig, ax_dict
1356+
1357+
12771358
def subplot2grid(shape, loc, rowspan=1, colspan=1, fig=None, **kwargs):
12781359
"""
12791360
Create a subplot at a specific location inside a regular grid.

0 commit comments

Comments
 (0)