diff --git a/examples/gallery/basemaps/double_y_axes.py b/examples/gallery/basemaps/double_y_axes.py index f38826fbd56..d1865ea4517 100644 --- a/examples/gallery/basemaps/double_y_axes.py +++ b/examples/gallery/basemaps/double_y_axes.py @@ -20,6 +20,7 @@ class can control which axes should be plotted and optionally show annotations, # %% import numpy as np import pygmt +from pygmt.params import Position # Generate two sample Y-data from one common X-data x = np.linspace(1.0, 9.0, num=9) @@ -63,8 +64,7 @@ class can control which axes should be plotted and optionally show annotations, # Plot points for y2-data fig.plot(x=x, y=y2, style="s0.28c", fill="red", label="y2") -# Create a legend in the Top Left (TL) corner of the plot with an -# offset of 0.1 centimeters -fig.legend(position="jTL+o0.1c", box=True) +# Create a legend in the Top Left (TL) corner of the plot with a 0.1-cm offset. +fig.legend(position=Position("TL", offset=0.1), box=True) fig.show() diff --git a/examples/gallery/embellishments/legend.py b/examples/gallery/embellishments/legend.py index acf2a8ab2fd..27c0ffa5feb 100644 --- a/examples/gallery/embellishments/legend.py +++ b/examples/gallery/embellishments/legend.py @@ -2,21 +2,21 @@ Legend ====== -The :meth:`pygmt.Figure.legend` method can automatically create a legend for -symbols plotted using :meth:`pygmt.Figure.plot`. A legend entry is only added -when the ``label`` parameter is used to state the desired text. Optionally, -to adjust the legend, users can append different modifiers. A list of all -available modifiers can be found at :gmt-docs:`gmt.html#l-full`. To create a -multiple-column legend **+N** is used with the desired number of columns. -For more complicated legends, users may want to write an ASCII file with -instructions for the layout of the legend items and pass it to the ``spec`` -parameter of :meth:`pygmt.Figure.legend`. For details on how to set up such a +The :meth:`pygmt.Figure.legend` method can automatically create a legend for symbols +plotted using :meth:`pygmt.Figure.plot`. A legend entry is only added when the ``label`` +parameter is used to state the desired text. Optionally, to adjust the legend, users can +append different modifiers. A list of all available modifiers can be found at +:gmt-docs:`gmt.html#l-full`. To create a multiple-column legend **+N** is used with the +desired number of columns. For more complicated legends, users may want to write an +ASCII file with instructions for the layout of the legend items and pass it to the +``spec`` parameter of :meth:`pygmt.Figure.legend`. For details on how to set up such a file, please see the GMT documentation at :gmt-docs:`legend.html#legend-codes`. """ # %% import numpy as np import pygmt +from pygmt.params import Position # Set up some test data x = np.arange(-10, 10.2, 0.2) @@ -39,13 +39,11 @@ # Use the label parameter to state the text label for the legend entry fig.plot(x=x, y=y1, pen="1p,green3", label="sin(x)+1.1") - fig.plot(x=x, y=y2, style="c0.07c", fill="dodgerblue", label="cos(x)+1.1") -# Add a legend to the plot; place it within the plot bounding box with both -# reference ("J") and anchor ("+j") points being the Top Right (TR) corner and an -# offset of 0.2 centimeters in x- and y-directions; surround the legend with a box -fig.legend(position="JTR+jTR+o0.2c", box=True) +# Add a legend to the plot at the Top Right (TR) corner with a 0.2-cm offset in x- and +# y-directions; surround the legend with a box. +fig.legend(position=Position("TR", offset=0.2), box=True) # ----------------------------------------------------------------------------- # Bottom: Horizontal legend (here two columns) @@ -55,8 +53,8 @@ fig.plot(x=x, y=y4, style="s0.07c", fill="orange", label="cos(x/2)-1.1") -# For a multi-column legend, users have to provide the width via "+w", here it is -# set to 6 centimeters; reference and anchor points are the Bottom Right (BR) corner -fig.legend(position="JBR+jBR+o0.2c+w6c", box=True) +# For a multi-column legend, users have to provide the width, here it is set to 6 cm; +# the legend is placed at the Bottom Right (BR) corner. +fig.legend(position=Position("BR", offset=0.2), width="6c", box=True) fig.show() diff --git a/examples/gallery/lines/hlines_vlines.py b/examples/gallery/lines/hlines_vlines.py index 19049c71d5a..96b6e07b019 100644 --- a/examples/gallery/lines/hlines_vlines.py +++ b/examples/gallery/lines/hlines_vlines.py @@ -12,7 +12,7 @@ # In Cartesian coordinate systems lines are plotted as straight lines. import pygmt -from pygmt.params import Box +from pygmt.params import Box, Position fig = pygmt.Figure() @@ -32,7 +32,7 @@ fig.hlines( y=[2, 3], xmin=[0, 1], xmax=[7, 7.5], pen="1.5p,dodgerblue3", label="Lines 7 & 8" ) -fig.legend(position="JBR+jBR+o0.2c", box=Box(pen="1p", fill="white")) +fig.legend(position=Position("BR", offset=0.2), box=Box(pen="1p", fill="white")) fig.shift_origin(xshift="w+2c") diff --git a/examples/tutorials/advanced/legends.py b/examples/tutorials/advanced/legends.py index 7b148d0229a..1114b85b535 100644 --- a/examples/tutorials/advanced/legends.py +++ b/examples/tutorials/advanced/legends.py @@ -10,7 +10,7 @@ import io import pygmt -from pygmt.params import Box +from pygmt.params import Box, Position # %% # Create an auto-legend @@ -47,9 +47,8 @@ # Adjust the position # ------------------- # -# Use the ``position`` parameter to adjust the position of the legend. Add an offset via -# **+o** for the x- and y-directions. Additionally append **+w** to adjust the width -# of the legend. Note, no box is drawn by default if ``position`` is used. +# Use the ``position`` parameter to adjust the position of the legend. Note, no box is +# drawn by default if ``position`` is used. fig = pygmt.Figure() fig.basemap(region=[-5, 5, -5, 5], projection="X5c", frame=True) @@ -58,10 +57,9 @@ fig.plot(x=1, y=0, style="t0.3c", fill="pink", pen="black", label="pink triangle") fig.plot(x=[-3, 3], y=[-2, -2], pen="darkred", label="darkred line") -# Set the reference point to the Top Left corner within (lowercase "j") the bounding box -# of the plot and use offsets of 0.3 and 0.2 centimeters in the x- and y-directions, -# respectively. -fig.legend(position="jTL+o0.3c/0.2c") +# Set the reference point to the Top Left corner inside the plot and use offsets of 0.3 +# and 0.2 centimeters in the x- and y-directions, respectively. +fig.legend(position=Position("TL", offset=(0.3, 0.2))) fig.show() @@ -69,9 +67,7 @@ # %% # Add a box # --------- -# Use the ``box`` parameter for adjusting the box around the legend. The outline of the -# box can be adjusted by appending **+p**. Append **+g** to fill the legend with a color -# (or pattern) [Default is no fill]. The default of ``position`` is preserved. +# Use the ``box`` parameter for adjusting the box around the legend. fig = pygmt.Figure() fig.basemap(region=[-5, 5, -5, 5], projection="X5c", frame="rltb+glightgray") @@ -80,7 +76,7 @@ fig.plot(x=1, y=0, style="t0.3c", fill="pink", pen="black", label="pink triangle") fig.plot(x=[-3, 3], y=[-2, -2], pen="darkred", label="darkred line") -fig.legend(position="jTL+o0.3c/0.2c", box=True) +fig.legend(position=Position("TL", offset=(0.3, 0.2)), box=True) fig.shift_origin(xshift="w+1c") fig.basemap(region=[-5, 5, -5, 5], projection="X5c", frame="rltb+glightgray") @@ -91,7 +87,9 @@ # Add a box with a 2-point thick blue, solid outline and a white fill with a # transparency of 30 percent ("@30"). -fig.legend(position="jTL+o0.3c/0.2c", box=Box(pen="2p,blue", fill="white@30")) +fig.legend( + position=Position("TL", offset=(0.3, 0.2)), box=Box(pen="2p,blue", fill="white@30") +) fig.show() @@ -146,15 +144,17 @@ # %% # Now, we can add a legend based on this :class:`io.StringIO` object. For multi-columns -# legends, the width (**+w**) has to be specified via a the ``position`` parameter. - +# legends, the width must be specified. fig = pygmt.Figure() # Note, that we are now using a Mercator projection fig.basemap(region=[-5, 5, -5, 5], projection="M10c", frame=True) - # Pass the io.StringIO object to the "spec" parameter -fig.legend(spec=spec_io, position="jMC+w9c", box=Box(pen="1p,gray50", fill="gray95")) - +fig.legend( + spec=spec_io, + position=Position("MC"), + width="9c", + box=Box(pen="1p,gray50", fill="gray95"), +) fig.show() # sphinx_gallery_thumbnail_number = 4 diff --git a/examples/tutorials/basics/plot.py b/examples/tutorials/basics/plot.py index 73453fbb3b8..ab15b77c38a 100644 --- a/examples/tutorials/basics/plot.py +++ b/examples/tutorials/basics/plot.py @@ -13,6 +13,7 @@ import io import pygmt +from pygmt.params import Position # %% # For example, let's load the sample dataset of tsunami generating earthquakes @@ -71,7 +72,7 @@ legend = io.StringIO( "\n".join(f"S 0.4 c {0.02 * 2**m:.2f} - 1p 1 Mw {m}" for m in [3, 4, 5]) ) -fig.legend(spec=legend, position="jBR+o0.2c+l2", box=True) +fig.legend(spec=legend, position=Position("BR", offset=0.2), line_spacing=2.0, box=True) fig.show() # %% @@ -101,7 +102,7 @@ pen="black", ) fig.colorbar(frame="xaf+lDepth (km)") -fig.legend(spec=legend, position="jBR+o0.2c+l2", box=True) +fig.legend(spec=legend, position=Position("BR", offset=0.2), line_spacing=2.0, box=True) fig.show() # sphinx_gallery_thumbnail_number = 3 diff --git a/pygmt/src/legend.py b/pygmt/src/legend.py index 27be8e11f95..715a0cffbae 100644 --- a/pygmt/src/legend.py +++ b/pygmt/src/legend.py @@ -9,25 +9,21 @@ from pygmt._typing import PathLike from pygmt.alias import Alias, AliasSystem from pygmt.clib import Session -from pygmt.exceptions import GMTTypeError -from pygmt.helpers import ( - build_arg_list, - data_kind, - fmt_docstring, - is_nonstr_iter, - use_alias, -) -from pygmt.params import Box +from pygmt.exceptions import GMTInvalidInput, GMTTypeError +from pygmt.helpers import build_arg_list, data_kind, fmt_docstring, is_nonstr_iter +from pygmt.params import Box, Position @fmt_docstring -@use_alias(D="position") def legend( # noqa: PLR0913 self, spec: PathLike | io.StringIO | None = None, - scale: float | None = None, - position="JTR+jTR+o0.2c", + position: Position | None = None, + width: float | str | None = None, + height: float | str | None = None, + line_spacing: float | None = None, box: Box | bool = False, + scale: float | None = None, projection: str | None = None, region: Sequence[float | str] | str | None = None, frame: str | Sequence[str] | bool = False, @@ -38,18 +34,23 @@ def legend( # noqa: PLR0913 perspective: float | Sequence[float] | str | bool = False, **kwargs, ): - r""" + """ Plot a legend. - Makes legends that can be overlaid on maps. Reads specific - legend-related information from an input file, or automatically creates - legend entries from plotted symbols that have labels. Unless otherwise - noted, annotations will be made using the primary annotation font and - size in effect (i.e., :gmt-term:`FONT_ANNOT_PRIMARY`). + Makes legends that can be overlaid on plots. It reads specific legend-related + information from an input file, a :class:`io.StringIO` object, or automatically + creates legend entries from plotted symbols that have labels. Unless otherwise + noted, annotations will be made using the primary annotation font and size in effect + (i.e., :gmt-term:`FONT_ANNOT_PRIMARY`). Full GMT docs at :gmt-docs:`legend.html`. - $aliases + **Aliases:** + + .. hlist:: + :columns: 3 + + - D = position, **+w**: width/height, **+l**: line_spacing - B = frame - F = box - J = projection @@ -71,13 +72,26 @@ def legend( # noqa: PLR0913 - A :class:`io.StringIO` object containing the legend specification See :gmt-docs:`legend.html` for the definition of the legend specification. - position : str - [**g**\|\ **j**\|\ **J**\|\ **n**\|\ **x**]\ *refpoint*\ - **+w**\ *width*\ [/*height*]\ [**+j**\ *justify*]\ [**+l**\ *spacing*]\ - [**+o**\ *dx*\ [/*dy*]]. - Define the reference point on the map for the legend. By default, uses - **JTR**\ **+jTR**\ **+o**\ 0.2c which places the legend at the Top Right corner - inside the map frame, with a 0.2 cm offset. + position + Specify the position of the legend on the plot. If not specified, defaults to + the top right corner inside the plot with a 0.2-cm offset. See + :class:`pygmt.params.Position` for details. + width + height + Specify the width and height of the legend box in plot coordinates (inches, cm, + etc.). If not given, the width and height are computed automatically based on + the contents of the legend specification. + + If unit is ``%`` (percentage) then width is computed as that fraction of the + plot width. If height is given as percentage then height is recomputed as that + fraction of the legend width (not plot height). + + **Note:** Currently, the automatic height calculation only works when legend + codes **D**, **H**, **L**, **S**, or **V** are used and that the number of + symbol columns (**N**) is 1. + line_spacing + Specify the line-spacing factor between legend entries in units of the current + font size [Default is 1.1]. box Draw a background box behind the legend. If set to ``True``, a simple rectangular box is drawn using :gmt-term:`MAP_FRAME_PEN`. To customize the box @@ -95,12 +109,27 @@ def legend( # noqa: PLR0913 """ self._activate_figure() - # Default position and box when not specified. - if kwargs.get("D") is None: - kwargs["D"] = position - if box is False and kwargs.get("F") is None: + # Prior PyGMT v0.17.0, 'position' can accept a raw GMT CLI string. Check for + # conflicts with other parameters. + if isinstance(position, str) and any( + v is not None for v in (width, height, line_spacing) + ): + msg = ( + "Parameter 'position' is given with a raw GMT command string, and conflicts " + "with parameters 'width', 'height', and 'line_spacing'." + ) + raise GMTInvalidInput(msg) + + # Set default position if not specified. + if kwargs.get("D", position) is None: + position = Position("TR", anchor="TR", offset=0.2) + if kwargs.get("F", box) is False: box = Box(pen="1p", fill="white") # Default box + # Set default width to 0 if height is given but width is not. + if height is not None and width is None: + width = 0 + kind = data_kind(spec) if kind not in {"empty", "file", "stringio"}: raise GMTTypeError(type(spec)) @@ -110,6 +139,12 @@ def legend( # noqa: PLR0913 ) aliasdict = AliasSystem( + D=[ + Alias(position, name="position"), + Alias(width, name="width", prefix="+w"), # +wwidth/height + Alias(height, name="height", prefix="/"), + Alias(line_spacing, name="line_spacing", prefix="+l"), + ], F=Alias(box, name="box"), S=Alias(scale, name="scale"), ).add_common( diff --git a/pygmt/tests/baseline/test_legend_position.png.dvc b/pygmt/tests/baseline/test_legend_position.png.dvc deleted file mode 100644 index 09d9b8e5dd3..00000000000 --- a/pygmt/tests/baseline/test_legend_position.png.dvc +++ /dev/null @@ -1,5 +0,0 @@ -outs: -- md5: c0e2d094a25066a6a3cf473579ab97b8 - size: 25243 - path: test_legend_position.png - hash: md5 diff --git a/pygmt/tests/baseline/test_legend_width_height.png.dvc b/pygmt/tests/baseline/test_legend_width_height.png.dvc new file mode 100644 index 00000000000..e740f4a3e84 --- /dev/null +++ b/pygmt/tests/baseline/test_legend_width_height.png.dvc @@ -0,0 +1,5 @@ +outs: +- md5: 88cdea7af99c1edd19ade5b4c9b6c09e + size: 115141 + hash: md5 + path: test_legend_width_height.png diff --git a/pygmt/tests/test_legend.py b/pygmt/tests/test_legend.py index b78d143e572..7e7ffd39755 100644 --- a/pygmt/tests/test_legend.py +++ b/pygmt/tests/test_legend.py @@ -7,8 +7,9 @@ import pytest from pygmt import Figure -from pygmt.exceptions import GMTTypeError +from pygmt.exceptions import GMTInvalidInput, GMTTypeError from pygmt.helpers import GMTTempFile +from pygmt.params import Position @pytest.fixture(scope="module", name="legend_spec") @@ -44,20 +45,6 @@ def fixture_legend_spec(): """ -@pytest.mark.mpl_image_compare -def test_legend_position(): - """ - Test positioning the legend with different coordinate systems. - """ - fig = Figure() - fig.basemap(region=[-2, 2, -2, 2], frame=True) - positions = ["jTR+jTR", "g0/1", "n0.2/0.2", "x4i/2i/2i"] - for i, position in enumerate(positions): - fig.plot(x=[0], y=[0], style="p10p", label=i) - fig.legend(position=position, box=True) - return fig - - @pytest.mark.mpl_image_compare def test_legend_default_position(): """ @@ -87,7 +74,7 @@ def test_legend_entries(): ) fig.plot(data="@Table_5_11.txt", pen="1.5p,gray", label="My lines") fig.plot(data="@Table_5_11.txt", style="t0.15i", fill="orange", label="Oranges") - fig.legend(position="JTR+jTR") + fig.legend(position=Position("TR", cstype="outside", anchor="TR")) return fig @@ -100,7 +87,11 @@ def test_legend_specfile(legend_spec): Path(specfile.name).write_text(legend_spec, encoding="utf-8") fig = Figure() fig.basemap(projection="x6i", region=[0, 1, 0, 1], frame=True) - fig.legend(specfile.name, position="JMC+jMC+w5i") + fig.legend( + specfile.name, + position=Position("MC", cstype="outside", anchor="MC"), + width="5i", + ) return fig @@ -112,7 +103,48 @@ def test_legend_stringio(legend_spec): spec = io.StringIO(legend_spec) fig = Figure() fig.basemap(projection="x6i", region=[0, 1, 0, 1], frame=True) - fig.legend(spec, position="JMC+jMC+w5i") + fig.legend(spec, position=Position("MC", cstype="outside", anchor="MC"), width="5i") + return fig + + +@pytest.mark.mpl_image_compare +def test_legend_width_height(): + """ + Test legend with specified width and height. + """ + spec = io.StringIO( + """ +S 0.1i c 0.15i p300/12 0.25p 0.3i This circle is hachured +S 0.1i e 0.15i yellow 0.25p 0.3i This ellipse is yellow +S 0.1i w 0.15i green 0.25p 0.3i This wedge is green +S 0.1i f0.1i+l+t 0.25i blue 0.25p 0.3i This is a fault +S 0.1i - 0.15i - 0.25p,- 0.3i A dashed contour +S 0.1i v0.1i+a40+e 0.25i magenta 0.25p 0.3i This is a vector +S 0.1i i 0.15i cyan 0.25p 0.3i This triangle is boring +""" + ) + fig = Figure() + fig.basemap(projection="x1c", region=[0, 20, 0, 20], frame="g1") + # Default width and height + fig.legend(spec, position=Position("TL"), box=True) + + # Width only + fig.legend(spec, position=Position("TC"), width="6c", box=True) + # Width as percentage of plot width + fig.legend(spec, position=Position("TR"), width="25%", box=True) + + # Height only, with automatic width + fig.legend(spec, position=Position("ML"), height="4.5c", box=True) + # Height as percentage of legend width + fig.legend(spec, position=Position("BL"), height="75%", box=True) + + # Both width and height + fig.legend(spec, position=Position("MC"), width="6c", height="4.5c", box=True) + # Height as percentage of legend width + fig.legend(spec, position=Position("BC"), width="6c", height="75%", box=True) + # Width as percentage of plot width and height as percentage of legend width + fig.legend(spec, position=Position("BR"), width="25%", height="75%", box=True) + return fig @@ -126,3 +158,30 @@ def test_legend_fails(): with pytest.raises(GMTTypeError): fig.legend(spec=[1, 2]) + + +@pytest.mark.mpl_image_compare(filename="test_legend_specfile.png") +def test_legend_position_deprecated_syntax(legend_spec): + """ + Test using a deprecated syntax for legend position. + """ + spec = io.StringIO(legend_spec) + fig = Figure() + fig.basemap(projection="x6i", region=[0, 1, 0, 1], frame=True) + fig.legend(spec, position="JMC+jMC+w5i") + return fig + + +def test_legend_position_mixed_syntax(legend_spec): + """ + Test using a mixed syntax for legend position. + """ + spec = io.StringIO(legend_spec) + fig = Figure() + fig.basemap(projection="x6i", region=[0, 1, 0, 1], frame=True) + with pytest.raises(GMTInvalidInput): + fig.legend(spec, position="JMC", width="5i") + with pytest.raises(GMTInvalidInput): + fig.legend(spec, position="JMC", height="5i") + with pytest.raises(GMTInvalidInput): + fig.legend(spec, position="JMC", line_spacing=2.0)