Skip to content
Open
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
eec0fb7
Initial implemention of the Position class
seisman Nov 16, 2025
69d4d42
Merge branch 'main' into params/position
seisman Nov 20, 2025
539f66f
Fix styling
seisman Nov 20, 2025
97f015f
Add tests and improve docstrings
seisman Nov 23, 2025
854804e
Add to API doc
seisman Nov 23, 2025
6b55dde
Add an inline doctest
seisman Nov 23, 2025
3d629cb
position is not required
seisman Nov 23, 2025
576b822
Default to plotcoords
seisman Nov 23, 2025
f54bec9
Updates
seisman Nov 23, 2025
5a2e20b
Merge branch 'main' into params/position
seisman Nov 24, 2025
2c59b7f
Improve the checking in Figure.logo
seisman Nov 24, 2025
d0b62ec
Merge branch 'main' into params/position
seisman Nov 24, 2025
fe18c87
Improve docstrings
seisman Nov 24, 2025
038161b
Improve docstrings
seisman Nov 24, 2025
a6e75bc
Improve docstrings
seisman Nov 25, 2025
3ec8c06
Improve docstrings
seisman Nov 25, 2025
339ce00
Improve docstrings
seisman Nov 25, 2025
4d616de
Revert changes in logo.py
seisman Nov 25, 2025
ad9e0aa
Simplify tests
seisman Nov 25, 2025
b084e5f
Validate values
seisman Nov 25, 2025
d4ad6e0
type will be validated in the Alias System
seisman Nov 25, 2025
7dc37bd
Use the image from the GMT docs
seisman Nov 25, 2025
bfecb2d
Fix width and alignment
seisman Nov 25, 2025
18b90b3
Improve docstrings
seisman Nov 25, 2025
6b1b5bc
Remove unneeded blank lines
seisman Nov 25, 2025
1eae742
Improve docstrings
seisman Nov 25, 2025
721b46f
Validate anchor code
seisman Nov 25, 2025
669b16d
Merge branch 'main' into params/position
seisman Nov 26, 2025
2a38111
Merge branch 'main' into params/position
seisman Nov 26, 2025
0f9ed6c
offset can be a single value
seisman Nov 26, 2025
7d1b076
Merge branch 'main' into params/position
seisman Nov 29, 2025
a779431
Merge branch 'main' into params/position
seisman Dec 1, 2025
2a9cc92
Merge branch 'main' into params/position
seisman Dec 4, 2025
10a0dfb
Use is_nonstr_iter to check the location parameter
seisman Dec 4, 2025
6f1c2c4
Merge remote-tracking branch 'origin/params/position' into params/pos…
seisman Dec 4, 2025
c27213f
Fix a typo [skip ci]
seisman Dec 4, 2025
d47aaeb
Fix a typo [skip ci]
seisman Dec 4, 2025
7fc6ffc
Fix the wrong logic in checking location
seisman Dec 4, 2025
d82f4ba
Add a tests for passing a single value to offset
seisman Dec 4, 2025
5d29e66
Merge branch 'main' into params/position
seisman Dec 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/api/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ Class-style Parameters

Box
Pattern
Position

Enums
-----
Expand Down
1 change: 1 addition & 0 deletions pygmt/params/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@

from pygmt.params.box import Box
from pygmt.params.pattern import Pattern
from pygmt.params.position import Position
209 changes: 209 additions & 0 deletions pygmt/params/position.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
"""
The Position class for positioning GMT embellishments.
"""

import dataclasses
from collections.abc import Sequence
from typing import Literal

from pygmt._typing import AnchorCode
from pygmt.alias import Alias
from pygmt.exceptions import GMTValueError
from pygmt.params.base import BaseParam


@dataclasses.dataclass(repr=False)
class Position(BaseParam):
"""
Class for positioning embellishments on a plot.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


.. figure:: https://docs.generic-mapping-tools.org/dev/_images/GMT_anchor.png
:width: 600 px
:align: center

The placement of a GMT embellishment (represented by a green rectangle) in
relation to the underlying plot (represented by a bisque rectangle).

This class provides flexible positioning for GMT embellishments (e.g., logo, scale,
rose) by defining a *reference point* on the plot and an *anchor point* on the
embellishment. The embellishment is positioned so these two points overlap.

**Conceptual Model**

Think of it like dropping an anchor from a boat:

1. The boat navigates to the *reference point* (a location on the plot)
2. The *anchor point* (a specific point on the embellishment) is aligned with the
*reference point*
3. The embellishment is "dropped" at that position

**Reference Point**

The *reference point* can be specified in five different ways using the ``type`` and
``location`` attributes:

``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"``.

``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 of the bounding box of the current plot. Specify
``location`` as (*nx*, *ny*). Useful for positioning relative to plot dimensions
without units.
Comment on lines +60 to +64
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be noted that, although the GMT documentation says that nx/ny are values in 0-1, but actually other values are also supported. For example:

gmt begin map
gmt basemap -R0/10/0/5 -JX10c/5c -Baf
gmt logo -Dn-1/-1
gmt end show
Image


**Example:** ``location=(0.2, 0.1), type="boxcoords"``

``type="inside"`` Inside Plot
Select one of the nine :doc:`justification codes </techref/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"]

``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"]

**Anchor Point**

The *anchor point* determines which part of the embellishment aligns with the
*reference point*. It uses one of nine
:doc:`justification codes </techref/justification_codes>`.

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"``
(bottom-left) for other embellishments

**Offset**

The ``offset`` parameter shifts the *anchor point* from its default position.
Offsets are applied to the projected plot coordinates, with positive values moving
in the direction indicated by the *anchor point*'s justification code. It should be
a single value (applied to both x and y) or as (*offset_x*, *offset_y*).

Examples
--------
Position the GMT logo at map coordinates (3, 3) with the logo's middle-left point as
the anchor, offset by (0.2, 0.2):

>>> import pygmt
>>> from pygmt.params import Position
>>> fig = pygmt.Figure()
>>> fig.basemap(region=[0, 10, 0, 10], projection="X10c", frame=True)
>>> fig.logo(
... position=Position((3, 3), type="mapcoords", anchor="ML", offset=(0.2, 0.2)),
... box=True,
... )
>>> fig.show()

Position the GMT logo at the top-left corner inside the plot:

>>> fig = pygmt.Figure()
>>> fig.basemap(region=[0, 10, 0, 10], projection="X10c", frame=True)
>>> fig.logo(position=Position("TL", type="inside", offset="0.2c"), box=True)
>>> fig.show()
"""

#: Location of the reference point on the plot. The format depends on ``type``:
#:
#: - ``type="mapcoords"``: (*longitude*, *latitude*)
#: - ``type="plotcoords"``: (*x*, *y*) with plot units
#: - ``type="boxcoords"``: (*nx*, *ny*)
#: - ``type="inside"`` or ``"outside"``:
#: :doc:`2-character justification codes </techref/justification_codes>`
location: Sequence[float | str] | AnchorCode
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

position or location?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think location is better as the parameter is already called position and we overall will have the Position class.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring above calls this 'reference point', so how about something like ref or refpt?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe refpoint?

In fact, I'd prefer to make it a positional-only parameter, so it will be used like Position("TL", type="inside"), Position((1, 2)).

In this case, the specific parameter name doesn't really matter, though dataclasses doesn't support positional-only attributes.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I did think of positional-only, but a little tricky with dataclasses as you said. Ok to got with refpoint, but let's see what @yvonnefroehlich thinks.


#: Types of the reference point. Valid values are:
#:
#: - ``"mapcoords"``: Map/Data coordinates
#: - ``"plotcoords"``: Plot coordinates
#: - ``"boxcoords"``: Normalized coordinates
#: - ``"inside"`` or ``"outside"``: Justification codes
#:
#: If not specified, defaults to ``"inside"`` if ``location`` is a justification
#: code; otherwise defaults to ``"plotcoords"``.
Comment on lines +142 to +143
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In GMT, the default is "plotcoords", i.e., the code x. Thus, -D1/1 is equivalent to -Dx1/1.

When location is an anchor code, we know that "plotcoords" makes no sense (e.g., -DxTL). type should be either "inside" or "outside". Here, I choose "inside" as the default.

type: (
Literal["mapcoords", "inside", "outside", "boxcoords", "plotcoords"] | None
) = None

#: Anchor point on the embellishment using a
#: :doc:`2-character justification codes </techref/justification_codes>`.
#: If ``None``, defaults are applied based on ``type`` (see above).
anchor: AnchorCode | None = None

#: Offset for the anchor point as a single value or (*offset_x*, *offset_y*).
#: If a single value is given, the offset is applied to both x and y directions.
offset: 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"
}
Comment on lines +161 to +163
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably can reuse these codes to validate anchor codes in other wrappers, e.g., the justify parameter in the text module. But I feel we can do it in the future.


# 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.",
)
# Validate the anchor if specified.
if self.anchor is not None and self.anchor not in _valid_anchors:
raise GMTValueError(
self.anchor,
description="anchor point",
reason="Expect a valid 2-character justification code.",
)

@property
def _aliases(self):
return [
Alias(
self.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", prefix="+j"),
Alias(self.offset, name="offset", prefix="+o", sep="/", size=2),
]
58 changes: 58 additions & 0 deletions pygmt/tests/test_params_position.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""
Test the Position class.
"""

import pytest
from pygmt.exceptions import GMTValueError
from pygmt.params import Position


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("MR", type="inside")) == "jMR"
assert str(Position("BR", type="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(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((1, 2, 3), 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")


def test_params_position_invalid_anchor():
"""
Test that invalid anchor inputs raise GMTValueError.
"""
with pytest.raises(GMTValueError):
Position((10, 20), type="mapcoords", anchor="XX")
Loading