diff --git a/doc/api/index.rst b/doc/api/index.rst index 3656bba286e..264f5a9175a 100644 --- a/doc/api/index.rst +++ b/doc/api/index.rst @@ -214,6 +214,7 @@ Class-style Parameters Box Pattern + Position Enums ----- diff --git a/pygmt/params/__init__.py b/pygmt/params/__init__.py index b80b921407a..d1a00a7f5f2 100644 --- a/pygmt/params/__init__.py +++ b/pygmt/params/__init__.py @@ -4,3 +4,4 @@ from pygmt.params.box import Box from pygmt.params.pattern import Pattern +from pygmt.params.position import Position diff --git a/pygmt/params/position.py b/pygmt/params/position.py new file mode 100644 index 00000000000..bb8df3c1ae9 --- /dev/null +++ b/pygmt/params/position.py @@ -0,0 +1,212 @@ +""" +The Position class for positioning GMT embellishments. +""" + +import dataclasses +from collections.abc import Sequence +from typing import Literal + +from pygmt._typing import AnchorCode +from pygmt.alias import Alias +from pygmt.exceptions import GMTValueError +from pygmt.helpers import is_nonstr_iter +from pygmt.params.base import BaseParam + + +@dataclasses.dataclass(repr=False) +class Position(BaseParam): + """ + Class for positioning embellishments on a plot. + + .. figure:: https://docs.generic-mapping-tools.org/dev/_images/GMT_anchor.png + :width: 600 px + :align: center + + The placement of a GMT embellishment (represented by a green rectangle) in + relation to the underlying plot (represented by a bisque rectangle). + + This class provides flexible positioning for GMT embellishments (e.g., logo, scale, + rose) by defining a *reference point* on the plot and an *anchor point* on the + embellishment. The embellishment is positioned so these two points overlap. + + **Conceptual Model** + + Think of it like dropping an anchor from a boat: + + 1. The boat navigates to the *reference point* (a location on the plot) + 2. The *anchor point* (a specific point on the embellishment) is aligned with the + *reference point* + 3. The embellishment is "dropped" at that position + + **Reference Point** + + The *reference point* can be specified in five different ways using the ``cstype`` + and ``refpoint`` attributes: + + ``cstype="mapcoords"`` Map Coordinates + Use data/geographic coordinates. Specify ``refpoint`` as + (*longitude*, *latitude*). Useful when tying the embellishment to a specific + geographic location. + + **Example:** ``refpoint=(135, 20), cstype="mapcoords"`` + + ``cstype="plotcoords"`` Plot Coordinates + Use plot coordinates as distances from the lower-left plot origin. Specify + ``refpoint`` as (*x*, *y*) with units (e.g., inches, centimeters, points). + Useful for precise layout control. + + **Example:** ``refpoint=("2c", "2.5c"), cstype="plotcoords"`` + + ``cstype="boxcoords"`` Normalized Coordinates + Use normalized coordinates where (0, 0) is the lower-left corner and (1, 1) is + the upper-right corner of the bounding box of the current plot. Specify + ``refpoint`` as (*nx*, *ny*). Useful for positioning relative to plot dimensions + without units. + + **Example:** ``refpoint=(0.2, 0.1), cstype="boxcoords"`` + + ``cstype="inside"`` Inside Plot + Select one of the nine :doc:`justification codes ` + as the *reference point*. The *anchor point* defaults to be the same as the + *reference point*, so the embellishment is placed inside the plot. + + **Example:** ``refpoint="TL", cstype="inside"`` + + ``cstype="outside"`` Outside Plot + Similar to ``cstype="inside"``, but the *anchor point* defaults to the mirror + opposite of the *reference point*. Useful for placing embellishments outside + the plot boundaries (e.g., color bars). + + **Example:** ``refpoint="TL", cstype="outside"`` + + **Anchor Point** + + The *anchor point* determines which part of the embellishment aligns with the + *reference point*. It uses one of nine + :doc:`justification codes `. + + Set ``anchor`` explicitly to override these defaults. If not set, the default + *anchor* behaviors are: + + - ``cstype="inside"``: Same as the *reference point* justification code + - ``cstype="outside"``: Mirror opposite of the *reference point* justification code + - Other cstypes: ``"MC"`` (middle center) for map rose and scale, ``"BL"`` + (bottom-left) for other embellishments + + **Offset** + + The ``offset`` parameter shifts the *anchor point* from its default position. + Offsets are applied to the projected plot coordinates, with positive values moving + in the direction indicated by the *anchor point*'s justification code. It should be + a single value (applied to both x and y) or as (*offset_x*, *offset_y*). + + Examples + -------- + Position the GMT logo at map coordinates (3, 3) with the logo's middle-left point as + the anchor, offset by (0.2, 0.2): + + >>> import pygmt + >>> from pygmt.params import Position + >>> fig = pygmt.Figure() + >>> fig.basemap(region=[0, 10, 0, 10], projection="X10c", frame=True) + >>> fig.logo( + ... position=Position( + ... (3, 3), cstype="mapcoords", anchor="ML", offset=(0.2, 0.2) + ... ), + ... box=True, + ... ) + >>> fig.show() + + Position the GMT logo at the top-left corner inside the plot: + + >>> fig = pygmt.Figure() + >>> fig.basemap(region=[0, 10, 0, 10], projection="X10c", frame=True) + >>> fig.logo(position=Position("TL", cstype="inside", offset="0.2c"), box=True) + >>> fig.show() + """ + + #: Location of the reference point on the plot. The format depends on ``cstype``: + #: + #: - ``cstype="mapcoords"``: (*longitude*, *latitude*) + #: - ``cstype="plotcoords"``: (*x*, *y*) with plot units + #: - ``cstype="boxcoords"``: (*nx*, *ny*) + #: - ``cstype="inside"`` or ``"outside"``: + #: :doc:`2-character justification codes ` + refpoint: Sequence[float | str] | AnchorCode + + #: cstype of the reference point. Valid values are: + #: + #: - ``"mapcoords"``: Map/Data coordinates + #: - ``"plotcoords"``: Plot coordinates + #: - ``"boxcoords"``: Normalized coordinates + #: - ``"inside"`` or ``"outside"``: Justification codes + #: + #: If not specified, defaults to ``"inside"`` if ``refpoint`` is a justification + #: code; otherwise defaults to ``"plotcoords"``. + cstype: ( + Literal["mapcoords", "inside", "outside", "boxcoords", "plotcoords"] | None + ) = None + + #: Anchor point on the embellishment using a + #: :doc:`2-character justification code `. + #: If ``None``, defaults are applied based on ``cstype`` (see above). + anchor: AnchorCode | None = None + + #: Offset for the anchor point as a single value or (*offset_x*, *offset_y*). + #: If a single value is given, the offset is applied to both x and y directions. + offset: float | str | Sequence[float | str] | None = None + + def _validate(self): + """ + Validate the parameters. + """ + _valid_anchors = {f"{h}{v}" for v in "TMB" for h in "LCR"} | { + f"{v}{h}" for v in "TMB" for h in "LCR" + } + + # Default to "inside" if cstype is not specified and location is an anchor code. + if self.cstype is None: + self.cstype = "inside" if isinstance(self.refpoint, str) else "plotcoords" + + # Validate the location based on cstype. + match self.cstype: + case "mapcoords" | "plotcoords" | "boxcoords": + if not is_nonstr_iter(self.refpoint) or len(self.refpoint) != 2: + raise GMTValueError( + self.refpoint, + description="reference point", + reason="Expect a sequence of two values.", + ) + case "inside" | "outside": + if self.refpoint not in _valid_anchors: + raise GMTValueError( + self.refpoint, + description="reference point", + reason="Expect a valid 2-character justification code.", + ) + # Validate the anchor if specified. + if self.anchor is not None and self.anchor not in _valid_anchors: + raise GMTValueError( + self.anchor, + description="anchor point", + reason="Expect a valid 2-character justification code.", + ) + + @property + def _aliases(self): + return [ + Alias( + self.cstype, + name="cstype", + mapping={ + "mapcoords": "g", + "boxcoords": "n", + "plotcoords": "x", + "inside": "j", + "outside": "J", + }, + ), + Alias(self.refpoint, name="refpoint", sep="/", size=2), + Alias(self.anchor, name="anchor", prefix="+j"), + Alias(self.offset, name="offset", prefix="+o", sep="/", size=2), + ] diff --git a/pygmt/tests/test_params_position.py b/pygmt/tests/test_params_position.py new file mode 100644 index 00000000000..a9d67de36c5 --- /dev/null +++ b/pygmt/tests/test_params_position.py @@ -0,0 +1,59 @@ +""" +Test the Position class. +""" + +import pytest +from pygmt.exceptions import GMTValueError +from pygmt.params import Position + + +def test_params_position_cstypes(): + """ + Test the Position class with different cstypes of coordinate systems. + """ + # Default cstype is "plotcoords" for (x,y) and "inside" for anchor codes. + assert str(Position((1, 2))) == "x1/2" + assert str(Position("TL")) == "jTL" + + assert str(Position((10, 20), cstype="mapcoords")) == "g10/20" + assert str(Position((0.1, 0.2), cstype="boxcoords")) == "n0.1/0.2" + assert str(Position(("5c", "3c"), cstype="plotcoords")) == "x5c/3c" + assert str(Position("MR", cstype="inside")) == "jMR" + assert str(Position("BR", cstype="outside")) == "JBR" + + +def test_params_position_anchor_offset(): + """ + Test the Position class with anchor and offset parameters. + """ + assert str(Position((10, 20), cstype="mapcoords", anchor="TL")) == "g10/20+jTL" + assert str(Position((10, 20), cstype="mapcoords", offset=(1, 2))) == "g10/20+o1/2" + pos = Position("TL", cstype="inside", anchor="MC", offset=("1c", "2c")) + assert str(pos) == "jTL+jMC+o1c/2c" + assert str(Position("TL", anchor="BR", offset=0.5)) == "jTL+jBR+o0.5" + + +def test_params_position_invalid_location(): + """ + Test that invalid location inputs raise GMTValueError. + """ + with pytest.raises(GMTValueError): + Position("invalid", cstype="mapcoords") + with pytest.raises(GMTValueError): + Position((1, 2, 3), cstype="mapcoords") + with pytest.raises(GMTValueError): + Position(5, cstype="plotcoords") + with pytest.raises(GMTValueError): + Position((0.5,), cstype="boxcoords") + with pytest.raises(GMTValueError): + Position((10, 20), cstype="inside") + with pytest.raises(GMTValueError): + Position("TT", cstype="outside") + + +def test_params_position_invalid_anchor(): + """ + Test that invalid anchor inputs raise GMTValueError. + """ + with pytest.raises(GMTValueError): + Position((10, 20), cstype="mapcoords", anchor="XX")