Skip to content

Commit e9fec83

Browse files
committed
additional reprojectipon options for navplot
1 parent feaa6a4 commit e9fec83

2 files changed

Lines changed: 352 additions & 14 deletions

File tree

python/themachinethatgoesping/pingprocessing/overview/nav_plot.py

Lines changed: 297 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)