From b6999352e1e00e46ef63cafe56c08d82a22a1fe5 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Thu, 18 Dec 2025 18:36:14 +0800 Subject: [PATCH] Figure.inset: Add parameters position/width/height to specify inset positiona and dimensions --- pygmt/src/inset.py | 107 +++++++++--------- .../test_inset_default_position.png.dvc | 5 + pygmt/tests/test_inset.py | 58 +++++++++- 3 files changed, 115 insertions(+), 55 deletions(-) create mode 100644 pygmt/tests/baseline/test_inset_default_position.png.dvc diff --git a/pygmt/src/inset.py b/pygmt/src/inset.py index b190163541d..dd3911cbc4f 100644 --- a/pygmt/src/inset.py +++ b/pygmt/src/inset.py @@ -6,8 +6,10 @@ from collections.abc import Sequence from typing import Literal +from pygmt._typing import AnchorCode from pygmt.alias import Alias, AliasSystem from pygmt.clib import Session +from pygmt.exceptions import GMTInvalidInput from pygmt.helpers import ( build_arg_list, deprecate_parameter, @@ -15,22 +17,26 @@ kwargs_to_strings, use_alias, ) -from pygmt.params import Box +from pygmt.params import Box, Position +from pygmt.src._common import _parse_position __doctest_skip__ = ["inset"] @fmt_docstring @deprecate_parameter("margin", "clearance", "v0.18.0", remove_version="v0.20.0") +@use_alias(C="clearance") +@kwargs_to_strings(C="sequence") @contextlib.contextmanager -@use_alias(D="position", C="clearance") -@kwargs_to_strings(D="sequence", C="sequence") def inset( self, - projection: str | None = None, - region: Sequence[float | str] | str | None = None, + position: Position | Sequence[float | str] | AnchorCode | None = None, + width: float | str | None = None, + height: float | str | None = None, box: Box | bool = False, no_clip: bool = False, + projection: str | None = None, + region: Sequence[float | str] | str | None = None, verbose: Literal["quiet", "error", "warning", "timing", "info", "compat", "debug"] | bool = False, **kwargs, @@ -38,13 +44,15 @@ def inset( r""" Manage figure inset setup and completion. - This method sets the position, frame, and margins for a smaller figure - inside of the larger figure. Plotting methods that are called within the + + This method carves out a sub-region of the current plot canvas and restrict further + plotting to that section of the canvas. Plotting methods that are called within the context manager are added to the inset figure. Full GMT docs at :gmt-docs:`inset.html`. $aliases + - D = position, **+w**: width/height - F = box - J = projection - N = no_clip @@ -53,45 +61,21 @@ def inset( Parameters ---------- - position : str or list - *xmin/xmax/ymin/ymax*\ [**+r**][**+u**\ *unit*]] \ - | [**g**\|\ **j**\|\ **J**\|\ **n**\|\ **x**]\ *refpoint*\ - **+w**\ *width*\ [/*height*][**+j**\ *justify*]\ - [**+o**\ *dx*\ [/*dy*]]. - - *This is the only required parameter.* - Define the map inset rectangle on the map. Specify the rectangle - in one of three ways: - - Append **g**\ *lon*/*lat* for map (user) coordinates, - **j**\ *code* or **J**\ *code* for setting the *refpoint* via a - :doc:`2-character justification code ` - that refers to the (invisible) - projected map bounding box, **n**\ *xn*/*yn* for normalized (0-1) - bounding box coordinates, or **x**\ *x*/*y* for plot - coordinates (inches, centimeters, points, append unit). - All but **x** requires both ``region`` and ``projection`` to be - specified. You can offset the reference point via - **+o**\ *dx*/*dy* in the direction implied by *code* or - **+j**\ *justify*. - - Alternatively, give *west/east/south/north* of geographic - rectangle bounded by parallels and meridians; append **+r** if the - coordinates instead are the lower left and upper right corners of - the desired rectangle. (Or, give *xmin/xmax/ymin/ymax* of bounding - rectangle in projected coordinates and optionally - append **+u**\ *unit* [Default coordinate unit is meters (**e**)]. - - Append **+w**\ *width*\ [/*height*] of bounding rectangle or box - in plot coordinates (inches, centimeters, etc.). By default, the - anchor point on the scale is assumed to be the bottom left corner - (**BL**), but this can be changed by appending **+j** followed by a - :doc:`2-character justification code ` - *justify*. - **Note**: If **j** is used then *justify* defaults to the same - as *refpoint*, if **J** is used then *justify* defaults to the - mirror opposite of *refpoint*. Specify inset box attributes via - the ``box`` parameter [Default is outline only]. + position + Position of the inset 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 bottom-left corner of the plot. + width + height + Width and height of the inset. Width must be specified and height is set to be + equal to width if not specified. box Draw a background box behind the inset. If set to ``True``, a simple rectangular box is drawn using :gmt-term:`MAP_FRAME_PEN`. To customize the box appearance, @@ -109,8 +93,9 @@ def inset( no_clip Do **not** clip features extruding outside the inset frame boundaries [Default is ``False``]. - $region + $projection + $region $verbose Examples @@ -118,13 +103,16 @@ def inset( >>> import pygmt >>> from pygmt.params import Box, Position >>> - >>> # 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 + >>> # 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", clearance=0, box=Box(pen="green")): - ... # Map elements under the "with" statement are plotted in the inset + >>> with fig.inset( + ... position=Position("TL", offset=0.2), + ... width="3.5c", + ... clearance=0, + ... box=Box(pen="green"), + ... ): # Map elements under the "with" statement are plotted in the inset ... fig.coast( ... region="g", ... projection="G47/-20/?", @@ -132,14 +120,29 @@ def inset( ... water="white", ... dcw="MG+gred", ... ) - ... >>> # Map elements outside the "with" statement are plotted in the main figure >>> fig.logo(position=Position("BR", offset=0.2), width="3c") >>> fig.show() """ self._activate_figure() + position = _parse_position( + position, + kwdict={"width": width, "height": height}, + default=Position((0, 0), cstype="plotcoords"), # Default to (0,0) in plotcoords + ) + + # width is mandatory. + if width is None and not isinstance(position, str): + msg = "Parameter 'width' must be specified." + raise GMTInvalidInput(msg) + aliasdict = AliasSystem( + D=[ + Alias(position, name="position"), + Alias(width, name="width", prefix="+w"), # +wwidth/height + Alias(height, name="height", prefix="/"), + ], F=Alias(box, name="box"), N=Alias(no_clip, name="no_clip"), ).add_common( diff --git a/pygmt/tests/baseline/test_inset_default_position.png.dvc b/pygmt/tests/baseline/test_inset_default_position.png.dvc new file mode 100644 index 00000000000..78b3702410e --- /dev/null +++ b/pygmt/tests/baseline/test_inset_default_position.png.dvc @@ -0,0 +1,5 @@ +outs: +- md5: a99b3af4882f90e34ded939d9b5adcdc + size: 31554 + hash: md5 + path: test_inset_default_position.png diff --git a/pygmt/tests/test_inset.py b/pygmt/tests/test_inset.py index abb60b85d18..f64f7d0e5e2 100644 --- a/pygmt/tests/test_inset.py +++ b/pygmt/tests/test_inset.py @@ -4,7 +4,8 @@ import pytest from pygmt import Figure -from pygmt.params import Box +from pygmt.exceptions import GMTInvalidInput +from pygmt.params import Box, Position @pytest.mark.benchmark @@ -15,7 +16,12 @@ def test_inset_aliases(): """ fig = Figure() fig.basemap(region="MG+r2", frame="afg") - with fig.inset(position="jTL+w3.5c+o0.2c", clearance=0.2, box=Box(pen="green")): + with fig.inset( + position=Position("TL", offset=0.2), + width="3.5c", + clearance=0.2, + box=Box(pen="green"), + ): fig.basemap(region="g", projection="G47/-20/?", frame="afg") return fig @@ -28,7 +34,53 @@ 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", clearance=0.2, box=True): + with fig.inset( + position=Position("BL", offset=0.2), width="3c", clearance=0.2, box=True + ): fig.basemap(region="g", projection="G47/-20/?", frame="afg") fig.basemap(rose="jTR+w3c") # Pass rose argument with basemap after the inset return fig + + +@pytest.mark.mpl_image_compare +def test_inset_default_position(): + """ + Test that the inset defaults to the bottom-left corner when no position is given. + """ + fig = Figure() + fig.basemap(region="MG+r2", frame="afg") + with fig.inset(width="3.5c", box=True): + fig.basemap(region="g", projection="G47/-20/?", frame="afg") + return fig + + +@pytest.mark.mpl_image_compare(filename="test_inset_aliases.png") +def test_inset_deprecated_position(): + """ + Test that the deprecated raw GMT CLI string for position still works. + """ + fig = Figure() + fig.basemap(region="MG+r2", frame="afg") + with fig.inset(position="jTL+w3.5c+o0.2c", clearance=0.2, box=Box(pen="green")): + fig.basemap(region="g", projection="G47/-20/?", frame="afg") + return fig + + +def test_inset_invalid_inputs(): + """ + Test that an error is raised when invalid inputs are provided. + """ + fig = Figure() + fig.basemap(region="MG+r2", frame="afg") + # Width is not given + with pytest.raises(GMTInvalidInput): + with fig.inset(position=Position("TL")): + pass + # Height is given but width is not given + with pytest.raises(GMTInvalidInput): + with fig.inset(position=Position("TL"), height="5c"): + pass + # Old position syntax conflicts with width/height + with pytest.raises(GMTInvalidInput): + with fig.inset(position="jTL+w3.5c", width="3.5c"): + pass