Skip to content

Commit 2085c2f

Browse files
authored
Add add ticks to GeoAxes (#126)
New feature added to allow ticks to be visible on rectilinear GeoAxes. Works with both cartopy and mpl backends.
1 parent 2485183 commit 2085c2f

File tree

3 files changed

+221
-6
lines changed

3 files changed

+221
-6
lines changed

docs/projections.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@
344344
# borders using :ref:`settings <rc_UltraPlot>` like `land`, `landcolor`, `coast`,
345345
# `coastcolor`, and `coastlinewidth`. Finally, since `ultraplot.axes.GeoAxes.format`
346346
# calls `ultraplot.axes.Axes.format`, it can be used to add axes titles, a-b-c labels,
347-
# and figure titles, just like :func:`ultraplot.axes.CartesianAxes.format`.
347+
# and figure titles, just like :func:`ultraplot.axes.CartesianAxes.format`. UltraPlot also adds the ability to add tick marks for longitude and latitude using the keywords `lontick` and `lattick` for rectilinear projections only. This can enhance contrast and readability under some conditions, e.g. when overlaying contours.
348348
#
349349
# For details, see the `ultraplot.axes.GeoAxes.format` documentation.
350350

ultraplot/axes/geo.py

+159-4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"""
55
import copy
66
import inspect
7+
from functools import partial
78

89
import matplotlib.axis as maxis
910
import matplotlib.path as mpath
@@ -15,7 +16,14 @@
1516
from .. import proj as pproj
1617
from ..config import rc
1718
from ..internals import ic # noqa: F401
18-
from ..internals import _not_none, _pop_rc, _version_cartopy, docstring, warnings
19+
from ..internals import (
20+
_not_none,
21+
_pop_rc,
22+
_version_cartopy,
23+
docstring,
24+
warnings,
25+
)
26+
from ..utils import units
1927
from . import plot
2028

2129
try:
@@ -65,6 +73,10 @@
6573
longridminor, latgridminor, gridminor : bool, default: :rc:`gridminor`
6674
Whether to draw "minor" longitude and latitude lines.
6775
Use the keyword `gridminor` to toggle both at once.
76+
lonticklen, latticklen, ticklen : unit-spec, default: :rc:`tick.len`
77+
Major tick lengths for the longitudinal (x) and latitude (y) axis.
78+
%(units.pt)s
79+
Use the keyword `ticklen` to set both at once.
6880
latmax : float, default: 80
6981
The maximum absolute latitude for gridlines. Longitude gridlines are cut off
7082
poleward of this value (note this feature does not work in cartopy 0.18).
@@ -562,6 +574,9 @@ def format(
562574
latgrid=None,
563575
longridminor=None,
564576
latgridminor=None,
577+
ticklen=None,
578+
lonticklen=None,
579+
latticklen=None,
565580
latmax=None,
566581
nsteps=None,
567582
lonlocator=None,
@@ -761,10 +776,84 @@ def format(
761776
latgrid=latgridminor,
762777
nsteps=nsteps,
763778
)
779+
# Set tick lengths for flat projections
780+
if lonticklen or latticklen:
781+
# Only add warning when ticks are given
782+
if _is_rectilinear_projection(self):
783+
self._add_geoticks("x", lonticklen, ticklen)
784+
self._add_geoticks("y", latticklen, ticklen)
785+
else:
786+
warnings._warn_ultraplot(
787+
f"Projection is not rectilinear. Ignoring {lonticklen=} and {latticklen=} settings."
788+
)
764789

765790
# Parent format method
766791
super().format(rc_kw=rc_kw, rc_mode=rc_mode, **kwargs)
767792

793+
def _add_geoticks(self, x_or_y, itick, ticklen):
794+
"""
795+
Add tick marks to the geographic axes.
796+
797+
Parameters
798+
----------
799+
x_or_y : {'x', 'y'}
800+
The axis to add ticks to ('x' for longitude, 'y' for latitude).
801+
itick, ticklen : unit-spec, default: :rc:`tick.len`
802+
Major tick lengths for the x and y axis.
803+
%(units.pt)s
804+
Use the argument `ticklen` to set both at once.
805+
806+
Notes
807+
-----
808+
This method handles proper tick mark drawing for geographic projections
809+
while respecting the current gridline settings.
810+
"""
811+
812+
size = _not_none(itick, ticklen)
813+
# Skip if no tick size specified
814+
if size is None:
815+
return
816+
size = units(size) * rc["tick.len"]
817+
818+
ax = getattr(self, f"{x_or_y}axis")
819+
820+
# Get the tick positions based on the locator
821+
gl = self.gridlines_major
822+
# Note: set_xticks points to a different method than self.[x/y]axis.set_ticks
823+
# from the mpl backend. For basemap we are adding the ticks to the mpl backend
824+
# and for cartopy we are simple using their functions by showing the axis.
825+
if isinstance(gl, tuple):
826+
locator = gl[0] if x_or_y == "x" else gl[1]
827+
tick_positions = np.asarray(list(locator.keys()))
828+
# Show the ticks but hide the labels
829+
ax.set_ticks(tick_positions)
830+
ax.set_major_formatter(mticker.NullFormatter())
831+
832+
# Always show the ticks
833+
ax.set_visible(True)
834+
835+
# Apply tick parameters
836+
# Move the labels outwards if specified
837+
# Offset of 2 * size is aesthetically nice
838+
if isinstance(gl, tuple):
839+
locator = gl[0] if x_or_y == "x" else gl[1]
840+
for loc, objects in locator.items():
841+
for object in objects:
842+
# text is wrapped in a list
843+
if isinstance(object, list) and len(object) > 0:
844+
object = object[0]
845+
if isinstance(object, mtext.Text):
846+
object.set_visible(True)
847+
else:
848+
setattr(gl, f"{x_or_y}padding", 2 * size)
849+
850+
# Note: set grid_alpha to 0 as it is controlled through the gridlines_major
851+
# object (which is not the same ticker)
852+
sizes = [size, 0.6 * size if isinstance(size, (int, float)) else size]
853+
for size, which in zip(sizes, ["major", "minor"]):
854+
self.tick_params(axis=x_or_y, which=which, length=size, grid_alpha=0)
855+
self.stale = True
856+
768857
@property
769858
def gridlines_major(self):
770859
"""
@@ -864,8 +953,6 @@ def __init__(self, *args, map_projection=None, **kwargs):
864953
super().__init__(*args, projection=self.projection, **kwargs)
865954
else:
866955
super().__init__(*args, map_projection=self.projection, **kwargs)
867-
for axis in (self.xaxis, self.yaxis):
868-
axis.set_tick_params(which="both", size=0) # prevent extra label offset
869956

870957
def _apply_axis_sharing(self): # noqa: U100
871958
"""
@@ -1173,6 +1260,7 @@ def _update_gridlines(
11731260
lonlines = (np.asarray(lonlines) + 180) % 360 - 180 # only for cartopy
11741261
gl.xlocator = mticker.FixedLocator(lonlines)
11751262
gl.ylocator = mticker.FixedLocator(latlines)
1263+
self.stale = True
11761264

11771265
def _update_major_gridlines(
11781266
self,
@@ -1202,6 +1290,8 @@ def _update_major_gridlines(
12021290
)
12031291
gl.xformatter = self._lonaxis.get_major_formatter()
12041292
gl.yformatter = self._lataxis.get_major_formatter()
1293+
self.xaxis.set_major_formatter(mticker.NullFormatter())
1294+
self.yaxis.set_major_formatter(mticker.NullFormatter())
12051295

12061296
# Update gridline label parameters
12071297
# NOTE: Cartopy 0.18 and 0.19 can not draw both edge and inline labels. Instead
@@ -1211,7 +1301,8 @@ def _update_major_gridlines(
12111301
# TODO: Cartopy has had two formatters for a while but we use the newer one.
12121302
# See https://github.com/SciTools/cartopy/pull/1066
12131303
if labelpad is not None:
1214-
gl.xpadding = gl.ypadding = labelpad
1304+
gl.xpadding = labelpad
1305+
gl.ypadding = labelpad
12151306
if loninline is not None:
12161307
gl.x_inline = bool(loninline)
12171308
if latinline is not None:
@@ -1673,3 +1764,67 @@ def _update_minor_gridlines(self, longrid=None, latgrid=None, nsteps=None):
16731764
# Apply signature obfuscation after storing previous signature
16741765
GeoAxes._format_signatures[GeoAxes] = inspect.signature(GeoAxes.format)
16751766
GeoAxes.format = docstring._obfuscate_kwargs(GeoAxes.format)
1767+
1768+
1769+
def _is_rectilinear_projection(ax):
1770+
"""Check if the axis has a flat projection (works with Cartopy)."""
1771+
# Determine what the projection function is
1772+
# Create a square and determine if the lengths are preserved
1773+
# For geoaxes projc is always set in format, and thus is not None
1774+
proj = getattr(ax, "projection", None)
1775+
transform = None
1776+
if hasattr(proj, "transform_point"): # cartopy
1777+
if proj.transform_point is not None:
1778+
transform = partial(proj.transform_point, src_crs=proj.as_geodetic())
1779+
elif hasattr(proj, "projection"): # basemap
1780+
transform = proj
1781+
1782+
if transform is not None:
1783+
# Create three collinear points (in a straight line)
1784+
line_points = [(0, 0), (10, 10), (20, 20)]
1785+
1786+
# Transform the points using the projection
1787+
transformed_points = [transform(x, y) for x, y in line_points]
1788+
1789+
# Check if the transformed points are still collinear
1790+
# Points are collinear if the slopes between consecutive points are equal
1791+
x0, y0 = transformed_points[0]
1792+
x1, y1 = transformed_points[1]
1793+
x2, y2 = transformed_points[2]
1794+
1795+
# Calculate slopes
1796+
xdiff1 = x1 - x0
1797+
xdiff2 = x2 - x1
1798+
if np.allclose(xdiff1, 0) or np.allclose(xdiff2, 0): # Avoid division by zero
1799+
# Check if both are vertical lines
1800+
return np.allclose(xdiff1, 0) and np.allclose(xdiff2, 0)
1801+
1802+
slope1 = (y1 - y0) / xdiff1
1803+
slope2 = (y2 - y1) / xdiff2
1804+
1805+
# If slopes are equal (within a small tolerance), the projection preserves straight lines
1806+
return np.allclose(slope1 - slope2, 0)
1807+
# Cylindrical projections are generally rectilinear
1808+
rectilinear_projections = {
1809+
# Cartopy projections
1810+
"platecarree",
1811+
"mercator",
1812+
"lambertcylindrical",
1813+
"miller",
1814+
# Basemap projections
1815+
"cyl",
1816+
"merc",
1817+
"mill",
1818+
"rect",
1819+
"rectilinear",
1820+
"unknown",
1821+
}
1822+
1823+
# For Cartopy
1824+
if hasattr(proj, "name"):
1825+
return proj.name.lower() in rectilinear_projections
1826+
# For Basemap
1827+
elif hasattr(proj, "projection"):
1828+
return proj.projection.lower() in rectilinear_projections
1829+
# If we can't determine, assume it's not rectilinear
1830+
return False

ultraplot/tests/test_geographic.py

+61-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import ultraplot as plt, numpy as np
1+
import ultraplot as plt, numpy as np, warnings
22
import pytest
33

44

@@ -114,3 +114,63 @@ def test_drawing_in_projection_with_globe():
114114
abcborder=False,
115115
)
116116
return fig
117+
118+
119+
@pytest.mark.mpl_image_compare
120+
def test_geoticks():
121+
122+
lonlim = (-140, 60)
123+
latlim = (-10, 50)
124+
basemap_projection = plt.Proj(
125+
"cyl",
126+
lonlim=lonlim,
127+
latlim=latlim,
128+
backend="basemap",
129+
)
130+
fig, ax = plt.subplots(
131+
ncols=3,
132+
proj=(
133+
"cyl", # cartopy
134+
"cyl", # cartopy
135+
basemap_projection, # basemap
136+
),
137+
share=0,
138+
)
139+
settings = dict(land=True, labels=True, lonlines=20, latlines=20)
140+
# Shows sensible "default"; uses cartopy backend to show the grid lines with ticks
141+
ax[0].format(
142+
lonlim=lonlim,
143+
latlim=latlim,
144+
**settings,
145+
)
146+
147+
# Add lateral ticks only
148+
ax[1].format(
149+
latticklen=True,
150+
gridminor=True,
151+
lonlim=lonlim,
152+
latlim=latlim,
153+
**settings,
154+
)
155+
156+
ax[2].format(
157+
latticklen=5.0,
158+
lonticklen=2.0,
159+
grid=False,
160+
gridminor=False,
161+
**settings,
162+
)
163+
return fig
164+
165+
166+
def test_geoticks_input_handling(recwarn):
167+
fig, ax = plt.subplots(proj="aeqd")
168+
# Should warn that about non-rectilinear projection.
169+
with pytest.warns(plt.warnings.UltraplotWarning):
170+
ax.format(lonticklen=True)
171+
# When set to None the latticks are not added.
172+
# No warnings should be raised.
173+
ax.format(lonticklen=None)
174+
assert len(recwarn) == 0
175+
# Can parse a string
176+
ax.format(lonticklen="1em")

0 commit comments

Comments
 (0)