diff --git a/doc/api/index.rst b/doc/api/index.rst index 01fac7d7a89..40749561689 100644 --- a/doc/api/index.rst +++ b/doc/api/index.rst @@ -29,6 +29,7 @@ Plotting map elements Figure.basemap Figure.coast Figure.colorbar + Figure.hlines Figure.inset Figure.legend Figure.logo diff --git a/pygmt/figure.py b/pygmt/figure.py index 4163ab52eb1..f96dad18404 100644 --- a/pygmt/figure.py +++ b/pygmt/figure.py @@ -409,6 +409,7 @@ def _repr_html_(self): grdimage, grdview, histogram, + hlines, image, inset, legend, diff --git a/pygmt/src/__init__.py b/pygmt/src/__init__.py index e4db7321963..7fa068f4505 100644 --- a/pygmt/src/__init__.py +++ b/pygmt/src/__init__.py @@ -29,6 +29,7 @@ from pygmt.src.grdview import grdview from pygmt.src.grdvolume import grdvolume from pygmt.src.histogram import histogram +from pygmt.src.hlines import hlines from pygmt.src.image import image from pygmt.src.info import info from pygmt.src.inset import inset diff --git a/pygmt/src/hlines.py b/pygmt/src/hlines.py new file mode 100644 index 00000000000..cd0e10e2b9f --- /dev/null +++ b/pygmt/src/hlines.py @@ -0,0 +1,135 @@ +""" +hlines - Plot horizontal lines. +""" + +from collections.abc import Sequence + +import numpy as np +from pygmt.exceptions import GMTInvalidInput + +__doctest_skip__ = ["hlines"] + + +def hlines( + self, + y: float | Sequence[float], + xmin: float | Sequence[float] | None = None, + xmax: float | Sequence[float] | None = None, + pen: str | None = None, + label: str | None = None, + no_clip: bool = False, + perspective: str | bool | None = None, +): + """ + Plot one or multiple horizontal line(s). + + This method is a high-level wrapper around :meth:`pygmt.Figure.plot`, focusing on + plotting horizontal lines at Y-coordinates specified by the ``y`` parameter. The + ``y`` parameter can be a single value (for a single horizontal line) or a sequence + of values (for multiple horizontal lines). + + By default, the X-coordinates of the start and end points of the lines are set to + be the X-limits of the current plot, but this can be overridden by specifying the + ``xmin`` and ``xmax`` parameters. ``xmin`` and ``xmax`` can either be a single + value or a sequence of values. If a single value is provided, it is applied to all + lines. If a sequence is provided, the length of ``xmin`` and ``xmax`` must match + the length of ``y``. + + The term "horizontal" lines can be interpreted differently in different coordinate + systems: + + - **Cartesian** coordinate system: lines are plotted as straight lines. + - **Polar** projection: lines are plotted as arcs along constant radius. + - **Geographic** projection: lines are plotted as parallels along constant latitude. + + Parameters + ---------- + y + Y-coordinates to plot the lines. It can be a single value (for a single line) + or a sequence of values (for multiple lines). + xmin/xmax + X-coordinates of the start/end point of the line(s). If ``None``, defaults to + the X-limits of the current plot. ``xmin`` and ``xmax`` can either be a single + value or a sequence of values. If a single value is provided, it is applied to + all lines. If a sequence is provided, the length of ``xmin`` and ``xmax`` must + match the length of ``y``. + pen + Pen attributes for the line(s), in the format of *width,color,style*. + label + Label for the line(s), to be displayed in the legend. + no_clip + If ``True``, do not clip lines outside the plot region. Only makes sense in + Cartesian coordinate system. + perspective + Select perspective view and set the azimuth and elevation angle of the + viewpoint. Refer to :meth:`pygmt.Figure.plot` for details. + + Examples + -------- + >>> import pygmt + >>> fig = pygmt.Figure() + >>> fig.basemap(region=[0, 10, 0, 10], projection="X10c/10c", frame=True) + >>> fig.hlines(y=1, pen="1p,black", label="Line 1") + >>> fig.hlines(y=2, xmin=2, xmax=8, pen="1p,red,-", label="Line 2") + >>> fig.hlines(y=[3, 4], xmin=3, xmax=7, pen="1p,black,.", label="Line 3") + >>> fig.hlines(y=[5, 6], xmin=4, xmax=9, pen="1p,red", label="Line 4") + >>> fig.hlines(y=[7, 8], xmin=[0, 1], xmax=[7, 8], pen="1p,blue", label="Line 5") + >>> fig.legend() + >>> fig.show() + """ + self._preprocess() + + # Determine the x limits from the current plot region if not specified. + if xmin is None or xmax is None: + xlimits = self.region[:2] + if xmin is None: + xmin = xlimits[0] + if xmax is None: + xmax = xlimits[1] + + # Ensure y/xmin/xmax are 1-D arrays. + _y = np.atleast_1d(y) + _xmin = np.atleast_1d(xmin) + _xmax = np.atleast_1d(xmax) + + nlines = len(_y) # Number of lines to plot. + + # Check if xmin/xmax are scalars or have the expected length. + if _xmin.size not in {1, nlines} or _xmax.size not in {1, nlines}: + msg = ( + f"'xmin' and 'xmax' are expected to be scalars or have lengths '{nlines}', " + f"but lengths '{_xmin.size}' and '{_xmax.size}' are given." + ) + raise GMTInvalidInput(msg) + + # Repeat xmin/xmax to match the length of y if they are scalars. + if nlines != 1: + if _xmin.size == 1: + _xmin = np.repeat(_xmin, nlines) + if _xmax.size == 1: + _xmax = np.repeat(_xmax, nlines) + + # Call the Figure.plot method to plot the lines. + for i in range(nlines): + # Special handling for label. + # 1. Only specify label when plotting the first line. + # 2. The -l option can accept comma-separated labels for labeling multiple lines + # with auto-coloring enabled. We don't need this feature here, so we need to + # replace comma with \054 if the label contains commas. + _label = label.replace(",", "\\054") if label and i == 0 else None + + # By default, points are connected as great circle arcs in geographic coordinate + # system and straight lines in Cartesian coordinate system (including polar + # projection). To plot "horizontal" lines along constant latitude (in geographic + # coordinate system) or constant radius (in polar projection), we need to + # resample the line to at least 4 points. + npoints = 4 # 2 for Cartesian, at least 4 for geographic and polar projections. + self.plot( + x=np.linspace(_xmin[i], _xmax[i], npoints), + y=[_y[i]] * npoints, + pen=pen, + label=_label, + no_clip=no_clip, + perspective=perspective, + straight_line="p", # Any one of "m"/"p"/"r"/"t"/"x"/"y" works. + ) diff --git a/pygmt/tests/baseline/test_hlines_clip.png.dvc b/pygmt/tests/baseline/test_hlines_clip.png.dvc new file mode 100644 index 00000000000..1c24bb1c16d --- /dev/null +++ b/pygmt/tests/baseline/test_hlines_clip.png.dvc @@ -0,0 +1,5 @@ +outs: +- md5: e87ea1b80ae5d32d49e9ad94a5c25f96 + size: 7199 + hash: md5 + path: test_hlines_clip.png diff --git a/pygmt/tests/baseline/test_hlines_geographic_global_d.png.dvc b/pygmt/tests/baseline/test_hlines_geographic_global_d.png.dvc new file mode 100644 index 00000000000..960f3a05fdc --- /dev/null +++ b/pygmt/tests/baseline/test_hlines_geographic_global_d.png.dvc @@ -0,0 +1,5 @@ +outs: +- md5: b7055f03ff5bc152c0f6b72f2d39f32c + size: 29336 + hash: md5 + path: test_hlines_geographic_global_d.png diff --git a/pygmt/tests/baseline/test_hlines_geographic_global_g.png.dvc b/pygmt/tests/baseline/test_hlines_geographic_global_g.png.dvc new file mode 100644 index 00000000000..29b83d3b44f --- /dev/null +++ b/pygmt/tests/baseline/test_hlines_geographic_global_g.png.dvc @@ -0,0 +1,5 @@ +outs: +- md5: ab2e7717cad6ac4132fd3e3af1fefa89 + size: 29798 + hash: md5 + path: test_hlines_geographic_global_g.png diff --git a/pygmt/tests/baseline/test_hlines_multiple_lines.png.dvc b/pygmt/tests/baseline/test_hlines_multiple_lines.png.dvc new file mode 100644 index 00000000000..2498a0a19b5 --- /dev/null +++ b/pygmt/tests/baseline/test_hlines_multiple_lines.png.dvc @@ -0,0 +1,5 @@ +outs: +- md5: a197938e93553232447205b03c9132b3 + size: 13801 + hash: md5 + path: test_hlines_multiple_lines.png diff --git a/pygmt/tests/baseline/test_hlines_one_line.png.dvc b/pygmt/tests/baseline/test_hlines_one_line.png.dvc new file mode 100644 index 00000000000..aa42ce3f492 --- /dev/null +++ b/pygmt/tests/baseline/test_hlines_one_line.png.dvc @@ -0,0 +1,5 @@ +outs: +- md5: 121970f75d34c552e632cacc692f09e9 + size: 13685 + hash: md5 + path: test_hlines_one_line.png diff --git a/pygmt/tests/baseline/test_hlines_polar_projection.png.dvc b/pygmt/tests/baseline/test_hlines_polar_projection.png.dvc new file mode 100644 index 00000000000..4e5bef96dc6 --- /dev/null +++ b/pygmt/tests/baseline/test_hlines_polar_projection.png.dvc @@ -0,0 +1,5 @@ +outs: +- md5: 0c0eeb160dd6beb06bb6d3dcc264127a + size: 57789 + hash: md5 + path: test_hlines_polar_projection.png diff --git a/pygmt/tests/test_hlines.py b/pygmt/tests/test_hlines.py new file mode 100644 index 00000000000..5303e7e061e --- /dev/null +++ b/pygmt/tests/test_hlines.py @@ -0,0 +1,121 @@ +""" +Tests for Figure.hlines. +""" + +import pytest +from pygmt import Figure +from pygmt.exceptions import GMTInvalidInput + + +@pytest.mark.mpl_image_compare +def test_hlines_one_line(): + """ + Plot one horizontal line. + """ + fig = Figure() + fig.basemap(region=[0, 10, 0, 10], projection="X10c/10c", frame=True) + fig.hlines(1) + fig.hlines(2, xmin=1) + fig.hlines(3, xmax=9) + fig.hlines(4, xmin=3, xmax=8) + fig.hlines(5, xmin=4, xmax=8, pen="1p,blue", label="Line at y=5") + fig.hlines(6, xmin=5, xmax=7, pen="1p,red", label="Line at y=6") + fig.legend() + return fig + + +@pytest.mark.mpl_image_compare +def test_hlines_multiple_lines(): + """ + Plot multiple horizontal lines. + """ + fig = Figure() + fig.basemap(region=[0, 10, 0, 16], projection="X10c/10c", frame=True) + fig.hlines([1, 2]) + fig.hlines([3, 4, 5], xmin=[1, 2, 3]) + fig.hlines([6, 7, 8], xmax=[7, 8, 9]) + fig.hlines([9, 10], xmin=[1, 2], xmax=[9, 10]) + fig.hlines([11, 12], xmin=1, xmax=9, pen="1p,blue", label="Line at y=11,12") + fig.hlines( + [13, 14], xmin=[3, 4], xmax=[8, 9], pen="1p,red", label="Line at y=13,14" + ) + fig.legend() + return fig + + +@pytest.mark.mpl_image_compare +def test_hlines_clip(): + """ + Plot horizontal lines with clipping or not. + """ + fig = Figure() + fig.basemap(region=[0, 10, 0, 4], projection="X10c/4c", frame=True) + fig.hlines(1, xmin=-2, xmax=12) + fig.hlines(2, xmin=-2, xmax=12, no_clip=True) + return fig + + +@pytest.mark.mpl_image_compare +@pytest.mark.parametrize("region", ["g", "d"]) +def test_hlines_geographic_global(region): + """ + Plot horizontal lines in geographic coordinates. + """ + fig = Figure() + fig.basemap(region=region, projection="R15c", frame=True) + # Plot lines with longitude range of 0 to 360. + fig.hlines(10, pen="1p") + fig.hlines(20, xmin=0, xmax=360, pen="1p") + fig.hlines(30, xmin=0, xmax=180, pen="1p") + fig.hlines(40, xmin=180, xmax=360, pen="1p") + fig.hlines(50, xmin=0, xmax=90, pen="1p") + fig.hlines(60, xmin=90, xmax=180, pen="1p") + fig.hlines(70, xmin=180, xmax=270, pen="1p") + fig.hlines(80, xmin=270, xmax=360, pen="1p") + + # Plot lines with longitude range of -180 to 180. + fig.hlines(-10, pen="1p,red") + fig.hlines(-20, xmin=-180, xmax=180, pen="1p,red") + fig.hlines(-30, xmin=-180, xmax=0, pen="1p,red") + fig.hlines(-40, xmin=0, xmax=180, pen="1p,red") + fig.hlines(-50, xmin=-180, xmax=-90, pen="1p,red") + fig.hlines(-60, xmin=-90, xmax=0, pen="1p,red") + fig.hlines(-70, xmin=0, xmax=90, pen="1p,red") + fig.hlines(-80, xmin=90, xmax=180, pen="1p,red") + return fig + + +@pytest.mark.mpl_image_compare +def test_hlines_polar_projection(): + """ + Plot horizontal lines in polar projection. + """ + fig = Figure() + fig.basemap(region=[0, 360, 0, 1], projection="P15c", frame=True) + fig.hlines(0.1, pen="1p") + fig.hlines(0.2, xmin=0, xmax=360, pen="1p") + fig.hlines(0.3, xmin=0, xmax=180, pen="1p") + fig.hlines(0.4, xmin=180, xmax=360, pen="1p") + fig.hlines(0.5, xmin=0, xmax=90, pen="1p") + fig.hlines(0.6, xmin=90, xmax=180, pen="1p") + fig.hlines(0.7, xmin=180, xmax=270, pen="1p") + fig.hlines(0.8, xmin=270, xmax=360, pen="1p") + return fig + + +def test_hlines_invalid_input(): + """ + Test invalid input for hlines. + """ + fig = Figure() + fig.basemap(region=[0, 10, 0, 6], projection="X10c/6c", frame=True) + with pytest.raises(GMTInvalidInput): + fig.hlines(1, xmin=2, xmax=[3, 4]) + with pytest.raises(GMTInvalidInput): + fig.hlines(1, xmin=[2, 3], xmax=4) + with pytest.raises(GMTInvalidInput): + fig.hlines(1, xmin=[2, 3], xmax=[4, 5]) + with pytest.raises(GMTInvalidInput): + fig.hlines([1, 2], xmin=[2, 3, 4], xmax=3) + with pytest.raises(GMTInvalidInput): + fig.hlines([1, 2], xmin=[2, 3], xmax=[4, 5, 6])