From 02d87139ea165f2bd6f6237a7341564913bc02e2 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Sat, 26 Jul 2025 15:27:00 +0800 Subject: [PATCH 1/3] Add the Axes, Axis, Frame classes for the frame parameter --- pygmt/params/__init__.py | 1 + pygmt/params/frame.py | 139 +++++++++++++++++++++++++++++++ pygmt/tests/test_params_frame.py | 30 +++++++ 3 files changed, 170 insertions(+) create mode 100644 pygmt/params/frame.py create mode 100644 pygmt/tests/test_params_frame.py diff --git a/pygmt/params/__init__.py b/pygmt/params/__init__.py index b80b921407a..12a788e3352 100644 --- a/pygmt/params/__init__.py +++ b/pygmt/params/__init__.py @@ -3,4 +3,5 @@ """ from pygmt.params.box import Box +from pygmt.params.frame import Axes, Axis, Frame from pygmt.params.pattern import Pattern diff --git a/pygmt/params/frame.py b/pygmt/params/frame.py new file mode 100644 index 00000000000..6c4c2969cfa --- /dev/null +++ b/pygmt/params/frame.py @@ -0,0 +1,139 @@ +""" +The Axes, Axis, and Frame classes for specifying the frame. +""" + +import dataclasses +from typing import Any, Literal + +from pygmt.alias import Alias +from pygmt.params.base import BaseParam + + +@dataclasses.dataclass(repr=False) +class Axis(BaseParam): + """ + Class for setting up one axis of a plot. + """ + + #: Intervals for annotations and major tick spacing, minor tick spacing, and/or + #: grid line spacing. + interval: float | str + + #: Plot slanted annotations (for Cartesian plots only), where *angle* is measured + #: with respect to the horizontal and must be in the -90 <= *angle* <= 90 range. + #: Default is normal (i.e., ``angle=90``) for y-axis and parallel (i.e., + #: ``angle=0``) for x-axis annotations. These defaults can be changed via + #: :gmt-term:`MAP_ANNOT_ORTHO`. + angle: float | None = None + + #: Skip annotations that fall exactly at the ends of the axis. Choose from ``left`` + #: or ``right`` to skip only the lower or upper annotation, respectively, or + #: ``True`` to skip both. + skip_edge: Literal["left", "right"] | bool = False + + #: Give fancy annotations with W|E|S|N suffixes encoding the sign (for geographic + #: axes only). + fancy: bool = False + + #: Add a label to the axis (for Cartesian plots only). The label is placed parallel + #: to the axis by default; use **hlabel** to force a horizontal label for y-axis, + #: which is useful for very short labels. + label: str | None = None + hlabel: str | None = None + + #: Add an alternate label for the right or upper axes. The label is placed parallel + #: to the axis by default; use **alt_hlabel** to force a horizontal label for + #: y-axis, which is useful for very short labels. [For Cartesian plots only]. + alt_label: str | None = None + alt_hlabel: str | None = None + + #: Add a leading text prefix for axis annotation (e.g., dollar sign for plots + #: related to money) (for Cartesian plots only). For geographic maps the addition + #: of degree symbols, etc. is automatic and controlled by + #: :gmt-term:`FORMAT_GEO_MAP`. + prefix: str | None = None + + #: Append a unit to the annotations (for Cartesian plots only). For geographic maps + #: the addition of degree symbols, etc. is automatic and controlled by + #: :gmt-term:`FORMAT_GEO_MAP`. + unit: str | None = None + + @property + def _aliases(self): + return [ + Alias(self.interval, name="interval"), + Alias(self.angle, name="angle", prefix="+a"), + Alias( + self.skip_edge, + name="skip_edge", + prefix="+e", + mapping={True: True, "left": "l", "right": "r"}, + ), + Alias(self.fancy, name="fancy", prefix="+f"), + Alias(self.label, name="label", prefix="+l"), + Alias(self.hlabel, name="hlabel", prefix="+L"), + Alias(self.alt_label, name="alt_label", prefix="+s"), + Alias(self.alt_hlabel, name="alt_hlabel", prefix="+S"), + Alias(self.unit, name="unit", prefix="+u"), + ] + + +@dataclasses.dataclass(repr=False) +class Axes(BaseParam): + """ + Class for specifying the frame of a plot. + """ + + #: Specify which axes to draw and their attributes. + axes: str | None = None + + #: Fill for the interior of the canvas [Default is no fill]. This also sets fill + #: for the two back-walls in 3-D plots. + fill: str | None = None + + #: The title string centered above the plot frame [Default is no title]. + title: str | None = None + + #: The subtitle string beneath the title [Default is no subtitle]. This requires + #: ``title`` to be set. + subtitle: str | None = None + + @property + def _aliases(self): + return [ + Alias(self.axes, name="axes"), + Alias(self.fill, name="fill", prefix="+g"), + Alias(self.title, name="title", prefix="+t"), + Alias(self.subtitle, name="subtitle", prefix="+s"), + ] + + +@dataclasses.dataclass(repr=False) +class Frame(BaseParam): + """ + Class for setting up the frame of a plot. + """ + + axes: Any = None + xaxis: Any = None + yaxis: Any = None + zaxis: Any = None + + @property + def _aliases(self): + return [ + Alias(self.axes), + Alias(self.xaxis, prefix="x"), + Alias(self.yaxis, prefix="y"), + Alias(self.zaxis, prefix="z"), + ] + + def __iter__(self): + """ + Iterate over the aliases of the class. + + Yields + ------ + The value of each alias in the class. None are excluded. + """ + yield from (alias._value for alias in self._aliases if alias._value is not None) diff --git a/pygmt/tests/test_params_frame.py b/pygmt/tests/test_params_frame.py new file mode 100644 index 00000000000..85de1bb406b --- /dev/null +++ b/pygmt/tests/test_params_frame.py @@ -0,0 +1,30 @@ +""" +Test the Frame/Axes/Axis classes. +""" + +from pygmt.params import Axes, Axis + + +def test_params_axis(): + """ + Test the Axis class. + """ + assert str(Axis(interval="a1f0.5")) == "a1f0.5" + assert str(Axis(interval="a1f0.5", angle=30)) == "a1f0.5+a30" + assert str(Axis(interval="a1f0.5", angle=30, skip_edge="left")) == "a1f0.5+a30+el" + assert str(Axis(interval="a1f0.5", fancy=True)) == "a1f0.5+f" + assert str(Axis(interval="a1f0.5", label="My Label")) == "a1f0.5+lMy Label" + assert str(Axis(interval="a1f0.5", hlabel="My HLabel")) == "a1f0.5+LMy HLabel" + assert str(Axis(interval="a1f0.5", alt_label="Alt Label")) == "a1f0.5+sAlt Label" + assert str(Axis(interval="a1f0.5", alt_hlabel="Alt HLabel")) == "a1f0.5+SAlt HLabel" + assert str(Axis(interval="a1f0.5", unit="km")) == "a1f0.5+ukm" + + +def test_params_axes(): + """ + Test the Axes class. + """ + assert ( + str(Axes("WSen", title="My Plot Title", fill="lightred")) + == "WSen+glightred+tMy Plot Title" + ) From 6f97c57ad48b3e5f1bfe9466002357ce834db272 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Sat, 15 Nov 2025 13:50:20 +0800 Subject: [PATCH 2/3] Add to API documentation --- doc/api/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/api/index.rst b/doc/api/index.rst index 3656bba286e..5c4c4cc0ed6 100644 --- a/doc/api/index.rst +++ b/doc/api/index.rst @@ -213,6 +213,7 @@ Class-style Parameters :template: autosummary/params.rst Box + Frame Pattern Enums From b2c98771175cdb1176ef68bb0f4c6c054175f327 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Sat, 6 Dec 2025 01:06:02 +0800 Subject: [PATCH 3/3] Improve Axis/Axes/Frame and add more tests --- pygmt/params/frame.py | 56 +++++++++++++++++++++---- pygmt/tests/test_params_frame.py | 70 +++++++++++++++++++++++++------- 2 files changed, 102 insertions(+), 24 deletions(-) diff --git a/pygmt/params/frame.py b/pygmt/params/frame.py index 6c4c2969cfa..5999f8a6bf1 100644 --- a/pygmt/params/frame.py +++ b/pygmt/params/frame.py @@ -6,6 +6,7 @@ from typing import Any, Literal from pygmt.alias import Alias +from pygmt.exceptions import GMTInvalidInput from pygmt.params.base import BaseParam @@ -15,9 +16,17 @@ class Axis(BaseParam): Class for setting up one axis of a plot. """ - #: Intervals for annotations and major tick spacing, minor tick spacing, and/or - #: grid line spacing. - interval: float | str + #: Specify annotation for the axis. Provide a specific interval with an optional + #: unit. Set to ``True`` to use default interval. + annotation: float | str | bool = False + + #: Specify ticks for the axis. Provide a specific interval with an optional unit. + #: Set to ``True`` to use default interval. + tick: float | str | bool = False + + #: Specify grid lines for the axis. Provide a specific interval with an optional + #: unit. Set to ``True`` to use default interval. + grid: float | str | bool = False #: Plot slanted annotations (for Cartesian plots only), where *angle* is measured #: with respect to the horizontal and must be in the -90 <= *angle* <= 90 range. @@ -26,10 +35,10 @@ class Axis(BaseParam): #: :gmt-term:`MAP_ANNOT_ORTHO`. angle: float | None = None - #: Skip annotations that fall exactly at the ends of the axis. Choose from ``left`` - #: or ``right`` to skip only the lower or upper annotation, respectively, or + #: Skip annotations that fall exactly at the ends of the axis. Choose from ``lower`` + #: or ``upper`` to skip only the lower or upper annotation, respectively, or #: ``True`` to skip both. - skip_edge: Literal["left", "right"] | bool = False + skip_edge: Literal["lower", "upper"] | bool = False #: Give fancy annotations with W|E|S|N suffixes encoding the sign (for geographic #: axes only). @@ -58,22 +67,43 @@ class Axis(BaseParam): #: :gmt-term:`FORMAT_GEO_MAP`. unit: str | None = None + def _validate(self): + """ + Validate the parameters. + """ + if self.label is not None and self.hlabel is not None: + msg = "Parameters 'label' and 'hlabel' cannot be both set." + raise GMTInvalidInput(msg) + if self.alt_label is not None and self.alt_hlabel is not None: + msg = "Parameters 'alt_label' and 'alt_hlabel' cannot be both set." + raise GMTInvalidInput(msg) + @property def _aliases(self): return [ - Alias(self.interval, name="interval"), - Alias(self.angle, name="angle", prefix="+a"), + Alias(self.annotation, name="annotation", prefix="a"), + Alias(self.tick, name="tick", prefix="f"), + Alias(self.grid, name="grid", prefix="g"), + Alias( + self.angle, + name="angle", + prefix="+a", + mapping={"normal": "n", "parallel": "p"} + if isinstance(self.angle, str) + else None, + ), Alias( self.skip_edge, name="skip_edge", prefix="+e", - mapping={True: True, "left": "l", "right": "r"}, + mapping={True: True, "lower": "l", "upper": "u"}, ), Alias(self.fancy, name="fancy", prefix="+f"), Alias(self.label, name="label", prefix="+l"), Alias(self.hlabel, name="hlabel", prefix="+L"), Alias(self.alt_label, name="alt_label", prefix="+s"), Alias(self.alt_hlabel, name="alt_hlabel", prefix="+S"), + Alias(self.prefix, name="prefix", prefix="+p"), Alias(self.unit, name="unit", prefix="+u"), ] @@ -115,17 +145,25 @@ class Frame(BaseParam): """ axes: Any = None + axis: Any = None xaxis: Any = None yaxis: Any = None zaxis: Any = None + xaxis_secondary: Any = None + yaxis_secondary: Any = None + zaxis_secondary: Any = None @property def _aliases(self): return [ Alias(self.axes), + Alias(self.axis), Alias(self.xaxis, prefix="x"), Alias(self.yaxis, prefix="y"), Alias(self.zaxis, prefix="z"), + Alias(self.xaxis_secondary, prefix="sx"), + Alias(self.yaxis_secondary, prefix="sy"), + Alias(self.zaxis_secondary, prefix="sz"), ] def __iter__(self): diff --git a/pygmt/tests/test_params_frame.py b/pygmt/tests/test_params_frame.py index 85de1bb406b..ed6a0d97657 100644 --- a/pygmt/tests/test_params_frame.py +++ b/pygmt/tests/test_params_frame.py @@ -5,26 +5,66 @@ from pygmt.params import Axes, Axis -def test_params_axis(): +def test_params_axis_intervals(): """ - Test the Axis class. + Test the annotation, tick, and grid parameters of the Axis class. """ - assert str(Axis(interval="a1f0.5")) == "a1f0.5" - assert str(Axis(interval="a1f0.5", angle=30)) == "a1f0.5+a30" - assert str(Axis(interval="a1f0.5", angle=30, skip_edge="left")) == "a1f0.5+a30+el" - assert str(Axis(interval="a1f0.5", fancy=True)) == "a1f0.5+f" - assert str(Axis(interval="a1f0.5", label="My Label")) == "a1f0.5+lMy Label" - assert str(Axis(interval="a1f0.5", hlabel="My HLabel")) == "a1f0.5+LMy HLabel" - assert str(Axis(interval="a1f0.5", alt_label="Alt Label")) == "a1f0.5+sAlt Label" - assert str(Axis(interval="a1f0.5", alt_hlabel="Alt HLabel")) == "a1f0.5+SAlt HLabel" - assert str(Axis(interval="a1f0.5", unit="km")) == "a1f0.5+ukm" + assert str(Axis(annotation=1)) == "a1" + assert str(Axis(tick=2)) == "f2" + assert str(Axis(grid=3)) == "g3" + assert str(Axis(annotation=1, tick=2, grid=3)) == "a1f2g3" + + assert str(Axis(annotation=True)) == "a" + assert str(Axis(tick=True)) == "f" + assert str(Axis(grid=True)) == "g" + assert str(Axis(annotation=True, tick=True)) == "af" + assert str(Axis(annotation=True, grid=True)) == "ag" + assert str(Axis(tick=True, grid=True)) == "fg" + assert str(Axis(annotation=True, tick=True, grid=True)) == "afg" + + +def test_params_axis_modifiers(): + """ + Test the modifiers of the Axis class. + """ + assert str(Axis(annotation=True, angle=30)) == "a+a30" + assert str(Axis(annotation=True, angle="normal")) == "a+an" + assert str(Axis(annotation=True, angle="parallel")) == "a+ap" + + assert str(Axis(annotation=True, skip_edge=True)) == "a+e" + assert str(Axis(annotation=True, skip_edge="lower")) == "a+el" + assert str(Axis(annotation=True, skip_edge="upper")) == "a+eu" + + assert str(Axis(annotation=True, fancy=True)) == "a+f" + + assert str(Axis(annotation=True, label="My Label")) == "a+lMy Label" + assert str(Axis(annotation=True, hlabel="My HLabel")) == "a+LMy HLabel" + assert str(Axis(annotation=True, alt_label="Alt Label")) == "a+sAlt Label" + assert str(Axis(annotation=True, alt_hlabel="Alt HLabel")) == "a+SAlt HLabel" + + axis = Axis(annotation=True, label="My Label", alt_label="My HLabel") + assert str(axis) == "a+lMy Label+sMy HLabel" + + assert str(Axis(annotation=True, prefix="$")) == "a+p$" + + assert str(Axis(annotation=True, unit="km")) == "a+ukm" def test_params_axes(): """ Test the Axes class. """ - assert ( - str(Axes("WSen", title="My Plot Title", fill="lightred")) - == "WSen+glightred+tMy Plot Title" - ) + + assert str(Axes(axes="WSen")) == "WSen" + assert str(Axes(fill="lightred")) == "+glightred" + assert str(Axes(title="My Plot Title")) == "+tMy Plot Title" + assert str(Axes(subtitle="My Subtitle")) == "+sMy Subtitle" + + axes = Axes(axes="WSen", fill="lightred", title="My Plot Title") + assert str(axes) == "WSen+glightred+tMy Plot Title" + + +def test_params_frame(): + """ + Test the Frame class. + """