4
4
"""
5
5
import copy
6
6
import inspect
7
+ from functools import partial
7
8
8
9
import matplotlib .axis as maxis
9
10
import matplotlib .path as mpath
15
16
from .. import proj as pproj
16
17
from ..config import rc
17
18
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
19
27
from . import plot
20
28
21
29
try :
65
73
longridminor, latgridminor, gridminor : bool, default: :rc:`gridminor`
66
74
Whether to draw "minor" longitude and latitude lines.
67
75
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.
68
80
latmax : float, default: 80
69
81
The maximum absolute latitude for gridlines. Longitude gridlines are cut off
70
82
poleward of this value (note this feature does not work in cartopy 0.18).
@@ -562,6 +574,9 @@ def format(
562
574
latgrid = None ,
563
575
longridminor = None ,
564
576
latgridminor = None ,
577
+ ticklen = None ,
578
+ lonticklen = None ,
579
+ latticklen = None ,
565
580
latmax = None ,
566
581
nsteps = None ,
567
582
lonlocator = None ,
@@ -761,10 +776,84 @@ def format(
761
776
latgrid = latgridminor ,
762
777
nsteps = nsteps ,
763
778
)
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
+ )
764
789
765
790
# Parent format method
766
791
super ().format (rc_kw = rc_kw , rc_mode = rc_mode , ** kwargs )
767
792
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
+
768
857
@property
769
858
def gridlines_major (self ):
770
859
"""
@@ -864,8 +953,6 @@ def __init__(self, *args, map_projection=None, **kwargs):
864
953
super ().__init__ (* args , projection = self .projection , ** kwargs )
865
954
else :
866
955
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
869
956
870
957
def _apply_axis_sharing (self ): # noqa: U100
871
958
"""
@@ -1173,6 +1260,7 @@ def _update_gridlines(
1173
1260
lonlines = (np .asarray (lonlines ) + 180 ) % 360 - 180 # only for cartopy
1174
1261
gl .xlocator = mticker .FixedLocator (lonlines )
1175
1262
gl .ylocator = mticker .FixedLocator (latlines )
1263
+ self .stale = True
1176
1264
1177
1265
def _update_major_gridlines (
1178
1266
self ,
@@ -1202,6 +1290,8 @@ def _update_major_gridlines(
1202
1290
)
1203
1291
gl .xformatter = self ._lonaxis .get_major_formatter ()
1204
1292
gl .yformatter = self ._lataxis .get_major_formatter ()
1293
+ self .xaxis .set_major_formatter (mticker .NullFormatter ())
1294
+ self .yaxis .set_major_formatter (mticker .NullFormatter ())
1205
1295
1206
1296
# Update gridline label parameters
1207
1297
# NOTE: Cartopy 0.18 and 0.19 can not draw both edge and inline labels. Instead
@@ -1211,7 +1301,8 @@ def _update_major_gridlines(
1211
1301
# TODO: Cartopy has had two formatters for a while but we use the newer one.
1212
1302
# See https://github.com/SciTools/cartopy/pull/1066
1213
1303
if labelpad is not None :
1214
- gl .xpadding = gl .ypadding = labelpad
1304
+ gl .xpadding = labelpad
1305
+ gl .ypadding = labelpad
1215
1306
if loninline is not None :
1216
1307
gl .x_inline = bool (loninline )
1217
1308
if latinline is not None :
@@ -1673,3 +1764,67 @@ def _update_minor_gridlines(self, longrid=None, latgrid=None, nsteps=None):
1673
1764
# Apply signature obfuscation after storing previous signature
1674
1765
GeoAxes ._format_signatures [GeoAxes ] = inspect .signature (GeoAxes .format )
1675
1766
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
0 commit comments