diff --git a/examples/gallery/embellishments/gmt_logo.py b/examples/gallery/embellishments/gmt_logo.py index b0311881577..28e55bd89ab 100644 --- a/examples/gallery/embellishments/gmt_logo.py +++ b/examples/gallery/embellishments/gmt_logo.py @@ -7,12 +7,12 @@ # %% import pygmt +from pygmt.params import Position fig = pygmt.Figure() fig.basemap(region=[0, 10, 0, 2], projection="X6c", frame=True) # Add the GMT logo in the Top Right (TR) corner of the current plot, scaled up to be 3 # centimeters wide and offset by 0.3 cm in x-direction and 0.6 cm in y-direction. -fig.logo(position="jTR+o0.3c/0.6c+w3c") - +fig.logo(position=Position("TR", offset=(0.3, 0.6)), width="3c") fig.show() diff --git a/pygmt/src/_common.py b/pygmt/src/_common.py index 4f0008464d5..92202828df2 100644 --- a/pygmt/src/_common.py +++ b/pygmt/src/_common.py @@ -7,7 +7,8 @@ from pathlib import Path from typing import Any, ClassVar, Literal -from pygmt.exceptions import GMTValueError +from pygmt.exceptions import GMTInvalidInput, GMTValueError +from pygmt.params.position import Position from pygmt.src.which import which @@ -244,3 +245,113 @@ def from_params( if set(param_list).issubset(set(params)): return cls(convention, component=component) # type: ignore[arg-type] raise GMTValueError(params, description="focal mechanism parameters") + + +def _parse_position( + position: Position | Sequence[float | str] | str | None, + kwdict: dict[str, Any], + default: Position | None, +) -> Position | str: + """ + Parse the "position" parameter for embellishment-plotting functions. + + Parameters + ---------- + position + The position argument to parse. It can be one of the following: + + - A :class:`pygmt.params.Position` object. + - A sequence of two values representing x and y coordinates in plot coordinates. + - A 2-character justification code. + - A raw GMT command string (for backward compatibility). + - ``None``, in which case the default position is used. + kwdict + The keyword arguments dictionary that conflicts with ``position`` if + ``position`` is given as a raw GMT command string. + default + The default Position object to use if ``position`` is ``None``. + + Returns + ------- + position + The parsed Position object or raw GMT command string. + + Examples + -------- + >>> from pygmt.params import Position + >>> _parse_position( + ... Position((3, 3), cstype="mapcoords"), + ... kwdict={"width": None, "height": None}, + ... default=Position((0, 0), cstype="plotcoords"), + ... ) + Position(refpoint=(3, 3), cstype='mapcoords') + + >>> _parse_position( + ... (3, 3), + ... kwdict={"width": None, "height": None}, + ... default=Position((0, 0), cstype="plotcoords"), + ... ) + Position(refpoint=(3, 3), cstype='plotcoords') + >>> _parse_position( + ... "TL", + ... kwdict={"width": None, "height": None}, + ... default=Position((0, 0), cstype="plotcoords"), + ... ) + Position(refpoint='TL', cstype='inside') + + >>> _parse_position( + ... None, + ... kwdict={"width": None, "height": None}, + ... default=Position((0, 0), cstype="plotcoords"), + ... ) + Position(refpoint=(0, 0), cstype='plotcoords') + + >>> _parse_position( + ... "x3c/4c+w2c", + ... kwdict={"width": None, "height": None}, + ... default=Position((0, 0), cstype="plotcoords"), + ... ) + 'x3c/4c+w2c' + + >>> _parse_position( + ... "x3c/4c+w2c", + ... kwdict={"width": 2, "height": None}, + ... default=Position((0, 0), cstype="plotcoords"), + ... ) + Traceback (most recent call last): + ... + pygmt.exceptions.GMTInvalidInput: Parameter 'position' is given with a raw GMT... + + >>> _parse_position( + ... 123, + ... kwdict={"width": None, "height": None}, + ... default=Position((0, 0), cstype="plotcoords"), + ... ) + Traceback (most recent call last): + ... + pygmt.exceptions.GMTInvalidInput: Invalid type for parameter 'position':... + """ + + _valid_anchors = {f"{h}{v}" for v in "TMB" for h in "LCR"} | { + f"{v}{h}" for v in "TMB" for h in "LCR" + } + match position: + case str() if position in _valid_anchors: # Anchor code + position = Position(position, cstype="inside") + case str(): # Raw GMT command string. + if any(v is not None for v in kwdict.values()): + msg = ( + "Parameter 'position' is given with a raw GMT command string, and " + f"conflicts with parameters {', '.join(repr(c) for c in kwdict)}." + ) + raise GMTInvalidInput(msg) + case Sequence() if len(position) == 2: # A sequence of x and y coordinates. + position = Position(position, cstype="plotcoords") + case Position(): # Already a Position object. + pass + case None if default is not None: # Set default position. + position = default + case _: + msg = f"Invalid type for parameter 'position': {type(position)}." + raise GMTInvalidInput(msg) + return position diff --git a/pygmt/src/inset.py b/pygmt/src/inset.py index 18f33efe4d0..36a3778369f 100644 --- a/pygmt/src/inset.py +++ b/pygmt/src/inset.py @@ -109,7 +109,7 @@ def inset( Examples -------- >>> import pygmt - >>> from pygmt.params import Box + >>> from pygmt.params import Box, Position >>> >>> # Create the larger figure >>> fig = pygmt.Figure() @@ -126,9 +126,8 @@ def inset( ... dcw="MG+gred", ... ) ... - >>> # Map elements outside the "with" statement are plotted in the main - >>> # figure - >>> fig.logo(position="jBR+o0.2c+w3c") + >>> # Map elements outside the "with" statement are plotted in the main figure + >>> fig.logo(position=Position("BR", offset=0.2), width="3c") >>> fig.show() """ self._activate_figure() diff --git a/pygmt/src/logo.py b/pygmt/src/logo.py index 0087a7dddec..eee645e9a12 100644 --- a/pygmt/src/logo.py +++ b/pygmt/src/logo.py @@ -5,38 +5,51 @@ from collections.abc import Sequence from typing import Literal +from pygmt._typing import AnchorCode from pygmt.alias import Alias, AliasSystem from pygmt.clib import Session -from pygmt.helpers import build_arg_list, fmt_docstring, use_alias -from pygmt.params import Box +from pygmt.exceptions import GMTInvalidInput +from pygmt.helpers import build_arg_list, fmt_docstring +from pygmt.params import Box, Position +from pygmt.src._common import _parse_position @fmt_docstring -@use_alias(D="position") -def logo( +def logo( # noqa: PLR0913 self, + position: Position | Sequence[float | str] | AnchorCode | None = None, + width: float | str | None = None, + height: float | str | None = None, + box: Box | bool = False, + style: Literal["standard", "url", "no_label"] = "standard", projection: str | None = None, region: Sequence[float | str] | str | None = None, - style: Literal["standard", "url", "no_label"] = "standard", - box: Box | bool = False, verbose: Literal["quiet", "error", "warning", "timing", "info", "compat", "debug"] | bool = False, panel: int | Sequence[int] | bool = False, - transparency: float | None = None, perspective: float | Sequence[float] | str | bool = False, + transparency: float | None = None, **kwargs, ): - r""" + """ Plot the GMT logo. - By default, the GMT logo is 2 inches wide and 1 inch high and - will be positioned relative to the current plot origin. - Use various options to change this and to place a transparent or - opaque rectangular map panel behind the GMT logo. + .. figure:: https://docs.generic-mapping-tools.org/6.6/_images/GMT_coverlogo.png + :alt: GMT logo + :align: center + :width: 300px + + By default, the GMT logo is 2 inches wide and 1 inch high and will be positioned + relative to the current plot origin. Full GMT docs at :gmt-docs:`gmtlogo.html`. - $aliases + **Aliases:** + + .. hlist:: + :columns: 3 + + - D = position, **+w**: width, **+h**: height - F = box - J = projection - R = region @@ -48,12 +61,22 @@ def logo( Parameters ---------- - $projection - $region - position : str - [**g**\|\ **j**\|\ **J**\|\ **n**\|\ **x**]\ *refpoint*\ - **+w**\ *width*\ [**+j**\ *justify*]\ [**+o**\ *dx*\ [/*dy*]]. - Set reference point on the map for the image. + position + Position of the GMT logo on the plot. It can be specified in multiple ways: + + - A :class:`pygmt.params.Position` object to fully control the reference point, + anchor point, and offset. + - A sequence of two values representing the x and y coordinates in plot + coordinates, e.g., ``(1, 2)`` or ``("1c", "2c")``. + - A :doc:`2-character justification code ` for a + position inside the plot, e.g., ``"TL"`` for Top Left corner inside the plot. + + If not specified, defaults to the lower-left corner of the plot (position + ``(0, 0)`` with anchor ``"BL"``). + width + height + Width or height of the GMT logo. Since the aspect ratio is fixed, only one of + the two can be specified. [Default is 2 inches wide and 1 inch high]. box Draw a background box behind the logo. If set to ``True``, a simple rectangular box is drawn using :gmt-term:`MAP_FRAME_PEN`. To customize the box appearance, @@ -65,14 +88,32 @@ def logo( - ``"standard"``: The text label "The Generic Mapping Tools". - ``"no_label"``: Skip the text label. - ``"url"``: The URL to the GMT website. + $projection + $region $verbose $panel - $transparency $perspective + $transparency """ self._activate_figure() + position = _parse_position( + position, + kwdict={"width": width, "height": height}, + default=Position((0, 0), cstype="plotcoords"), # Default to (0,0) in plotcoords + ) + + # width and height are mutually exclusive. + if width is not None and height is not None: + msg = "Cannot specify both 'width' and 'height'." + raise GMTInvalidInput(msg) + aliasdict = AliasSystem( + D=[ + Alias(position, name="position"), + Alias(height, name="height", prefix="+h"), + Alias(width, name="width", prefix="+w"), + ], F=Alias(box, name="box"), S=Alias( style, name="style", mapping={"standard": "l", "url": "u", "no_label": "n"} diff --git a/pygmt/tests/baseline/test_logo_default_position.png.dvc b/pygmt/tests/baseline/test_logo_default_position.png.dvc new file mode 100644 index 00000000000..febd8f47240 --- /dev/null +++ b/pygmt/tests/baseline/test_logo_default_position.png.dvc @@ -0,0 +1,5 @@ +outs: +- md5: 6fa301eb7cd0e467285160cf261f9a10 + size: 44372 + hash: md5 + path: test_logo_default_position.png diff --git a/pygmt/tests/test_logo.py b/pygmt/tests/test_logo.py index 62bddf4eb24..ad70acf62cd 100644 --- a/pygmt/tests/test_logo.py +++ b/pygmt/tests/test_logo.py @@ -4,6 +4,8 @@ import pytest from pygmt import Figure +from pygmt.exceptions import GMTInvalidInput +from pygmt.params import Position @pytest.mark.benchmark @@ -17,6 +19,17 @@ def test_logo(): return fig +@pytest.mark.mpl_image_compare +def test_logo_default_position(): + """ + Test that the default position is at the plot origin when no position is specified. + """ + fig = Figure() + fig.basemap(region=[0, 10, 0, 10], projection="X10c", frame=True) + fig.logo() + return fig + + @pytest.mark.mpl_image_compare def test_logo_on_a_map(): """ @@ -24,5 +37,36 @@ def test_logo_on_a_map(): """ fig = Figure() fig.basemap(region=[-90, -70, 0, 20], projection="M15c", frame=True) - fig.logo(position="jTR+o0.25c/0.25c+w7.5c", box=True) + fig.logo(position=Position("TR", offset=(0.25, 0.25)), width="7.5c", box=True) return fig + + +@pytest.mark.mpl_image_compare(filename="test_logo_on_a_map.png") +def test_logo_position_deprecated_syntax(): + """ + Test that passing the deprecated GMT CLI syntax string to 'position' works. + """ + fig = Figure() + fig.basemap(region=[-90, -70, 0, 20], projection="M15c", frame=True) + fig.logo(position="jTR+o0.25/0.25+w7.5c", box=True) + return fig + + +def test_logo_width_and_height(): + """ + Test that an error is raised when both width and height are specified. + """ + fig = Figure() + with pytest.raises(GMTInvalidInput): + fig.logo(width="5c", height="5c") + + +def test_logo_position_mixed_syntax(): + """ + Test that an error is raised when mixing new and deprecated syntax in 'position'. + """ + fig = Figure() + with pytest.raises(GMTInvalidInput): + fig.logo(position="jTL", width="5c") + with pytest.raises(GMTInvalidInput): + fig.logo(position="jTL", height="6c")