Skip to content

Commit 0fdd8a3

Browse files
committed
Add Figure.scalebar to plot a scale bar on maps
1 parent 530c11d commit 0fdd8a3

File tree

9 files changed

+245
-2
lines changed

9 files changed

+245
-2
lines changed

doc/api/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Plotting map elements
3131
Figure.inset
3232
Figure.legend
3333
Figure.logo
34+
Figure.scalebar
3435
Figure.solar
3536
Figure.text
3637
Figure.timestamp

pygmt/figure.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,7 @@ def _repr_html_(self) -> str:
427427
plot3d,
428428
psconvert,
429429
rose,
430+
scalebar,
430431
set_panel,
431432
shift_origin,
432433
solar,

pygmt/src/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
from pygmt.src.project import project
4444
from pygmt.src.psconvert import psconvert
4545
from pygmt.src.rose import rose
46+
from pygmt.src.scalebar import scalebar
4647
from pygmt.src.select import select
4748
from pygmt.src.shift_origin import shift_origin
4849
from pygmt.src.solar import solar

pygmt/src/_common.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,8 @@ def _parse_position(
269269
The keyword arguments dictionary that conflicts with ``position`` if
270270
``position`` is given as a raw GMT command string.
271271
default
272-
The default Position object to use if ``position`` is ``None``.
272+
The default Position object to use if ``position`` is ``None``. If ``default``
273+
is ``None``, the GMT default is used.
273274
274275
Returns
275276
-------
@@ -349,7 +350,7 @@ def _parse_position(
349350
position = Position(position, cstype="plotcoords")
350351
case Position(): # Already a Position object.
351352
pass
352-
case None if default is not None: # Set default position.
353+
case None: # Set default position.
353354
position = default
354355
case _:
355356
msg = f"Invalid type for parameter 'position': {type(position)}."

pygmt/src/scalebar.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""
2+
scalebar - Add a scale bar.
3+
"""
4+
5+
from collections.abc import Sequence
6+
from typing import Literal
7+
8+
from pygmt._typing import AnchorCode
9+
from pygmt.alias import Alias, AliasSystem
10+
from pygmt.clib import Session
11+
from pygmt.exceptions import GMTInvalidInput
12+
from pygmt.helpers import build_arg_list, fmt_docstring
13+
from pygmt.params import Box, Position
14+
from pygmt.src._common import _parse_position
15+
16+
__doctest_skip__ = ["scalebar"]
17+
18+
19+
@fmt_docstring
20+
def scalebar( # noqa: PLR0913
21+
self,
22+
position: Position | Sequence[float | str] | AnchorCode | None = None,
23+
length: float | str | None = None,
24+
height: float | str | None = None,
25+
scale_position: float | Sequence[float] | bool = False,
26+
label: str | bool = False,
27+
label_alignment: Literal["left", "right", "top", "bottom"] | None = None,
28+
unit: bool = False,
29+
fancy: bool = False,
30+
vertical: bool = False,
31+
box: Box | bool = False,
32+
verbose: Literal["quiet", "error", "warning", "timing", "info", "compat", "debug"]
33+
| bool = False,
34+
panel: int | Sequence[int] | bool = False,
35+
perspective: float | Sequence[float] | str | bool = False,
36+
transparency: float | None = None,
37+
):
38+
"""
39+
Add a scale bar on the plot.
40+
41+
Parameters
42+
----------
43+
position
44+
Position of the scale bar on the plot. It can be specified in multiple ways:
45+
46+
- A :class:`pygmt.params.Position` object to fully control the reference point,
47+
anchor point, and offset.
48+
- A sequence of two values representing the x and y coordinates in plot
49+
coordinates, e.g., ``(1, 2)`` or ``("1c", "2c")``.
50+
- A :doc:`2-character justification code </techref/justification_codes>` for a
51+
position inside the plot, e.g., ``"TL"`` for Top Left corner inside the plot.
52+
53+
If not specified, defaults to the lower-left corner of the plot.
54+
length
55+
Length of the scale bar in km. Append a suffix to specify different units. Valid
56+
units are: **e**: meters; **f**: feet; **k**: kilometers; **M**: statute mile;
57+
**n**: nautical miles; **u**: US Survey foot.
58+
height
59+
Height of the scale bar. Only works when ``fancy=True``. [Default is ``"5p"``].
60+
scale_position
61+
Specify the location where on a geographic map the scale applies. It can be:
62+
63+
- *slat*: Map scale is calculated for latitude *slat*
64+
- (*slon*, *slat*): Map scale is calculated for latitude *slat* and longitude
65+
*slon*, which is useful for oblique projections.
66+
- ``True``: Map scale is calculated for the middle of the map.
67+
- ``False``: Default to the location of the reference point.
68+
label
69+
Text string to use as the scale bar label. If ``False``, no label is drawn. If
70+
``True``, the distance unit provided in the ``length`` parameter (default is km)
71+
is used as the label. This parameter requires ``fancy=True``.
72+
label_alignment
73+
Alignment of the scale bar label. Choose from ``"left"``, ``"right"``,
74+
``"top"``, or ``"bottom"``. [Default is ``"top"``].
75+
fancy
76+
If ``True``, draw a "fancy" scale bar, which is a segmented bar with alternating
77+
black and white rectangles. If ``False``, draw a plain scale bar.
78+
unit
79+
If ``True``, append the unit to all distance annotations along the scale. For a
80+
plain scale, this will instead select the unit to be appended to the distance
81+
length. The unit is determined from the suffix in the ``length`` or defaults to
82+
``"km"``.
83+
vertical
84+
If ``True``, plot a vertical rather than a horizontal Cartesian scale.
85+
box
86+
Draw a background box behind the directional rose. If set to ``True``, a simple
87+
rectangular box is drawn using :gmt-term:`MAP_FRAME_PEN`. To customize the box
88+
appearance, pass a :class:`pygmt.params.Box` object to control style, fill, pen,
89+
and other box properties.
90+
$verbose
91+
$panel
92+
$perspective
93+
$transparency
94+
95+
Examples
96+
--------
97+
>>> import pygmt
98+
>>> from pygmt.params import Box, Position
99+
>>> fig = pygmt.Figure()
100+
>>> fig.basemap(region=[0, 80, -30, 30], projection="M10c", frame=True)
101+
>>> fig.scalebar(
102+
... position=Position((10, 10), cstype="mapcoords"),
103+
... length=1000,
104+
... fancy=True,
105+
... label="Scale",
106+
... unit=True,
107+
... )
108+
>>> fig.show()
109+
"""
110+
self._activate_figure()
111+
112+
position = _parse_position(
113+
position,
114+
kwdict={
115+
"length": length,
116+
"height": height,
117+
"label_alignment": label_alignment,
118+
"scale_position": scale_position,
119+
"fancy": fancy,
120+
"label": label,
121+
"unit": unit,
122+
"vertical": vertical,
123+
},
124+
default=None, # Use the GMT default.
125+
)
126+
127+
if length is None:
128+
msg = "Parameter 'length' must be specified."
129+
raise GMTInvalidInput(msg)
130+
131+
aliasdict = AliasSystem(
132+
F=Alias(box, name="box"),
133+
L=[
134+
Alias(position, name="position"),
135+
Alias(length, name="length", prefix="+w"),
136+
Alias(
137+
label_alignment,
138+
name="label_alignment",
139+
prefix="+a",
140+
mapping={"left": "l", "right": "r", "top": "t", "bottom": "b"},
141+
),
142+
Alias(scale_position, name="scale_position", prefix="+c", sep="/", size=2),
143+
Alias(fancy, name="fancy", prefix="+f"),
144+
Alias(label, name="label", prefix="+l"),
145+
Alias(unit, name="unit", prefix="+u"),
146+
Alias(vertical, name="vertical", prefix="+v"),
147+
],
148+
).add_common(
149+
V=verbose,
150+
c=panel,
151+
p=perspective,
152+
t=transparency,
153+
)
154+
155+
confdict = {}
156+
if height is not None:
157+
confdict["MAP_SCALE_HEIGHT"] = height
158+
159+
with Session() as lib:
160+
lib.call_module(
161+
module="basemap", args=build_arg_list(aliasdict, confdict=confdict)
162+
)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
outs:
2+
- md5: 79fc9a29d68df6467b032c38e6b0b263
3+
size: 10288
4+
hash: md5
5+
path: test_scalebar.png
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
outs:
2+
- md5: e09a7c67f6146530ea594694853b6f98
3+
size: 6508
4+
hash: md5
5+
path: test_scalebar_cartesian.png
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
outs:
2+
- md5: c018b219d3ebc719fb1b1686e074dcd9
3+
size: 11749
4+
hash: md5
5+
path: test_scalebar_complete.png

pygmt/tests/test_scalebar.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""
2+
Test Figure.scalebar.
3+
"""
4+
5+
import pytest
6+
from pygmt import Figure
7+
from pygmt.exceptions import GMTInvalidInput
8+
from pygmt.params import Position
9+
10+
11+
@pytest.mark.mpl_image_compare
12+
def test_scalebar():
13+
"""
14+
Create a map with a scale bar.
15+
"""
16+
fig = Figure()
17+
fig.basemap(region=[100, 120, 20, 30], projection="M10c", frame=True)
18+
fig.scalebar(length=500)
19+
return fig
20+
21+
22+
@pytest.mark.mpl_image_compare
23+
def test_scalebar_complete():
24+
"""
25+
Test all parameters of scalebar.
26+
"""
27+
fig = Figure()
28+
fig.basemap(region=[100, 120, 20, 30], projection="M10c", frame=True)
29+
fig.scalebar(
30+
position=Position((110, 22), cstype="mapcoords"),
31+
length=1000,
32+
height="10p",
33+
fancy=True,
34+
label="Scale",
35+
label_alignment="left",
36+
scale_position=(110, 25),
37+
unit=True,
38+
box=True,
39+
)
40+
return fig
41+
42+
43+
@pytest.mark.mpl_image_compare
44+
def test_scalebar_cartesian():
45+
"""
46+
Test scale bar in Cartesian coordinates.
47+
"""
48+
fig = Figure()
49+
fig.basemap(region=[0, 10, 0, 5], projection="X10c/5c", frame=True)
50+
fig.scalebar(position=Position((2, 1), cstype="mapcoords"), length=1)
51+
fig.scalebar(position=Position((4, 1), cstype="mapcoords"), length=1, vertical=True)
52+
return fig
53+
54+
55+
def test_scalebar_no_length():
56+
"""
57+
Test that an error is raised when length is not provided.
58+
"""
59+
fig = Figure()
60+
fig.basemap(region=[100, 120, 20, 30], projection="M10c", frame=True)
61+
with pytest.raises(GMTInvalidInput):
62+
fig.scalebar(position=Position((118, 22), cstype="mapcoords"))

0 commit comments

Comments
 (0)