Source code for nav.ui.mosaic_viewer.graticule

"""Lat/lon graticule polylines for non-rectangular body-mosaic projections.

:func:`graticule_polylines` samples parallels and meridians in geographic
coordinates, projects them through the current :class:`ProjectionParams`, and
returns screen-space polyline segments ready for ``QPainter.drawPolyline``.

Polylines are split wherever consecutive samples jump discontinuously (e.g.
at the limb of a sphere or a map seam), so each returned segment is a
continuous visible arc.
"""

import math

import numpy as np

from nav.ui.mosaic_viewer.projections import ProjectionKind, ProjectionParams, lonlat_to_display

# Maximum allowed squared-pixel step between consecutive samples before the
# polyline is broken.  Prevents stitching across jumps at the sphere limb or
# the antimeridian.
_MAX_STEP_SQ: float = 100.0**2  # 100 px gap

# Meridian label radius in polar projections, as a fraction of ``ProjectionParams.scale``.
_POLAR_MERIDIAN_LABEL_RADIUS_FRAC: float = 0.55


[docs] def graticule_polylines( params: ProjectionParams, *, lat_step_deg: float, lon_step_deg: float, samples_per_line: int = 181, show_parallels: bool = True, show_meridians: bool = True, ) -> tuple[list[list[tuple[float, float]]], list[list[tuple[float, float]]]]: """Compute graticule screen-space polylines for the given projection. Each "line" in the returned lists is a list of (vx, vy) tuples that can be drawn as a connected polyline. Lines are pre-split at discontinuities (sphere limb, map seam) so callers do not need to check for jumps. Parameters: params: Current projection parameters. For RECT mode the caller should use the existing straight-line overlay path instead. lat_step_deg: Spacing of parallels (deg). Pass 0 to disable. lon_step_deg: Spacing of meridians (deg). Pass 0 to disable. samples_per_line: Number of sample points along each graticule line. show_parallels: Include parallel (constant-latitude) lines. show_meridians: Include meridian (constant-longitude) lines. Returns: Tuple ``(parallel_lines, meridian_lines)`` where each element is a list of polyline segments. A segment is a list of ``(vx, vy)`` float tuples. Raises: TypeError: If ``params`` is ``None``. ValueError: If ``lat_step_deg`` or ``lon_step_deg`` is negative, or if ``samples_per_line`` is less than 2. """ if params is None: raise TypeError('graticule_polylines: params must not be None') if lat_step_deg < 0 or lon_step_deg < 0: raise ValueError('graticule_polylines: lat_step_deg and lon_step_deg must be non-negative') if samples_per_line < 2: raise ValueError('graticule_polylines: samples_per_line must be at least 2') parallel_segs: list[list[tuple[float, float]]] = [] meridian_segs: list[list[tuple[float, float]]] = [] if show_parallels and lat_step_deg > 0: i_lat_min = math.ceil(-90.0 / lat_step_deg) i_lat_max = math.floor((90.0 + 1e-9) / lat_step_deg) for i_lat in range(i_lat_min, i_lat_max + 1): lat = i_lat * lat_step_deg lons = np.linspace(0.0, 360.0, samples_per_line) lats = np.full_like(lons, lat) vx, vy, vis = lonlat_to_display(lons, lats, params) parallel_segs.extend(_split_polyline(vx, vy, vis)) if show_meridians and lon_step_deg > 0: i_lon_max = math.floor((360.0 - 1e-6) / lon_step_deg) for i_lon in range(0, i_lon_max + 1): lon = i_lon * lon_step_deg lats = np.linspace(-90.0, 90.0, samples_per_line) lons = np.full_like(lats, lon) vx, vy, vis = lonlat_to_display(lons, lats, params) meridian_segs.extend(_split_polyline(vx, vy, vis)) return parallel_segs, meridian_segs
[docs] def graticule_label_anchors( params: ProjectionParams, *, lat_step_deg: float, lon_step_deg: float, ) -> tuple[list[tuple[float, float, str]], list[tuple[float, float, str]]]: """Return label anchor positions for visible graticule lines. Finds the best visible point along each parallel and meridian at which to draw a tick label. For parallels the anchor is the point closest to the screen centre along the line; for meridians similarly. Parameters: params: Current projection parameters. lat_step_deg: Spacing of parallels (deg). lon_step_deg: Spacing of meridians (deg). Returns: Tuple ``(parallel_anchors, meridian_anchors)``. Each anchor is ``(vx, vy, label_text)``. Raises: TypeError: If ``params`` is ``None``. ValueError: If ``lat_step_deg`` or ``lon_step_deg`` is negative. """ if params is None: raise TypeError('graticule_label_anchors: params must not be None') if lat_step_deg < 0 or lon_step_deg < 0: raise ValueError( 'graticule_label_anchors: lat_step_deg and lon_step_deg must be non-negative' ) parallel_anchors: list[tuple[float, float, str]] = [] meridian_anchors: list[tuple[float, float, str]] = [] cx, cy = params.cx, params.cy is_polar = params.kind in (ProjectionKind.POLAR_N, ProjectionKind.POLAR_S) if lat_step_deg > 0: i_lat_min = math.ceil(-90.0 / lat_step_deg) i_lat_max = math.floor((90.0 + 1e-9) / lat_step_deg) for i_lat in range(i_lat_min, i_lat_max + 1): lat = i_lat * lat_step_deg lons = np.linspace(0.0, 360.0, 181) lats = np.full_like(lons, lat) vx, vy, vis = lonlat_to_display(lons, lats, params) if is_polar: # Parallels are concentric circles; label at the rightmost point # so all parallel labels appear consistently on the east side. anchor = _rightmost_visible(vx, vy, vis) else: anchor = _nearest_visible_to_centre(vx, vy, vis, cx, cy) if anchor is not None: parallel_anchors.append((anchor[0], anchor[1], f'{lat:.0f}°')) if lon_step_deg > 0: i_lon_max = math.floor((360.0 - 1e-6) / lon_step_deg) for i_lon in range(0, i_lon_max + 1): lon = i_lon * lon_step_deg lats = np.linspace(-90.0, 90.0, 181) lons = np.full_like(lats, lon) vx, vy, vis = lonlat_to_display(lons, lats, params) if is_polar: # Meridians converge at the pole (the projection centre), so # nearest-to-centre puts every label on top of every other. # Instead place each label at ~55 % of the projection radius. anchor = _at_target_radius( vx, vy, vis, cx, cy, _POLAR_MERIDIAN_LABEL_RADIUS_FRAC * params.scale ) else: anchor = _nearest_visible_to_centre(vx, vy, vis, cx, cy) if anchor is not None: meridian_anchors.append((anchor[0], anchor[1], f'{lon:.0f}°')) return parallel_anchors, meridian_anchors
# --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _split_polyline( vx: np.ndarray, vy: np.ndarray, vis: np.ndarray, ) -> list[list[tuple[float, float]]]: """Split (vx, vy, vis) arrays into continuous visible segments. A new segment starts whenever: - ``vis[i]`` is False (invisible / off-disk sample), or - The distance between consecutive visible points exceeds ``sqrt(_MAX_STEP_SQ)`` pixels (discontinuous seam). Parameters: vx: Viewport X coordinates. vy: Viewport Y coordinates. vis: Visibility boolean mask (same length). Returns: List of polyline segments; each segment has >= 2 points. """ segments: list[list[tuple[float, float]]] = [] current: list[tuple[float, float]] = [] for i in range(len(vx)): if not vis[i]: if len(current) >= 2: segments.append(current) current = [] continue pt = (float(vx[i]), float(vy[i])) if current: dx = pt[0] - current[-1][0] dy = pt[1] - current[-1][1] if dx * dx + dy * dy > _MAX_STEP_SQ: if len(current) >= 2: segments.append(current) current = [] current.append(pt) if len(current) >= 2: segments.append(current) return segments def _nearest_visible_to_centre( vx: np.ndarray, vy: np.ndarray, vis: np.ndarray, cx: float, cy: float, ) -> tuple[float, float] | None: """Return the visible point closest to (cx, cy), or None if none visible.""" if not np.any(vis): return None dx = vx[vis] - cx dy = vy[vis] - cy idx = int(np.argmin(dx**2 + dy**2)) vx_vis = vx[vis] vy_vis = vy[vis] return float(vx_vis[idx]), float(vy_vis[idx]) def _at_target_radius( vx: np.ndarray, vy: np.ndarray, vis: np.ndarray, cx: float, cy: float, target_r: float, ) -> tuple[float, float] | None: """Return the visible point whose distance from (cx, cy) is closest to target_r.""" if not np.any(vis): return None vx_vis = vx[vis] vy_vis = vy[vis] r = np.sqrt((vx_vis - cx) ** 2 + (vy_vis - cy) ** 2) idx = int(np.argmin(np.abs(r - target_r))) return float(vx_vis[idx]), float(vy_vis[idx]) def _rightmost_visible( vx: np.ndarray, vy: np.ndarray, vis: np.ndarray, ) -> tuple[float, float] | None: """Return the visible point with the largest viewport-x coordinate.""" if not np.any(vis): return None vx_vis = vx[vis] vy_vis = vy[vis] idx = int(np.argmax(vx_vis)) return float(vx_vis[idx]), float(vy_vis[idx])