diff --git a/doc/api/index.rst b/doc/api/index.rst index 7828a225652..ccb049f996a 100644 --- a/doc/api/index.rst +++ b/doc/api/index.rst @@ -203,6 +203,16 @@ Xarray Integration GMTBackendEntrypoint GMTDataArrayAccessor +Class-style Parameters +---------------------- + +.. currentmodule:: pygmt.params + +.. autosummary:: + :toctree: generated + + Box + Enums ----- diff --git a/examples/gallery/embellishments/inset.py b/examples/gallery/embellishments/inset.py index dd2ba4f7631..51d98b3a07a 100644 --- a/examples/gallery/embellishments/inset.py +++ b/examples/gallery/embellishments/inset.py @@ -10,6 +10,7 @@ # %% import pygmt +from pygmt.params import Box fig = pygmt.Figure() # Create the primary figure, setting the region to Madagascar, the land color @@ -19,7 +20,7 @@ # Create an inset, placing it in the Top Left (TL) corner with a width of 3.5 cm and # x- and y-offsets of 0.2 cm. The margin is set to 0, and the border is "gold" with a # pen size of 1.5 points. -with fig.inset(position="jTL+w3.5c+o0.2c", margin=0, box="+p1.5p,gold"): +with fig.inset(position="jTL+w3.5c+o0.2c", margin=0, box=Box(pen="1.5p,gold")): # Create a figure in the inset using coast. This example uses the azimuthal # orthogonal projection centered at 47E, 20S. The land color is set to # "gray" and Madagascar is highlighted in "red3". diff --git a/examples/gallery/embellishments/inset_rectangle_region.py b/examples/gallery/embellishments/inset_rectangle_region.py index e646182e6b4..9f374836771 100644 --- a/examples/gallery/embellishments/inset_rectangle_region.py +++ b/examples/gallery/embellishments/inset_rectangle_region.py @@ -10,6 +10,7 @@ # %% import pygmt +from pygmt.params import Box # Set the region of the main figure region = [137.5, 141, 34, 37] @@ -34,7 +35,7 @@ # a pen of "1p". with fig.inset( position="jBR+o0.1c", - box="+gwhite+p1p", + box=Box(fill="white", pen="1p"), region=[129, 146, 30, 46], projection="U54S/3c", ): diff --git a/examples/gallery/embellishments/scalebar.py b/examples/gallery/embellishments/scalebar.py index 1980307b602..569d3131ae5 100644 --- a/examples/gallery/embellishments/scalebar.py +++ b/examples/gallery/embellishments/scalebar.py @@ -42,6 +42,7 @@ # %% import pygmt +from pygmt.params import Box # Create a new Figure instance fig = pygmt.Figure() @@ -103,7 +104,7 @@ # Fill the box in white with a transparency of 30 percent, add a solid # outline in darkgray (gray30) with a thickness of 0.5 points, and use # rounded edges with a radius of 3 points - box="+gwhite@30+p0.5p,gray30,solid+r3p", + box=Box(fill="white@30", pen="0.5p,gray30,solid", radius="3p"), ) fig.show() diff --git a/examples/gallery/images/cross_section.py b/examples/gallery/images/cross_section.py index 816f7133c52..959b9b3759b 100644 --- a/examples/gallery/images/cross_section.py +++ b/examples/gallery/images/cross_section.py @@ -14,6 +14,7 @@ # %% import pygmt +from pygmt.params import Box # Define region of study area # lon_min, lon_max, lat_min, lat_max in degrees East and North @@ -44,9 +45,9 @@ # corner with an offset ("+o") of 0.7 centimeters and 0.3 centimeters in x- or y- # directions, respectively; move the x-label above the horizontal colorbar ("+ml") position="jBR+o0.7c/0.8c+h+w5c/0.3c+ml", - # Add a box around the colobar with a fill ("+g") in "white" color and a - # transparency ("@") of 30 % and with a 0.8-points thick, black, outline ("+p") - box="+gwhite@30+p0.8p,black", + # Add a box around the colobar, filled in white and a 30 % transparency, with a + # 0.8-points thick, black, outline. + box=Box(pen="0.8p,black", fill="white@30"), # Add x- and y-labels ("+l") frame=["x+lElevation", "y+lm"], ) diff --git a/examples/gallery/lines/hlines_vlines.py b/examples/gallery/lines/hlines_vlines.py index ec0d73dddab..19049c71d5a 100644 --- a/examples/gallery/lines/hlines_vlines.py +++ b/examples/gallery/lines/hlines_vlines.py @@ -12,6 +12,7 @@ # In Cartesian coordinate systems lines are plotted as straight lines. import pygmt +from pygmt.params import Box fig = pygmt.Figure() @@ -31,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="+gwhite+p1p") +fig.legend(position="JBR+jBR+o0.2c", box=Box(pen="1p", fill="white")) fig.shift_origin(xshift="w+2c") diff --git a/examples/tutorials/advanced/insets.py b/examples/tutorials/advanced/insets.py index 49d6ba2e631..18aa618e882 100644 --- a/examples/tutorials/advanced/insets.py +++ b/examples/tutorials/advanced/insets.py @@ -10,6 +10,7 @@ # %% import pygmt +from pygmt.params import Box # %% # Prior to creating an inset figure, a larger figure must first be plotted. In @@ -48,7 +49,7 @@ water="lightblue", frame="a", ) -with fig.inset(position="jBL+w3c", box="+pblack+glightred"): +with fig.inset(position="jBL+w3c", box=Box(pen="black", fill="lightred")): # pass is used to exit the with statement as no plotting methods are # called pass @@ -72,7 +73,7 @@ water="lightblue", frame="a", ) -with fig.inset(position="jBL+w3c+o0.5c/0.2c", box="+pblack+glightred"): +with fig.inset(position="jBL+w3c+o0.5c/0.2c", box=Box(pen="black", fill="lightred")): pass fig.show() @@ -97,7 +98,7 @@ # parameters. with fig.inset( position="jBL+o0.5c/0.2c", - box="+pblack", + box=Box(pen="black"), region=[-80, -65, 35, 50], projection="M3c", ): diff --git a/examples/tutorials/advanced/legends.py b/examples/tutorials/advanced/legends.py index 15598bc7343..1870e3a87d9 100644 --- a/examples/tutorials/advanced/legends.py +++ b/examples/tutorials/advanced/legends.py @@ -10,6 +10,7 @@ import io import pygmt +from pygmt.params import Box # %% # Create an auto-legend @@ -89,8 +90,8 @@ fig.plot(x=[-3, 3], y=[-2, -2], pen="darkred", label="darkred line") # Add a box with a 2-points thick blue, solid outline and a white fill with a -# transparency of 70 percentage ("@30"). -fig.legend(position="jTL+o0.3c/0.2c", box="+p2p,blue+gwhite@30") +# transparency of 30 percent ("@30"). +fig.legend(position="jTL+o0.3c/0.2c", box=Box(pen="2p,blue", fill="white@30")) fig.show() @@ -152,7 +153,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="jMC+w9c", box="+p1p,gray50+ggray95") +fig.legend(spec=spec_io, position="jMC+w9c", box=Box(pen="1p,gray50", fill="gray95")) fig.show() diff --git a/pygmt/params/__init__.py b/pygmt/params/__init__.py new file mode 100644 index 00000000000..f2904afba94 --- /dev/null +++ b/pygmt/params/__init__.py @@ -0,0 +1,5 @@ +""" +Classes for common parameters in PyGMT. +""" + +from pygmt.params.box import Box diff --git a/pygmt/params/base.py b/pygmt/params/base.py new file mode 100644 index 00000000000..b85591f58f3 --- /dev/null +++ b/pygmt/params/base.py @@ -0,0 +1,97 @@ +""" +Base class for common parameters shared in PyGMT. +""" + +from abc import ABC, abstractmethod + + +class BaseParam(ABC): + """ + Base class for parameters in PyGMT. + + To define a new parameter class, inherit from this class and define the attributes + that correspond to the parameters you want to include. + + The class should also implement the ``_aliases`` property, which returns a list of + ``Alias`` objects. Each ``Alias`` object represents a parameter and its value, and + the ``__str__`` method will concatenate these values into a single string that can + be passed to GMT. + + Optionally, you can override the ``_validate`` method to perform any necessary + validation on the parameters after initialization. + + Examples + -------- + >>> from typing import Any + >>> import dataclasses + >>> from pygmt.params.base import BaseParam + >>> from pygmt.alias import Alias + >>> + >>> @dataclasses.dataclass(repr=False) + ... class Test(BaseParam): + ... par1: Any = None + ... par2: Any = None + ... par3: Any = None + ... + ... @property + ... def _aliases(self): + ... return [ + ... Alias(self.par1), + ... Alias(self.par2, prefix="+a"), + ... Alias(self.par3, prefix="+b", sep="/"), + ... ] + + >>> var = Test(par1="val1") + >>> str(var) + 'val1' + >>> repr(var) + "Test(par1='val1')" + + >>> var = Test(par1="val1", par2="val2", par3=("val3a", "val3b")) + >>> str(var) + 'val1+aval2+bval3a/val3b' + >>> repr(var) + "Test(par1='val1', par2='val2', par3=('val3a', 'val3b'))" + """ + + def __post_init__(self): + """ + Post-initialization method to _validate the _aliases property. + """ + self._validate() + + def _validate(self): # noqa: B027 + """ + Validate the parameters of the object. + + Optional method but can be overridden in subclasses to perform any necessary + validation on the parameters. + """ + + @property + @abstractmethod + def _aliases(self): + """ + List of Alias objects representing the parameters of this class. + + Must be implemented in subclasses to define the parameters and their aliases. + """ + + def __str__(self): + """ + String representation of the object that can be passed to GMT directly. + """ + return "".join( + [alias._value for alias in self._aliases if alias._value is not None] + ) + + def __repr__(self): + """ + String representation of the object. + """ + params = ", ".join( + f"{k}={v!r}" + for k, v in vars(self).items() + if v is not None and v is not False + ) + return f"{self.__class__.__name__}({params})" diff --git a/pygmt/params/box.py b/pygmt/params/box.py new file mode 100644 index 00000000000..280dddac1e0 --- /dev/null +++ b/pygmt/params/box.py @@ -0,0 +1,112 @@ +""" +The Box class for specifying the box around GMT embellishments. +""" + +import dataclasses +from collections.abc import Sequence + +from pygmt.alias import Alias +from pygmt.exceptions import GMTInvalidInput, GMTValueError +from pygmt.helpers import is_nonstr_iter +from pygmt.params.base import BaseParam + +__doctest_skip__ = ["Box"] + + +@dataclasses.dataclass(repr=False) +class Box(BaseParam): + """ + Class for specifying the box around GMT embellishments. + + Attributes + ---------- + clearance + Set clearances between the embellishment and the box border. It can be either a + scalar value or a sequence of two/four values. + + - a scalar value means a uniform clearance in all four directions. + - a sequence of two values means separate clearances in x- and y-directions. + - a sequence of four values means separate clearances for left/right/bottom/top. + fill + Fill for the box [Default is no fill]. + inner_gap + Gap between the outer and inner borders [Default is ``"2p"``]. + inner_pen + Pen attributes for the inner border [Default to :gmt-term:`MAP_DEFAULT_PEN`]. + pen + Pen attributes for the box outline. + radius + Draw a rounded rectangular border instead of sharp. Passing a value with unit + to control the corner radius [Default is ``"6p"``]. + shade_offset + Place an offset background shaded region behind the box. A sequence of two + values (dx, dy) indicates the shift relative to the foreground frame [Default is + ``("4p", "-4p")``]. + shade_fill + Fill for the shaded region [Default is ``"gray50"``]. + + Examples + -------- + >>> import pygmt + >>> from pygmt.params import Box + >>> fig = pygmt.Figure() + >>> fig.logo(box=Box(pen="1p", radius="5p", shade_offset=("5p", "5p"))) + >>> fig.show() + """ + + clearance: float | str | Sequence[float | str] | None = None + fill: str | None = None + inner_gap: float | str | None = None + inner_pen: str | None = None + pen: str | None = None + radius: str | bool = False + shade_offset: Sequence[float | str] | None = None + shade_fill: str | None = None + + def _validate(self): + """ + Validate the parameters. + """ + # inner_pen is required when inner_gap is set. + if self.inner_gap is not None and self.inner_pen is None: + msg = "Parameter 'inner_pen' is required when 'inner_gap' is set." + raise GMTInvalidInput(msg) + + # shade_offset must be a sequence of two values or None. + if self.shade_offset and not ( + is_nonstr_iter(self.shade_offset) and len(self.shade_offset) == 2 + ): + raise GMTValueError( + self.shade_offset, + description="value for parameter 'shade_offset'", + reason="Must be a sequence of two values (dx, dy) or None.", + ) + + @property + def _innerborder(self) -> list[str | float] | None: + """ + Inner border of the box, formatted as a list of 1-2 values, or None. + """ + return [v for v in (self.inner_gap, self.inner_pen) if v is not None] or None + + @property + def _shade(self) -> list[str | float] | None: + """ + Shading for the box, formatted as a list of 1-3 values, or None. + """ + _shade_offset = self.shade_offset or [] + return [v for v in (*_shade_offset, self.shade_fill) if v is not None] or None + + @property + def _aliases(self): + """ + Aliases for the parameter. + """ + return [ + Alias(self.clearance, name="clearance", prefix="+c", sep="/", size=(2, 4)), + Alias(self.fill, name="fill", prefix="+g"), + Alias(self._innerborder, name="inner_gap/inner_pen", prefix="+i", sep="/"), + Alias(self.pen, name="pen", prefix="+p"), + Alias(self.radius, name="radius", prefix="+r"), + Alias(self._shade, name="shade_offset/shade_fill", prefix="+s", sep="/"), + ] diff --git a/pygmt/src/inset.py b/pygmt/src/inset.py index ad0195875e8..030d7a26d0c 100644 --- a/pygmt/src/inset.py +++ b/pygmt/src/inset.py @@ -123,13 +123,14 @@ def inset( Examples -------- >>> import pygmt + >>> from pygmt.params import Box >>> >>> # Create the larger figure >>> fig = pygmt.Figure() >>> fig.coast(region="MG+r2", water="lightblue", shorelines="thin") >>> # Use a "with" statement to initialize the inset context manager - >>> # Setting the position to top left and a width of 3.5 centimeters - >>> with fig.inset(position="jTL+w3.5c+o0.2c", margin=0, box="+pgreen"): + >>> # Setting the position to Top Left and a width of 3.5 centimeters + >>> with fig.inset(position="jTL+w3.5c+o0.2c", margin=0, box=Box(pen="green")): ... # Map elements under the "with" statement are plotted in the inset ... fig.coast( ... region="g", diff --git a/pygmt/tests/test_image.py b/pygmt/tests/test_image.py index 5bcc8c28145..e69a8d71938 100644 --- a/pygmt/tests/test_image.py +++ b/pygmt/tests/test_image.py @@ -4,6 +4,7 @@ import pytest from pygmt import Figure +from pygmt.params import Box @pytest.mark.mpl_image_compare @@ -12,5 +13,5 @@ def test_image(): Place images on map. """ fig = Figure() - fig.image(imagefile="@circuit.png", position="x0/0+w2c", box="+pthin,blue") + fig.image(imagefile="@circuit.png", position="x0/0+w2c", box=Box(pen="thin,blue")) return fig diff --git a/pygmt/tests/test_inset.py b/pygmt/tests/test_inset.py index 992ba9e48c0..ca5b411fe68 100644 --- a/pygmt/tests/test_inset.py +++ b/pygmt/tests/test_inset.py @@ -4,6 +4,7 @@ import pytest from pygmt import Figure +from pygmt.params import Box @pytest.mark.benchmark @@ -14,7 +15,7 @@ def test_inset_aliases(): """ fig = Figure() fig.basemap(region="MG+r2", frame="afg") - with fig.inset(position="jTL+w3.5c+o0.2c", margin=0, box="+pgreen"): + with fig.inset(position="jTL+w3.5c+o0.2c", margin=0, box=Box(pen="green")): fig.basemap(region="g", projection="G47/-20/4c", frame="afg") return fig @@ -27,7 +28,7 @@ def test_inset_context_manager(): """ fig = Figure() fig.basemap(region=[-74, -69.5, 41, 43], projection="M9c", frame=True) - with fig.inset(position="jBL+w3c+o0.2c", margin=0, box="+pblack"): + with fig.inset(position="jBL+w3c+o0.2c", margin=0, box=Box(pen="black")): fig.basemap(region=[-80, -65, 35, 50], projection="M3c", frame="afg") fig.basemap(rose="jTR+w3c") # Pass rose argument with basemap after the inset return fig diff --git a/pygmt/tests/test_params_box.py b/pygmt/tests/test_params_box.py new file mode 100644 index 00000000000..0bdd70adefe --- /dev/null +++ b/pygmt/tests/test_params_box.py @@ -0,0 +1,59 @@ +""" +Test the Box class. +""" + +import pytest +from pygmt.exceptions import GMTInvalidInput, GMTValueError +from pygmt.params import Box + + +def test_params_box(): + """ + Test the Box class. + """ + assert str(Box(clearance=0.1)) == "+c0.1" + assert str(Box(clearance=(0.1, 0.2))) == "+c0.1/0.2" + assert str(Box(clearance=(0.1, 0.2, 0.3, 0.4))) == "+c0.1/0.2/0.3/0.4" + + assert str(Box(fill="red@20")) == "+gred@20" + + assert str(Box(pen="blue")) == "+pblue" + + assert str(Box(radius=True)) == "+r" + assert str(Box(radius="10p")) == "+r10p" + + assert str(Box(inner_gap="2p", inner_pen="1p,red")) == "+i2p/1p,red" + + assert str(Box(shade_offset=("5p", "5p"))) == "+s5p/5p" + assert str(Box(shade_fill="red")) == "+sred" + assert str(Box(shade_offset=("5p", "5p"), shade_fill="red")) == "+s5p/5p/red" + + box = Box( + clearance=0.2, + fill="red@20", + pen="blue", + inner_gap="2p", + inner_pen="1p,red", + radius="10p", + shade_offset=("5p", "5p"), + shade_fill="lightred", + ) + assert str(box) == "+c0.2+gred@20+i2p/1p,red+pblue+r10p+s5p/5p/lightred" + + +def test_params_box_invalid_shade_offset(): + """ + Test that an invalid shade_offset raises a GMTValueError. + """ + with pytest.raises(GMTValueError): + _ = str(Box(shade_offset=("5p", "8p", "10p"))) + with pytest.raises(GMTValueError): + _ = str(Box(shade_offset="10p")) + + +def test_params_box_invalid_innerborder(): + """ + Test that inner_pen is required when inner_gap is set. + """ + with pytest.raises(GMTInvalidInput): + _ = str(Box(inner_gap="2p"))