From eec0fb74bd7e16dad6ec769ef64e50f532a74f97 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Sun, 16 Nov 2025 11:56:45 +0800 Subject: [PATCH 01/57] Initial implemention of the Position class --- pygmt/params/__init__.py | 1 + pygmt/params/position.py | 42 ++++++++++++++++++++++++++++++++++++++++ pygmt/src/logo.py | 20 +++++++++++++++---- 3 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 pygmt/params/position.py 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..060c2bed2a4 --- /dev/null +++ b/pygmt/params/position.py @@ -0,0 +1,42 @@ +""" +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.params.base import BaseParam + + +@dataclasses.dataclass(repr=False) +class Position(BaseParam): + """ + The class for positioning GMT embellishments. + """ + + location: str | tuple[float | str, float | str] + type: Literal["mapcoords", "inside", "outside", "boxcoords", "plotcoords"] + anchor: AnchorCode + offset: Sequence[float | str] + + @property + def _aliases(self): + return [ + Alias( + self.type, + name="type", + mapping={ + "mapcoords": "g", + "boxcoords": "n", + "plotcoords": "x", + "inside": "j", + "outside": "J", + }, + ), + Alias(self.location, name="location", sep="/", size=2), + Alias(self.anchor, name="anchor"), + Alias(self.offset, name="offset", sep="/", size=2), + ] diff --git a/pygmt/src/logo.py b/pygmt/src/logo.py index defdc065eb3..668dc0a7ca2 100644 --- a/pygmt/src/logo.py +++ b/pygmt/src/logo.py @@ -7,14 +7,16 @@ 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.helpers import build_arg_list, fmt_docstring +from pygmt.params import Box, Position @fmt_docstring -@use_alias(D="position") def logo( self, + position: Position, + width: float | str | None = None, + height: float | str | None = None, projection: str | None = None, region: Sequence[float | str] | str | None = None, style: Literal["standard", "url", "no_label"] = "standard", @@ -36,7 +38,12 @@ def logo( 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 @@ -73,6 +80,11 @@ def logo( self._activate_figure() aliasdict = AliasSystem( + D=[ + Alias(position, name="position"), + Alias(width, name="width", prefix="+w"), + Alias(height, name="height", prefix="+h"), + ], F=Alias(box, name="box"), S=Alias( style, name="style", mapping={"standard": "l", "url": "u", "no_label": "n"} From 539f66f25f97c4a4d3e3418a9bb9173d1047484b Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Thu, 20 Nov 2025 16:14:34 +0800 Subject: [PATCH 02/57] Fix styling --- pygmt/src/logo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygmt/src/logo.py b/pygmt/src/logo.py index 668dc0a7ca2..ab66ff3366f 100644 --- a/pygmt/src/logo.py +++ b/pygmt/src/logo.py @@ -12,7 +12,7 @@ @fmt_docstring -def logo( +def logo( # noqa: PLR0913 self, position: Position, width: float | str | None = None, From 97f015f040a0aab7bee4f58beb104557b98d7365 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Sun, 23 Nov 2025 16:45:43 +0800 Subject: [PATCH 03/57] Add tests and improve docstrings --- pygmt/params/position.py | 40 +++++++++++++++++++++++++---- pygmt/tests/test_params_position.py | 30 ++++++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 pygmt/tests/test_params_position.py diff --git a/pygmt/params/position.py b/pygmt/params/position.py index 060c2bed2a4..4353841424f 100644 --- a/pygmt/params/position.py +++ b/pygmt/params/position.py @@ -17,10 +17,40 @@ class Position(BaseParam): The class for positioning GMT embellishments. """ - location: str | tuple[float | str, float | str] + #: Specify the reference point on the plot. The method of defining the reference + #: point is controlled by ``type``, and the exact location is set by ``position``. + location: Sequence[float | str] | AnchorCode + + #: Specify the type of coordinates used to define the reference point. It can be + #: one of the following values: + #: + #: - ``"mapcoords"``: ``position`` is specified as (*longitude*, *latitude*) in map + #: coordinates. + #: - ``"boxcoords"``: ``position`` is specified as (*nx*, *ny*) in normalized + #: coordinates, i.e., fractional values between 0 and 1 along the x- and y-axes. + #: - ``"plotcoords"``: ``position`` is specified as (*x*, *y*) in plot coordinates, + #: i.e., distances from the lower-left plot origin given in inches, centimeters, + #: or points. + #: - ``"inside"`` or ``"outside"``: ``position`` is one of the nine + #: :doc:`two-character justification codes `, + #: indicating a specific location relative to the plot bounding box. + #: type: Literal["mapcoords", "inside", "outside", "boxcoords", "plotcoords"] - anchor: AnchorCode - offset: Sequence[float | str] + + #: Specify the anchor point of the GMT logo, using one of the + #: :doc:`2-character justification codes `. The + #: default value depends on ``position_type``. + #: + #: - ``position_type="inside"``: ``anchor`` defaults to the same as ``position``. + #: - ``position_type="outside"``: ``anchor`` defaults to the mirror opposite of + #: ``position``. + #: - Otherwise, ``anchor`` defaults to ``"MC"`` (middle center). + anchor: AnchorCode | None = None + + #: Specifies an offset for the anchor point as *offset* or (*offset_x*, *offset_y*). + #: If a single value *offset* is given, both *offset_x* and *offset_y* are set to + #: *offset*. + offset: Sequence[float | str] | None = None @property def _aliases(self): @@ -37,6 +67,6 @@ def _aliases(self): }, ), Alias(self.location, name="location", sep="/", size=2), - Alias(self.anchor, name="anchor"), - Alias(self.offset, name="offset", 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..8999f2822c4 --- /dev/null +++ b/pygmt/tests/test_params_position.py @@ -0,0 +1,30 @@ +""" +Test the Position class. +""" + +from pygmt.params import Position + + +def test_params_position_types(): + """ + Test the Position class with different types of coordinate systems. + """ + assert str(Position(location=(10, 20), type="mapcoords")) == "g10/20" + assert str(Position(location=(0.1, 0.2), type="boxcoords")) == "n0.1/0.2" + assert str(Position(location=("5c", "3c"), type="plotcoords")) == "x5c/3c" + assert str(Position(location="TL", type="inside")) == "jTL" + assert str(Position(location="BR", type="outside")) == "JBR" + + +def test_params_position_anchor_offset(): + """ + Test the Position class with anchor and offset parameters. + """ + pos = Position(location=(10, 20), type="mapcoords", anchor="TL") + assert str(pos) == "g10/20+jTL" + + pos = Position(location=(10, 20), type="mapcoords", offset=(1, 2)) + assert str(pos) == "g10/20+o1/2" + + pos = Position(location="TL", type="inside", anchor="MC", offset=("1c", "2c")) + assert str(pos) == "jTL+jMC+o1c/2c" From 854804ef0f992ee6e291e9a4a5647a53d0fdac2f Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Sun, 23 Nov 2025 16:46:28 +0800 Subject: [PATCH 04/57] Add to API doc --- doc/api/index.rst | 1 + 1 file changed, 1 insertion(+) 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 ----- From 6b55dde70d63ab6d865bb3565adddea41c4e7bf4 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Sun, 23 Nov 2025 16:48:38 +0800 Subject: [PATCH 05/57] Add an inline doctest --- pygmt/params/position.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pygmt/params/position.py b/pygmt/params/position.py index 4353841424f..5858dd9103a 100644 --- a/pygmt/params/position.py +++ b/pygmt/params/position.py @@ -15,6 +15,20 @@ class Position(BaseParam): """ The class for positioning GMT embellishments. + + Example + ------- + >>> 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( + ... location=(3, 3), type="mapcoords", anchor="ML", offset=(0.2, 0.2) + ... ), + ... box=True, + ... ) + >>> fig.show() """ #: Specify the reference point on the plot. The method of defining the reference From 3d629cb52a0e7bb05968a8e0f7f2411289172a52 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Sun, 23 Nov 2025 19:27:50 +0800 Subject: [PATCH 06/57] position is not required --- pygmt/src/logo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygmt/src/logo.py b/pygmt/src/logo.py index ab66ff3366f..d98d4f3fb2f 100644 --- a/pygmt/src/logo.py +++ b/pygmt/src/logo.py @@ -14,7 +14,7 @@ @fmt_docstring def logo( # noqa: PLR0913 self, - position: Position, + position: Position | None = None, width: float | str | None = None, height: float | str | None = None, projection: str | None = None, From 576b822e51518096e8009cb2b0c20f99d45481a4 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Sun, 23 Nov 2025 19:33:58 +0800 Subject: [PATCH 07/57] Default to plotcoords --- pygmt/params/position.py | 5 ++++- pygmt/tests/test_params_position.py | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/pygmt/params/position.py b/pygmt/params/position.py index 5858dd9103a..6669a5dcb0a 100644 --- a/pygmt/params/position.py +++ b/pygmt/params/position.py @@ -49,7 +49,10 @@ class Position(BaseParam): #: :doc:`two-character justification codes `, #: indicating a specific location relative to the plot bounding box. #: - type: Literal["mapcoords", "inside", "outside", "boxcoords", "plotcoords"] + #: The default value is ``"plotcoords"``. + type: Literal["mapcoords", "inside", "outside", "boxcoords", "plotcoords"] = ( + "plotcoords" + ) #: Specify the anchor point of the GMT logo, using one of the #: :doc:`2-character justification codes `. The diff --git a/pygmt/tests/test_params_position.py b/pygmt/tests/test_params_position.py index 8999f2822c4..dbcf76b501e 100644 --- a/pygmt/tests/test_params_position.py +++ b/pygmt/tests/test_params_position.py @@ -9,6 +9,7 @@ def test_params_position_types(): """ Test the Position class with different types of coordinate systems. """ + assert str(Position(location=(10, 20))) == "x10/20" assert str(Position(location=(10, 20), type="mapcoords")) == "g10/20" assert str(Position(location=(0.1, 0.2), type="boxcoords")) == "n0.1/0.2" assert str(Position(location=("5c", "3c"), type="plotcoords")) == "x5c/3c" From f54bec989be2f53a938388947984c842c917813a Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Sun, 23 Nov 2025 19:35:53 +0800 Subject: [PATCH 08/57] Updates --- pygmt/params/position.py | 4 +--- pygmt/tests/test_params_position.py | 3 ++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/pygmt/params/position.py b/pygmt/params/position.py index 6669a5dcb0a..2f12d5fdb60 100644 --- a/pygmt/params/position.py +++ b/pygmt/params/position.py @@ -23,9 +23,7 @@ class Position(BaseParam): >>> fig = pygmt.Figure() >>> fig.basemap(region=[0, 10, 0, 10], projection="X10c", frame=True) >>> fig.logo( - ... position=Position( - ... location=(3, 3), type="mapcoords", anchor="ML", offset=(0.2, 0.2) - ... ), + ... position=Position((3, 3), type="mapcoords", anchor="ML", offset=(0.2, 0.2)), ... box=True, ... ) >>> fig.show() diff --git a/pygmt/tests/test_params_position.py b/pygmt/tests/test_params_position.py index dbcf76b501e..2ca05cf2150 100644 --- a/pygmt/tests/test_params_position.py +++ b/pygmt/tests/test_params_position.py @@ -9,7 +9,8 @@ def test_params_position_types(): """ Test the Position class with different types of coordinate systems. """ - assert str(Position(location=(10, 20))) == "x10/20" + assert str(Position((1, 2))) == "x1/2" + assert str(Position(location=(1, 2))) == "x1/2" assert str(Position(location=(10, 20), type="mapcoords")) == "g10/20" assert str(Position(location=(0.1, 0.2), type="boxcoords")) == "n0.1/0.2" assert str(Position(location=("5c", "3c"), type="plotcoords")) == "x5c/3c" From 2c59b7f1fd0c024c21bd95a1b0ccf379c1fd2dc5 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Mon, 24 Nov 2025 12:59:23 +0800 Subject: [PATCH 09/57] Improve the checking in Figure.logo --- pygmt/src/logo.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pygmt/src/logo.py b/pygmt/src/logo.py index d98d4f3fb2f..8d0e98699d4 100644 --- a/pygmt/src/logo.py +++ b/pygmt/src/logo.py @@ -7,6 +7,7 @@ from pygmt.alias import Alias, AliasSystem from pygmt.clib import Session +from pygmt.exceptions import GMTInvalidInput from pygmt.helpers import build_arg_list, fmt_docstring from pygmt.params import Box, Position @@ -79,6 +80,19 @@ def logo( # noqa: PLR0913 """ self._activate_figure() + if isinstance(position, str) and any(v is not None for v in (width, height)): + msg = ( + "Parameter 'position' is given with a raw GMT CLI syntax, and conflicts " + "with parameters 'height', and 'width'. Please refer to the documentation " + "for the recommended usage." + ) + raise GMTInvalidInput(msg) + + # 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"), From fe18c87297892681543c460e0fb395a2e4612667 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Mon, 24 Nov 2025 18:50:13 +0800 Subject: [PATCH 10/57] Improve docstrings --- pygmt/params/position.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/pygmt/params/position.py b/pygmt/params/position.py index 2f12d5fdb60..626baea27fe 100644 --- a/pygmt/params/position.py +++ b/pygmt/params/position.py @@ -29,21 +29,21 @@ class Position(BaseParam): >>> fig.show() """ - #: Specify the reference point on the plot. The method of defining the reference - #: point is controlled by ``type``, and the exact location is set by ``position``. + #: Location of the reference point on the plot. Its meaning depends on the value of + #: ``type``. location: Sequence[float | str] | AnchorCode - #: Specify the type of coordinates used to define the reference point. It can be - #: one of the following values: + #: The coordinates used to define the reference point. Valid values and meanings for + #: corresponding ``location`` are: #: - #: - ``"mapcoords"``: ``position`` is specified as (*longitude*, *latitude*) in map + #: - ``"mapcoords"``: ``location`` is specified as (*longitude*, *latitude*) in map #: coordinates. - #: - ``"boxcoords"``: ``position`` is specified as (*nx*, *ny*) in normalized + #: - ``"boxcoords"``: ``location`` is specified as (*nx*, *ny*) in normalized #: coordinates, i.e., fractional values between 0 and 1 along the x- and y-axes. - #: - ``"plotcoords"``: ``position`` is specified as (*x*, *y*) in plot coordinates, + #: - ``"plotcoords"``: ``location`` is specified as (*x*, *y*) in plot coordinates, #: i.e., distances from the lower-left plot origin given in inches, centimeters, #: or points. - #: - ``"inside"`` or ``"outside"``: ``position`` is one of the nine + #: - ``"inside"`` or ``"outside"``: ``location`` is one of the nine #: :doc:`two-character justification codes `, #: indicating a specific location relative to the plot bounding box. #: @@ -52,19 +52,19 @@ class Position(BaseParam): "plotcoords" ) - #: Specify the anchor point of the GMT logo, using one of the + #: Anchor point of the embellishment, using one of the #: :doc:`2-character justification codes `. The - #: default value depends on ``position_type``. + #: default value depends on ``type``. #: - #: - ``position_type="inside"``: ``anchor`` defaults to the same as ``position``. - #: - ``position_type="outside"``: ``anchor`` defaults to the mirror opposite of - #: ``position``. + #: - ``type="inside"``: ``anchor`` defaults to the same as ``location``. + #: - ``type="outside"``: ``anchor`` defaults to the mirror opposite of ``location``. #: - Otherwise, ``anchor`` defaults to ``"MC"`` (middle center). anchor: AnchorCode | None = None - #: Specifies an offset for the anchor point as *offset* or (*offset_x*, *offset_y*). - #: If a single value *offset* is given, both *offset_x* and *offset_y* are set to - #: *offset*. + #: Offset for the anchor point. It can be either a single value *offset* or a pair + #: (*offset_x*, *offset_y*), where *offset_x* and *offset_y* are the offsets in the + #: x- and y-directions, respectively. If a single value *offset* is given, both + #: *offset_x* and *offset_y* are set to *offset*. offset: Sequence[float | str] | None = None @property From 038161b0bce59484629fc6d92b4df9cdc25e8ddc Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Mon, 24 Nov 2025 20:14:56 +0800 Subject: [PATCH 11/57] Improve docstrings --- pygmt/params/position.py | 47 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/pygmt/params/position.py b/pygmt/params/position.py index 626baea27fe..218f83bd6e6 100644 --- a/pygmt/params/position.py +++ b/pygmt/params/position.py @@ -16,6 +16,53 @@ class Position(BaseParam): """ The class for positioning GMT embellishments. + .. figure:: https://github.com/user-attachments/assets/0f3e9b39-7d64-4628-8acb-58fe74ff6fa5 + :width: 400 px + + Positioning of GMT embellishment using the :class:`pygmt.params.Position` class. + + Placing an embellishment on the plot means selecting a *reference point* somewhere + on the plot, an *anchor point* somewhere on the embellishment, and then positioning + the embellishment so that the two points overlap. It may be helpful to consider the + analog of a boat dropping an anchor: The boat navigates to the *reference point* and + then, depending on where on the boat the *anchor* is located, moves so that the + *anchor* connection point overlies the *reference point*, then drops the *anchor*. + + There are five different ways to specify the *reference point* on a map, controlled + by the ``type`` and ``location`` attributes of this class, for complete freedom to + select any location inside or outside the map. + + ``type="mapcoords"`` + Specify the *reference point* using data coordinates. ``location`` is given as + (*longitude*, *latitude*). This mechanism is useful when you want to tie the + location of the embellishment to an actual point best described by data + coordinates. Example: ``location=(135, 20), type="mapcoords"``. + ``type="plotcoords"`` + Specify the *reference point* using plot coordinates, i.e., the distances in + inches, centimeters, or points from the lower left plot origin. This mechanism + is preferred when you wish to lay out an embellishment using familiar + measurements of distance from origins. Example: + ``location=("2c", "2.5c"), type="plotcoords"``. + ``type="boxcoords"`` + Specify the *reference point* using normalized coordinates, i.e., fractional + coordinates between 0 and 1 in both the x and y directions. This mechanism + avoids units and is useful if you want to always place embellishments at + locations best referenced as fractions of the plot dimensions. Example: + ``location=(0.2, 0.1), type="boxcoords"``. + ``type="inside"`` + Specify the *reference point* using one of the nine justification codes. This + mechanism is preferred when you just want to place the embellishment inside the + basemap at one of the corners or centered at one of the sides (or even smack in + the middle). Example: ``location="TL", type="inside"``. When used, the anchor + point on the map embellishments will default to the same justification, i.e., + ``"TL"`` in this example. + ``type="outside"`` + Same ``type="inside"`` except it implies that the default anchor point is the + mirror opposite of the justification code. Thus, when using + ``location="TL", type="outside"``, the anchor point on the map embellishment + will default to ``"BR"``. This is practical for embellishments that are drawn + outside of the basemap (like color bars often are). + Example ------- >>> import pygmt From a6e75bcb1512a06eca66cc93fe45a41ecb4bb075 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Tue, 25 Nov 2025 08:26:15 +0800 Subject: [PATCH 12/57] Improve docstrings --- pygmt/params/position.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/pygmt/params/position.py b/pygmt/params/position.py index 218f83bd6e6..3789293a51f 100644 --- a/pygmt/params/position.py +++ b/pygmt/params/position.py @@ -63,6 +63,26 @@ class Position(BaseParam): will default to ``"BR"``. This is practical for embellishments that are drawn outside of the basemap (like color bars often are). + While the reference point selection gives unlimited flexibility to pick any point + inside or outside the map region, the anchor point selection is limited to the nine + justification points. Set ``anchor`` to indicate which justification point on the + map feature should be co-registered with the chosen reference point. If an anchor + point is not specified then it defaults to the justification point set for the + reference point (for ``type="inside"``), or to the mirror + opposite of the reference point (for ``type="outside"``); with all other + specifications of the reference point, the anchor point takes on the default value + of ``MC`` (for map rose and map scale) or ``BL`` (all other map features). Setting + ``anchor`` overrules those defaults. For instance, ``anchor="TR"`` would select the + top right point on the map feature as the anchor. + + It is likely that you will wish to offset the anchor point away from your selection + by some arbitrary amount, particularly if the reference point is specified with + ``type="inside"`` or ``type="outside"``. This can be done by setting ``offset``. + These increments are added to the projected plot coordinates of the anchor point, + with positive values moving the reference point in the same direction as the + 2-character code of the anchor point implies. Finally, the adjusted anchor point is + matched with the reference point. + Example ------- >>> import pygmt From 3ec8c0686f7251f6dce05679f3cc614c3a6d026f Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Tue, 25 Nov 2025 13:22:23 +0800 Subject: [PATCH 13/57] Improve docstrings --- pygmt/params/position.py | 41 ++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/pygmt/params/position.py b/pygmt/params/position.py index 3789293a51f..8ba585a261c 100644 --- a/pygmt/params/position.py +++ b/pygmt/params/position.py @@ -28,9 +28,9 @@ class Position(BaseParam): then, depending on where on the boat the *anchor* is located, moves so that the *anchor* connection point overlies the *reference point*, then drops the *anchor*. - There are five different ways to specify the *reference point* on a map, controlled + There are five different ways to specify the *reference point* on a plot, controlled by the ``type`` and ``location`` attributes of this class, for complete freedom to - select any location inside or outside the map. + select any location inside or outside the plot. ``type="mapcoords"`` Specify the *reference point* using data coordinates. ``location`` is given as @@ -50,30 +50,31 @@ class Position(BaseParam): locations best referenced as fractions of the plot dimensions. Example: ``location=(0.2, 0.1), type="boxcoords"``. ``type="inside"`` - Specify the *reference point* using one of the nine justification codes. This - mechanism is preferred when you just want to place the embellishment inside the - basemap at one of the corners or centered at one of the sides (or even smack in - the middle). Example: ``location="TL", type="inside"``. When used, the anchor - point on the map embellishments will default to the same justification, i.e., - ``"TL"`` in this example. + Specify the *reference point* using one of the nine + :doc:`justification codes `. This mechanism is + preferred when you just want to place the embellishment inside the basemap at + one of the corners or centered at one of the sides (or even smack in the + middle). Example: ``location="TL", type="inside"``. When used, the anchor point + on the embellishments will default to the same justification, i.e., ``"TL"`` in + this example. ``type="outside"`` Same ``type="inside"`` except it implies that the default anchor point is the mirror opposite of the justification code. Thus, when using - ``location="TL", type="outside"``, the anchor point on the map embellishment - will default to ``"BR"``. This is practical for embellishments that are drawn - outside of the basemap (like color bars often are). + ``location="TL", type="outside"``, the anchor point on the embellishment will + default to ``"BR"``. This is practical for embellishments that are drawn outside + of the basemap (like color bars often are). - While the reference point selection gives unlimited flexibility to pick any point + While the *reference point* selection gives unlimited flexibility to pick any point inside or outside the map region, the anchor point selection is limited to the nine justification points. Set ``anchor`` to indicate which justification point on the - map feature should be co-registered with the chosen reference point. If an anchor - point is not specified then it defaults to the justification point set for the - reference point (for ``type="inside"``), or to the mirror - opposite of the reference point (for ``type="outside"``); with all other - specifications of the reference point, the anchor point takes on the default value - of ``MC`` (for map rose and map scale) or ``BL`` (all other map features). Setting - ``anchor`` overrules those defaults. For instance, ``anchor="TR"`` would select the - top right point on the map feature as the anchor. + map embellishment should be co-registered with the chosen reference point. If an + anchor point is not specified then it defaults to the justification point set for + the reference point (for ``type="inside"``), or to the mirror opposite of the + reference point (for ``type="outside"``); with all other specifications of the + reference point, the anchor point takes on the default value of ``MC`` (for map + rose and map scale) or ``BL`` (all other map embellishments). Setting ``anchor`` + overrules those defaults. For instance, ``anchor="TR"`` would select the top right + point on the map embellishment as the anchor. It is likely that you will wish to offset the anchor point away from your selection by some arbitrary amount, particularly if the reference point is specified with From 339ce004c9daab8208d445dcfe9fbd24278e26fa Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Tue, 25 Nov 2025 15:38:25 +0800 Subject: [PATCH 14/57] Improve docstrings --- pygmt/params/position.py | 202 +++++++++++++++++++++------------------ 1 file changed, 107 insertions(+), 95 deletions(-) diff --git a/pygmt/params/position.py b/pygmt/params/position.py index 8ba585a261c..6dea68311e0 100644 --- a/pygmt/params/position.py +++ b/pygmt/params/position.py @@ -14,78 +14,94 @@ @dataclasses.dataclass(repr=False) class Position(BaseParam): """ - The class for positioning GMT embellishments. + Class for positioning embellishments on a plot. .. figure:: https://github.com/user-attachments/assets/0f3e9b39-7d64-4628-8acb-58fe74ff6fa5 :width: 400 px - Positioning of GMT embellishment using the :class:`pygmt.params.Position` class. - - Placing an embellishment on the plot means selecting a *reference point* somewhere - on the plot, an *anchor point* somewhere on the embellishment, and then positioning - the embellishment so that the two points overlap. It may be helpful to consider the - analog of a boat dropping an anchor: The boat navigates to the *reference point* and - then, depending on where on the boat the *anchor* is located, moves so that the - *anchor* connection point overlies the *reference point*, then drops the *anchor*. - - There are five different ways to specify the *reference point* on a plot, controlled - by the ``type`` and ``location`` attributes of this class, for complete freedom to - select any location inside or outside the plot. - - ``type="mapcoords"`` - Specify the *reference point* using data coordinates. ``location`` is given as - (*longitude*, *latitude*). This mechanism is useful when you want to tie the - location of the embellishment to an actual point best described by data - coordinates. Example: ``location=(135, 20), type="mapcoords"``. - ``type="plotcoords"`` - Specify the *reference point* using plot coordinates, i.e., the distances in - inches, centimeters, or points from the lower left plot origin. This mechanism - is preferred when you wish to lay out an embellishment using familiar - measurements of distance from origins. Example: - ``location=("2c", "2.5c"), type="plotcoords"``. - ``type="boxcoords"`` - Specify the *reference point* using normalized coordinates, i.e., fractional - coordinates between 0 and 1 in both the x and y directions. This mechanism - avoids units and is useful if you want to always place embellishments at - locations best referenced as fractions of the plot dimensions. Example: - ``location=(0.2, 0.1), type="boxcoords"``. - ``type="inside"`` - Specify the *reference point* using one of the nine - :doc:`justification codes `. This mechanism is - preferred when you just want to place the embellishment inside the basemap at - one of the corners or centered at one of the sides (or even smack in the - middle). Example: ``location="TL", type="inside"``. When used, the anchor point - on the embellishments will default to the same justification, i.e., ``"TL"`` in - this example. - ``type="outside"`` - Same ``type="inside"`` except it implies that the default anchor point is the - mirror opposite of the justification code. Thus, when using - ``location="TL", type="outside"``, the anchor point on the embellishment will - default to ``"BR"``. This is practical for embellishments that are drawn outside - of the basemap (like color bars often are). - - While the *reference point* selection gives unlimited flexibility to pick any point - inside or outside the map region, the anchor point selection is limited to the nine - justification points. Set ``anchor`` to indicate which justification point on the - map embellishment should be co-registered with the chosen reference point. If an - anchor point is not specified then it defaults to the justification point set for - the reference point (for ``type="inside"``), or to the mirror opposite of the - reference point (for ``type="outside"``); with all other specifications of the - reference point, the anchor point takes on the default value of ``MC`` (for map - rose and map scale) or ``BL`` (all other map embellishments). Setting ``anchor`` - overrules those defaults. For instance, ``anchor="TR"`` would select the top right - point on the map embellishment as the anchor. - - It is likely that you will wish to offset the anchor point away from your selection - by some arbitrary amount, particularly if the reference point is specified with - ``type="inside"`` or ``type="outside"``. This can be done by setting ``offset``. - These increments are added to the projected plot coordinates of the anchor point, - with positive values moving the reference point in the same direction as the - 2-character code of the anchor point implies. Finally, the adjusted anchor point is - matched with the reference point. - - Example - ------- + Positioning of GMT embellishment. + + 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 Types** + + The reference point can be specified in five different ways using the ``type`` and + ``location`` attributes: + + **type="mapcoords"** (Map Coordinates) + Use data/geographic coordinates. Set ``location`` as (*longitude*, *latitude*). + Useful when tying the embellishment to a specific geographic location. + + Example: ``location=(135, 20), type="mapcoords"``. + + **type="plotcoords"** (Plot Coordinates) + Use plot coordinates as distances from the lower-left plot origin. Specify + ``location`` as (*x*, *y*) with units (e.g., inches, centimeters, points). + Useful for precise layout control. + + Example: ``location=("2c", "2.5c"), type="plotcoords"`` + + **type="boxcoords"** (Normalized Coordinates) + Use normalized coordinates where (0, 0) is the lower-left corner and (1, 1) is + the upper-right corner. Set ``location`` as (*nx*, *ny*) with values between + 0 and 1. Useful for positioning relative to plot dimensions without units. + + Example: ``location=(0.2, 0.1), type="boxcoords"`` + + **type="inside"** (Inside Plot) + Use a :doc:`justification code ` (e.g., ``"TL"``) + to place the embellishment inside the plot. Set ``location`` to one of the nine + 2-character codes. + + Example: ``location="TL", type="inside"`` + + **type="outside"** (Outside Plot) + Similar to ``type="inside"``, but the anchor point defaults to the mirror + opposite of the justification code. Useful for placing embellishments outside + the plot boundaries (e.g., color bars). + + Example: ``location="TL", type="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: + + - For ``type="inside"``: Same as the reference point justification + - For ``type="outside"``: Mirror opposite of the reference point justification + - For other types: ``"MC"`` (middle center) for map rose and scale, ``"BL"`` + (bottom-left) for other embellishments + + Example: ``anchor="TR"`` selects the top-right point of the embellishment. + + **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. + + Specify as a single value (applied to both x and y) or as (*offset_x*, *offset_y*). + + Examples + -------- + Position a 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() @@ -95,44 +111,40 @@ class Position(BaseParam): ... box=True, ... ) >>> fig.show() + + Position an embellishment 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", type="inside", offset="0.2c"), box=True) + >>> fig.show() """ - #: Location of the reference point on the plot. Its meaning depends on the value of - #: ``type``. + #: Location of the reference point on the plot. The format depends on ``type``: + #: + #: - ``type="mapcoords"``: (*longitude*, *latitude*) + #: - ``type="plotcoords"``: (*x*, *y*) with units (e.g., ``"2c"``) + #: - ``type="boxcoords"``: (*nx*, *ny*) with values between 0 and 1 + #: - ``type="inside"`` or ``"outside"``: 2-character justification code location: Sequence[float | str] | AnchorCode - #: The coordinates used to define the reference point. Valid values and meanings for - #: corresponding ``location`` are: + #: Coordinate system for the reference point. Valid values are: #: - #: - ``"mapcoords"``: ``location`` is specified as (*longitude*, *latitude*) in map - #: coordinates. - #: - ``"boxcoords"``: ``location`` is specified as (*nx*, *ny*) in normalized - #: coordinates, i.e., fractional values between 0 and 1 along the x- and y-axes. - #: - ``"plotcoords"``: ``location`` is specified as (*x*, *y*) in plot coordinates, - #: i.e., distances from the lower-left plot origin given in inches, centimeters, - #: or points. - #: - ``"inside"`` or ``"outside"``: ``location`` is one of the nine - #: :doc:`two-character justification codes `, - #: indicating a specific location relative to the plot bounding box. - #: - #: The default value is ``"plotcoords"``. + #: - ``"mapcoords"``: Map/Data coordinates + #: - ``"plotcoords"``: Plot coordinates + #: - ``"boxcoords"``: Normalized coordinates + #: - ``"inside"`` or ``"outside"``: Justification codes type: Literal["mapcoords", "inside", "outside", "boxcoords", "plotcoords"] = ( "plotcoords" ) - #: Anchor point of the embellishment, using one of the - #: :doc:`2-character justification codes `. The - #: default value depends on ``type``. - #: - #: - ``type="inside"``: ``anchor`` defaults to the same as ``location``. - #: - ``type="outside"``: ``anchor`` defaults to the mirror opposite of ``location``. - #: - Otherwise, ``anchor`` defaults to ``"MC"`` (middle center). + #: Anchor point on the embellishment using a + #: :doc:`2-character justification codes `. + #: If ``None``, defaults are applied based on ``type`` (see above). anchor: AnchorCode | None = None - #: Offset for the anchor point. It can be either a single value *offset* or a pair - #: (*offset_x*, *offset_y*), where *offset_x* and *offset_y* are the offsets in the - #: x- and y-directions, respectively. If a single value *offset* is given, both - #: *offset_x* and *offset_y* are set to *offset*. + #: 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: Sequence[float | str] | None = None @property From 4d616de507fdd4e9f0b1d331f1b626bac3981960 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Tue, 25 Nov 2025 16:19:24 +0800 Subject: [PATCH 15/57] Revert changes in logo.py --- pygmt/src/logo.py | 36 +++++------------------------------- 1 file changed, 5 insertions(+), 31 deletions(-) diff --git a/pygmt/src/logo.py b/pygmt/src/logo.py index 8d0e98699d4..defdc065eb3 100644 --- a/pygmt/src/logo.py +++ b/pygmt/src/logo.py @@ -7,17 +7,14 @@ from pygmt.alias import Alias, AliasSystem from pygmt.clib import Session -from pygmt.exceptions import GMTInvalidInput -from pygmt.helpers import build_arg_list, fmt_docstring -from pygmt.params import Box, Position +from pygmt.helpers import build_arg_list, fmt_docstring, use_alias +from pygmt.params import Box @fmt_docstring -def logo( # noqa: PLR0913 +@use_alias(D="position") +def logo( self, - position: Position | None = None, - width: float | str | None = None, - height: float | str | None = None, projection: str | None = None, region: Sequence[float | str] | str | None = None, style: Literal["standard", "url", "no_label"] = "standard", @@ -39,12 +36,7 @@ def logo( # noqa: PLR0913 Full GMT docs at :gmt-docs:`gmtlogo.html`. - **Aliases:** - - .. hlist:: - :columns: 3 - - - D = position, **+w**: width, **+h**: height + {aliases} - F = box - J = projection - R = region @@ -80,25 +72,7 @@ def logo( # noqa: PLR0913 """ self._activate_figure() - if isinstance(position, str) and any(v is not None for v in (width, height)): - msg = ( - "Parameter 'position' is given with a raw GMT CLI syntax, and conflicts " - "with parameters 'height', and 'width'. Please refer to the documentation " - "for the recommended usage." - ) - raise GMTInvalidInput(msg) - - # 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(width, name="width", prefix="+w"), - Alias(height, name="height", prefix="+h"), - ], F=Alias(box, name="box"), S=Alias( style, name="style", mapping={"standard": "l", "url": "u", "no_label": "n"} From ad9e0aa9153addba8307b15be0ec98e387731d2b Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Tue, 25 Nov 2025 16:21:21 +0800 Subject: [PATCH 16/57] Simplify tests --- pygmt/tests/test_params_position.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/pygmt/tests/test_params_position.py b/pygmt/tests/test_params_position.py index 2ca05cf2150..346f4d3b03b 100644 --- a/pygmt/tests/test_params_position.py +++ b/pygmt/tests/test_params_position.py @@ -10,23 +10,20 @@ def test_params_position_types(): Test the Position class with different types of coordinate systems. """ assert str(Position((1, 2))) == "x1/2" - assert str(Position(location=(1, 2))) == "x1/2" - assert str(Position(location=(10, 20), type="mapcoords")) == "g10/20" - assert str(Position(location=(0.1, 0.2), type="boxcoords")) == "n0.1/0.2" - assert str(Position(location=("5c", "3c"), type="plotcoords")) == "x5c/3c" - assert str(Position(location="TL", type="inside")) == "jTL" - assert str(Position(location="BR", type="outside")) == "JBR" + assert str(Position((10, 20), type="mapcoords")) == "g10/20" + assert str(Position((0.1, 0.2), type="boxcoords")) == "n0.1/0.2" + assert str(Position(("5c", "3c"), type="plotcoords")) == "x5c/3c" + assert str(Position("TL", type="inside")) == "jTL" + assert str(Position("BR", type="outside")) == "JBR" def test_params_position_anchor_offset(): """ Test the Position class with anchor and offset parameters. """ - pos = Position(location=(10, 20), type="mapcoords", anchor="TL") - assert str(pos) == "g10/20+jTL" + assert str(Position((10, 20), type="mapcoords", anchor="TL")) == "g10/20+jTL" - pos = Position(location=(10, 20), type="mapcoords", offset=(1, 2)) - assert str(pos) == "g10/20+o1/2" + assert str(Position((10, 20), type="mapcoords", offset=(1, 2))) == "g10/20+o1/2" - pos = Position(location="TL", type="inside", anchor="MC", offset=("1c", "2c")) + pos = Position("TL", type="inside", anchor="MC", offset=("1c", "2c")) assert str(pos) == "jTL+jMC+o1c/2c" From b084e5f98af6e64639ff913e796a962108845702 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Tue, 25 Nov 2025 16:48:21 +0800 Subject: [PATCH 17/57] Validate values --- pygmt/params/position.py | 41 ++++++++++++++++++++++++++--- pygmt/tests/test_params_position.py | 23 +++++++++++++++- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/pygmt/params/position.py b/pygmt/params/position.py index 6dea68311e0..06204f6d6b9 100644 --- a/pygmt/params/position.py +++ b/pygmt/params/position.py @@ -8,6 +8,7 @@ from pygmt._typing import AnchorCode from pygmt.alias import Alias +from pygmt.exceptions import GMTValueError from pygmt.params.base import BaseParam @@ -134,9 +135,12 @@ class Position(BaseParam): #: - ``"plotcoords"``: Plot coordinates #: - ``"boxcoords"``: Normalized coordinates #: - ``"inside"`` or ``"outside"``: Justification codes - type: Literal["mapcoords", "inside", "outside", "boxcoords", "plotcoords"] = ( - "plotcoords" - ) + #: + #: If not specified, defaults to ``"inside"`` if ``location`` is a justification + #: code; otherwise defaults to ``"plotcoords"``. + type: ( + Literal["mapcoords", "inside", "outside", "boxcoords", "plotcoords"] | None + ) = None #: Anchor point on the embellishment using a #: :doc:`2-character justification codes `. @@ -147,6 +151,37 @@ class Position(BaseParam): #: If a single value is given, the offset is applied to both x and y directions. offset: 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 type is not specified and location is an anchor code. + if self.type is None: + self.type = "inside" if isinstance(self.location, str) else "plotcoords" + + # Validate the location based on type. + match self.type: + case "mapcoords" | "plotcoords" | "boxcoords": + if not isinstance(self.location, Sequence) or len(self.location) != 2: + raise GMTValueError( + self.location, + description="reference point", + reason="Expect a sequence of two values.", + ) + case "inside" | "outside": + if self.location not in _valid_anchors: + raise GMTValueError( + self.location, + description="reference point", + reason="Expect a valid 2-character justification code.", + ) + case _: + pass # Will check type in the Alias system. + @property def _aliases(self): return [ diff --git a/pygmt/tests/test_params_position.py b/pygmt/tests/test_params_position.py index 346f4d3b03b..1ce981ec1f8 100644 --- a/pygmt/tests/test_params_position.py +++ b/pygmt/tests/test_params_position.py @@ -2,6 +2,8 @@ Test the Position class. """ +import pytest +from pygmt.exceptions import GMTValueError from pygmt.params import Position @@ -9,11 +11,14 @@ def test_params_position_types(): """ Test the Position class with different types of coordinate systems. """ + # Default type 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), type="mapcoords")) == "g10/20" assert str(Position((0.1, 0.2), type="boxcoords")) == "n0.1/0.2" assert str(Position(("5c", "3c"), type="plotcoords")) == "x5c/3c" - assert str(Position("TL", type="inside")) == "jTL" + assert str(Position("MR", type="inside")) == "jMR" assert str(Position("BR", type="outside")) == "JBR" @@ -27,3 +32,19 @@ def test_params_position_anchor_offset(): pos = Position("TL", type="inside", anchor="MC", offset=("1c", "2c")) assert str(pos) == "jTL+jMC+o1c/2c" + + +def test_params_position_invalid_location(): + """ + Test that invalid location inputs raise GMTValueError. + """ + with pytest.raises(GMTValueError): + Position("invalid", type="mapcoords") + with pytest.raises(GMTValueError): + Position(5, type="plotcoords") + with pytest.raises(GMTValueError): + Position((0.5,), type="boxcoords") + with pytest.raises(GMTValueError): + Position((10, 20), type="inside") + with pytest.raises(GMTValueError): + Position("TT", type="outside") From d4ad6e0b955ea030d813eb94591846328c957c5b Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Tue, 25 Nov 2025 16:54:15 +0800 Subject: [PATCH 18/57] type will be validated in the Alias System --- pygmt/params/position.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pygmt/params/position.py b/pygmt/params/position.py index 06204f6d6b9..05406a47037 100644 --- a/pygmt/params/position.py +++ b/pygmt/params/position.py @@ -179,8 +179,6 @@ def _validate(self): description="reference point", reason="Expect a valid 2-character justification code.", ) - case _: - pass # Will check type in the Alias system. @property def _aliases(self): From 7dc37bd96c728b818e9465561b00db1fc04b2117 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Tue, 25 Nov 2025 18:14:09 +0800 Subject: [PATCH 19/57] Use the image from the GMT docs --- pygmt/params/position.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygmt/params/position.py b/pygmt/params/position.py index 05406a47037..59d8afd0f7c 100644 --- a/pygmt/params/position.py +++ b/pygmt/params/position.py @@ -17,7 +17,7 @@ class Position(BaseParam): """ Class for positioning embellishments on a plot. - .. figure:: https://github.com/user-attachments/assets/0f3e9b39-7d64-4628-8acb-58fe74ff6fa5 + .. figure:: https://docs.generic-mapping-tools.org/dev/_images/GMT_anchor.png :width: 400 px Positioning of GMT embellishment. From bfecb2deca103acda01b77727234a9c47db9feae Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Tue, 25 Nov 2025 18:25:24 +0800 Subject: [PATCH 20/57] Fix width and alignment --- pygmt/params/position.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pygmt/params/position.py b/pygmt/params/position.py index 59d8afd0f7c..96f6371a1ea 100644 --- a/pygmt/params/position.py +++ b/pygmt/params/position.py @@ -18,7 +18,8 @@ class Position(BaseParam): Class for positioning embellishments on a plot. .. figure:: https://docs.generic-mapping-tools.org/dev/_images/GMT_anchor.png - :width: 400 px + :width: 600 px + :align: center Positioning of GMT embellishment. From 18b90b3e5f412cbbc8902577306f6a74d6d7b09a Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Tue, 25 Nov 2025 20:15:14 +0800 Subject: [PATCH 21/57] Improve docstrings --- pygmt/params/position.py | 81 ++++++++++++++++++++-------------------- 1 file changed, 41 insertions(+), 40 deletions(-) diff --git a/pygmt/params/position.py b/pygmt/params/position.py index 96f6371a1ea..c445d6bd3d2 100644 --- a/pygmt/params/position.py +++ b/pygmt/params/position.py @@ -21,7 +21,8 @@ class Position(BaseParam): :width: 600 px :align: center - Positioning of GMT embellishment. + 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 @@ -36,73 +37,72 @@ class Position(BaseParam): *reference point* 3. The embellishment is "dropped" at that position - **Reference Point Types** + **Reference Point** - The reference point can be specified in five different ways using the ``type`` and + The *reference point* can be specified in five different ways using the ``type`` and ``location`` attributes: - **type="mapcoords"** (Map Coordinates) - Use data/geographic coordinates. Set ``location`` as (*longitude*, *latitude*). - Useful when tying the embellishment to a specific geographic location. + ``type="mapcoords"`` Map Coordinates + Use data/geographic coordinates. Specify ``location`` as + (*longitude*, *latitude*). Useful when tying the embellishment to a specific + geographic location. - Example: ``location=(135, 20), type="mapcoords"``. + **Example:** ``location=(135, 20), type="mapcoords"``. - **type="plotcoords"** (Plot Coordinates) + ``type="plotcoords"`` Plot Coordinates Use plot coordinates as distances from the lower-left plot origin. Specify ``location`` as (*x*, *y*) with units (e.g., inches, centimeters, points). Useful for precise layout control. - Example: ``location=("2c", "2.5c"), type="plotcoords"`` + **Example:** ``location=("2c", "2.5c"), type="plotcoords"`` - **type="boxcoords"** (Normalized Coordinates) + ``type="boxcoords"`` Normalized Coordinates Use normalized coordinates where (0, 0) is the lower-left corner and (1, 1) is - the upper-right corner. Set ``location`` as (*nx*, *ny*) with values between - 0 and 1. Useful for positioning relative to plot dimensions without units. + the upper-right corner of the bounding box of the current plot. Specify + ``location`` as (*nx*, *ny*). Useful for positioning relative to plot dimensions + without units. - Example: ``location=(0.2, 0.1), type="boxcoords"`` + **Example:** ``location=(0.2, 0.1), type="boxcoords"`` - **type="inside"** (Inside Plot) - Use a :doc:`justification code ` (e.g., ``"TL"``) - to place the embellishment inside the plot. Set ``location`` to one of the nine - 2-character codes. + ``type="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: ``location="TL", type="inside"`` + **Example:** ``location="TL", type="inside"`` [anchor point defaults to "TL"] - **type="outside"** (Outside Plot) - Similar to ``type="inside"``, but the anchor point defaults to the mirror + ``type="outside"`` Outside Plot + Similar to ``type="inside"``, but the *anchor point* defaults to the mirror opposite of the justification code. Useful for placing embellishments outside the plot boundaries (e.g., color bars). - Example: ``location="TL", type="outside"`` + **Example:** ``location="TL", type="outside"`` [anchor point defaults to "BR"] **Anchor Point** - The anchor point determines which part of the embellishment aligns with the - reference point. It uses one of nine + 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: + *anchor* behaviors are: - - For ``type="inside"``: Same as the reference point justification - - For ``type="outside"``: Mirror opposite of the reference point justification - - For other types: ``"MC"`` (middle center) for map rose and scale, ``"BL"`` + - ``type="inside"``: Same as the *reference point* justification code + - ``type="outside"``: Mirror opposite of the *reference point* justification code + - Other types: ``"MC"`` (middle center) for map rose and scale, ``"BL"`` (bottom-left) for other embellishments - Example: ``anchor="TR"`` selects the top-right point of the embellishment. - **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. - - Specify as a single value (applied to both x and y) or as (*offset_x*, *offset_y*). + 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 a logo at map coordinates (3, 3) with the logo's middle-left point as the - anchor, offset by (0.2, 0.2): + 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 @@ -114,7 +114,7 @@ class Position(BaseParam): ... ) >>> fig.show() - Position an embellishment at the top-left corner inside the plot: + 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) @@ -125,9 +125,10 @@ class Position(BaseParam): #: Location of the reference point on the plot. The format depends on ``type``: #: #: - ``type="mapcoords"``: (*longitude*, *latitude*) - #: - ``type="plotcoords"``: (*x*, *y*) with units (e.g., ``"2c"``) - #: - ``type="boxcoords"``: (*nx*, *ny*) with values between 0 and 1 - #: - ``type="inside"`` or ``"outside"``: 2-character justification code + #: - ``type="plotcoords"``: (*x*, *y*) with plot units (e.g., ``"2c"``) + #: - ``type="boxcoords"``: (*nx*, *ny*) + #: - ``type="inside"`` or ``"outside"``: + #: :doc:`2-character justification codes ` location: Sequence[float | str] | AnchorCode #: Coordinate system for the reference point. Valid values are: From 6b1b5bc3a89605edf34394b36ff13656cf133dfc Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Tue, 25 Nov 2025 20:18:48 +0800 Subject: [PATCH 22/57] Remove unneeded blank lines --- pygmt/tests/test_params_position.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pygmt/tests/test_params_position.py b/pygmt/tests/test_params_position.py index 1ce981ec1f8..161c571c7a3 100644 --- a/pygmt/tests/test_params_position.py +++ b/pygmt/tests/test_params_position.py @@ -27,9 +27,7 @@ def test_params_position_anchor_offset(): Test the Position class with anchor and offset parameters. """ assert str(Position((10, 20), type="mapcoords", anchor="TL")) == "g10/20+jTL" - assert str(Position((10, 20), type="mapcoords", offset=(1, 2))) == "g10/20+o1/2" - pos = Position("TL", type="inside", anchor="MC", offset=("1c", "2c")) assert str(pos) == "jTL+jMC+o1c/2c" From 1eae742469349587ad80b63b2299b637977e8dd5 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Tue, 25 Nov 2025 22:52:13 +0800 Subject: [PATCH 23/57] Improve docstrings --- pygmt/params/position.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pygmt/params/position.py b/pygmt/params/position.py index c445d6bd3d2..433731b8e40 100644 --- a/pygmt/params/position.py +++ b/pygmt/params/position.py @@ -73,7 +73,7 @@ class Position(BaseParam): ``type="outside"`` Outside Plot Similar to ``type="inside"``, but the *anchor point* defaults to the mirror - opposite of the justification code. Useful for placing embellishments outside + opposite of the *reference point*. Useful for placing embellishments outside the plot boundaries (e.g., color bars). **Example:** ``location="TL", type="outside"`` [anchor point defaults to "BR"] @@ -125,13 +125,13 @@ class Position(BaseParam): #: Location of the reference point on the plot. The format depends on ``type``: #: #: - ``type="mapcoords"``: (*longitude*, *latitude*) - #: - ``type="plotcoords"``: (*x*, *y*) with plot units (e.g., ``"2c"``) + #: - ``type="plotcoords"``: (*x*, *y*) with plot units #: - ``type="boxcoords"``: (*nx*, *ny*) #: - ``type="inside"`` or ``"outside"``: #: :doc:`2-character justification codes ` location: Sequence[float | str] | AnchorCode - #: Coordinate system for the reference point. Valid values are: + #: Types of the reference point. Valid values are: #: #: - ``"mapcoords"``: Map/Data coordinates #: - ``"plotcoords"``: Plot coordinates From 721b46fa4f366fe8ea0e350479174611bb18c2cf Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Tue, 25 Nov 2025 22:58:08 +0800 Subject: [PATCH 24/57] Validate anchor code --- pygmt/params/position.py | 7 +++++++ pygmt/tests/test_params_position.py | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/pygmt/params/position.py b/pygmt/params/position.py index 433731b8e40..b955b36fbe6 100644 --- a/pygmt/params/position.py +++ b/pygmt/params/position.py @@ -181,6 +181,13 @@ def _validate(self): 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): diff --git a/pygmt/tests/test_params_position.py b/pygmt/tests/test_params_position.py index 161c571c7a3..91305da1752 100644 --- a/pygmt/tests/test_params_position.py +++ b/pygmt/tests/test_params_position.py @@ -38,6 +38,8 @@ def test_params_position_invalid_location(): """ with pytest.raises(GMTValueError): Position("invalid", type="mapcoords") + with pytest.raises(GMTValueError): + Position((1, 2, 3), type="mapcoords") with pytest.raises(GMTValueError): Position(5, type="plotcoords") with pytest.raises(GMTValueError): @@ -46,3 +48,11 @@ def test_params_position_invalid_location(): Position((10, 20), type="inside") with pytest.raises(GMTValueError): Position("TT", type="outside") + + +def test_params_position_invalid_anchor(): + """ + Test that invalid anchor inputs raise GMTValueError. + """ + with pytest.raises(GMTValueError): + Position((10, 20), type="mapcoords", anchor="XX") From 0f9ed6ca2f74aaf3d9161d9229834aa6ae1b7f01 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Wed, 26 Nov 2025 16:16:28 +0800 Subject: [PATCH 25/57] offset can be a single value --- pygmt/params/position.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygmt/params/position.py b/pygmt/params/position.py index b955b36fbe6..1b44b0f45a0 100644 --- a/pygmt/params/position.py +++ b/pygmt/params/position.py @@ -151,7 +151,7 @@ class Position(BaseParam): #: 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: Sequence[float | str] | None = None + offset: float | str | Sequence[float | str] | None = None def _validate(self): """ From 10a0dfb4acb013492170b7a2acdbc6928a03a684 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Thu, 4 Dec 2025 14:29:37 +0800 Subject: [PATCH 26/57] Use is_nonstr_iter to check the location parameter --- pygmt/params/position.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pygmt/params/position.py b/pygmt/params/position.py index 1b44b0f45a0..3eaca277af1 100644 --- a/pygmt/params/position.py +++ b/pygmt/params/position.py @@ -9,6 +9,7 @@ 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 @@ -168,7 +169,7 @@ def _validate(self): # Validate the location based on type. match self.type: case "mapcoords" | "plotcoords" | "boxcoords": - if not isinstance(self.location, Sequence) or len(self.location) != 2: + if is_nonstr_iter(self.location) or len(self.location) != 2: raise GMTValueError( self.location, description="reference point", From c27213f77281363559c022cc464242dee13acd25 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Thu, 4 Dec 2025 14:32:42 +0800 Subject: [PATCH 27/57] Fix a typo [skip ci] Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pygmt/params/position.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygmt/params/position.py b/pygmt/params/position.py index 3eaca277af1..5b4c1454c7f 100644 --- a/pygmt/params/position.py +++ b/pygmt/params/position.py @@ -132,7 +132,7 @@ class Position(BaseParam): #: :doc:`2-character justification codes ` location: Sequence[float | str] | AnchorCode - #: Types of the reference point. Valid values are: + #: Type of the reference point. Valid values are: #: #: - ``"mapcoords"``: Map/Data coordinates #: - ``"plotcoords"``: Plot coordinates From d47aaeb44fd8b84951017fc9d6814bed396370fe Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Thu, 4 Dec 2025 14:33:05 +0800 Subject: [PATCH 28/57] Fix a typo [skip ci] Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pygmt/params/position.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygmt/params/position.py b/pygmt/params/position.py index 5b4c1454c7f..2b0fcfbd53b 100644 --- a/pygmt/params/position.py +++ b/pygmt/params/position.py @@ -146,7 +146,7 @@ class Position(BaseParam): ) = None #: Anchor point on the embellishment using a - #: :doc:`2-character justification codes `. + #: :doc:`2-character justification code `. #: If ``None``, defaults are applied based on ``type`` (see above). anchor: AnchorCode | None = None From 7fc6ffcbd6e202129f6fb3f2d2a338c805643b55 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Thu, 4 Dec 2025 14:36:28 +0800 Subject: [PATCH 29/57] Fix the wrong logic in checking location --- pygmt/params/position.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygmt/params/position.py b/pygmt/params/position.py index 2b0fcfbd53b..6276bacabd6 100644 --- a/pygmt/params/position.py +++ b/pygmt/params/position.py @@ -169,7 +169,7 @@ def _validate(self): # Validate the location based on type. match self.type: case "mapcoords" | "plotcoords" | "boxcoords": - if is_nonstr_iter(self.location) or len(self.location) != 2: + if not is_nonstr_iter(self.location) or len(self.location) != 2: raise GMTValueError( self.location, description="reference point", From d82f4ba53a66d3a83527e9300c307f7e46d8dc97 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Thu, 4 Dec 2025 14:36:42 +0800 Subject: [PATCH 30/57] Add a tests for passing a single value to offset --- pygmt/tests/test_params_position.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pygmt/tests/test_params_position.py b/pygmt/tests/test_params_position.py index 91305da1752..484ec1b63d0 100644 --- a/pygmt/tests/test_params_position.py +++ b/pygmt/tests/test_params_position.py @@ -30,6 +30,7 @@ def test_params_position_anchor_offset(): assert str(Position((10, 20), type="mapcoords", offset=(1, 2))) == "g10/20+o1/2" pos = Position("TL", type="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(): From 620da5291a7c15696b7a9db6d407ad1e55903111 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Sun, 10 Aug 2025 20:56:50 +0800 Subject: [PATCH 31/57] Figure.legend: Refactor using the new alias system --- pygmt/src/legend.py | 53 +++++++++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/pygmt/src/legend.py b/pygmt/src/legend.py index 6f5be423489..7aff870cfb4 100644 --- a/pygmt/src/legend.py +++ b/pygmt/src/legend.py @@ -6,29 +6,26 @@ from collections.abc import Sequence from typing import Literal -from pygmt._typing import PathLike +from pygmt._typing import AnchorCode, 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.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( +def legend( # noqa: PLR0913 self, spec: PathLike | io.StringIO | None = None, + position: Position | None = None, + width: float | str | None = None, + height: float | str | None = None, + justify: AnchorCode | None = None, + spacing: float | None = None, + box: Box | bool = False, projection: str | None = None, region: Sequence[float | str] | str | None = None, - position="JTR+jTR+o0.2c", - box: Box | bool = False, verbose: Literal["quiet", "error", "warning", "timing", "info", "compat", "debug"] | bool = False, panel: int | Sequence[int] | bool = False, @@ -48,6 +45,7 @@ def legend( Full GMT docs at :gmt-docs:`legend.html`. $aliases + - D = position, **+w**: width/height, **+j**: justify, **+l**: spacing - F = box - J = projection - R = region @@ -67,21 +65,23 @@ def legend( - A :class:`io.StringIO` object containing the legend specification See :gmt-docs:`legend.html` for the definition of the legend specification. - $projection - $region - 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 map. See :class:`pygmt.enums.Position` + for details. + width + height + Specify the width and height of the legend box. + justify + Specify the justification of the legend box contents. + spacing + Specify the spacing between legend entries. 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 appearance, pass a :class:`pygmt.params.Box` object to control style, fill, pen, and other box properties. + $projection + $region $verbose $panel $perspective @@ -104,6 +104,13 @@ def legend( ) aliasdict = AliasSystem( + D=[ + Alias(position, name="position", sep="/", size=2), + Alias(width, name="width", prefix="+w"), # +wwidth/height + Alias(height, name="height", prefix="/"), + Alias(justify, name="justify", prefix="+j"), + Alias(spacing, name="spacing", prefix="+l"), + ], F=Alias(box, name="box"), ).add_common( J=projection, From c9c422261787e1ac36e03229528e8ae85748eb80 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Sun, 7 Dec 2025 14:01:53 +0800 Subject: [PATCH 32/57] Rename position to refpoint --- pygmt/params/position.py | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/pygmt/params/position.py b/pygmt/params/position.py index 6276bacabd6..ef6072a9f75 100644 --- a/pygmt/params/position.py +++ b/pygmt/params/position.py @@ -41,43 +41,42 @@ class Position(BaseParam): **Reference Point** The *reference point* can be specified in five different ways using the ``type`` and - ``location`` attributes: + ``refpoint`` attributes: ``type="mapcoords"`` Map Coordinates - Use data/geographic coordinates. Specify ``location`` as + Use data/geographic coordinates. Specify ``refpoint`` as (*longitude*, *latitude*). Useful when tying the embellishment to a specific geographic location. - **Example:** ``location=(135, 20), type="mapcoords"``. - + **Example:** ``refpoint=(135, 20), type="mapcoords"``. ``type="plotcoords"`` Plot Coordinates Use plot coordinates as distances from the lower-left plot origin. Specify - ``location`` as (*x*, *y*) with units (e.g., inches, centimeters, points). + ``refpoint`` as (*x*, *y*) with units (e.g., inches, centimeters, points). Useful for precise layout control. - **Example:** ``location=("2c", "2.5c"), type="plotcoords"`` + **Example:** ``refpoint=("2c", "2.5c"), type="plotcoords"`` ``type="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 - ``location`` as (*nx*, *ny*). Useful for positioning relative to plot dimensions + ``refpoint`` as (*nx*, *ny*). Useful for positioning relative to plot dimensions without units. - **Example:** ``location=(0.2, 0.1), type="boxcoords"`` + **Example:** ``refpoint=(0.2, 0.1), type="boxcoords"`` ``type="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:** ``location="TL", type="inside"`` [anchor point defaults to "TL"] + **Example:** ``refpoint="TL", type="inside"`` [anchor point defaults to "TL"] ``type="outside"`` Outside Plot Similar to ``type="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:** ``location="TL", type="outside"`` [anchor point defaults to "BR"] + **Example:** ``refpoint="TL", type="outside"`` [anchor point defaults to "BR"] **Anchor Point** @@ -130,7 +129,7 @@ class Position(BaseParam): #: - ``type="boxcoords"``: (*nx*, *ny*) #: - ``type="inside"`` or ``"outside"``: #: :doc:`2-character justification codes ` - location: Sequence[float | str] | AnchorCode + refpoint: Sequence[float | str] | AnchorCode #: Type of the reference point. Valid values are: #: @@ -139,7 +138,7 @@ class Position(BaseParam): #: - ``"boxcoords"``: Normalized coordinates #: - ``"inside"`` or ``"outside"``: Justification codes #: - #: If not specified, defaults to ``"inside"`` if ``location`` is a justification + #: If not specified, defaults to ``"inside"`` if ``refpoint`` is a justification #: code; otherwise defaults to ``"plotcoords"``. type: ( Literal["mapcoords", "inside", "outside", "boxcoords", "plotcoords"] | None @@ -164,21 +163,21 @@ def _validate(self): # Default to "inside" if type is not specified and location is an anchor code. if self.type is None: - self.type = "inside" if isinstance(self.location, str) else "plotcoords" + self.type = "inside" if isinstance(self.refpoint, str) else "plotcoords" # Validate the location based on type. match self.type: case "mapcoords" | "plotcoords" | "boxcoords": - if not is_nonstr_iter(self.location) or len(self.location) != 2: + if not is_nonstr_iter(self.refpoint) or len(self.refpoint) != 2: raise GMTValueError( - self.location, + self.refpoint, description="reference point", reason="Expect a sequence of two values.", ) case "inside" | "outside": - if self.location not in _valid_anchors: + if self.refpoint not in _valid_anchors: raise GMTValueError( - self.location, + self.refpoint, description="reference point", reason="Expect a valid 2-character justification code.", ) @@ -204,7 +203,7 @@ def _aliases(self): "outside": "J", }, ), - Alias(self.location, name="location", sep="/", size=2), + 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), ] From 0064cde007612e6ac8720c4547dcc4d61a070e34 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Sun, 7 Dec 2025 14:08:29 +0800 Subject: [PATCH 33/57] Fix formatting --- pygmt/params/position.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pygmt/params/position.py b/pygmt/params/position.py index ef6072a9f75..9b0d8ab440b 100644 --- a/pygmt/params/position.py +++ b/pygmt/params/position.py @@ -69,14 +69,16 @@ class Position(BaseParam): 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", type="inside"`` [anchor point defaults to "TL"] + **Example:** ``refpoint="TL", type="inside"`` [anchor point defaults to + ``"TL"``] ``type="outside"`` Outside Plot Similar to ``type="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", type="outside"`` [anchor point defaults to "BR"] + **Example:** ``refpoint="TL", type="outside"`` [anchor point defaults to + ``"BR"``] **Anchor Point** From d702ce68cf52f8ccc88601347fddda807b9c78ff Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Sun, 7 Dec 2025 14:43:18 +0800 Subject: [PATCH 34/57] Improve Figure.legend and tests --- pygmt/src/legend.py | 53 +++++++++++++++++++++++--------------- pygmt/tests/test_legend.py | 18 ++++++++++--- 2 files changed, 46 insertions(+), 25 deletions(-) diff --git a/pygmt/src/legend.py b/pygmt/src/legend.py index 7aff870cfb4..f736d4c92b6 100644 --- a/pygmt/src/legend.py +++ b/pygmt/src/legend.py @@ -6,7 +6,7 @@ from collections.abc import Sequence from typing import Literal -from pygmt._typing import AnchorCode, PathLike +from pygmt._typing import PathLike from pygmt.alias import Alias, AliasSystem from pygmt.clib import Session from pygmt.exceptions import GMTTypeError @@ -21,7 +21,6 @@ def legend( # noqa: PLR0913 position: Position | None = None, width: float | str | None = None, height: float | str | None = None, - justify: AnchorCode | None = None, spacing: float | None = None, box: Box | bool = False, projection: str | None = None, @@ -36,16 +35,20 @@ def legend( # noqa: PLR0913 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 - - D = position, **+w**: width/height, **+j**: justify, **+l**: spacing + **Aliases:** + + .. hlist:: + :columns: 3 + + - D = position, **+w**: width/height, **+l**: spacing - F = box - J = projection - R = region @@ -66,15 +69,24 @@ def legend( # noqa: PLR0913 See :gmt-docs:`legend.html` for the definition of the legend specification. position - Specify the position of the legend on the map. See :class:`pygmt.enums.Position` - for details. + Specify the position of the legend on the plot. By default, the anchor point on + the legend is assumed to be the bottom left corner (``"BL"``). See + :class:`pygmt.enums.Position` for details. width height - Specify the width and height of the legend box. - justify - Specify the justification of the legend box contents. + Specify the width and height of the legend box in plot coordinates (inches, cm, + etc.). If unit is ``%`` (percentage) then width as computed as that fraction of + the plot width. If height is given as percentage then then height is recomputed + as that fraction of the legend width (not plot height). + + **Note:** If ``width`` is not given, the width defaults to be computed within + the Postscript code. Currently, this is only possible if just legend codes + **D**, **H**, **L**, **S**, or **V** are used and that the number of symbol + columns (**N**) is 1. If ``height`` is zero or not given then we estimate height + based the expected vertical extent of the items to be placed. spacing - Specify the spacing between legend entries. + Specify the line-spacing factor 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 @@ -89,10 +101,10 @@ 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: + # 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 None: box = Box(pen="1p", fill="white") # Default box kind = data_kind(spec) @@ -105,10 +117,9 @@ def legend( # noqa: PLR0913 aliasdict = AliasSystem( D=[ - Alias(position, name="position", sep="/", size=2), + Alias(position, name="position"), Alias(width, name="width", prefix="+w"), # +wwidth/height Alias(height, name="height", prefix="/"), - Alias(justify, name="justify", prefix="+j"), Alias(spacing, name="spacing", prefix="+l"), ], F=Alias(box, name="box"), diff --git a/pygmt/tests/test_legend.py b/pygmt/tests/test_legend.py index 2fba712a4dd..e4225a8a9f0 100644 --- a/pygmt/tests/test_legend.py +++ b/pygmt/tests/test_legend.py @@ -9,6 +9,7 @@ from pygmt import Figure from pygmt.exceptions import GMTTypeError from pygmt.helpers import GMTTempFile +from pygmt.params import Position @pytest.fixture(scope="module", name="legend_spec") @@ -51,7 +52,12 @@ def test_legend_position(): """ fig = Figure() fig.basemap(region=[-2, 2, -2, 2], frame=True) - positions = ["jTR+jTR", "g0/1", "n0.2/0.2", "x4i/2i/2i"] + positions = [ + Position("TR", anchor="TR"), + Position((0, 1), type="mapcoords"), + Position((0.2, 0.2), type="boxcoords"), + Position(("4i", "2i"), type="plotcoords"), + ] for i, position in enumerate(positions): fig.plot(x=[0], y=[0], style="p10p", label=i) fig.legend(position=position, box=True) @@ -87,7 +93,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", type="outside", anchor="TR")) return fig @@ -100,7 +106,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="JTM+jCM+w5i") + fig.legend( + specfile.name, + position=Position("MC", type="outside", anchor="CM"), + width="5i", + ) return fig @@ -112,7 +122,7 @@ 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="JTM+jCM+w5i") + fig.legend(spec, position=Position("MC", type="outside", anchor="CM"), width="5i") return fig From ceb345e3352f0abfa97c06d030b0c56631a6cd71 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Sun, 7 Dec 2025 15:13:35 +0800 Subject: [PATCH 35/57] Improve docstrings of width/height --- pygmt/src/legend.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/pygmt/src/legend.py b/pygmt/src/legend.py index f736d4c92b6..9ac394c7661 100644 --- a/pygmt/src/legend.py +++ b/pygmt/src/legend.py @@ -75,15 +75,16 @@ def legend( # noqa: PLR0913 width height Specify the width and height of the legend box in plot coordinates (inches, cm, - etc.). If unit is ``%`` (percentage) then width as computed as that fraction of - the plot width. If height is given as percentage then then height is recomputed - as that fraction of the legend width (not plot height). - - **Note:** If ``width`` is not given, the width defaults to be computed within - the Postscript code. Currently, this is only possible if just legend codes - **D**, **H**, **L**, **S**, or **V** are used and that the number of symbol - columns (**N**) is 1. If ``height`` is zero or not given then we estimate height - based the expected vertical extent of the items to be placed. + 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 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. spacing Specify the line-spacing factor in units of the current font size [Default is 1.1]. @@ -107,6 +108,10 @@ def legend( # noqa: PLR0913 if kwargs.get("F", box) is None: 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)) From 9922da8e76235f9ab8237ee9ab9027bd0d927339 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Sun, 7 Dec 2025 15:41:06 +0800 Subject: [PATCH 36/57] Add one test for width/height --- .../baseline/test_legend_width_height.png.dvc | 5 +++ pygmt/tests/test_legend.py | 41 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 pygmt/tests/baseline/test_legend_width_height.png.dvc 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 e4225a8a9f0..dde3386c2a3 100644 --- a/pygmt/tests/test_legend.py +++ b/pygmt/tests/test_legend.py @@ -126,6 +126,47 @@ def test_legend_stringio(legend_spec): 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 + + def test_legend_fails(): """ Test legend fails with invalid spec. From c91719271c00e01454a33e14673744c3d6394b56 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Sun, 7 Dec 2025 16:03:36 +0800 Subject: [PATCH 37/57] Fix checking of box --- pygmt/src/legend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygmt/src/legend.py b/pygmt/src/legend.py index 9ac394c7661..8ca05a938fc 100644 --- a/pygmt/src/legend.py +++ b/pygmt/src/legend.py @@ -105,7 +105,7 @@ def legend( # noqa: PLR0913 # 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 None: + 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. From cf11c13393976c85fe9c1f6ac86123aa4afec13e Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Sun, 7 Dec 2025 22:58:06 +0800 Subject: [PATCH 38/57] Update the position argument in gallery examples --- examples/gallery/basemaps/double_y_axes.py | 6 ++-- examples/gallery/embellishments/legend.py | 32 +++++++++---------- examples/gallery/lines/hlines_vlines.py | 4 +-- examples/tutorials/advanced/legends.py | 36 +++++++++++----------- examples/tutorials/basics/plot.py | 5 +-- 5 files changed, 41 insertions(+), 42 deletions(-) diff --git a/examples/gallery/basemaps/double_y_axes.py b/examples/gallery/basemaps/double_y_axes.py index f38826fbd56..0a2ebff87e1 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 an 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..1068252b17c 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 an 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 +# centimeters; 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 1870e3a87d9..46f77403bc9 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-points 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 From 816d8b9d7fc4414c8cf202ebe926b334e4bbe16a Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Sun, 7 Dec 2025 22:59:00 +0800 Subject: [PATCH 39/57] Rename spacing to line_spacing --- pygmt/src/legend.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pygmt/src/legend.py b/pygmt/src/legend.py index 8ca05a938fc..76c9376e8b8 100644 --- a/pygmt/src/legend.py +++ b/pygmt/src/legend.py @@ -21,7 +21,7 @@ def legend( # noqa: PLR0913 position: Position | None = None, width: float | str | None = None, height: float | str | None = None, - spacing: float | None = None, + line_spacing: float | None = None, box: Box | bool = False, projection: str | None = None, region: Sequence[float | str] | str | None = None, @@ -32,7 +32,7 @@ def legend( # noqa: PLR0913 perspective: float | Sequence[float] | str | bool = False, **kwargs, ): - r""" + """ Plot a legend. Makes legends that can be overlaid on plots. It reads specific legend-related @@ -85,7 +85,7 @@ def legend( # noqa: PLR0913 **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. - spacing + line_spacing Specify the line-spacing factor in units of the current font size [Default is 1.1]. box @@ -125,7 +125,7 @@ def legend( # noqa: PLR0913 Alias(position, name="position"), Alias(width, name="width", prefix="+w"), # +wwidth/height Alias(height, name="height", prefix="/"), - Alias(spacing, name="spacing", prefix="+l"), + Alias(line_spacing, name="line_spacing", prefix="+l"), ], F=Alias(box, name="box"), ).add_common( From 7ff9f6a0fab09fb770b31e07630bda7dd0911d05 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Sun, 7 Dec 2025 23:00:44 +0800 Subject: [PATCH 40/57] Fix one more spacing to line_spacing --- pygmt/src/legend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygmt/src/legend.py b/pygmt/src/legend.py index 76c9376e8b8..e7d1a67917b 100644 --- a/pygmt/src/legend.py +++ b/pygmt/src/legend.py @@ -48,7 +48,7 @@ def legend( # noqa: PLR0913 .. hlist:: :columns: 3 - - D = position, **+w**: width/height, **+l**: spacing + - D = position, **+w**: width/height, **+l**: line_spacing - F = box - J = projection - R = region From 82426cdbbceeac45e2e7041b39c8e27e632f7b14 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Sun, 7 Dec 2025 23:27:32 +0800 Subject: [PATCH 41/57] Improve docstrings --- pygmt/src/legend.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pygmt/src/legend.py b/pygmt/src/legend.py index e7d1a67917b..74898b2ab58 100644 --- a/pygmt/src/legend.py +++ b/pygmt/src/legend.py @@ -69,8 +69,8 @@ def legend( # noqa: PLR0913 See :gmt-docs:`legend.html` for the definition of the legend specification. position - Specify the position of the legend on the plot. By default, the anchor point on - the legend is assumed to be the bottom left corner (``"BL"``). See + 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.enums.Position` for details. width height @@ -79,15 +79,15 @@ def legend( # noqa: PLR0913 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 then height is recomputed as - that fraction of the legend width (not plot height). + 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 in units of the current font size [Default is - 1.1]. + 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 From cdf63776333ce0ff2033528b7e9333204c5a24d4 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Sun, 7 Dec 2025 23:31:55 +0800 Subject: [PATCH 42/57] Fix typos --- examples/gallery/basemaps/double_y_axes.py | 2 +- examples/gallery/embellishments/legend.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/gallery/basemaps/double_y_axes.py b/examples/gallery/basemaps/double_y_axes.py index 0a2ebff87e1..d1865ea4517 100644 --- a/examples/gallery/basemaps/double_y_axes.py +++ b/examples/gallery/basemaps/double_y_axes.py @@ -64,7 +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 0.1-cm offset. +# 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 1068252b17c..27c0ffa5feb 100644 --- a/examples/gallery/embellishments/legend.py +++ b/examples/gallery/embellishments/legend.py @@ -41,7 +41,7 @@ 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 at the Top Right (TR) corner with an 0.2-cm offset in x- and +# 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) @@ -53,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``, here it is set to 6 -# centimeters; the legend is placed at the Bottom Right (BR) corner. +# 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() From 0c276dd770313312a498797456b64534d7a1f842 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Sun, 7 Dec 2025 23:54:02 +0800 Subject: [PATCH 43/57] Remove the test_legend_position test because it's already covered in Position --- .../baseline/test_legend_position.png.dvc | 5 ----- pygmt/tests/test_legend.py | 19 ------------------- 2 files changed, 24 deletions(-) delete mode 100644 pygmt/tests/baseline/test_legend_position.png.dvc 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/test_legend.py b/pygmt/tests/test_legend.py index dde3386c2a3..6f6e5f4764b 100644 --- a/pygmt/tests/test_legend.py +++ b/pygmt/tests/test_legend.py @@ -45,25 +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 = [ - Position("TR", anchor="TR"), - Position((0, 1), type="mapcoords"), - Position((0.2, 0.2), type="boxcoords"), - Position(("4i", "2i"), type="plotcoords"), - ] - 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(): """ From 5bc0bb104aa0a0fe420cbfa76bd87a150e93d9a3 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Mon, 8 Dec 2025 00:04:36 +0800 Subject: [PATCH 44/57] Check compatibility with old syntax --- pygmt/src/legend.py | 13 ++++++++++++- pygmt/tests/test_legend.py | 27 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/pygmt/src/legend.py b/pygmt/src/legend.py index 74898b2ab58..f9880ff3b44 100644 --- a/pygmt/src/legend.py +++ b/pygmt/src/legend.py @@ -9,7 +9,7 @@ from pygmt._typing import PathLike from pygmt.alias import Alias, AliasSystem from pygmt.clib import Session -from pygmt.exceptions import GMTTypeError +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 @@ -102,6 +102,17 @@ def legend( # noqa: PLR0913 """ self._activate_figure() + # 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) diff --git a/pygmt/tests/test_legend.py b/pygmt/tests/test_legend.py index 6f6e5f4764b..12338e5c5ed 100644 --- a/pygmt/tests/test_legend.py +++ b/pygmt/tests/test_legend.py @@ -158,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+jCM+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(GMTTypeError): + fig.legend(spec, position="JMC", width="5i") + with pytest.raises(GMTTypeError): + fig.legend(spec, position="JMC", height="5i") + with pytest.raises(GMTTypeError): + fig.legend(spec, position="JMC", line_spacing=2.0) From ff6392dde3216e7d57cfcae6db779aa06be3c91f Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Tue, 9 Dec 2025 10:38:10 +0800 Subject: [PATCH 45/57] Update pygmt/params/position.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Yvonne Fröhlich <94163266+yvonnefroehlich@users.noreply.github.com> --- pygmt/params/position.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pygmt/params/position.py b/pygmt/params/position.py index 9b0d8ab440b..2c95c43d835 100644 --- a/pygmt/params/position.py +++ b/pygmt/params/position.py @@ -69,8 +69,7 @@ class Position(BaseParam): 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", type="inside"`` [anchor point defaults to - ``"TL"``] + **Example:** ``refpoint="TL", type="inside"`` ``type="outside"`` Outside Plot Similar to ``type="inside"``, but the *anchor point* defaults to the mirror From 2310b22f636661de9e490cc82d765602b5dbdc8d Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Tue, 9 Dec 2025 10:38:26 +0800 Subject: [PATCH 46/57] Update pygmt/params/position.py [skip ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Yvonne Fröhlich <94163266+yvonnefroehlich@users.noreply.github.com> --- pygmt/params/position.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pygmt/params/position.py b/pygmt/params/position.py index 2c95c43d835..d192c0190ef 100644 --- a/pygmt/params/position.py +++ b/pygmt/params/position.py @@ -76,8 +76,7 @@ class Position(BaseParam): opposite of the *reference point*. Useful for placing embellishments outside the plot boundaries (e.g., color bars). - **Example:** ``refpoint="TL", type="outside"`` [anchor point defaults to - ``"BR"``] + **Example:** ``refpoint="TL", type="outside"`` **Anchor Point** From a3185e8729eb2e5f7a945e09404b383f3f301e5b Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Tue, 9 Dec 2025 10:39:51 +0800 Subject: [PATCH 47/57] Fix styling --- pygmt/params/position.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pygmt/params/position.py b/pygmt/params/position.py index d192c0190ef..58991bbc7dd 100644 --- a/pygmt/params/position.py +++ b/pygmt/params/position.py @@ -48,7 +48,8 @@ class Position(BaseParam): (*longitude*, *latitude*). Useful when tying the embellishment to a specific geographic location. - **Example:** ``refpoint=(135, 20), type="mapcoords"``. + **Example:** ``refpoint=(135, 20), type="mapcoords"`` + ``type="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). From e153ebf12936541a3ddd36b92aebc6e76ebc3bf0 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Tue, 9 Dec 2025 11:26:20 +0800 Subject: [PATCH 48/57] Rename type to cstype --- pygmt/params/position.py | 68 +++++++++++++++-------------- pygmt/tests/test_params_position.py | 36 +++++++-------- 2 files changed, 53 insertions(+), 51 deletions(-) diff --git a/pygmt/params/position.py b/pygmt/params/position.py index 58991bbc7dd..bb8df3c1ae9 100644 --- a/pygmt/params/position.py +++ b/pygmt/params/position.py @@ -40,44 +40,44 @@ class Position(BaseParam): **Reference Point** - The *reference point* can be specified in five different ways using the ``type`` and - ``refpoint`` attributes: + The *reference point* can be specified in five different ways using the ``cstype`` + and ``refpoint`` attributes: - ``type="mapcoords"`` Map Coordinates + ``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), type="mapcoords"`` + **Example:** ``refpoint=(135, 20), cstype="mapcoords"`` - ``type="plotcoords"`` Plot Coordinates + ``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"), type="plotcoords"`` + **Example:** ``refpoint=("2c", "2.5c"), cstype="plotcoords"`` - ``type="boxcoords"`` Normalized Coordinates + ``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), type="boxcoords"`` + **Example:** ``refpoint=(0.2, 0.1), cstype="boxcoords"`` - ``type="inside"`` Inside Plot + ``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", type="inside"`` + **Example:** ``refpoint="TL", cstype="inside"`` - ``type="outside"`` Outside Plot - Similar to ``type="inside"``, but the *anchor point* defaults to the mirror + ``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", type="outside"`` + **Example:** ``refpoint="TL", cstype="outside"`` **Anchor Point** @@ -88,9 +88,9 @@ class Position(BaseParam): Set ``anchor`` explicitly to override these defaults. If not set, the default *anchor* behaviors are: - - ``type="inside"``: Same as the *reference point* justification code - - ``type="outside"``: Mirror opposite of the *reference point* justification code - - Other types: ``"MC"`` (middle center) for map rose and scale, ``"BL"`` + - ``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** @@ -110,7 +110,9 @@ class Position(BaseParam): >>> fig = pygmt.Figure() >>> fig.basemap(region=[0, 10, 0, 10], projection="X10c", frame=True) >>> fig.logo( - ... position=Position((3, 3), type="mapcoords", anchor="ML", offset=(0.2, 0.2)), + ... position=Position( + ... (3, 3), cstype="mapcoords", anchor="ML", offset=(0.2, 0.2) + ... ), ... box=True, ... ) >>> fig.show() @@ -119,20 +121,20 @@ class Position(BaseParam): >>> fig = pygmt.Figure() >>> fig.basemap(region=[0, 10, 0, 10], projection="X10c", frame=True) - >>> fig.logo(position=Position("TL", type="inside", offset="0.2c"), box=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 ``type``: + #: Location of the reference point on the plot. The format depends on ``cstype``: #: - #: - ``type="mapcoords"``: (*longitude*, *latitude*) - #: - ``type="plotcoords"``: (*x*, *y*) with plot units - #: - ``type="boxcoords"``: (*nx*, *ny*) - #: - ``type="inside"`` or ``"outside"``: + #: - ``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 - #: Type of the reference point. Valid values are: + #: cstype of the reference point. Valid values are: #: #: - ``"mapcoords"``: Map/Data coordinates #: - ``"plotcoords"``: Plot coordinates @@ -141,13 +143,13 @@ class Position(BaseParam): #: #: If not specified, defaults to ``"inside"`` if ``refpoint`` is a justification #: code; otherwise defaults to ``"plotcoords"``. - type: ( + 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 ``type`` (see above). + #: 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*). @@ -162,12 +164,12 @@ def _validate(self): f"{v}{h}" for v in "TMB" for h in "LCR" } - # Default to "inside" if type is not specified and location is an anchor code. - if self.type is None: - self.type = "inside" if isinstance(self.refpoint, str) else "plotcoords" + # 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 type. - match self.type: + # 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( @@ -194,8 +196,8 @@ def _validate(self): def _aliases(self): return [ Alias( - self.type, - name="type", + self.cstype, + name="cstype", mapping={ "mapcoords": "g", "boxcoords": "n", diff --git a/pygmt/tests/test_params_position.py b/pygmt/tests/test_params_position.py index 484ec1b63d0..a9d67de36c5 100644 --- a/pygmt/tests/test_params_position.py +++ b/pygmt/tests/test_params_position.py @@ -7,28 +7,28 @@ from pygmt.params import Position -def test_params_position_types(): +def test_params_position_cstypes(): """ - Test the Position class with different types of coordinate systems. + Test the Position class with different cstypes of coordinate systems. """ - # Default type is "plotcoords" for (x,y) and "inside" for anchor codes. + # 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), type="mapcoords")) == "g10/20" - assert str(Position((0.1, 0.2), type="boxcoords")) == "n0.1/0.2" - assert str(Position(("5c", "3c"), type="plotcoords")) == "x5c/3c" - assert str(Position("MR", type="inside")) == "jMR" - assert str(Position("BR", type="outside")) == "JBR" + 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), type="mapcoords", anchor="TL")) == "g10/20+jTL" - assert str(Position((10, 20), type="mapcoords", offset=(1, 2))) == "g10/20+o1/2" - pos = Position("TL", type="inside", anchor="MC", offset=("1c", "2c")) + 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" @@ -38,17 +38,17 @@ def test_params_position_invalid_location(): Test that invalid location inputs raise GMTValueError. """ with pytest.raises(GMTValueError): - Position("invalid", type="mapcoords") + Position("invalid", cstype="mapcoords") with pytest.raises(GMTValueError): - Position((1, 2, 3), type="mapcoords") + Position((1, 2, 3), cstype="mapcoords") with pytest.raises(GMTValueError): - Position(5, type="plotcoords") + Position(5, cstype="plotcoords") with pytest.raises(GMTValueError): - Position((0.5,), type="boxcoords") + Position((0.5,), cstype="boxcoords") with pytest.raises(GMTValueError): - Position((10, 20), type="inside") + Position((10, 20), cstype="inside") with pytest.raises(GMTValueError): - Position("TT", type="outside") + Position("TT", cstype="outside") def test_params_position_invalid_anchor(): @@ -56,4 +56,4 @@ def test_params_position_invalid_anchor(): Test that invalid anchor inputs raise GMTValueError. """ with pytest.raises(GMTValueError): - Position((10, 20), type="mapcoords", anchor="XX") + Position((10, 20), cstype="mapcoords", anchor="XX") From 5bf51be0eb5135b231a486a571746570959a9736 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Tue, 9 Dec 2025 22:21:29 +0800 Subject: [PATCH 49/57] Fix typos --- pygmt/src/legend.py | 2 +- pygmt/tests/test_legend.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pygmt/src/legend.py b/pygmt/src/legend.py index 8d56ded4ea5..715a0cffbae 100644 --- a/pygmt/src/legend.py +++ b/pygmt/src/legend.py @@ -75,7 +75,7 @@ def legend( # noqa: PLR0913 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.enums.Position` for details. + :class:`pygmt.params.Position` for details. width height Specify the width and height of the legend box in plot coordinates (inches, cm, diff --git a/pygmt/tests/test_legend.py b/pygmt/tests/test_legend.py index 20c8cdf9976..c860bb88e85 100644 --- a/pygmt/tests/test_legend.py +++ b/pygmt/tests/test_legend.py @@ -74,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=Position("TR", type="outside", anchor="TR")) + fig.legend(position=Position("TR", cstype="outside", anchor="TR")) return fig From b45fa78b5e8b4b46e6929a87dbd24c1283d56598 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Tue, 9 Dec 2025 22:23:06 +0800 Subject: [PATCH 50/57] Fix CM to MC --- pygmt/tests/test_legend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygmt/tests/test_legend.py b/pygmt/tests/test_legend.py index c860bb88e85..2ef775b4f21 100644 --- a/pygmt/tests/test_legend.py +++ b/pygmt/tests/test_legend.py @@ -168,7 +168,7 @@ def test_legend_position_deprecated_syntax(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+jCM+w5i") + fig.legend(spec, position="JMC+jMC+w5i") return fig From 36f25193f4e7ddfd1d4853f842572f0f30740e17 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Wed, 10 Dec 2025 22:05:33 +0800 Subject: [PATCH 51/57] Fix typos --- pygmt/tests/test_legend.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pygmt/tests/test_legend.py b/pygmt/tests/test_legend.py index 2ef775b4f21..01eef99fd6c 100644 --- a/pygmt/tests/test_legend.py +++ b/pygmt/tests/test_legend.py @@ -7,7 +7,7 @@ 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 @@ -179,9 +179,9 @@ def test_legend_position_mixed_syntax(legend_spec): spec = io.StringIO(legend_spec) fig = Figure() fig.basemap(projection="x6i", region=[0, 1, 0, 1], frame=True) - with pytest.raises(GMTTypeError): + with pytest.raises(GMTInvalidInput): fig.legend(spec, position="JMC", width="5i") - with pytest.raises(GMTTypeError): + with pytest.raises(GMTInvalidInput): fig.legend(spec, position="JMC", height="5i") - with pytest.raises(GMTTypeError): + with pytest.raises(GMTInvalidInput): fig.legend(spec, position="JMC", line_spacing=2.0) From 19e00c4bfe0af18de3aa9264f2b6ff7bc3547581 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Thu, 11 Dec 2025 11:48:13 +0800 Subject: [PATCH 52/57] Update pygmt/tests/test_legend.py [skip ci] --- pygmt/tests/test_legend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygmt/tests/test_legend.py b/pygmt/tests/test_legend.py index 01eef99fd6c..63395112f7a 100644 --- a/pygmt/tests/test_legend.py +++ b/pygmt/tests/test_legend.py @@ -89,7 +89,7 @@ def test_legend_specfile(legend_spec): fig.basemap(projection="x6i", region=[0, 1, 0, 1], frame=True) fig.legend( specfile.name, - position=Position("MC", cstype="outside", anchor="CM"), + position=Position("MC", cstype="outside", anchor="MC"), width="5i", ) return fig From 2f58300d1a911b48b97fbb03b2afbee0b5158367 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Thu, 11 Dec 2025 11:48:28 +0800 Subject: [PATCH 53/57] Update pygmt/tests/test_legend.py [skip ci] --- pygmt/tests/test_legend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygmt/tests/test_legend.py b/pygmt/tests/test_legend.py index 63395112f7a..7e7ffd39755 100644 --- a/pygmt/tests/test_legend.py +++ b/pygmt/tests/test_legend.py @@ -103,7 +103,7 @@ 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=Position("MC", cstype="outside", anchor="CM"), width="5i") + fig.legend(spec, position=Position("MC", cstype="outside", anchor="MC"), width="5i") return fig From 5165727696b56bb2aaa2a1135f14e315d32ad76e Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Thu, 18 Dec 2025 10:27:22 +0800 Subject: [PATCH 54/57] Refactor with the _parse_position function --- pygmt/src/legend.py | 61 +++++++++++++++++++++++---------------------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/pygmt/src/legend.py b/pygmt/src/legend.py index 715a0cffbae..89b7a0e747c 100644 --- a/pygmt/src/legend.py +++ b/pygmt/src/legend.py @@ -6,19 +6,20 @@ from collections.abc import Sequence from typing import Literal -from pygmt._typing import PathLike +from pygmt._typing import AnchorCode, PathLike from pygmt.alias import Alias, AliasSystem from pygmt.clib import Session -from pygmt.exceptions import GMTInvalidInput, GMTTypeError +from pygmt.exceptions import GMTTypeError from pygmt.helpers import build_arg_list, data_kind, fmt_docstring, is_nonstr_iter from pygmt.params import Box, Position +from pygmt.src._common import _parse_position @fmt_docstring def legend( # noqa: PLR0913 self, spec: PathLike | io.StringIO | None = None, - position: Position | None = None, + position: Position | Sequence[float | str] | AnchorCode | None = None, width: float | str | None = None, height: float | str | None = None, line_spacing: float | None = None, @@ -30,8 +31,8 @@ def legend( # noqa: PLR0913 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, ): """ @@ -73,25 +74,31 @@ def legend( # noqa: PLR0913 See :gmt-docs:`legend.html` for the definition of the legend specification. 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. + Position of the legend 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 top right corner inside the plot with a 0.2-cm + offset. 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 + Width and height of the legend box. 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]. + 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 @@ -109,22 +116,16 @@ def legend( # noqa: PLR0913 """ self._activate_figure() - # 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 box if both position and box are not given. + # The default position will be set later in _parse_position(). + if kwargs.get("D", position) is None and kwargs.get("F", box) is False: + box = Box(pen="1p", fill="white") - # 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 + position = _parse_position( + position, + kwdict={"width": width, "height": height, "line_spacing": line_spacing}, + default=Position("TR", offset=0.2), # Default to TR. + ) # Set default width to 0 if height is given but width is not. if height is not None and width is None: From a3176e677dffd572698be5427c70fcead8e7c372 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Thu, 18 Dec 2025 10:30:30 +0800 Subject: [PATCH 55/57] Use shortcut syntax for position --- examples/tutorials/advanced/legends.py | 5 +---- pygmt/tests/test_legend.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/examples/tutorials/advanced/legends.py b/examples/tutorials/advanced/legends.py index 1114b85b535..d55ae388187 100644 --- a/examples/tutorials/advanced/legends.py +++ b/examples/tutorials/advanced/legends.py @@ -150,10 +150,7 @@ 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=Position("MC"), - width="9c", - box=Box(pen="1p,gray50", fill="gray95"), + spec=spec_io, position="MC", width="9c", box=Box(pen="1p,gray50", fill="gray95") ) fig.show() diff --git a/pygmt/tests/test_legend.py b/pygmt/tests/test_legend.py index 7e7ffd39755..e24e038dc81 100644 --- a/pygmt/tests/test_legend.py +++ b/pygmt/tests/test_legend.py @@ -126,24 +126,24 @@ def test_legend_width_height(): 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) + fig.legend(spec, position="TL", box=True) # Width only - fig.legend(spec, position=Position("TC"), width="6c", box=True) + fig.legend(spec, position="TC", width="6c", box=True) # Width as percentage of plot width - fig.legend(spec, position=Position("TR"), width="25%", box=True) + fig.legend(spec, position="TR", width="25%", box=True) # Height only, with automatic width - fig.legend(spec, position=Position("ML"), height="4.5c", box=True) + fig.legend(spec, position="ML", height="4.5c", box=True) # Height as percentage of legend width - fig.legend(spec, position=Position("BL"), height="75%", box=True) + fig.legend(spec, position="BL", height="75%", box=True) # Both width and height - fig.legend(spec, position=Position("MC"), width="6c", height="4.5c", box=True) + fig.legend(spec, 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) + fig.legend(spec, 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) + fig.legend(spec, position="BR", width="25%", height="75%", box=True) return fig From 25eb22506720f3d6d7dcde3c3ec080927ee3ece8 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Thu, 18 Dec 2025 23:12:52 +0800 Subject: [PATCH 56/57] Improve a few comments --- pygmt/src/legend.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pygmt/src/legend.py b/pygmt/src/legend.py index 89b7a0e747c..5d1fc63147a 100644 --- a/pygmt/src/legend.py +++ b/pygmt/src/legend.py @@ -124,10 +124,10 @@ def legend( # noqa: PLR0913 position = _parse_position( position, kwdict={"width": width, "height": height, "line_spacing": line_spacing}, - default=Position("TR", offset=0.2), # Default to TR. + default=Position("TR", offset=0.2), # Default to TR with 0.2-cm offset. ) - # Set default width to 0 if height is given but width is not. + # Set default width to 0 (auto calculated) if height is given but width is not. if height is not None and width is None: width = 0 From bca06d8c94e1ce009ecfe6b53fc3984dcc17d894 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Thu, 18 Dec 2025 23:18:41 +0800 Subject: [PATCH 57/57] Simplify a few tests --- pygmt/src/legend.py | 2 +- pygmt/tests/test_legend.py | 17 ++++++----------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/pygmt/src/legend.py b/pygmt/src/legend.py index 5d1fc63147a..21376da083f 100644 --- a/pygmt/src/legend.py +++ b/pygmt/src/legend.py @@ -127,7 +127,7 @@ def legend( # noqa: PLR0913 default=Position("TR", offset=0.2), # Default to TR with 0.2-cm offset. ) - # Set default width to 0 (auto calculated) if height is given but width is not. + # Set width to 0 (auto calculated) if height is given but width is not. if height is not None and width is None: width = 0 diff --git a/pygmt/tests/test_legend.py b/pygmt/tests/test_legend.py index e24e038dc81..e43b1b39cad 100644 --- a/pygmt/tests/test_legend.py +++ b/pygmt/tests/test_legend.py @@ -9,7 +9,6 @@ from pygmt import Figure from pygmt.exceptions import GMTInvalidInput, GMTTypeError from pygmt.helpers import GMTTempFile -from pygmt.params import Position @pytest.fixture(scope="module", name="legend_spec") @@ -74,7 +73,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=Position("TR", cstype="outside", anchor="TR")) + fig.legend(position="TR") return fig @@ -87,11 +86,7 @@ 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=Position("MC", cstype="outside", anchor="MC"), - width="5i", - ) + fig.legend(specfile.name, position="MC", width="5i") return fig @@ -103,7 +98,7 @@ 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=Position("MC", cstype="outside", anchor="MC"), width="5i") + fig.legend(spec, position="MC", width="5i") return fig @@ -180,8 +175,8 @@ def test_legend_position_mixed_syntax(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") + fig.legend(spec, position="jTL", width="5i") with pytest.raises(GMTInvalidInput): - fig.legend(spec, position="JMC", height="5i") + fig.legend(spec, position="jTL", height="5i") with pytest.raises(GMTInvalidInput): - fig.legend(spec, position="JMC", line_spacing=2.0) + fig.legend(spec, position="jTL", line_spacing=2.0)