From f2ac98b48feb5871c44ba013934791888996f079 Mon Sep 17 00:00:00 2001 From: Wei Ji Date: Sat, 30 Jan 2021 20:21:21 +1300 Subject: [PATCH] Wrap subplot - version 3 Wrapping the `subplot` function, in a `with` statement! Original GMT `subplot` function can be found at https://docs.generic-mapping-tools.org/6.1/subplot.html. This is my 3rd attempt at implementing `subplot` in PyGMT, with commits heavily re-adapted/cherry-picked from https://github.com/GenericMappingTools/pygmt/pull/412 and https://github.com/GenericMappingTools/pygmt/pull/427. --- .pylintrc | 1 + doc/index.rst | 1 + examples/tutorials/subplots.py | 238 +++++++++++++++++++++++++++++++++ pygmt/base_plotting.py | 9 +- pygmt/src/__init__.py | 1 + pygmt/src/subplot.py | 168 +++++++++++++++++++++++ pygmt/tests/test_subplot.py | 85 ++++++++++++ 7 files changed, 499 insertions(+), 4 deletions(-) create mode 100644 examples/tutorials/subplots.py create mode 100644 pygmt/src/subplot.py create mode 100644 pygmt/tests/test_subplot.py diff --git a/.pylintrc b/.pylintrc index 766bdd46b8b..e42a50bf488 100644 --- a/.pylintrc +++ b/.pylintrc @@ -441,6 +441,7 @@ function-naming-style=snake_case good-names=i, j, k, + ax, ex, Run, _, diff --git a/doc/index.rst b/doc/index.rst index 71fc08e900b..765b70e524d 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -33,6 +33,7 @@ tutorials/coastlines.rst tutorials/regions.rst tutorials/plot.rst + tutorials/subplots.rst tutorials/lines.rst tutorials/text.rst tutorials/contour-map.rst diff --git a/examples/tutorials/subplots.py b/examples/tutorials/subplots.py new file mode 100644 index 00000000000..857967a34b5 --- /dev/null +++ b/examples/tutorials/subplots.py @@ -0,0 +1,238 @@ +""" +Subplots +======== + +When you're preparing a figure for a paper, there will often be times when +you'll need to put many individual plots into one large figure, and label them +'abcd'. These individual plots are called subplots. + +There are two main ways to create subplots in GMT: + +- Use :meth:`pygmt.Figure.shift_origin` to manually move each individual plot + to the right position. +- Use :meth:`pygmt.Figure.subplot` to define the layout of the subplots. + +The first method is easier to use and should handle simple cases involving a +couple of subplots. For more advanced subplot layouts however, we recommend the +use of :meth:`pygmt.Figure.subplot` which offers finer grained control, and +this is what the tutorial below will cover. +""" + +############################################################################### +# Let's start by importing the PyGMT library and initializing a Figure + +import pygmt + +fig = pygmt.Figure() + +############################################################################### +# Define subplot layout +# --------------------- +# +# The :meth:`pygmt.Figure.subplot` command is used to setup the layout, size, +# and other attributes of the figure. It divides the whole canvas into regular +# grid areas with n rows and m columns. Each grid area can contain an +# individual subplot. For example: + +############################################################################### +# .. code-block:: default +# +# with fig.subplot( +# nrows=2, ncols=3, figsize=("15c", "6c"), frame="lrtb" +# ) as axs: +# ... + +############################################################################### +# will define our figure to have a 2 row and 3 column grid layout. +# ``figsize=("15c", "6c")`` defines the overall size of the figure to be 15cm +# wide by 6cm high. Using ``frame="lrtb"`` allows us to customize the map frame +# for all subplots instead of setting them individually. The figure layout will +# look like the following: + +with fig.subplot(nrows=2, ncols=3, figsize=("15c", "6c"), frame="lrtb") as axs: + for index in axs.flatten(): + i = index // axs.shape[1] # row + j = index % axs.shape[1] # column + fig.sca(ax=axs[i, j]) # sets the current Axes + fig.text( + position="MC", + text=f"index: {index}, row: {i}, col: {j}", + region=[0, 1, 0, 1], + ) +fig.show() + +############################################################################### +# The ``fig.sca`` command activates a specified subplot, and all subsequent +# plotting commands will take place in that subplot. This is similar to +# matplotlib's ``plt.sca`` method. In order to specify a subplot, you will need +# to provide the identifier for that subplot via the ``ax`` argument. This can +# be found in the ``axs`` variable referenced by the ``row`` and ``col`` +# number. + +############################################################################### +# .. note:: +# +# The row and column numbering starts from 0. So for a subplot layout with +# N rows and M columns, row numbers will go from 0 to N-1, and column +# numbers will go from 0 to M-1. + +############################################################################### +# For example, to activate the subplot on the top right corner (index: 2) at +# ``row=0`` and ``col=2``, so that all subsequent plotting commands happen +# there, you can use the following command: + +############################################################################### +# .. code-block:: default +# +# fig.sca(ax=axs[0, 2]) + +############################################################################### +# Making your first subplot +# ------------------------- +# Next, let's use what we learned above to make a 2 row by 2 column subplot +# figure. We'll also pick up on some new parameters to configure our subplot. + +fig = pygmt.Figure() +with fig.subplot( + nrows=2, + ncols=2, + figsize=("15c", "6c"), + autolabel=True, + margins=["0.1c", "0.2c"], + title='"My Subplot Heading"', +) as axs: + fig.basemap( + region=[0, 10, 0, 10], projection="X?", frame=["af", "WSne"], ax=axs[0, 0] + ) + fig.basemap( + region=[0, 20, 0, 10], projection="X?", frame=["af", "WSne"], ax=axs[0, 1] + ) + fig.basemap( + region=[0, 10, 0, 20], projection="X?", frame=["af", "WSne"], ax=axs[1, 0] + ) + fig.basemap( + region=[0, 20, 0, 20], projection="X?", frame=["af", "WSne"], ax=axs[1, 1] + ) +fig.show() + +############################################################################### +# In this example, we define a 2-row, 2-column (2x2) subplot layout using +# :meth:`pygmt.Figure.subplot`. The overall figure dimensions is set to be 15cm +# wide and 6cm high (``figsize=["15c", "6c"]``). In addition, we used some +# optional parameters to fine tune some details of the figure creation: +# +# - ``autolabel=True``: Each subplot is automatically labelled abcd +# - ``margins=["0.1c", "0.2c"]``: adjusts the space between adjacent subplots. +# In this case, it is set as 0.1 cm in the X direction and 0.2 cm in the Y +# direction. +# - ``title="My Subplot Heading"``: adds a title on top of the whole figure. +# +# Notice that each subplot was set to use a linear projection ``"X?"``. +# Usually, we need to specify the width and height of the map frame, but it is +# also possible to use a question mark ``"?"`` to let GMT decide automatically +# on what is the most appropriate width/height for the each subplot's map +# frame. + +############################################################################### +# .. tip:: +# +# In the above example, we used the following commands to activate the +# four subplots explicitly one after another:: +# +# fig.basemap(..., ax=axs[0, 0]) +# fig.basemap(..., ax=axs[0, 1]) +# fig.basemap(..., ax=axs[1, 0]) +# fig.basemap(..., ax=axs[1, 1]) +# +# In fact, we can just use ``fig.basemap(..., ax=True)`` without specifying +# any subplot index number, and GMT will automatically activate the next +# subplot. + +############################################################################### +# Shared X and Y axis labels +# -------------------------- +# In the example above with the four subplots, the two subplots for each row +# have the same Y-axis range, and the two subplots for each column have the +# same X-axis range. You can use the **layout** option to set a common X and/or +# Y axis between subplots. + +fig = pygmt.Figure() +with fig.subplot( + nrows=2, + ncols=2, + figsize=("15c", "6c"), + autolabel=True, + margins=["0.3c", "0.2c"], + title='"My Subplot Heading"', + layout=["Rl", "Cb"], + frame="WSrt", +) as axs: + fig.basemap(region=[0, 10, 0, 10], projection="X?", ax=True) + fig.basemap(region=[0, 20, 0, 10], projection="X?", ax=True) + fig.basemap(region=[0, 10, 0, 20], projection="X?", ax=True) + fig.basemap(region=[0, 20, 0, 20], projection="X?", ax=True) +fig.show() + +############################################################################### +# **Rl** indicates that subplots within a **R**\ ow will share the y-axis, and +# only the **l**\ eft axis is displayed. **Cb** indicates that subplots in +# a column will share the x-axis, and only the **b**\ ottom axis is displayed. +# +# Of course, instead of using the **layout** option, you can also set a +# different **frame** for each subplot to control the axis properties +# individually for each subplot. + +############################################################################### +# Advanced subplot layouts +# ------------------------ +# +# Nested subplot are currently not supported. If you want to create more +# complex subplot layouts, some manual adjustments are needed. +# +# The following example draws three subplots in a 2-row, 2-column layout, with +# the first subplot occupying the first row. + +fig = pygmt.Figure() +with fig.subplot(nrows=2, ncols=2, figsize=("15c", "6c"), autolabel=True): + fig.basemap( + region=[0, 10, 0, 10], projection="X15c/3c", frame=["af", "WSne"], ax=axs[0, 0] + ) + fig.text(text="TEXT", x=5, y=5, projection="X15c/3c") + fig.basemap( + region=[0, 5, 0, 5], projection="X?", frame=["af", "WSne"], ax=axs[1, 0] + ) + fig.basemap( + region=[0, 5, 0, 5], projection="X?", frame=["af", "WSne"], ax=axs[1, 1] + ) +fig.show() + +############################################################################### +# +# When drawing the three basemaps, the last two basemaps use +# ``projection="X?"``, so GMT will automatically determine the size of the +# subplot according to the size of the subplot area. In order for the first +# subplot to fill up the entire top row space, we use manually adjusted the +# subplot width to 15cm using ``projection="X15c/3c"``. + +############################################################################### +# .. note:: +# +# There are bugs that have not been fixed in the above example. +# +# In subplot mode, the size of each subgraph is controlled by the +# ``figsize`` option of :meth:`pygmt.Figure.subplot`. Users can override +# this and use``projection`` to specify the size of an individual subplot, +# but this size will not be remembered. If the next command does not +# specify``projection``, the default size of the subplot mode will be used, +# and the resulting plot will be inccorect. +# +# The current workaround is to use the same ``projection`` option in all +# commands for the subplot. For example, we forced subplot (a) to have a +# different size using ``projection="15c/3c``. The next command within the +# subplot (e.g. ``text``) must also use ``projection="x15c/3c"``, otherwise +# the placement will be wrong. + +############################################################################### +# Since we skipped the second subplot, the auto label function will name the +# three subplots as a, c and d, which is not what we want, so we have to use +# ``fig.sca(A=""(a)"`` to manually set the subplot label. diff --git a/pygmt/base_plotting.py b/pygmt/base_plotting.py index 3a28c9895be..192cf7a17d6 100644 --- a/pygmt/base_plotting.py +++ b/pygmt/base_plotting.py @@ -1222,6 +1222,7 @@ def contour(self, x=None, y=None, z=None, data=None, **kwargs): V="verbose", X="xshift", Y="yshift", + c="ax", p="perspective", t="transparency", ) @@ -1265,9 +1266,9 @@ def basemap(self, **kwargs): {t} """ kwargs = self._preprocess(**kwargs) - if not args_in_kwargs(args=["B", "L", "Td", "Tm"], kwargs=kwargs): + if not args_in_kwargs(args=["B", "L", "Td", "Tm", "c"], kwargs=kwargs): raise GMTInvalidInput( - "At least one of frame, map_scale, compass, or rose must be specified." + "At least one of frame, map_scale, compass, rose, or ax must be specified." ) with Session() as lib: lib.call_module("basemap", build_arg_string(kwargs)) @@ -1642,5 +1643,5 @@ def text( arg_str = " ".join([fname, build_arg_string(kwargs)]) lib.call_module("text", arg_str) - # GMT Supplementary modules - from pygmt.src import meca # pylint: disable=import-outside-toplevel + # GMT plotting modules + from pygmt.src import meca, sca, subplot # pylint: disable=import-outside-toplevel diff --git a/pygmt/src/__init__.py b/pygmt/src/__init__.py index cdbabf1468a..f9b7fb9094a 100644 --- a/pygmt/src/__init__.py +++ b/pygmt/src/__init__.py @@ -3,3 +3,4 @@ """ # pylint: disable=import-outside-toplevel from pygmt.src.meca import meca +from pygmt.src.subplot import sca, subplot diff --git a/pygmt/src/subplot.py b/pygmt/src/subplot.py new file mode 100644 index 00000000000..a2c55d698df --- /dev/null +++ b/pygmt/src/subplot.py @@ -0,0 +1,168 @@ +""" +subplot - Manage modern mode figure subplot configuration and selection. +""" +import contextlib + +import numpy as np +from pygmt.clib import Session +from pygmt.helpers import build_arg_string, fmt_docstring, kwargs_to_strings, use_alias + + +@fmt_docstring +@contextlib.contextmanager +@use_alias( + Ff="figsize", + Fs="subsize", + A="autolabel", + B="frame", + C="clearance", + M="margins", + S="layout", + T="title", +) +@kwargs_to_strings(Ff="sequence", Fs="sequence", M="sequence") +def subplot(self, nrows=1, ncols=1, **kwargs): + """ + The **subplot** module is used to split the current figure into a + rectangular layout of subplots that each may contain a single self- + contained figure. + + Begin by defining the layout of the entire multi-panel illustration. + Several options are available to specify the systematic layout, labeling, + dimensions, and more for the subplots. + + Full option list at :gmt-docs:`subplot.html#synopsis-begin-mode` + + {aliases} + + Parameters + ---------- + nrows : int + Number of vertical rows of the subplot grid. + ncols : int + Number of horizontal columns of the subplot grid. + figsize : tuple + Specify the final figure dimensions as ``(width, height)``. + subsize : tuple + Specify the dimensions of each subplot directly as ``(width, height)``. + + autolabel : bool or str + ``[autolabel][+cdx[/dy]][+gfill][+j|Jrefpoint][+odx[/dy]][+ppen][+r|R] + [+v]``. + Specify automatic tagging of each subplot. Append either a number or + letter [a]. This sets the tag of the first, top-left subplot and others + follow sequentially. Surround the number or letter by parentheses on + any side if these should be typeset as part of the tag. Use + **+j|J**\\ *refpoint* to specify where the tag should be placed in the + subplot [TL]. Note: **+j** sets the justification of the tag to + *refpoint* (suitable for interior tags) while **+J** instead selects + the mirror opposite (suitable for exterior tags). Append + **+c**\\ *dx*[/*dy*] to set the clearance between the tag and a + surrounding text box requested via **+g** or **+p** [3p/3p, i.e., 15% + of the FONT_TAG size dimension]. Append **+g**\\ *fill* to paint the + tag's text box with *fill* [no painting]. Append + **+o**\\ *dx*\\ [/*dy*] to offset the tag's reference point in the + direction implied by the justification [4p/4p, i.e., 20% of the + FONT_TAG size]. Append **+p**\\ *pen* to draw the outline of the tag's + text box using selected *pen* [no outline]. Append **+r** to typeset + your tag numbers using lowercase Roman numerals; use **+R** for + uppercase Roman numerals [Arabic numerals]. Append **+v** to increase + tag numbers vertically down columns [horizontally across rows]. + clearance : str + ``[side]clearance``. + Reserve a space of dimension *clearance* between the margin and the + subplot on the specified side, using *side* values from **w**, **e**, + **s**, or **n**, or **x** for both **w** and **e** or **y** for both + **s** and **n**. No *side* means all sides. The option is repeatable + to set aside space on more than one side. Such space will be left + untouched by the main map plotting but can be accessed by modules that + plot scales, bars, text, etc. Settings specified under **begin** + directive apply to all subplots, while settings under **set** only + apply to the selected (active) subplot. **Note**: Common options + **x_offset** and **y_offset* are not available during subplots; use + **clearance** instead. + margins : tuple + This is margin space that is added between neighboring subplots (i.e., + the interior margins) in addition to the automatic space added for tick + marks, annotations, and labels. The margins can be specified as either: + + - a single value (for same margin on all sides). E.g. '5c'. + - a pair of values (for setting separate horizontal and vertical + margins). E.g. ['5c', '3c']. + - a set of four values (for setting separate left, right, bottom, and + top margins). E.g. ['1c', '2c', '3c', '4c']. + + The actual gap created is always a sum of the margins for the two + opposing sides (e.g., east plus west or south plus north margins) + [Default is half the primary annotation font size, giving the full + annotation font size as the default gap]. + layout : str or list + Set subplot layout for shared axes. May be set separately for rows + (**R**) and columns (**C**). E.g. ``layout=['Rl', 'Cb']``. + Considerations for **C**: Use when all subplots in a **C**\\ olumn + share a common *x*-range. The first (i.e., **t**\\ op) and the last + (i.e., **b**\\ ottom) rows will have *x* annotations; append **t** or + **b** to select only one of those two rows [both]. Append **+l** if + annotated *x*-axes should have a label [none]; optionally append the + label if it is the same for the entire subplot. Append **+t** to make + space for subplot titles for each row; use **+tc** for top row titles + only [no subplot titles]. Labels and titles that depends on which row + or column are specified as usual via a subplot's own **frame** setting. + Considerations for **R**: Use when all subplots in a **R**\\ ow share a + common *y*-range. The first (i.e., **l**\\ eft) and the last (i.e., + **r**\\ ight) columns will have *y*-annotations; append **l** or **r** + to select only one of those two columns [both]. Append **+l** if + annotated *y*-axes will have a label [none]; optionally, append the + label if it is the same for the entire subplot. Append **+p** to make + all annotations axis-parallel [horizontal]; if not used you may have to + set **clearance** to secure extra space for long horizontal + annotations. Append **+w** to draw horizontal and vertical lines + between interior panels using selected pen [no lines]. + title : str + Overarching heading for the entire figure. Font is determined by + setting ``FONT_HEADING``. + + Yields + ------ + axs : numpy.ndarray + Array of Axes objects. + """ + kwargs = self._preprocess(**kwargs) # pylint: disable=protected-access + + with Session() as lib: + try: + arg_str = " ".join(["begin", f"{nrows}x{ncols}", build_arg_string(kwargs)]) + lib.call_module(module="subplot", args=arg_str) + # Setup matplotlib-like Axes + axs = np.empty(shape=(nrows, ncols), dtype=object) + for index in range(nrows * ncols): + i = index // ncols # row + j = index % ncols # column + axs[i, j] = index + yield axs + finally: + v_arg = build_arg_string(kwargs.fromkeys("V")) + lib.call_module("subplot", f"end {v_arg}".strip()) + + +@fmt_docstring +@use_alias(F="dimensions") +def sca(self, ax=None, **kwargs): + """ + Set the current Axes instance to *ax*. + + Before you start plotting you must first select the active subplot. Note: + If any *projection* option is passed with **?** as scale or width when + plotting subplots, then the dimensions of the map are automatically + determined by the subplot size and your region. For Cartesian plots: If you + want the scale to apply equally to both dimensions then you must specify + ``projection="x"`` [The default ``projection="X"`` will fill the subplot by + using unequal scales]. + + {aliases} + """ + kwargs = self._preprocess(**kwargs) # pylint: disable=protected-access + + arg_str = " ".join(["set", f"{ax}", build_arg_string(kwargs)]) + with Session() as lib: + lib.call_module(module="subplot", args=arg_str) diff --git a/pygmt/tests/test_subplot.py b/pygmt/tests/test_subplot.py new file mode 100644 index 00000000000..3e648ff5b18 --- /dev/null +++ b/pygmt/tests/test_subplot.py @@ -0,0 +1,85 @@ +""" +Tests subplot. +""" +from pygmt import Figure +from pygmt.helpers.testing import check_figures_equal + + +@check_figures_equal() +def test_subplot_basic_frame(): + """ + Create a subplot figure with 1 vertical row and 2 horizontal columns, and + ensure map frame setting is applied to all subplot figures. + """ + fig_ref, fig_test = Figure(), Figure() + with fig_ref.subplot(nrows=1, ncols=2, Ff="6c/3c", B="WSne"): + fig_ref.sca(ax=0) + fig_ref.basemap(region=[0, 3, 0, 3], frame="+tplot0") + fig_ref.sca(ax=1) + fig_ref.basemap(region=[0, 3, 0, 3], frame="+tplot1") + with fig_test.subplot(nrows=1, ncols=2, figsize=("6c", "3c"), frame="WSne") as axs: + fig_test.sca(ax=axs[0, 0]) + fig_test.basemap(region=[0, 3, 0, 3], frame="+tplot0") + fig_test.sca(ax=axs[0, 1]) + fig_test.basemap(region=[0, 3, 0, 3], frame="+tplot1") + return fig_ref, fig_test + + +@check_figures_equal() +def test_subplot_direct(): + """ + Plot map elements to subplot directly using ax argument. + """ + fig_ref, fig_test = Figure(), Figure() + with fig_ref.subplot(nrows=2, ncols=1, Fs="3c/3c"): + fig_ref.basemap(region=[0, 3, 0, 3], frame=True, ax=0) + fig_ref.basemap(region=[0, 3, 0, 3], frame=True, ax=1) + with fig_test.subplot(nrows=2, ncols=1, subsize=("3c", "3c")) as axs: + fig_test.basemap(region=[0, 3, 0, 3], frame=True, ax=axs[0, 0]) + fig_test.basemap(region=[0, 3, 0, 3], frame=True, ax=axs[1, 0]) + return fig_ref, fig_test + + +@check_figures_equal() +def test_subplot_autolabel_margins_title(): + """ + Make subplot figure with autolabels, setting some margins and a title. + """ + fig_ref, fig_test = Figure(), Figure() + kwargs = dict(nrows=2, ncols=1, figsize=("15c", "6c")) + + with fig_ref.subplot(A="(1)", M="0.3c/0.1c", T='"Subplot Title"', **kwargs): + fig_ref.basemap(region=[0, 1, 2, 3], frame="WSne", c="0,0") + fig_ref.basemap(region=[4, 5, 6, 7], frame="WSne", c="1,0") + + with fig_test.subplot( + autolabel="(1)", margins=["0.3c", "0.1c"], title='"Subplot Title"', **kwargs + ) as axs_test: + fig_test.basemap(region=[0, 1, 2, 3], frame="WSne", ax=axs_test[0, 0]) + fig_test.basemap(region=[4, 5, 6, 7], frame="WSne", ax=axs_test[1, 0]) + + return fig_ref, fig_test + + +@check_figures_equal() +def test_subplot_clearance_and_shared_xy_axis_layout(): + """ + Ensure subplot clearance works, and that the layout can be set to use + shared X and Y axis labels across columns and rows. + """ + fig_ref, fig_test = Figure(), Figure() + kwargs = dict(nrows=2, ncols=2, frame="WSrt", figsize=("5c", "5c")) + + with fig_ref.subplot(C="y0.2", SR="l", SC="t", **kwargs): + fig_ref.basemap(region=[0, 4, 0, 4], projection="X?", ax=True) + fig_ref.basemap(region=[0, 8, 0, 4], projection="X?", ax=True) + fig_ref.basemap(region=[0, 4, 0, 8], projection="X?", ax=True) + fig_ref.basemap(region=[0, 8, 0, 8], projection="X?", ax=True) + + with fig_test.subplot(clearance="y0.2", layout=["Rl", "Ct"], **kwargs): + fig_test.basemap(region=[0, 4, 0, 4], projection="X?", ax=True) + fig_test.basemap(region=[0, 8, 0, 4], projection="X?", ax=True) + fig_test.basemap(region=[0, 4, 0, 8], projection="X?", ax=True) + fig_test.basemap(region=[0, 8, 0, 8], projection="X?", ax=True) + + return fig_ref, fig_test