diff --git a/doc/api/index.rst b/doc/api/index.rst index 264f5a9175a..9b312ca2f9f 100644 --- a/doc/api/index.rst +++ b/doc/api/index.rst @@ -31,6 +31,7 @@ Plotting map elements Figure.inset Figure.legend Figure.logo + Figure.scalebar Figure.solar Figure.text Figure.timestamp diff --git a/pygmt/figure.py b/pygmt/figure.py index 56ad2c3d5cf..3c48080c482 100644 --- a/pygmt/figure.py +++ b/pygmt/figure.py @@ -427,6 +427,7 @@ def _repr_html_(self) -> str: plot3d, psconvert, rose, + scalebar, set_panel, shift_origin, solar, diff --git a/pygmt/src/__init__.py b/pygmt/src/__init__.py index 8905124f917..2aa4e6b5587 100644 --- a/pygmt/src/__init__.py +++ b/pygmt/src/__init__.py @@ -43,6 +43,7 @@ from pygmt.src.project import project from pygmt.src.psconvert import psconvert from pygmt.src.rose import rose +from pygmt.src.scalebar import scalebar from pygmt.src.select import select from pygmt.src.shift_origin import shift_origin from pygmt.src.solar import solar diff --git a/pygmt/src/scalebar.py b/pygmt/src/scalebar.py new file mode 100644 index 00000000000..f6e54f646ee --- /dev/null +++ b/pygmt/src/scalebar.py @@ -0,0 +1,139 @@ +""" +scalebar - Add a scale bar. +""" + +from collections.abc import Sequence +from typing import Literal + +from pygmt.alias import Alias, AliasSystem +from pygmt.clib import Session +from pygmt.exceptions import GMTInvalidInput +from pygmt.helpers import build_arg_list, fmt_docstring +from pygmt.params import Box, Position + +__doctest_skip__ = ["scalebar"] + + +@fmt_docstring +def scalebar( # noqa: PLR0913 + self, + position: Position | None = None, + length: float | str | None = None, + height: float | str | None = None, + scale_position: float | Sequence[float] | bool = False, + label: str | bool = False, + label_alignment: Literal["left", "right", "top", "bottom"] | None = None, + unit: bool = False, + fancy: bool = False, + vertical: bool = False, + box: Box | bool = False, + verbose: Literal["quiet", "error", "warning", "timing", "info", "compat", "debug"] + | bool = False, + panel: int | Sequence[int] | bool = False, + transparency: float | None = None, + perspective: float | Sequence[float] | str | bool = False, +): + """ + Add a scale bar on the map. + + Parameters + ---------- + position + Specify the location of the scale bar. See :class:`pygmt.params.Position` for + more details. + length + Length of the scale bar in km. Append a suffix to specify different units. Valid + units are: **e**: meters; **f**: feet; **k**: kilometers; **M**: statute mile; + **n**: nautical miles; **u**: US Survey foot. + height + Height of the scale bar. Only works when ``fancy=True``. [Default is ``"5p"``]. + scale_position + Specify the location where on a geographic map the scale applies. It can be: + + - *slat*: Map scale is calculated for latitude *slat* + - (*slon*, *slat*): Map scale is calculated for latitude *slat* and longitude + *slon*, which is useful for oblique projections. + - ``True``: Map scale is calculated for the middle of the map. + - ``False``: Default to the location of the reference point. + label + Text string to use as the scale bar label. If ``False``, no label is drawn. If + ``True``, the distance unit provided in the ``length`` parameter (default is km) + is used as the label. This parameter requires ``fancy=True``. + label_alignment + Alignment of the scale bar label. Choose from ``"left"``, ``"right"``, + ``"top"``, or ``"bottom"``. [Default is ``"top"``]. + fancy + If ``True``, draw a "fancy" scale bar, which is a segmented bar with alternating + black and white rectangles. If ``False``, draw a plain scale bar. + unit + If ``True``, append the unit to all distance annotations along the scale. For a + plain scale, this will instead select the unit to be appended to the distance + length. The unit is determined from the suffix in the ``length`` or defaults to + ``"km"``. + vertical + If ``True``, plot a vertical rather than a horizontal Cartesian scale. + box + Draw a background box behind the directional rose. If set to ``True``, a simple + rectangular box is drawn using :gmt-term:`MAP_FRAME_PEN`. To customize the box + appearance, pass a :class:`pygmt.params.Box` object to control style, fill, pen, + and other box properties. + $perspective + $verbose + $transparency + + Examples + -------- + >>> import pygmt + >>> from pygmt.params import Box, Position + >>> fig = pygmt.Figure() + >>> fig.basemap(region=[0, 80, -30, 30], projection="M10c", frame=True) + >>> fig.scalebar( + ... position=Position((10, 10), cstype="mapcoords"), + ... length=1000, + ... fancy=True, + ... label="Scale", + ... unit=True, + ... ) + >>> fig.show() + """ + self._activate_figure() + + if position is None: + msg = "Parameter 'position' must be specified." + raise GMTInvalidInput(msg) + if length is None: + msg = "Parameter 'length' must be specified." + raise GMTInvalidInput(msg) + + aliasdict = AliasSystem( + F=Alias(box, name="box"), + L=[ + Alias(position, name="position"), + Alias(length, name="length", prefix="+w"), + Alias( + label_alignment, + name="label_alignment", + prefix="+a", + mapping={"left": "l", "right": "r", "top": "t", "bottom": "b"}, + ), + Alias(scale_position, name="scale_position", prefix="+c", sep="/", size=2), + Alias(fancy, name="fancy", prefix="+f"), + Alias(label, name="label", prefix="+l"), + Alias(unit, name="unit", prefix="+u"), + Alias(vertical, name="vertical", prefix="+v"), + ], + ).add_common( + V=verbose, + c=panel, + p=perspective, + t=transparency, + ) + + confdict = {} + if height is not None: + confdict["MAP_SCALE_HEIGHT"] = height + + with Session() as lib: + lib.call_module( + module="basemap", args=build_arg_list(aliasdict, confdict=confdict) + ) diff --git a/pygmt/tests/baseline/test_scalebar.png.dvc b/pygmt/tests/baseline/test_scalebar.png.dvc new file mode 100644 index 00000000000..cd1b775689d --- /dev/null +++ b/pygmt/tests/baseline/test_scalebar.png.dvc @@ -0,0 +1,5 @@ +outs: +- md5: 0bc967a510306086752fae3b556cc7e2 + size: 10201 + hash: md5 + path: test_scalebar.png diff --git a/pygmt/tests/baseline/test_scalebar_cartesian.png.dvc b/pygmt/tests/baseline/test_scalebar_cartesian.png.dvc new file mode 100644 index 00000000000..0d01ea6cca5 --- /dev/null +++ b/pygmt/tests/baseline/test_scalebar_cartesian.png.dvc @@ -0,0 +1,5 @@ +outs: +- md5: e09a7c67f6146530ea594694853b6f98 + size: 6508 + hash: md5 + path: test_scalebar_cartesian.png diff --git a/pygmt/tests/baseline/test_scalebar_complete.png.dvc b/pygmt/tests/baseline/test_scalebar_complete.png.dvc new file mode 100644 index 00000000000..4ac6b71bb99 --- /dev/null +++ b/pygmt/tests/baseline/test_scalebar_complete.png.dvc @@ -0,0 +1,5 @@ +outs: +- md5: c018b219d3ebc719fb1b1686e074dcd9 + size: 11749 + hash: md5 + path: test_scalebar_complete.png diff --git a/pygmt/tests/test_scalebar.py b/pygmt/tests/test_scalebar.py new file mode 100644 index 00000000000..827d0f4330d --- /dev/null +++ b/pygmt/tests/test_scalebar.py @@ -0,0 +1,72 @@ +""" +Test Figure.scalebar. +""" + +import pytest +from pygmt import Figure +from pygmt.exceptions import GMTInvalidInput +from pygmt.params import Position + + +@pytest.mark.mpl_image_compare +def test_scalebar(): + """ + Create a map with a scale bar. + """ + fig = Figure() + fig.basemap(region=[100, 120, 20, 30], projection="M10c", frame=True) + fig.scalebar(position=Position((118, 22), cstype="mapcoords"), length=200) + return fig + + +@pytest.mark.mpl_image_compare +def test_scalebar_complete(): + """ + Test all parameters of scalebar. + """ + fig = Figure() + fig.basemap(region=[100, 120, 20, 30], projection="M10c", frame=True) + fig.scalebar( + position=Position((110, 22), cstype="mapcoords"), + length=1000, + height="10p", + fancy=True, + label="Scale", + label_alignment="left", + scale_position=(110, 25), + unit=True, + box=True, + ) + return fig + + +@pytest.mark.mpl_image_compare +def test_scalebar_cartesian(): + """ + Test scale bar in Cartesian coordinates. + """ + fig = Figure() + fig.basemap(region=[0, 10, 0, 5], projection="X10c/5c", frame=True) + fig.scalebar(position=Position((2, 1), cstype="mapcoords"), length=1) + fig.scalebar(position=Position((4, 1), cstype="mapcoords"), length=1, vertical=True) + return fig + + +def test_scalebar_no_position(): + """ + Test that an error is raised when position is not provided. + """ + fig = Figure() + fig.basemap(region=[100, 120, 20, 30], projection="M10c", frame=True) + with pytest.raises(GMTInvalidInput): + fig.scalebar(length=200) + + +def test_scalebar_no_length(): + """ + Test that an error is raised when length is not provided. + """ + fig = Figure() + fig.basemap(region=[100, 120, 20, 30], projection="M10c", frame=True) + with pytest.raises(GMTInvalidInput): + fig.scalebar(position=Position((118, 22), cstype="mapcoords"))