@@ -85,7 +85,7 @@ def create_figure(
8585
8686 if background_image_path :
8787 background_map = rio .open (background_image_path )
88- _kwargs = {"cmap" : "Greys_r" }
88+ _kwargs = {"cmap" : "Greys_r" , "adjust" : False }
8989 _kwargs .update (kwargs )
9090
9191
@@ -112,7 +112,172 @@ def create_figure(
112112 return fig , ax
113113
114114
115- def plot_latlon (lat , lon , ax , label = "survey" , annotate = True , max_points = 100000 , ** kwargs ):
115+ def _format_dd (degrees , decimal_places = 4 ):
116+ """Format decimal degrees (DD)."""
117+ if not np .isfinite (degrees ):
118+ return ""
119+ return f"{ degrees :.{decimal_places }f} °"
120+
121+
122+ def _format_ddm (degrees , decimal_places = 2 ):
123+ """Format degrees decimal minutes (DDM)."""
124+ if not np .isfinite (degrees ):
125+ return ""
126+ sign = "-" if degrees < 0 else ""
127+ degrees = abs (degrees )
128+ d = int (degrees )
129+ m = (degrees - d ) * 60
130+ return f"{ sign } { d } °{ m :0{decimal_places + 3 }.{decimal_places }f} '"
131+
132+
133+ def _format_dms (degrees , decimal_places = 1 ):
134+ """Format degrees minutes decimal seconds (DMS)."""
135+ if not np .isfinite (degrees ):
136+ return ""
137+ sign = "-" if degrees < 0 else ""
138+ degrees = abs (degrees )
139+ d = int (degrees )
140+ m_full = (degrees - d ) * 60
141+ m = int (m_full )
142+ s = (m_full - m ) * 60
143+ return f"{ sign } { d } °{ m :02d} '{ s :0{decimal_places + 3 }.{decimal_places }f} \" "
144+
145+
146+ # Nice tick intervals in degrees for each coordinate format
147+ _NICE_INTERVALS_DD = [
148+ 0.0001 , 0.0002 , 0.0005 ,
149+ 0.001 , 0.002 , 0.005 ,
150+ 0.01 , 0.02 , 0.05 ,
151+ 0.1 , 0.2 , 0.5 ,
152+ 1 , 2 , 5 , 10 , 20 , 45 , 90 ,
153+ ]
154+
155+ _NICE_INTERVALS_DDM = [
156+ 1 / 60 , 2 / 60 , 5 / 60 , 10 / 60 , 15 / 60 , 30 / 60 , # 1', 2', 5', 10', 15', 30'
157+ 1 , 2 , 5 , 10 , 20 , 45 , 90 ,
158+ ]
159+
160+ _NICE_INTERVALS_DMS = [
161+ 1 / 3600 , 2 / 3600 , 5 / 3600 , 10 / 3600 , 15 / 3600 , 30 / 3600 , # 1", 2", 5", 10", 15", 30"
162+ 1 / 60 , 2 / 60 , 5 / 60 , 10 / 60 , 15 / 60 , 30 / 60 , # 1', 2', 5', 10', 15', 30'
163+ 1 , 2 , 5 , 10 , 20 , 45 , 90 ,
164+ ]
165+
166+
167+ def _get_nice_intervals (coord_format ):
168+ """Return the list of nice tick intervals for a coordinate format."""
169+ coord_format = coord_format .upper ()
170+ if coord_format == "DD" :
171+ return _NICE_INTERVALS_DD
172+ elif coord_format == "DDM" :
173+ return _NICE_INTERVALS_DDM
174+ elif coord_format == "DMS" :
175+ return _NICE_INTERVALS_DMS
176+ else :
177+ return _NICE_INTERVALS_DD
178+
179+
180+ def _pick_nice_interval (data_range , target_ticks = 6 , coord_format = "DD" ):
181+ """Choose a nice tick interval for the given data range and format.
182+
183+ Parameters:
184+ data_range (float): The span of the axis in degrees.
185+ target_ticks (int): Desired approximate number of ticks.
186+ coord_format (str): ``'DD'``, ``'DDM'``, or ``'DMS'``.
187+
188+ Returns:
189+ float: The chosen interval in degrees.
190+ """
191+ if data_range <= 0 :
192+ return 1.0
193+ ideal = data_range / target_ticks
194+ intervals = _get_nice_intervals (coord_format )
195+ # Pick the interval closest to the ideal
196+ best = min (intervals , key = lambda iv : abs (iv - ideal ))
197+ return best
198+
199+
200+ from matplotlib .ticker import Locator
201+
202+
203+ class _CoordLocator (Locator ):
204+ """Matplotlib tick locator that places ticks at nice
205+ coordinate boundaries (full degrees, minutes, or seconds).
206+
207+ Parameters:
208+ coord_format (str): ``'DD'``, ``'DDM'``, or ``'DMS'``.
209+ target_ticks (int): Approximate number of ticks desired.
210+ """
211+
212+ def __init__ (self , coord_format = "DD" , target_ticks = 6 ):
213+ super ().__init__ ()
214+ self .coord_format = coord_format
215+ self .target_ticks = target_ticks
216+
217+ def __call__ (self ):
218+ return self .tick_values (* self .axis .get_view_interval ())
219+
220+ def tick_values (self , vmin , vmax ):
221+ import math
222+ data_range = vmax - vmin
223+ if not np .isfinite (data_range ) or data_range <= 0 :
224+ return np .array ([])
225+ interval = _pick_nice_interval (data_range , self .target_ticks , self .coord_format )
226+ start = math .ceil (vmin / interval ) * interval
227+ ticks = []
228+ v = start
229+ while v <= vmax + interval * 1e-9 :
230+ ticks .append (round (v , 12 ))
231+ v += interval
232+ return np .array (ticks )
233+
234+
235+ def _apply_coord_format (ax , coord_format , decimal_places = None ):
236+ """Apply coordinate format and nice tick locator to both axes.
237+
238+ Parameters:
239+ ax: matplotlib axes.
240+ coord_format (str): ``'DD'``, ``'DDM'``, or ``'DMS'``.
241+ decimal_places (int, optional): Override default decimal places.
242+ """
243+ from matplotlib .ticker import FuncFormatter
244+
245+ fmt = _get_coord_formatter (coord_format , decimal_places )
246+ formatter = FuncFormatter (lambda v , _pos : fmt (v ))
247+
248+ for axis in (ax .xaxis , ax .yaxis ):
249+ locator = _CoordLocator (coord_format )
250+ axis .set_major_locator (locator )
251+ axis .set_major_formatter (formatter )
252+
253+
254+ def _get_coord_formatter (coord_format , decimal_places = None ):
255+ """Return a formatting function for the given coordinate format.
256+
257+ Parameters:
258+ coord_format (str): One of ``'DD'``, ``'DDM'``, ``'DMS'``.
259+ decimal_places (int, optional): Override the default decimal places
260+ for the chosen format.
261+
262+ Returns:
263+ callable: A function ``(degrees) -> str``.
264+ """
265+ coord_format = coord_format .upper ()
266+ if coord_format == "DD" :
267+ dp = decimal_places if decimal_places is not None else 4
268+ return lambda v : _format_dd (v , dp )
269+ elif coord_format == "DDM" :
270+ dp = decimal_places if decimal_places is not None else 2
271+ return lambda v : _format_ddm (v , dp )
272+ elif coord_format == "DMS" :
273+ dp = decimal_places if decimal_places is not None else 1
274+ return lambda v : _format_dms (v , dp )
275+ else :
276+ raise ValueError (f"Unknown coord_format '{ coord_format } '. Use 'DD', 'DDM', or 'DMS'." )
277+
278+
279+ def plot_latlon (lat , lon , ax , label = "survey" , annotate = True , max_points = 100000 ,
280+ coord_format = None , decimal_places = None , ** kwargs ):
116281 """
117282 Plot latitude and longitude coordinates on a given axis.
118283
@@ -123,6 +288,16 @@ def plot_latlon(lat, lon, ax, label="survey", annotate=True, max_points=100000,
123288 label (str, optional): Name of the survey. Defaults to 'survey'.
124289 annotate (bool, optional): Whether to annotate the plot with the survey name. Defaults to True.
125290 max_points (int, optional): Maximum number of points to plot. Defaults to 100000.
291+ coord_format (str, optional): Coordinate display format for axis ticks
292+ and annotations. One of ``'DD'`` (decimal degrees),
293+ ``'DDM'`` (degrees decimal minutes), or ``'DMS'`` (degrees
294+ minutes decimal seconds). ``None`` leaves the default numeric
295+ tick labels unchanged. Tick positions are automatically
296+ adjusted to fall on nice boundaries (full minutes, seconds,
297+ etc.).
298+ decimal_places (int, optional): Number of decimal places for the
299+ coordinate format. Defaults depend on *coord_format*:
300+ 4 for DD, 2 for DDM, 1 for DMS.
126301 **kwargs: Additional keyword arguments to be passed to the plot function.
127302
128303 Returns:
@@ -150,4 +325,123 @@ def plot_latlon(lat, lon, ax, label="survey", annotate=True, max_points=100000,
150325
151326 # Add label at the first point
152327 if annotate :
153- ax .annotate (f"Start { label } " , xy = (plot_lon [0 ], plot_lat [0 ]), xytext = (plot_lon [0 ], plot_lat [0 ]))
328+ if coord_format is not None :
329+ fmt = _get_coord_formatter (coord_format , decimal_places )
330+ annotation = f"Start { label } \n { fmt (plot_lat [0 ])} , { fmt (plot_lon [0 ])} "
331+ else :
332+ annotation = f"Start { label } "
333+ ax .annotate (annotation , xy = (plot_lon [0 ], plot_lat [0 ]), xytext = (plot_lon [0 ], plot_lat [0 ]))
334+
335+ # Apply coordinate formatters and locators to axis ticks
336+ if coord_format is not None :
337+ _apply_coord_format (ax , coord_format , decimal_places )
338+
339+
340+ def set_latlon_axes_labels (ax , src_crs , coord_format = "DD" , decimal_places = None ):
341+ """Replace projected axis tick labels with lat/lon values.
342+
343+ After plotting in a projected CRS (e.g. UTM), call this function to
344+ convert the axis tick labels back to latitude/longitude while keeping
345+ the spatial scaling of the projection. Tick positions are
346+ automatically adjusted to fall on nice coordinate boundaries.
347+
348+ Parameters:
349+ ax (matplotlib.axes.Axes): The axes whose tick labels should be
350+ converted.
351+ src_crs: The CRS of the data currently shown on the axes.
352+ Anything accepted by ``pyproj.CRS`` (e.g. ``'EPSG:32631'``).
353+ coord_format (str, optional): Coordinate display format. One of
354+ ``'DD'`` (decimal degrees), ``'DDM'`` (degrees decimal
355+ minutes), or ``'DMS'`` (degrees minutes decimal seconds).
356+ Defaults to ``'DD'``.
357+ decimal_places (int, optional): Number of decimal places. Defaults
358+ depend on *coord_format*: 4 for DD, 2 for DDM, 1 for DMS.
359+ """
360+ from pyproj import Transformer , CRS
361+ from matplotlib .ticker import FuncFormatter
362+ import math
363+
364+ crs = CRS (src_crs )
365+ transformer_to_latlon = Transformer .from_crs (crs , "EPSG:4326" , always_xy = True )
366+ transformer_from_latlon = Transformer .from_crs ("EPSG:4326" , crs , always_xy = True )
367+ fmt = _get_coord_formatter (coord_format , decimal_places )
368+
369+ class _ProjectedCoordLocator (Locator ):
370+ """Locator that places ticks at nice lat/lon boundaries in projected space."""
371+
372+ def __init__ (self , is_x ):
373+ super ().__init__ ()
374+ self .is_x = is_x
375+
376+ def __call__ (self ):
377+ vmin , vmax = self .axis .get_view_interval ()
378+ return self .tick_values (vmin , vmax )
379+
380+ def tick_values (self , vmin , vmax ):
381+ # Sample multiple points along the axis to get a robust lat/lon range
382+ n_samples = 20
383+ samples = np .linspace (vmin , vmax , n_samples )
384+
385+ if self .is_x :
386+ ymid_proj = np .mean (ax .get_ylim ())
387+ xmid_proj = np .mean ([vmin , vmax ])
388+ lons , lats = transformer_to_latlon .transform (
389+ samples , np .full (n_samples , ymid_proj )
390+ )
391+ valid = np .isfinite (lons )
392+ if not np .any (valid ):
393+ return np .array ([])
394+ deg_min , deg_max = np .min (lons [valid ]), np .max (lons [valid ])
395+ # Get the lat/lon midpoint for the perpendicular axis
396+ _ , lat_mid = transformer_to_latlon .transform (xmid_proj , ymid_proj )
397+ else :
398+ xmid_proj = np .mean (ax .get_xlim ())
399+ ymid_proj = np .mean ([vmin , vmax ])
400+ lons , lats = transformer_to_latlon .transform (
401+ np .full (n_samples , xmid_proj ), samples
402+ )
403+ valid = np .isfinite (lats )
404+ if not np .any (valid ):
405+ return np .array ([])
406+ deg_min , deg_max = np .min (lats [valid ]), np .max (lats [valid ])
407+ # Get the lat/lon midpoint for the perpendicular axis
408+ lon_mid , _ = transformer_to_latlon .transform (xmid_proj , ymid_proj )
409+
410+ if deg_min > deg_max :
411+ deg_min , deg_max = deg_max , deg_min
412+
413+ data_range = deg_max - deg_min
414+ if not np .isfinite (data_range ) or data_range <= 0 :
415+ return np .array ([])
416+
417+ interval = _pick_nice_interval (data_range , 6 , coord_format )
418+ start = math .ceil (deg_min / interval ) * interval
419+ ticks = []
420+ v = start
421+ while v <= deg_max + interval * 1e-9 :
422+ # Convert back to projected coordinates using lat/lon midpoints
423+ if self .is_x :
424+ proj_x , _ = transformer_from_latlon .transform (v , lat_mid )
425+ if np .isfinite (proj_x ):
426+ ticks .append (proj_x )
427+ else :
428+ _ , proj_y = transformer_from_latlon .transform (lon_mid , v )
429+ if np .isfinite (proj_y ):
430+ ticks .append (proj_y )
431+ v += interval
432+ return np .array (ticks )
433+
434+ def _lon_formatter (x , _pos ):
435+ lon , _ = transformer_to_latlon .transform (x , sum (ax .get_ylim ()) / 2 )
436+ return fmt (lon )
437+
438+ def _lat_formatter (y , _pos ):
439+ _ , lat = transformer_to_latlon .transform (sum (ax .get_xlim ()) / 2 , y )
440+ return fmt (lat )
441+
442+ ax .xaxis .set_major_locator (_ProjectedCoordLocator (is_x = True ))
443+ ax .yaxis .set_major_locator (_ProjectedCoordLocator (is_x = False ))
444+ ax .xaxis .set_major_formatter (FuncFormatter (_lon_formatter ))
445+ ax .yaxis .set_major_formatter (FuncFormatter (_lat_formatter ))
446+ ax .set_xlabel ("longitude" )
447+ ax .set_ylabel ("latitude" )
0 commit comments