Skip to content

Commit 38b4d07

Browse files
Add the Position class for GMT embellishment placement (#4212)
Co-authored-by: Yvonne Fröhlich <94163266+yvonnefroehlich@users.noreply.github.com>
1 parent 4a0e511 commit 38b4d07

File tree

4 files changed

+273
-0
lines changed

4 files changed

+273
-0
lines changed

doc/api/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ Class-style Parameters
214214

215215
Box
216216
Pattern
217+
Position
217218

218219
Enums
219220
-----

pygmt/params/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@
44

55
from pygmt.params.box import Box
66
from pygmt.params.pattern import Pattern
7+
from pygmt.params.position import Position

pygmt/params/position.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
"""
2+
The Position class for positioning GMT embellishments.
3+
"""
4+
5+
import dataclasses
6+
from collections.abc import Sequence
7+
from typing import Literal
8+
9+
from pygmt._typing import AnchorCode
10+
from pygmt.alias import Alias
11+
from pygmt.exceptions import GMTValueError
12+
from pygmt.helpers import is_nonstr_iter
13+
from pygmt.params.base import BaseParam
14+
15+
16+
@dataclasses.dataclass(repr=False)
17+
class Position(BaseParam):
18+
"""
19+
Class for positioning embellishments on a plot.
20+
21+
.. figure:: https://docs.generic-mapping-tools.org/dev/_images/GMT_anchor.png
22+
:width: 600 px
23+
:align: center
24+
25+
The placement of a GMT embellishment (represented by a green rectangle) in
26+
relation to the underlying plot (represented by a bisque rectangle).
27+
28+
This class provides flexible positioning for GMT embellishments (e.g., logo, scale,
29+
rose) by defining a *reference point* on the plot and an *anchor point* on the
30+
embellishment. The embellishment is positioned so these two points overlap.
31+
32+
**Conceptual Model**
33+
34+
Think of it like dropping an anchor from a boat:
35+
36+
1. The boat navigates to the *reference point* (a location on the plot)
37+
2. The *anchor point* (a specific point on the embellishment) is aligned with the
38+
*reference point*
39+
3. The embellishment is "dropped" at that position
40+
41+
**Reference Point**
42+
43+
The *reference point* can be specified in five different ways using the ``cstype``
44+
and ``refpoint`` attributes:
45+
46+
``cstype="mapcoords"`` Map Coordinates
47+
Use data/geographic coordinates. Specify ``refpoint`` as
48+
(*longitude*, *latitude*). Useful when tying the embellishment to a specific
49+
geographic location.
50+
51+
**Example:** ``refpoint=(135, 20), cstype="mapcoords"``
52+
53+
``cstype="plotcoords"`` Plot Coordinates
54+
Use plot coordinates as distances from the lower-left plot origin. Specify
55+
``refpoint`` as (*x*, *y*) with units (e.g., inches, centimeters, points).
56+
Useful for precise layout control.
57+
58+
**Example:** ``refpoint=("2c", "2.5c"), cstype="plotcoords"``
59+
60+
``cstype="boxcoords"`` Normalized Coordinates
61+
Use normalized coordinates where (0, 0) is the lower-left corner and (1, 1) is
62+
the upper-right corner of the bounding box of the current plot. Specify
63+
``refpoint`` as (*nx*, *ny*). Useful for positioning relative to plot dimensions
64+
without units.
65+
66+
**Example:** ``refpoint=(0.2, 0.1), cstype="boxcoords"``
67+
68+
``cstype="inside"`` Inside Plot
69+
Select one of the nine :doc:`justification codes </techref/justification_codes>`
70+
as the *reference point*. The *anchor point* defaults to be the same as the
71+
*reference point*, so the embellishment is placed inside the plot.
72+
73+
**Example:** ``refpoint="TL", cstype="inside"``
74+
75+
``cstype="outside"`` Outside Plot
76+
Similar to ``cstype="inside"``, but the *anchor point* defaults to the mirror
77+
opposite of the *reference point*. Useful for placing embellishments outside
78+
the plot boundaries (e.g., color bars).
79+
80+
**Example:** ``refpoint="TL", cstype="outside"``
81+
82+
**Anchor Point**
83+
84+
The *anchor point* determines which part of the embellishment aligns with the
85+
*reference point*. It uses one of nine
86+
:doc:`justification codes </techref/justification_codes>`.
87+
88+
Set ``anchor`` explicitly to override these defaults. If not set, the default
89+
*anchor* behaviors are:
90+
91+
- ``cstype="inside"``: Same as the *reference point* justification code
92+
- ``cstype="outside"``: Mirror opposite of the *reference point* justification code
93+
- Other cstypes: ``"MC"`` (middle center) for map rose and scale, ``"BL"``
94+
(bottom-left) for other embellishments
95+
96+
**Offset**
97+
98+
The ``offset`` parameter shifts the *anchor point* from its default position.
99+
Offsets are applied to the projected plot coordinates, with positive values moving
100+
in the direction indicated by the *anchor point*'s justification code. It should be
101+
a single value (applied to both x and y) or as (*offset_x*, *offset_y*).
102+
103+
Examples
104+
--------
105+
Position the GMT logo at map coordinates (3, 3) with the logo's middle-left point as
106+
the anchor, offset by (0.2, 0.2):
107+
108+
>>> import pygmt
109+
>>> from pygmt.params import Position
110+
>>> fig = pygmt.Figure()
111+
>>> fig.basemap(region=[0, 10, 0, 10], projection="X10c", frame=True)
112+
>>> fig.logo(
113+
... position=Position(
114+
... (3, 3), cstype="mapcoords", anchor="ML", offset=(0.2, 0.2)
115+
... ),
116+
... box=True,
117+
... )
118+
>>> fig.show()
119+
120+
Position the GMT logo at the top-left corner inside the plot:
121+
122+
>>> fig = pygmt.Figure()
123+
>>> fig.basemap(region=[0, 10, 0, 10], projection="X10c", frame=True)
124+
>>> fig.logo(position=Position("TL", cstype="inside", offset="0.2c"), box=True)
125+
>>> fig.show()
126+
"""
127+
128+
#: Location of the reference point on the plot. The format depends on ``cstype``:
129+
#:
130+
#: - ``cstype="mapcoords"``: (*longitude*, *latitude*)
131+
#: - ``cstype="plotcoords"``: (*x*, *y*) with plot units
132+
#: - ``cstype="boxcoords"``: (*nx*, *ny*)
133+
#: - ``cstype="inside"`` or ``"outside"``:
134+
#: :doc:`2-character justification codes </techref/justification_codes>`
135+
refpoint: Sequence[float | str] | AnchorCode
136+
137+
#: cstype of the reference point. Valid values are:
138+
#:
139+
#: - ``"mapcoords"``: Map/Data coordinates
140+
#: - ``"plotcoords"``: Plot coordinates
141+
#: - ``"boxcoords"``: Normalized coordinates
142+
#: - ``"inside"`` or ``"outside"``: Justification codes
143+
#:
144+
#: If not specified, defaults to ``"inside"`` if ``refpoint`` is a justification
145+
#: code; otherwise defaults to ``"plotcoords"``.
146+
cstype: (
147+
Literal["mapcoords", "inside", "outside", "boxcoords", "plotcoords"] | None
148+
) = None
149+
150+
#: Anchor point on the embellishment using a
151+
#: :doc:`2-character justification code </techref/justification_codes>`.
152+
#: If ``None``, defaults are applied based on ``cstype`` (see above).
153+
anchor: AnchorCode | None = None
154+
155+
#: Offset for the anchor point as a single value or (*offset_x*, *offset_y*).
156+
#: If a single value is given, the offset is applied to both x and y directions.
157+
offset: float | str | Sequence[float | str] | None = None
158+
159+
def _validate(self):
160+
"""
161+
Validate the parameters.
162+
"""
163+
_valid_anchors = {f"{h}{v}" for v in "TMB" for h in "LCR"} | {
164+
f"{v}{h}" for v in "TMB" for h in "LCR"
165+
}
166+
167+
# Default to "inside" if cstype is not specified and location is an anchor code.
168+
if self.cstype is None:
169+
self.cstype = "inside" if isinstance(self.refpoint, str) else "plotcoords"
170+
171+
# Validate the location based on cstype.
172+
match self.cstype:
173+
case "mapcoords" | "plotcoords" | "boxcoords":
174+
if not is_nonstr_iter(self.refpoint) or len(self.refpoint) != 2:
175+
raise GMTValueError(
176+
self.refpoint,
177+
description="reference point",
178+
reason="Expect a sequence of two values.",
179+
)
180+
case "inside" | "outside":
181+
if self.refpoint not in _valid_anchors:
182+
raise GMTValueError(
183+
self.refpoint,
184+
description="reference point",
185+
reason="Expect a valid 2-character justification code.",
186+
)
187+
# Validate the anchor if specified.
188+
if self.anchor is not None and self.anchor not in _valid_anchors:
189+
raise GMTValueError(
190+
self.anchor,
191+
description="anchor point",
192+
reason="Expect a valid 2-character justification code.",
193+
)
194+
195+
@property
196+
def _aliases(self):
197+
return [
198+
Alias(
199+
self.cstype,
200+
name="cstype",
201+
mapping={
202+
"mapcoords": "g",
203+
"boxcoords": "n",
204+
"plotcoords": "x",
205+
"inside": "j",
206+
"outside": "J",
207+
},
208+
),
209+
Alias(self.refpoint, name="refpoint", sep="/", size=2),
210+
Alias(self.anchor, name="anchor", prefix="+j"),
211+
Alias(self.offset, name="offset", prefix="+o", sep="/", size=2),
212+
]
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""
2+
Test the Position class.
3+
"""
4+
5+
import pytest
6+
from pygmt.exceptions import GMTValueError
7+
from pygmt.params import Position
8+
9+
10+
def test_params_position_cstypes():
11+
"""
12+
Test the Position class with different cstypes of coordinate systems.
13+
"""
14+
# Default cstype is "plotcoords" for (x,y) and "inside" for anchor codes.
15+
assert str(Position((1, 2))) == "x1/2"
16+
assert str(Position("TL")) == "jTL"
17+
18+
assert str(Position((10, 20), cstype="mapcoords")) == "g10/20"
19+
assert str(Position((0.1, 0.2), cstype="boxcoords")) == "n0.1/0.2"
20+
assert str(Position(("5c", "3c"), cstype="plotcoords")) == "x5c/3c"
21+
assert str(Position("MR", cstype="inside")) == "jMR"
22+
assert str(Position("BR", cstype="outside")) == "JBR"
23+
24+
25+
def test_params_position_anchor_offset():
26+
"""
27+
Test the Position class with anchor and offset parameters.
28+
"""
29+
assert str(Position((10, 20), cstype="mapcoords", anchor="TL")) == "g10/20+jTL"
30+
assert str(Position((10, 20), cstype="mapcoords", offset=(1, 2))) == "g10/20+o1/2"
31+
pos = Position("TL", cstype="inside", anchor="MC", offset=("1c", "2c"))
32+
assert str(pos) == "jTL+jMC+o1c/2c"
33+
assert str(Position("TL", anchor="BR", offset=0.5)) == "jTL+jBR+o0.5"
34+
35+
36+
def test_params_position_invalid_location():
37+
"""
38+
Test that invalid location inputs raise GMTValueError.
39+
"""
40+
with pytest.raises(GMTValueError):
41+
Position("invalid", cstype="mapcoords")
42+
with pytest.raises(GMTValueError):
43+
Position((1, 2, 3), cstype="mapcoords")
44+
with pytest.raises(GMTValueError):
45+
Position(5, cstype="plotcoords")
46+
with pytest.raises(GMTValueError):
47+
Position((0.5,), cstype="boxcoords")
48+
with pytest.raises(GMTValueError):
49+
Position((10, 20), cstype="inside")
50+
with pytest.raises(GMTValueError):
51+
Position("TT", cstype="outside")
52+
53+
54+
def test_params_position_invalid_anchor():
55+
"""
56+
Test that invalid anchor inputs raise GMTValueError.
57+
"""
58+
with pytest.raises(GMTValueError):
59+
Position((10, 20), cstype="mapcoords", anchor="XX")

0 commit comments

Comments
 (0)